full scope implementation
This commit is contained in:
+9
-1
@@ -20,7 +20,15 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "TOML", package: "swift-toml"),
|
.product(name: "TOML", package: "swift-toml"),
|
||||||
]
|
],
|
||||||
|
resources: [
|
||||||
|
.process("config.toml"),
|
||||||
|
.process("Themes/voltage.toml"),
|
||||||
|
// .process("Themes/catppuccin-mocha.toml"),
|
||||||
|
.process("Themes/dracula.toml"),
|
||||||
|
// .process("Themes/gruvbox.toml"),
|
||||||
|
// .process("Themes/tokyo-night.toml"),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "phbarTests",
|
name: "phbarTests",
|
||||||
|
|||||||
@@ -4,20 +4,35 @@ import AppKit
|
|||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var barController: PHController!
|
var barController: PHController!
|
||||||
var window: BarWindow!
|
var window: BarWindow!
|
||||||
|
private var observers: [IPCObserver] = []
|
||||||
|
|
||||||
init(screen: NSScreen, blocks: [PHBlock]) {
|
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool = false) {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
self.barController = PHController(screen: screen, blocks: blocks)
|
self.barController = PHController(config: config, screen: screen, theme: theme, blocks: blocks)
|
||||||
|
barController.debug = debug
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
window = BarWindow(controller: barController)
|
window = BarWindow(controller: barController)
|
||||||
window.orderFront(nil)
|
window.orderFront(nil)
|
||||||
barController.startAutoRefresh()
|
barController.startAutoRefresh()
|
||||||
|
|
||||||
|
// Listen for refresh notifications from `phbar refresh`.
|
||||||
|
let token = IPC.observe(.refresh) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers.append(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
barController.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
barController.stopAutoRefresh()
|
barController.stopAutoRefresh()
|
||||||
|
observers.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
extension phbar {
|
||||||
|
struct refresh: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "refresh",
|
||||||
|
abstract: "Refresh the running status bar."
|
||||||
|
)
|
||||||
|
|
||||||
|
mutating func run() throws {
|
||||||
|
IPC.post(.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,13 @@ extension phbar {
|
|||||||
abstract: "Start the status bar."
|
abstract: "Start the status bar."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Flag(name: .long, help: "Display visual guides to help align elements.")
|
||||||
|
var debug: Bool = false
|
||||||
|
|
||||||
mutating func run() throws {
|
mutating func run() throws {
|
||||||
|
let config = try PHConfig.load()
|
||||||
let screen = try targetScreen(monitor: 0)
|
let screen = try targetScreen(monitor: 0)
|
||||||
|
let theme = try PHTheme.load("voltage")
|
||||||
|
|
||||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||||
dispatchPrecondition(condition: .onQueue(.main))
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
@@ -18,7 +23,7 @@ extension phbar {
|
|||||||
try MainActor.assumeIsolated {
|
try MainActor.assumeIsolated {
|
||||||
let blocks = try PHBlock.load()
|
let blocks = try PHBlock.load()
|
||||||
|
|
||||||
let delegate = AppDelegate(screen: screen, blocks: blocks)
|
let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
|
||||||
let app = NSApplication.shared
|
let app = NSApplication.shared
|
||||||
app.setActivationPolicy(.accessory)
|
app.setActivationPolicy(.accessory)
|
||||||
app.delegate = delegate
|
app.delegate = delegate
|
||||||
|
|||||||
@@ -2,16 +2,55 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PHController: ObservableObject {
|
final class PHController: ObservableObject {
|
||||||
|
let config: PHConfig
|
||||||
let screen: NSScreen
|
let screen: NSScreen
|
||||||
|
let theme: PHTheme
|
||||||
|
var debug: Bool = false {
|
||||||
|
didSet {
|
||||||
|
for block in blocks {
|
||||||
|
block.debug = debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var blocks: [PHBlock]
|
@Published var blocks: [PHBlock]
|
||||||
|
|
||||||
init(screen: NSScreen, blocks: [PHBlock]) {
|
var arrangedBlocks: [PHBlock] {
|
||||||
self.screen = screen
|
blocks.filter { $0.centered != true }
|
||||||
self.blocks = blocks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Refresh
|
var centeredBlocks: [PHBlock] {
|
||||||
|
blocks.filter { $0.centered == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock]) {
|
||||||
|
self.config = config
|
||||||
|
self.screen = screen
|
||||||
|
self.theme = theme
|
||||||
|
self.blocks = blocks
|
||||||
|
|
||||||
|
for block in blocks {
|
||||||
|
block.style = style(for: block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style
|
||||||
|
|
||||||
|
extension PHController {
|
||||||
|
func style(for block: PHBlock) -> PHTheme.Style {
|
||||||
|
guard let style = theme.styles.first(where: { $0.name == block.styleName }) else {
|
||||||
|
guard let defaultStyle = theme.styles.first(where: { $0.name == "default" }) else {
|
||||||
|
return PHTheme.Style.default
|
||||||
|
}
|
||||||
|
return defaultStyle
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
|
||||||
|
extension PHController {
|
||||||
/// Start auto-refresh for every block.
|
/// Start auto-refresh for every block.
|
||||||
func startAutoRefresh() {
|
func startAutoRefresh() {
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A subscribable system event.
|
||||||
|
///
|
||||||
|
/// Declared as a `String`-backed enum so it decodes straight from the TOML
|
||||||
|
/// config (e.g. `events = ["volume", "network"]`) while staying typo-proof: an
|
||||||
|
/// unknown value fails to load instead of silently doing nothing.
|
||||||
|
///
|
||||||
|
/// To add a new event: add a case here, conform a `PHEventSource` to it in
|
||||||
|
/// `PHEventRegistry.defaultFactory`, and ship the source under `Events/Sources/`.
|
||||||
|
enum PHEvent: String, CaseIterable, Sendable {
|
||||||
|
/// System output volume or default output device changes (CoreAudio).
|
||||||
|
case volume
|
||||||
|
/// Network reachability / interface changes (Network framework).
|
||||||
|
case network
|
||||||
|
/// Light/Dark appearance changes (DistributedNotificationCenter).
|
||||||
|
case appearance
|
||||||
|
/// Power source changes — AC plug/unplug, battery updates (IOKit).
|
||||||
|
case power
|
||||||
|
/// Music Player Daemon state changes — playback, playlist, etc. (MPD `idle`).
|
||||||
|
case mpd
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHEvent: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let raw = try container.decode(String.self)
|
||||||
|
guard let value = Self(rawValue: raw) else {
|
||||||
|
throw DecodingError.dataCorrupted(.init(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "unknown event '\(raw)'; valid events: \(PHEvent.allCases.map(\.rawValue).joined(separator: ", "))"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
self = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Lazily-activated, ref-counted broker for system events.
|
||||||
|
///
|
||||||
|
/// Blocks never talk to `CoreAudio` / `Network` / `IOKit` directly. Instead they
|
||||||
|
/// `subscribe(_:)` to a `PHEvent` and receive a main-actor callback whenever it
|
||||||
|
/// fires. The registry keeps exactly one `PHEventSource` alive per event for as
|
||||||
|
/// long as at least one subscriber exists, so an event that no block cares about
|
||||||
|
/// costs nothing.
|
||||||
|
///
|
||||||
|
/// The shared instance is used app-wide; tests inject a registry built with a
|
||||||
|
/// custom `factory` (typically returning a fake source).
|
||||||
|
@MainActor
|
||||||
|
final class PHEventRegistry {
|
||||||
|
static let shared = PHEventRegistry()
|
||||||
|
|
||||||
|
private struct Slot {
|
||||||
|
let source: any PHEventSource
|
||||||
|
var subscribers: [UUID: @MainActor () -> Void] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var slots: [PHEvent: Slot] = [:]
|
||||||
|
private let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource
|
||||||
|
|
||||||
|
init(factory: @escaping @MainActor @Sendable (PHEvent) -> any PHEventSource = PHEventRegistry.defaultFactory) {
|
||||||
|
self.factory = factory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to `event`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - handler: Invoked on the main actor every time the event fires.
|
||||||
|
/// - Returns: A cancellable. The underlying listener is started on the first
|
||||||
|
/// subscriber and stopped once the last one cancels.
|
||||||
|
@discardableResult
|
||||||
|
func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription {
|
||||||
|
if slots[event] == nil {
|
||||||
|
slots[event] = Slot(source: factory(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let wasEmpty = slots[event]?.subscribers.isEmpty ?? true
|
||||||
|
slots[event]?.subscribers[id] = handler
|
||||||
|
|
||||||
|
if wasEmpty {
|
||||||
|
slots[event]?.source.start { [weak self] in
|
||||||
|
self?.broadcast(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PHEventSubscription { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in self.unsubscribe(event, id: id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of active subscribers for `event` (handy for tests/debugging).
|
||||||
|
func subscriberCount(for event: PHEvent) -> Int {
|
||||||
|
slots[event]?.subscribers.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward one firing to every current subscriber. Called on the main actor.
|
||||||
|
private func broadcast(_ event: PHEvent) {
|
||||||
|
guard let slot = slots[event] else { return }
|
||||||
|
for handler in slot.subscribers.values {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one subscriber, stopping and dropping the source when none remain.
|
||||||
|
private func unsubscribe(_ event: PHEvent, id: UUID) {
|
||||||
|
guard var slot = slots[event] else { return }
|
||||||
|
slot.subscribers[id] = nil
|
||||||
|
if slot.subscribers.isEmpty {
|
||||||
|
slot.source.stop()
|
||||||
|
slots[event] = nil
|
||||||
|
} else {
|
||||||
|
slots[event] = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps each event to its concrete adapter.
|
||||||
|
static func defaultFactory(_ event: PHEvent) -> any PHEventSource {
|
||||||
|
switch event {
|
||||||
|
case .volume: return VolumeEventSource()
|
||||||
|
case .network: return NetworkEventSource()
|
||||||
|
case .appearance: return AppearanceEventSource()
|
||||||
|
case .power: return PowerEventSource()
|
||||||
|
case .mpd: return MPDEventSource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single adapter that observes one kind of system event.
|
||||||
|
///
|
||||||
|
/// The registry owns source lifecycles: a source is `start`ed on its first
|
||||||
|
/// subscriber and `stop`ped when the last one cancels. Conformers are
|
||||||
|
/// `@MainActor` for their mutable state, but the `notify` closure they are
|
||||||
|
/// handed is `@MainActor`-isolated and `Sendable`, so an adapter that receives
|
||||||
|
/// callbacks on a background queue (CoreAudio, Network) must hop to the main
|
||||||
|
/// actor before calling it.
|
||||||
|
@MainActor
|
||||||
|
protocol PHEventSource: AnyObject {
|
||||||
|
/// Begin observing. `notify` must be retained for as long as the source is
|
||||||
|
/// started and is invoked on the main actor for every state change.
|
||||||
|
/// Idempotent: calling `start` while already started is a no-op.
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void)
|
||||||
|
|
||||||
|
/// Stop observing and release system resources. Idempotent.
|
||||||
|
func stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A cancellable handle returned by `PHEventRegistry.subscribe(_:)`.
|
||||||
|
///
|
||||||
|
/// Cancel it (e.g. in `PHBlock.stopAutoRefresh`) to decrement the event's
|
||||||
|
/// subscriber count; the registry tears the underlying listener down when the
|
||||||
|
/// last subscriber for an event cancels. `cancel` is safe to call from any
|
||||||
|
/// context and from `deinit`; the actual unregistration is performed on the
|
||||||
|
/// main actor.
|
||||||
|
final class PHEventSubscription: @unchecked Sendable {
|
||||||
|
private var cancellation: (@Sendable () -> Void)?
|
||||||
|
|
||||||
|
init(_ cancellation: @escaping @Sendable () -> Void) {
|
||||||
|
self.cancellation = cancellation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the subscription. Safe to call more than once.
|
||||||
|
func cancel() {
|
||||||
|
let cancellation = cancellation
|
||||||
|
self.cancellation = nil
|
||||||
|
cancellation?()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { cancellation?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Fires when the system appearance switches between Light and Dark mode.
|
||||||
|
///
|
||||||
|
/// `AppleInterfaceThemeChangedNotification` is a distributed notification posted
|
||||||
|
/// system-wide, so it reaches the bar even when it isn't frontmost.
|
||||||
|
@MainActor
|
||||||
|
final class AppearanceEventSource: PHEventSource {
|
||||||
|
static let notificationName = Notification.Name("AppleInterfaceThemeChangedNotification")
|
||||||
|
|
||||||
|
private var observer: NSObjectProtocol?
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
observer = DistributedNotificationCenter.default().addObserver(
|
||||||
|
forName: Self.notificationName,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
Task { @MainActor in notify() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
if let observer {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(observer)
|
||||||
|
}
|
||||||
|
observer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Fires whenever the Music Player Daemon (MPD) state changes — playback
|
||||||
|
/// start/stop/seek, playlist edits, volume, options, etc.
|
||||||
|
///
|
||||||
|
/// Connects to MPD's TCP socket (default `127.0.0.1:6600`) and issues the
|
||||||
|
/// `idle` command, which blocks server-side until a subsystem changes. When MPD
|
||||||
|
/// replies with `changed: …` lines terminated by `OK`, the source fires `notify`
|
||||||
|
/// and re-enters idle. The specifics of *what* changed are intentionally
|
||||||
|
/// ignored: blocks only need to know that the music state changed so they can
|
||||||
|
/// recompute. If the connection drops or MPD is unreachable, the source
|
||||||
|
/// reconnects after a short delay for as long as it is started.
|
||||||
|
@MainActor
|
||||||
|
final class MPDEventSource: PHEventSource {
|
||||||
|
/// Seconds to wait before retrying a failed/dropped connection.
|
||||||
|
static let reconnectDelay: Duration = .seconds(1)
|
||||||
|
/// The `idle` command, newline-terminated, as sent over the wire.
|
||||||
|
static let idleCommand = Data("idle\n".utf8)
|
||||||
|
|
||||||
|
private let host: NWEndpoint.Host
|
||||||
|
private let port: NWEndpoint.Port
|
||||||
|
private let queue = DispatchQueue(label: "phbar.mpd", qos: .utility)
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
/// Bumped on every `start`/`stop` so a reconnect scheduled by a torn-down
|
||||||
|
/// generation bails out instead of racing a fresh `start`.
|
||||||
|
private var generation = 0
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private var notify: (@MainActor @Sendable () -> Void)?
|
||||||
|
/// Accumulates partial lines across `receive` callbacks (TCP is a stream).
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
init(host: String = "127.0.0.1", port: UInt16 = 6600) {
|
||||||
|
self.host = NWEndpoint.Host(host)
|
||||||
|
self.port = NWEndpoint.Port(rawValue: port) ?? .any
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
self.notify = notify
|
||||||
|
buffer.removeAll()
|
||||||
|
openConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
generation += 1
|
||||||
|
connection?.cancel()
|
||||||
|
connection = nil
|
||||||
|
notify = nil
|
||||||
|
buffer.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection lifecycle
|
||||||
|
|
||||||
|
private func openConnection() {
|
||||||
|
guard started else { return }
|
||||||
|
let connection = NWConnection(host: host, port: port, using: .tcp)
|
||||||
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handle(state: state, connection: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
self.connection = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(state: NWConnection.State, connection: NWConnection) {
|
||||||
|
// Only react for the currently-active connection; an old connection
|
||||||
|
// winding down after a reconnect must not trigger another one.
|
||||||
|
guard self.connection === connection else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
beginReadLoop(on: connection)
|
||||||
|
case .failed, .cancelled:
|
||||||
|
self.connection = nil
|
||||||
|
buffer.removeAll()
|
||||||
|
if started {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the current connection; the state handler drives the reconnect.
|
||||||
|
private func fail(connection: NWConnection) {
|
||||||
|
guard self.connection === connection else { return }
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
let gen = generation
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: Self.reconnectDelay)
|
||||||
|
guard let self, self.started, self.generation == gen, !Task.isCancelled else { return }
|
||||||
|
self.openConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MPD idle protocol
|
||||||
|
|
||||||
|
/// Reads lines from `connection` until it fails or closes. `onLine` runs on
|
||||||
|
/// the main actor for each complete line.
|
||||||
|
private func readLines(
|
||||||
|
on connection: NWConnection,
|
||||||
|
_ onLine: @escaping @MainActor (String) -> Void
|
||||||
|
) {
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.started else { return }
|
||||||
|
if error != nil || isComplete {
|
||||||
|
self.fail(connection: connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let content, !content.isEmpty {
|
||||||
|
self.buffer.append(content)
|
||||||
|
}
|
||||||
|
while let line = self.buffer.popLine() {
|
||||||
|
onLine(line)
|
||||||
|
}
|
||||||
|
self.readLines(on: connection, onLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MPD greeting (e.g. `OK MPD 0.23.5`) arrives first; once it's consumed
|
||||||
|
/// the source enters the idle loop, re-sending `idle` after every `OK`.
|
||||||
|
private func beginReadLoop(on connection: NWConnection) {
|
||||||
|
var consumedGreeting = false
|
||||||
|
readLines(on: connection) { [weak self] line in
|
||||||
|
guard let self, self.started else { return }
|
||||||
|
if !consumedGreeting {
|
||||||
|
consumedGreeting = true
|
||||||
|
self.sendIdle(on: connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `OK` ends an idle response — fire and re-arm. `changed: …` and
|
||||||
|
// `ACK …` lines are ignored; only the fact of a change matters.
|
||||||
|
if line == "OK" {
|
||||||
|
self.fire()
|
||||||
|
self.sendIdle(on: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendIdle(on connection: NWConnection) {
|
||||||
|
guard started else { return }
|
||||||
|
connection.send(content: Self.idleCommand, completion: .contentProcessed { [weak self] error in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.started else { return }
|
||||||
|
if error != nil {
|
||||||
|
self.fail(connection: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fire() {
|
||||||
|
notify?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Line buffering
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
/// Removes and returns the first `\n`-terminated line (without the
|
||||||
|
/// terminator and any trailing `\r`), or `nil` if no complete line is buffered.
|
||||||
|
mutating func popLine() -> String? {
|
||||||
|
guard let newline = firstIndex(of: UInt8(ascii: "\n")) else { return nil }
|
||||||
|
var line = self[startIndex..<newline]
|
||||||
|
if line.last == UInt8(ascii: "\r") {
|
||||||
|
line = line.dropLast()
|
||||||
|
}
|
||||||
|
let string = String(data: line, encoding: .utf8) ?? ""
|
||||||
|
removeSubrange(startIndex...newline)
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Fires whenever the system's network path changes (Wi-Fi up/down, interface
|
||||||
|
/// switch, reachability). Uses `NWPathMonitor`, which needs no special
|
||||||
|
/// permissions — unlike reading the SSID via CoreWLAN.
|
||||||
|
@MainActor
|
||||||
|
final class NetworkEventSource: PHEventSource {
|
||||||
|
private var monitor: NWPathMonitor?
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
monitor.pathUpdateHandler = { _ in
|
||||||
|
Task { @MainActor in notify() }
|
||||||
|
}
|
||||||
|
monitor.start(queue: .global(qos: .utility))
|
||||||
|
self.monitor = monitor
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
monitor?.cancel()
|
||||||
|
monitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit.ps
|
||||||
|
|
||||||
|
/// Fires when the power source changes — AC adapter plugged/unplugged, battery
|
||||||
|
/// level/charging state updates — via IOKit's power-source run-loop source.
|
||||||
|
@MainActor
|
||||||
|
final class PowerEventSource: PHEventSource {
|
||||||
|
/// Holds the notify closure so the C callback can reach it without capturing.
|
||||||
|
fileprivate final class ContextBox: @unchecked Sendable {
|
||||||
|
let notify: @MainActor @Sendable () -> Void
|
||||||
|
init(notify: @escaping @MainActor @Sendable () -> Void) { self.notify = notify }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var runLoopSource: CFRunLoopSource?
|
||||||
|
private var contextBox: ContextBox?
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
|
||||||
|
let box = ContextBox(notify: notify)
|
||||||
|
contextBox = box
|
||||||
|
|
||||||
|
let source = IOPSNotificationCreateRunLoopSource(
|
||||||
|
phbarPowerSourceChanged,
|
||||||
|
Unmanaged.passUnretained(box).toOpaque()
|
||||||
|
).takeRetainedValue()
|
||||||
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
|
||||||
|
runLoopSource = source
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
if let runLoopSource {
|
||||||
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .defaultMode)
|
||||||
|
}
|
||||||
|
runLoopSource = nil
|
||||||
|
contextBox = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - IOKit callback
|
||||||
|
//
|
||||||
|
// `IOPowerSourceCallbackType` is a C function pointer. The source is added to
|
||||||
|
// the main run loop, so this runs on the main thread — safe to assume MainActor.
|
||||||
|
|
||||||
|
private func phbarPowerSourceChanged(_ context: UnsafeMutableRawPointer?) {
|
||||||
|
guard let context else { return }
|
||||||
|
let box = Unmanaged<PowerEventSource.ContextBox>.fromOpaque(context).takeUnretainedValue()
|
||||||
|
MainActor.assumeIsolated { box.notify() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Read access to the system output volume with change notifications.
|
||||||
|
///
|
||||||
|
/// Wraps the public CoreAudio HAL so callers can ignore C callbacks and property
|
||||||
|
/// addresses. This tracks the virtual master output volume (the value the macOS
|
||||||
|
/// volume slider controls) and re-attaches when the default device changes.
|
||||||
|
enum SystemAudioVolume {
|
||||||
|
/// Current default output volume in `0...1`, or `0` if unavailable.
|
||||||
|
static var current: Float {
|
||||||
|
guard let device = defaultDevice else { return 0 }
|
||||||
|
return volume(of: device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The system's default output device, if any.
|
||||||
|
static var defaultDevice: AudioDeviceID? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var device = AudioDeviceID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device)
|
||||||
|
return status == noErr ? device : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual master volume `[0, 1]` for a given output device.
|
||||||
|
static func volume(of device: AudioDeviceID) -> Float {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var value = Float32(0)
|
||||||
|
var size = UInt32(MemoryLayout<Float32>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value)
|
||||||
|
return status == noErr ? Float(value) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe volume changes on the current default device. The handler is
|
||||||
|
/// invoked immediately with the current value, then whenever the volume or
|
||||||
|
/// the default device changes. Keep the returned `Listener` alive.
|
||||||
|
static func observe(_ handler: @escaping @Sendable (Float) -> Void) -> Listener {
|
||||||
|
let listener = Listener(handler: handler)
|
||||||
|
listener.activate()
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listener
|
||||||
|
|
||||||
|
/// A self-contained listener that re-attaches when the default device changes.
|
||||||
|
/// Not `@MainActor`: CoreAudio delivers callbacks on its own thread.
|
||||||
|
final class Listener {
|
||||||
|
private let handler: @Sendable (Float) -> Void
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var observedDevice: AudioDeviceID?
|
||||||
|
private var systemListenerInstalled = false
|
||||||
|
|
||||||
|
init(handler: @escaping @Sendable (Float) -> Void) {
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
installSystemListener()
|
||||||
|
attach(to: SystemAudioVolume.defaultDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installSystemListener() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard !systemListenerInstalled else { return }
|
||||||
|
systemListenerInstalled = true
|
||||||
|
var address = Self.defaultDeviceAddress
|
||||||
|
AudioObjectAddPropertyListener(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attach(to device: AudioDeviceID?) {
|
||||||
|
lock.lock()
|
||||||
|
let previous = observedDevice
|
||||||
|
observedDevice = device
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
if let previous {
|
||||||
|
var address = Self.volumeAddress
|
||||||
|
AudioObjectRemovePropertyListener(
|
||||||
|
previous, &address, phbarVolumeChanged,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let device else { return }
|
||||||
|
var address = Self.volumeAddress
|
||||||
|
AudioObjectAddPropertyListener(
|
||||||
|
device, &address, phbarVolumeChanged,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
)
|
||||||
|
// Emit current value once on attach.
|
||||||
|
handler(SystemAudioVolume.volume(of: device))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the CoreAudio thread when the volume property fires.
|
||||||
|
fileprivate func fire(for device: AudioDeviceID) {
|
||||||
|
handler(SystemAudioVolume.volume(of: device))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the CoreAudio thread when the default device changes.
|
||||||
|
fileprivate func reattach() {
|
||||||
|
attach(to: SystemAudioVolume.defaultDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
if systemListenerInstalled {
|
||||||
|
var address = Self.defaultDeviceAddress
|
||||||
|
AudioObjectRemovePropertyListener(AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged, context)
|
||||||
|
}
|
||||||
|
if let device = observedDevice {
|
||||||
|
var address = Self.volumeAddress
|
||||||
|
AudioObjectRemovePropertyListener(device, &address, phbarVolumeChanged, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultDeviceAddress: AudioObjectPropertyAddress {
|
||||||
|
AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var volumeAddress: AudioObjectPropertyAddress {
|
||||||
|
AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreAudio callbacks
|
||||||
|
//
|
||||||
|
// `AudioObjectPropertyListenerProc` is a C function pointer, so these must be
|
||||||
|
// non-capturing top-level functions. They recover `self` via the client data
|
||||||
|
// pointer passed alongside the listener.
|
||||||
|
|
||||||
|
private func phbarVolumeChanged(
|
||||||
|
_ objectID: AudioObjectID,
|
||||||
|
_ count: UInt32,
|
||||||
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||||
|
_ clientData: UnsafeMutableRawPointer?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let clientData else { return noErr }
|
||||||
|
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
|
listener.fire(for: objectID)
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phbarDefaultDeviceChanged(
|
||||||
|
_ objectID: AudioObjectID,
|
||||||
|
_ count: UInt32,
|
||||||
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||||
|
_ clientData: UnsafeMutableRawPointer?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let clientData else { return noErr }
|
||||||
|
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
|
listener.reattach()
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHEventSource
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class VolumeEventSource: PHEventSource {
|
||||||
|
private var listener: SystemAudioVolume.Listener?
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
listener = SystemAudioVolume.observe { _ in
|
||||||
|
Task { @MainActor in notify() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
listener = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SwiftUI.Color {
|
||||||
|
init(hex: String, opacity: Double?) {
|
||||||
|
let scanner = Scanner(string: hex)
|
||||||
|
_ = scanner.scanString("#")
|
||||||
|
|
||||||
|
var rgb: UInt64 = 0
|
||||||
|
scanner.scanHexInt64(&rgb)
|
||||||
|
|
||||||
|
let red = Double((rgb >> 16) & 0xFF) / 255.0
|
||||||
|
let green = Double((rgb >> 8) & 0xFF) / 255.0
|
||||||
|
let blue = Double(rgb & 0xFF) / 255.0
|
||||||
|
|
||||||
|
self.init(red: red, green: green, blue: blue, opacity: opacity ?? 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Notification Name
|
||||||
|
|
||||||
|
/// A type-safe identifier for cross-process notifications.
|
||||||
|
///
|
||||||
|
/// Names should be reverse-DNS strings (e.g. `"com.paninihouse.phbar.refresh"`)
|
||||||
|
/// to avoid collisions with other applications using distributed notifications.
|
||||||
|
struct IPCNotificationName: Hashable, RawRepresentable, ExpressibleByStringLiteral {
|
||||||
|
let rawValue: String
|
||||||
|
|
||||||
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
|
init(_ rawValue: String) { self.rawValue = rawValue }
|
||||||
|
init(stringLiteral value: String) { self.rawValue = value }
|
||||||
|
|
||||||
|
/// The Foundation name used by the underlying distributed center.
|
||||||
|
fileprivate var nsName: Notification.Name { Notification.Name(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification
|
||||||
|
|
||||||
|
/// A received cross-process notification with its optional payload.
|
||||||
|
struct IPCNotification {
|
||||||
|
let name: IPCNotificationName
|
||||||
|
let userInfo: [String: Any]?
|
||||||
|
|
||||||
|
init(name: IPCNotificationName, userInfo: [String: Any]? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.userInfo = userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a Codable value from the notification's `userInfo` payload.
|
||||||
|
///
|
||||||
|
/// Returns `nil` if `userInfo` is missing or cannot be decoded as `type`.
|
||||||
|
func decode<T: Decodable>(_ type: T.Type) -> T? {
|
||||||
|
guard let userInfo,
|
||||||
|
JSONSerialization.isValidJSONObject(userInfo),
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: userInfo) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? JSONDecoder().decode(type, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Center
|
||||||
|
|
||||||
|
/// A thin layer over `DistributedNotificationCenter` for sending lightweight
|
||||||
|
/// cross-process signals between the `phbar` daemon and short-lived CLI commands.
|
||||||
|
///
|
||||||
|
/// Distributed notifications are delivered best-effort to processes running under
|
||||||
|
/// the same user. They are ideal for idempotent signals (e.g. "refresh"); do not
|
||||||
|
/// rely on them for critical, ordered, or large data transfer.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
/// ```swift
|
||||||
|
/// // Post a bare signal
|
||||||
|
/// IPC.post(.refresh)
|
||||||
|
///
|
||||||
|
/// // Post with a Codable payload
|
||||||
|
/// IPC.post(.updateItem, payload: Item(title: "Hello", count: 3))
|
||||||
|
///
|
||||||
|
/// // Observe (token must be retained)
|
||||||
|
/// let token = IPC.observe(.refresh) { notification in
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Observe a typed payload
|
||||||
|
/// let token = IPC.observe(.updateItem, as: Item.self) { item in
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
enum IPC {
|
||||||
|
/// The shared distributed notification center (one per user session).
|
||||||
|
static var center: DistributedNotificationCenter { .default() }
|
||||||
|
|
||||||
|
/// Post a signal with an optional property-list payload.
|
||||||
|
///
|
||||||
|
/// `userInfo` must contain only property-list types (`String`, `Number`,
|
||||||
|
/// `Date`, `Data`, `Array`, `Dictionary`); other values are dropped during
|
||||||
|
/// cross-process delivery.
|
||||||
|
static func post(_ name: IPCNotificationName, userInfo: [String: Any]? = nil) {
|
||||||
|
center.postNotificationName(
|
||||||
|
name.nsName,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo as [AnyHashable: Any]?,
|
||||||
|
deliverImmediately: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a signal carrying a Codable payload.
|
||||||
|
///
|
||||||
|
/// The payload is JSON-encoded into the notification's `userInfo`.
|
||||||
|
/// Returns `false` if encoding fails.
|
||||||
|
@discardableResult
|
||||||
|
static func post<T: Encodable>(_ name: IPCNotificationName, payload: T) -> Bool {
|
||||||
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
post(name, userInfo: dict)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a signal.
|
||||||
|
///
|
||||||
|
/// The handler is invoked on the main thread (distributed notifications are
|
||||||
|
/// delivered to the receiving process's main run loop).
|
||||||
|
///
|
||||||
|
/// - Returns: A token that must be retained while observing. Releasing it
|
||||||
|
/// automatically unregisters the observer.
|
||||||
|
static func observe(
|
||||||
|
_ name: IPCNotificationName,
|
||||||
|
handler: @escaping (IPCNotification) -> Void
|
||||||
|
) -> IPCObserver {
|
||||||
|
IPCObserver(center: center, name: name.nsName, handler: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a signal and decode its Codable payload.
|
||||||
|
///
|
||||||
|
/// `handler` is called only when the payload decodes successfully; malformed
|
||||||
|
/// or missing payloads are silently ignored.
|
||||||
|
static func observe<T: Decodable>(
|
||||||
|
_ name: IPCNotificationName,
|
||||||
|
as type: T.Type,
|
||||||
|
handler: @escaping (T) -> Void
|
||||||
|
) -> IPCObserver {
|
||||||
|
observe(name) { notification in
|
||||||
|
guard let payload: T = notification.decode(type) else { return }
|
||||||
|
handler(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observer
|
||||||
|
|
||||||
|
/// An observation token for a distributed notification.
|
||||||
|
///
|
||||||
|
/// Wraps the target/selector API of `DistributedNotificationCenter` so callers
|
||||||
|
/// can register a Swift closure. The notification center dispatches the selector
|
||||||
|
/// to this object; releasing the token removes the observer in `deinit`.
|
||||||
|
final class IPCObserver: NSObject {
|
||||||
|
private let center: DistributedNotificationCenter
|
||||||
|
private let name: Notification.Name
|
||||||
|
private let handler: (IPCNotification) -> Void
|
||||||
|
|
||||||
|
fileprivate init(
|
||||||
|
center: DistributedNotificationCenter,
|
||||||
|
name: Notification.Name,
|
||||||
|
handler: @escaping (IPCNotification) -> Void
|
||||||
|
) {
|
||||||
|
self.center = center
|
||||||
|
self.name = name
|
||||||
|
self.handler = handler
|
||||||
|
super.init()
|
||||||
|
center.addObserver(self, selector: #selector(handle(_:)), name: name, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handle(_ notification: Notification) {
|
||||||
|
let name = IPCNotificationName(notification.name.rawValue)
|
||||||
|
let userInfo = notification.userInfo as? [String: Any]
|
||||||
|
handler(IPCNotification(name: name, userInfo: userInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
center.removeObserver(self, name: name, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - PHBar Notification Names
|
||||||
|
|
||||||
|
extension IPCNotificationName {
|
||||||
|
/// Sent by `phbar refresh` to request the running status bar to update.
|
||||||
|
static let refresh = IPCNotificationName("com.paninihouse.phbar.refresh")
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
enum PHGesture: String {
|
||||||
|
case leftMouseDown = "left_mouse_down"
|
||||||
|
case rightMouseDown = "right_mouse_down"
|
||||||
|
case otherMouseDown = "other_mouse_down"
|
||||||
|
case scrollWheel = "scroll_wheel"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct PHGestureEvent {
|
||||||
|
static let typeKey = "GESTURE"
|
||||||
|
static let infoKey = "GESTURE_INFO"
|
||||||
|
|
||||||
|
let type: PHGesture
|
||||||
|
let data: String?
|
||||||
|
|
||||||
|
static func from(_ event: NSEvent) -> PHGestureEvent? {
|
||||||
|
switch event.type {
|
||||||
|
case .leftMouseDown:
|
||||||
|
return .init(type: .leftMouseDown, data: nil)
|
||||||
|
case .rightMouseDown:
|
||||||
|
return .init(type: .rightMouseDown, data: nil)
|
||||||
|
case .otherMouseDown:
|
||||||
|
return .init(type: .otherMouseDown, data: String(event.buttonNumber))
|
||||||
|
case .scrollWheel:
|
||||||
|
return .init(type: .scrollWheel, data: "\(event.scrollingDeltaX) \(event.scrollingDeltaY)")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,75 +8,162 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
let id = UUID()
|
let id = UUID()
|
||||||
let command: String
|
let command: String
|
||||||
let name: String?
|
let name: String?
|
||||||
let style: String?
|
let styleName: String?
|
||||||
|
@Published var style: PHTheme.Style = .default
|
||||||
let refresh: Double?
|
let refresh: Double?
|
||||||
|
let centered: Bool?
|
||||||
|
var debug: Bool = false
|
||||||
|
|
||||||
@Published private(set) var label = ""
|
var visible: Bool {
|
||||||
|
label != nil && !label!.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
/// The repeating refresh task, if any.
|
/// System events that should trigger a refresh (e.g. `["volume", "network"]`).
|
||||||
private var refreshTask: Task<Void, Never>?
|
/// `nil` when omitted from config.
|
||||||
|
let events: [PHEvent]?
|
||||||
|
|
||||||
|
/// Event source registry used to subscribe to system events. Defaults to the
|
||||||
|
/// shared instance; inject a custom one for testing.
|
||||||
|
var registry: PHEventRegistry = .shared
|
||||||
|
|
||||||
|
@Published private(set) var label: String?
|
||||||
|
|
||||||
|
/// The repeating interval task, if any.
|
||||||
|
private var intervalTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Active event subscriptions, torn down in `stopAutoRefresh`.
|
||||||
|
private var subscriptions: [PHEventSubscription] = []
|
||||||
|
|
||||||
|
/// Guards against stacking concurrent updates during rapid event bursts
|
||||||
|
/// (e.g. dragging the volume slider fires many events in quick succession).
|
||||||
|
private var updateScheduled = false
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case command, name, style, refresh
|
case command, name
|
||||||
|
case styleName = "style"
|
||||||
|
case refresh, events, centered
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
refreshTask?.cancel()
|
intervalTask?.cancel()
|
||||||
|
subscriptions.forEach { $0.cancel() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Refresh
|
// Kind
|
||||||
|
|
||||||
|
extension PHBlock {
|
||||||
|
enum Kind: String {
|
||||||
|
case text, space
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind: Kind {
|
||||||
|
if command.starts(with: "_space") { return .space }
|
||||||
|
return .text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
|
||||||
|
extension PHBlock {
|
||||||
/// Start keeping the label up to date.
|
/// Start keeping the label up to date.
|
||||||
///
|
///
|
||||||
/// The label is always recomputed immediately. When `refresh` is set to a
|
/// Three independent triggers drive refreshes, and any combination works:
|
||||||
/// positive number of seconds, it is then recomputed on that interval until
|
///
|
||||||
/// `stopAutoRefresh()` is called. With no interval set, only the initial
|
/// 1. **Interval** — when `refresh` is a positive number of seconds, the
|
||||||
/// computation runs and later updates must be triggered manually via
|
/// label is recomputed on that interval.
|
||||||
/// `update()`.
|
/// 2. **Events** — each name in `events` subscribes to a system event source
|
||||||
|
/// (volume, network, appearance, power). Sources are activated lazily by
|
||||||
|
/// the registry: a listener runs only while at least one block subscribes
|
||||||
|
/// to it, so unused events cost nothing.
|
||||||
|
/// 3. **Manual** — `update()`, `PHController.refresh()`, or `phbar refresh`.
|
||||||
|
///
|
||||||
|
/// The label is always recomputed once immediately on start, then again on
|
||||||
|
/// any of the triggers above until `stopAutoRefresh()` is called.
|
||||||
func startAutoRefresh() {
|
func startAutoRefresh() {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
|
||||||
guard let interval = refresh, interval > 0 else {
|
// Immediate refresh so the bar isn't blank until the first trigger fires.
|
||||||
// No interval: compute once, rely on manual updates afterwards.
|
scheduleUpdate()
|
||||||
Task { await update() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 1. Periodic refresh.
|
||||||
|
if let interval = refresh, interval > 0 {
|
||||||
let milliseconds = Int(interval * 1000)
|
let milliseconds = Int(interval * 1000)
|
||||||
refreshTask = Task { [weak self] in
|
intervalTask = Task { [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await self?.update()
|
|
||||||
try? await Task.sleep(for: .milliseconds(milliseconds))
|
try? await Task.sleep(for: .milliseconds(milliseconds))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await self?.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel the repeating auto-refresh, if any.
|
// 2. Event-driven refresh. The registry lazily activates each underlying
|
||||||
|
// system listener only while at least one block subscribes to it.
|
||||||
|
for event in (events ?? []) {
|
||||||
|
let subscription = registry.subscribe(event) { [weak self] in
|
||||||
|
self?.scheduleUpdate()
|
||||||
|
}
|
||||||
|
subscriptions.append(subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the interval task and all event subscriptions.
|
||||||
func stopAutoRefresh() {
|
func stopAutoRefresh() {
|
||||||
refreshTask?.cancel()
|
intervalTask?.cancel()
|
||||||
refreshTask = nil
|
intervalTask = nil
|
||||||
|
subscriptions.forEach { $0.cancel() }
|
||||||
|
subscriptions.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the label from `command`. Used by auto-refresh and manual updates.
|
/// Recompute the label from `command`. Used by the interval loop, event
|
||||||
|
/// handlers, and manual updates.
|
||||||
func update() async {
|
func update() async {
|
||||||
label = await compute()
|
label = await compute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request an update on the main actor, coalescing rapid bursts into a single
|
||||||
|
/// recomputation.
|
||||||
|
private func scheduleUpdate() {
|
||||||
|
guard !updateScheduled else { return }
|
||||||
|
updateScheduled = true
|
||||||
|
Task { [weak self] in
|
||||||
|
defer { self?.updateScheduled = false }
|
||||||
|
await self?.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGesture(_ event: PHGestureEvent) {
|
||||||
|
Task { label = await compute(with: event) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the provided command and returns its stdout.
|
/// Runs the provided command and returns its stdout.
|
||||||
///
|
///
|
||||||
/// Runs off the main actor so it can be awaited safely from SwiftUI views
|
/// Runs off the main actor so it can be awaited safely from SwiftUI views
|
||||||
/// without blocking the UI.
|
/// without blocking the UI.
|
||||||
///
|
///
|
||||||
/// - Returns: A non-optional String to use as the block label.
|
/// - Returns: A non-optional String to use as the block label.
|
||||||
func compute() async -> String {
|
func compute(with gestureEvent: PHGestureEvent? = nil) async -> String? {
|
||||||
|
guard kind == .text else { return nil }
|
||||||
|
|
||||||
let command = command
|
let command = command
|
||||||
return await Task.detached(priority: .userInitiated) {
|
|
||||||
|
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
|
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
|
||||||
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||||
process.arguments = ["-c", command]
|
process.arguments = ["-c", command]
|
||||||
|
|
||||||
// Inherit the parent's environment (includes $PATH, etc.)
|
// Inherit the parent's environment (includes $PATH, etc.)
|
||||||
process.environment = ProcessInfo.processInfo.environment
|
process.environment = ProcessInfo.processInfo.environment
|
||||||
|
if let gestureEvent {
|
||||||
|
let type = gestureEvent.type.rawValue
|
||||||
|
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
|
||||||
|
|
||||||
|
if let data = gestureEvent.data {
|
||||||
|
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Capture stdout via a pipe (otherwise standardOutput is always nil).
|
// Capture stdout via a pipe (otherwise standardOutput is always nil).
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
@@ -86,12 +173,18 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
return String(data: data, encoding: .utf8) ?? ""
|
let string = String(data: data, encoding: .utf8)
|
||||||
|
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
|
||||||
|
return lines
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore failures
|
// Silently ignore failures
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
|
guard let lines else { return nil }
|
||||||
|
if self.debug { for line in lines { print(line) } }
|
||||||
|
return lines.last?.description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,15 +199,15 @@ extension PHBlock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static var configFile: URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml")
|
||||||
|
}
|
||||||
|
|
||||||
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
|
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
|
||||||
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
||||||
static func load() throws -> [PHBlock] {
|
static func load() throws -> [PHBlock] {
|
||||||
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
|
||||||
path: ".config/phbar/blocks.toml"
|
return try load(from: Self.configFile)
|
||||||
)
|
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
|
||||||
return try load(from: url)
|
|
||||||
} else {
|
} else {
|
||||||
throw phbar.Error("Failed to load blocks file")
|
throw phbar.Error("Failed to load blocks file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
struct PHConfig: Decodable {
|
||||||
|
let text: Text
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
import TOML
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
/// Load configuration from the default path (~/.config/pmenu/config.toml).
|
||||||
|
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
||||||
|
static func load() throws -> PHConfig {
|
||||||
|
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
||||||
|
path: ".config/phbar/config.toml")
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||||
|
return try load(from: url)
|
||||||
|
} else {
|
||||||
|
guard
|
||||||
|
let defaultConfig = Bundle.module.url(
|
||||||
|
forResource: "config",
|
||||||
|
withExtension: "toml"
|
||||||
|
)?.resolvingSymlinksInPath()
|
||||||
|
else {
|
||||||
|
throw phbar.Error("Failed to load config file")
|
||||||
|
}
|
||||||
|
return try load(from: defaultConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode configuration from a file at URL.
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static func load(from url: URL) throws -> PHConfig {
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let contents = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
throw phbar.Error("Failed to load config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try load(from: contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode configuration from a TOML string.
|
||||||
|
/// If the content fails to parse, the execution is interrupted.
|
||||||
|
static func load(from contents: String) throws -> PHConfig {
|
||||||
|
do {
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
let configFile = try decoder.decode(PHConfig.self, from: contents)
|
||||||
|
return configFile
|
||||||
|
} catch {
|
||||||
|
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
struct Text: Decodable {
|
||||||
|
let fontFamily: String
|
||||||
|
let size: Double
|
||||||
|
let weight: Weight
|
||||||
|
let style: Style
|
||||||
|
let offset: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fontFamily = "font"
|
||||||
|
case size, weight, style, offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font
|
||||||
|
|
||||||
|
extension PHConfig.Text {
|
||||||
|
/// Construct an `NSFont` from the typeface settings.
|
||||||
|
///
|
||||||
|
/// Falls back to the system font if parsing fails.
|
||||||
|
var nsFont: NSFont {
|
||||||
|
if let systemDesign {
|
||||||
|
let baseFont = NSFont.systemFont(ofSize: size, weight: weight.nsWeight)
|
||||||
|
let descriptor =
|
||||||
|
baseFont.fontDescriptor.withDesign(systemDesign)
|
||||||
|
?? baseFont.fontDescriptor
|
||||||
|
|
||||||
|
return NSFont(descriptor: descriptor, size: size)
|
||||||
|
?? baseFont
|
||||||
|
} else {
|
||||||
|
let descriptor = NSFontDescriptor.init(fontAttributes: [
|
||||||
|
.family: fontFamily,
|
||||||
|
.traits: [
|
||||||
|
NSFontDescriptor.TraitKey.weight: weight.nsWeight
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
return NSFont(descriptor: descriptor, size: size)
|
||||||
|
?? NSFont(name: fontFamily, size: size)
|
||||||
|
?? NSFont.systemFont(ofSize: size, weight: weight.nsWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a `Font` from the typeface settings.
|
||||||
|
///
|
||||||
|
/// Falls back to the system font if parsing fails.
|
||||||
|
var font: Font {
|
||||||
|
if let design {
|
||||||
|
return Font.system(size: size, weight: weight.uiWeight, design: design)
|
||||||
|
} else {
|
||||||
|
return Font.custom(fontFamily, fixedSize: size).weight(weight.uiWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Design
|
||||||
|
|
||||||
|
extension PHConfig.Text {
|
||||||
|
var design: SwiftUI.Font.Design? {
|
||||||
|
switch fontFamily {
|
||||||
|
case "sans":
|
||||||
|
return .default
|
||||||
|
case "monospace":
|
||||||
|
return .monospaced
|
||||||
|
case "serif":
|
||||||
|
return .serif
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemDesign: NSFontDescriptor.SystemDesign? {
|
||||||
|
switch fontFamily {
|
||||||
|
case "sans":
|
||||||
|
return .default
|
||||||
|
case "monospace":
|
||||||
|
return .monospaced
|
||||||
|
case "serif":
|
||||||
|
return .serif
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight
|
||||||
|
|
||||||
|
extension PHConfig.Text {
|
||||||
|
enum Weight: String, Decodable {
|
||||||
|
case thin
|
||||||
|
case ultraLight = "ultralight"
|
||||||
|
case light
|
||||||
|
case regular
|
||||||
|
case medium
|
||||||
|
case semiBold = "semibold"
|
||||||
|
case bold
|
||||||
|
case heavy
|
||||||
|
case black
|
||||||
|
|
||||||
|
var uiWeight: SwiftUI.Font.Weight {
|
||||||
|
switch self {
|
||||||
|
case .thin:
|
||||||
|
return .thin
|
||||||
|
case .ultraLight:
|
||||||
|
return .ultraLight
|
||||||
|
case .light:
|
||||||
|
return .light
|
||||||
|
case .regular:
|
||||||
|
return .regular
|
||||||
|
case .medium:
|
||||||
|
return .medium
|
||||||
|
case .semiBold:
|
||||||
|
return .semibold
|
||||||
|
case .bold:
|
||||||
|
return .bold
|
||||||
|
case .heavy:
|
||||||
|
return .heavy
|
||||||
|
case .black:
|
||||||
|
return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsWeight: NSFont.Weight {
|
||||||
|
switch self {
|
||||||
|
case .thin:
|
||||||
|
return .thin
|
||||||
|
case .ultraLight:
|
||||||
|
return .ultraLight
|
||||||
|
case .light:
|
||||||
|
return .light
|
||||||
|
case .regular:
|
||||||
|
return .regular
|
||||||
|
case .medium:
|
||||||
|
return .medium
|
||||||
|
case .semiBold:
|
||||||
|
return .semibold
|
||||||
|
case .bold:
|
||||||
|
return .bold
|
||||||
|
case .heavy:
|
||||||
|
return .heavy
|
||||||
|
case .black:
|
||||||
|
return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style
|
||||||
|
|
||||||
|
extension PHConfig.Text {
|
||||||
|
enum Style: String, Decodable {
|
||||||
|
case normal, italic
|
||||||
|
}
|
||||||
|
|
||||||
|
var italic: Bool {
|
||||||
|
style == .italic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
struct PHTheme: Decodable {
|
||||||
|
static let `default` = "dracula"
|
||||||
|
|
||||||
|
static let bundled = [
|
||||||
|
"voltage",
|
||||||
|
"catppuccin-mocha",
|
||||||
|
"dracula",
|
||||||
|
"gruvbox",
|
||||||
|
"tokyo-night",
|
||||||
|
]
|
||||||
|
|
||||||
|
let window: Window
|
||||||
|
let styles: [Style]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case window
|
||||||
|
case styles = "style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window
|
||||||
|
|
||||||
|
extension PHTheme {
|
||||||
|
struct Window: Decodable {
|
||||||
|
let hasShadow: Bool
|
||||||
|
let blur: Double
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case hasShadow = "shadow"
|
||||||
|
case blur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style
|
||||||
|
|
||||||
|
extension PHTheme {
|
||||||
|
struct Style: Decodable {
|
||||||
|
let name: String
|
||||||
|
let foreground: PHTheme.Color
|
||||||
|
let background: PHTheme.Color
|
||||||
|
let padding: PHTheme.Padding
|
||||||
|
|
||||||
|
static let `default`: Self = {
|
||||||
|
Style(
|
||||||
|
name: "_default",
|
||||||
|
foreground: .init(color: "#000000", alpha: nil),
|
||||||
|
background: .init(color: "#ffffff", alpha: nil),
|
||||||
|
padding: .init(leading: 10, trailing: 10)
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension PHTheme {
|
||||||
|
struct Color: Decodable, ShapeStyle {
|
||||||
|
typealias Resolved = SwiftUI.Color
|
||||||
|
|
||||||
|
let color: String
|
||||||
|
let alpha: Double?
|
||||||
|
|
||||||
|
var uiColor: SwiftUI.Color {
|
||||||
|
SwiftUI.Color(hex: color, opacity: alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsColor: NSColor? {
|
||||||
|
guard let cgColor else { return nil }
|
||||||
|
return NSColor(cgColor: cgColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgColor: CGColor? {
|
||||||
|
uiColor.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(in environment: EnvironmentValues) -> SwiftUI.Color {
|
||||||
|
uiColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import TOML
|
||||||
|
|
||||||
|
extension PHTheme {
|
||||||
|
/// Load theme from the default path (~/.config/phbar/theme.toml).
|
||||||
|
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
||||||
|
static func load(_ theme: String?) throws -> PHTheme {
|
||||||
|
let theme = theme ?? Self.default
|
||||||
|
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
||||||
|
path: ".config/phbar/themes/\(theme).toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||||
|
return try load(from: url)
|
||||||
|
} else {
|
||||||
|
return try load(bundled: theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a bundled theme.
|
||||||
|
/// If the theme doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static func load(bundled theme: String) throws -> PHTheme {
|
||||||
|
guard
|
||||||
|
let defaultConfig = Bundle.module.url(
|
||||||
|
forResource: theme,
|
||||||
|
withExtension: "toml"
|
||||||
|
)?.resolvingSymlinksInPath()
|
||||||
|
else {
|
||||||
|
throw phbar.Error("Failed to load theme file")
|
||||||
|
}
|
||||||
|
return try load(from: defaultConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load theme from a file at URL.
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static func load(from url: URL) throws -> PHTheme {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
||||||
|
throw phbar.Error("the file is empty")
|
||||||
|
}
|
||||||
|
return try load(from: contents)
|
||||||
|
} catch {
|
||||||
|
throw phbar.Error("Failed to load theme file", underlyingError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load theme from a TOML string.
|
||||||
|
/// If the content fails to parse, the execution is interrupted.
|
||||||
|
static func load(from contents: String) throws -> PHTheme {
|
||||||
|
do {
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
return try decoder.decode(PHTheme.self, from: contents)
|
||||||
|
} catch {
|
||||||
|
throw phbar.Error("Failed to parse theme file", underlyingError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
extension PHTheme {
|
||||||
|
struct Padding: Decodable {
|
||||||
|
let leading: Double
|
||||||
|
let trailing: Double
|
||||||
|
|
||||||
|
var total: Double { leading + trailing }
|
||||||
|
|
||||||
|
static var zero: Padding {
|
||||||
|
Self.init(leading: 0, trailing: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# phbar theme file ~ Dracula
|
||||||
|
#
|
||||||
|
# Place at ~/.config/phbar/themes/dracula.toml
|
||||||
|
|
||||||
|
[window]
|
||||||
|
shadow = false
|
||||||
|
blur = 10.0
|
||||||
|
|
||||||
|
[[style]]
|
||||||
|
name = "default"
|
||||||
|
foreground = { color = "#f8f8f2" }
|
||||||
|
background = { color = "#000000", alpha = 0.5 }
|
||||||
|
padding = { leading = 0.0, trailing = 0.0 }
|
||||||
|
|
||||||
|
[[style]]
|
||||||
|
name = "tinted"
|
||||||
|
foreground = { color = "#282a36" }
|
||||||
|
background = { color = "#bd93f9" }
|
||||||
|
padding = { leading = 10.0, trailing = 10.0 }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# phbar theme file ~ Voltage
|
||||||
|
#
|
||||||
|
# Place at ~/.config/phbar/themes/voltage.toml
|
||||||
|
|
||||||
|
[window]
|
||||||
|
shadow = false
|
||||||
|
blur = 0.0
|
||||||
|
|
||||||
|
[[style]]
|
||||||
|
name = "default"
|
||||||
|
foreground = { color = "#aed3f3" }
|
||||||
|
background = { color = "#010408", alpha = 0.825 }
|
||||||
|
padding = { leading = 0.0, trailing = 0.0 }
|
||||||
|
|
||||||
|
[[style]]
|
||||||
|
name = "floating"
|
||||||
|
foreground = { color = "#aed3f3" }
|
||||||
|
background = { color = "#000000", alpha = 0 }
|
||||||
|
padding = { leading = 0.0, trailing = 0.0 }
|
||||||
|
|
||||||
|
[[style]]
|
||||||
|
name = "tinted"
|
||||||
|
foreground = { color = "#aed3f3" }
|
||||||
|
background = { color = "#0f304a" }
|
||||||
|
padding = { leading = 12.0, trailing = 12.0 }
|
||||||
@@ -8,14 +8,31 @@ struct BarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
ForEach(bar.blocks) { block in
|
ForEach(bar.arrangedBlocks) { block in
|
||||||
|
switch block.kind {
|
||||||
|
case .text:
|
||||||
TextBlock(block: block)
|
TextBlock(block: block)
|
||||||
|
case .space:
|
||||||
|
SpaceBlock(block: block)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SpaceBlock()
|
HStack(alignment: .center, spacing: 0) {
|
||||||
|
ForEach(bar.centeredBlocks) { block in
|
||||||
|
switch block.kind {
|
||||||
|
case .text:
|
||||||
|
TextBlock(block: block)
|
||||||
|
case .space:
|
||||||
|
SpaceBlock(block: block)
|
||||||
}
|
}
|
||||||
.font(.custom("Comic Code", size: 14))
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(bar.config.text.font)
|
||||||
|
.italic(bar.config.text.italic)
|
||||||
.frame(height: 30)
|
.frame(height: 30)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.environmentObject(bar)
|
.environmentObject(bar)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
|
|
||||||
final class BarWindow: NSPanel {
|
final class BarWindow: NSPanel {
|
||||||
let barController: PHController
|
let barController: PHController
|
||||||
|
|
||||||
|
var backgroundView: BarWindowBackground!
|
||||||
var barView: NSHostingView<BarView>!
|
var barView: NSHostingView<BarView>!
|
||||||
|
|
||||||
init(controller: PHController) {
|
init(controller: PHController) {
|
||||||
@@ -20,9 +22,6 @@ final class BarWindow: NSPanel {
|
|||||||
self.isFloatingPanel = true
|
self.isFloatingPanel = true
|
||||||
self.level = .floating
|
self.level = .floating
|
||||||
self.animationBehavior = .utilityWindow
|
self.animationBehavior = .utilityWindow
|
||||||
self.isOpaque = false
|
|
||||||
self.backgroundColor = .clear
|
|
||||||
// self.hasShadow = pmenu.config.window.shadow
|
|
||||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||||
self.acceptsMouseMovedEvents = false
|
self.acceptsMouseMovedEvents = false
|
||||||
self.isMovable = false
|
self.isMovable = false
|
||||||
@@ -33,11 +32,16 @@ final class BarWindow: NSPanel {
|
|||||||
self.isRestorable = false
|
self.isRestorable = false
|
||||||
self.displaysWhenScreenProfileChanges = true
|
self.displaysWhenScreenProfileChanges = true
|
||||||
|
|
||||||
|
self.backgroundView = BarWindowBackground(controller: barController)
|
||||||
|
backgroundView.autoresizingMask = [.width, .height]
|
||||||
|
backgroundView.frame = self.contentView?.bounds ?? .zero
|
||||||
|
|
||||||
self.barView = NSHostingView(rootView: BarView(controller: barController))
|
self.barView = NSHostingView(rootView: BarView(controller: barController))
|
||||||
barView.autoresizingMask = [.width, .height]
|
barView.autoresizingMask = [.width, .height]
|
||||||
barView.frame = self.contentView?.bounds ?? .zero
|
barView.frame = contentView?.bounds ?? .zero
|
||||||
|
self.backgroundView.addSubview(barView)
|
||||||
|
|
||||||
self.contentView = barView
|
self.contentView = self.backgroundView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SpaceBlock: View {
|
struct SpaceBlock: View {
|
||||||
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
|
var width: CGFloat? {
|
||||||
|
guard let arg = block.command.split(separator: " ").last else { return nil }
|
||||||
|
guard let width = Double(arg) else { return nil }
|
||||||
|
return CGFloat(width)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.blue)
|
.fill(block.style.background)
|
||||||
|
.frame(width: width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TextBlock: View {
|
struct TextBlock: View {
|
||||||
|
@EnvironmentObject private var bar: PHController
|
||||||
@ObservedObject var block: PHBlock
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
var body: some View {
|
@State var hovering = false
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.red)
|
|
||||||
|
|
||||||
Text(block.label)
|
private var adjustOffset: CGAffineTransform {
|
||||||
.foregroundColor(.white)
|
guard let offset = bar.config.text.offset else { return .init() }
|
||||||
|
return .init(translationX: 0, y: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let label = block.label, !label.isEmpty {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(block.style.background)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
Text(label)
|
||||||
|
.foregroundStyle(block.style.foreground)
|
||||||
|
.transformEffect(adjustOffset)
|
||||||
|
.border(bar.debug ? .blue : .clear)
|
||||||
|
}
|
||||||
|
.padding(.leading, block.style.padding.leading)
|
||||||
|
.padding(.trailing, block.style.padding.trailing)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
.border(bar.debug ? .red : .clear)
|
||||||
|
.onHover { hovering = $0 }
|
||||||
|
.onAppear {
|
||||||
|
NSEvent.addLocalMonitorForEvents(matching: [
|
||||||
|
.leftMouseDown,
|
||||||
|
.rightMouseDown,
|
||||||
|
.otherMouseDown,
|
||||||
|
.scrollWheel,
|
||||||
|
]) { event in
|
||||||
|
if hovering, let gestureEvent = PHGestureEvent.from(event) {
|
||||||
|
block.handleGesture(gestureEvent)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class BarWindowBackground: NSView {
|
||||||
|
var hasShadow: Bool {
|
||||||
|
didSet { configureShadow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var blur: CGFloat {
|
||||||
|
didSet { configureBlur() }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(controller: PHController) {
|
||||||
|
self.hasShadow = controller.theme.window.hasShadow
|
||||||
|
self.blur = controller.theme.window.blur
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setup() {
|
||||||
|
wantsLayer = true
|
||||||
|
configureShadow()
|
||||||
|
configureBlur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur is applied to the *window*, not the layer, so we need a window
|
||||||
|
// to exist first. Re-apply whenever the view moves to a (possibly new) window.
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
configureShadow()
|
||||||
|
configureBlur()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureShadow() {
|
||||||
|
guard let window else { return }
|
||||||
|
window.hasShadow = hasShadow
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBlur() {
|
||||||
|
guard let window else { return }
|
||||||
|
// Window must be non-opaque for the desktop/other windows behind it
|
||||||
|
// to be visible at all, let alone blurred.
|
||||||
|
window.isOpaque = false
|
||||||
|
// Near-zero (not fully .clear) alpha keeps the window eligible for
|
||||||
|
// compositor blur on some macOS versions; fully transparent windows
|
||||||
|
// are sometimes excluded from the blur pass. This mirrors Kitty's
|
||||||
|
// approach (NSColor(white: 0, alpha: 0.001) vs .clear).
|
||||||
|
if blur > 0 {
|
||||||
|
window.backgroundColor = NSColor(white: 0, alpha: 0.001)
|
||||||
|
} else {
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = Int32(clamping: Int(blur.rounded()))
|
||||||
|
let applied = CGSPrivate.applyBlur(to: window, radius: radius)
|
||||||
|
if !applied {
|
||||||
|
// Private symbols unavailable/renamed on this macOS version.
|
||||||
|
// Fails silently rather than crashing; falls back to no blur.
|
||||||
|
#if DEBUG
|
||||||
|
print("BackgroundView: failed to apply private window blur (radius: \(radius))")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: - Private CoreGraphics Services blur API
|
||||||
|
// This mirrors what Kitty terminal does on macOS: dlopen/dlsym the private
|
||||||
|
// CGSSetWindowBackgroundBlurRadius symbol at runtime rather than linking
|
||||||
|
// against it directly. This is UNDOCUMENTED, PRIVATE API:
|
||||||
|
// - Works reliably in practice (Kitty ships it to a huge user base)
|
||||||
|
// - Can break on future macOS updates with no notice
|
||||||
|
// - Will get an app rejected from the Mac App Store
|
||||||
|
private enum CGSPrivate {
|
||||||
|
typealias ConnectionFn = @convention(c) () -> UnsafeMutableRawPointer?
|
||||||
|
typealias BlurFn = @convention(c) (UnsafeMutableRawPointer?, Int, Int32) -> OSStatus
|
||||||
|
|
||||||
|
static let getConnection: ConnectionFn? = {
|
||||||
|
guard
|
||||||
|
let handle = dlopen(
|
||||||
|
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_NOW),
|
||||||
|
let sym = dlsym(handle, "CGSDefaultConnectionForThread")
|
||||||
|
else { return nil }
|
||||||
|
return unsafeBitCast(sym, to: ConnectionFn.self)
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let setBlurRadius: BlurFn? = {
|
||||||
|
guard
|
||||||
|
let handle = dlopen(
|
||||||
|
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_NOW),
|
||||||
|
let sym = dlsym(handle, "CGSSetWindowBackgroundBlurRadius")
|
||||||
|
else { return nil }
|
||||||
|
return unsafeBitCast(sym, to: BlurFn.self)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Returns true if the private symbols were resolved and the call was issued.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
static func applyBlur(to window: NSWindow, radius: Int32) -> Bool {
|
||||||
|
guard let getConnection, let setBlurRadius else { return false }
|
||||||
|
let connection = getConnection()
|
||||||
|
let status = setBlurRadius(connection, window.windowNumber, radius)
|
||||||
|
return status == noErr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[text]
|
||||||
|
font = "Comic Code"
|
||||||
|
size = 14
|
||||||
|
weight = "regular"
|
||||||
|
style = "normal"
|
||||||
|
offset = -0.5
|
||||||
@@ -1,9 +1,431 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import phbar
|
@testable import phbar
|
||||||
|
|
||||||
@Test func example() async throws {
|
// MARK: - Payload Types for Testing
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
|
||||||
// Swift Testing Documentation
|
private struct TestPayload: Codable, Equatable {
|
||||||
// https://developer.apple.com/documentation/testing
|
let message: String
|
||||||
|
let count: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Name Tests
|
||||||
|
|
||||||
|
@Test func notificationNameIsHashable() async throws {
|
||||||
|
let a = IPCNotificationName("com.example.test")
|
||||||
|
let b = IPCNotificationName("com.example.test")
|
||||||
|
let c = IPCNotificationName("com.example.other")
|
||||||
|
|
||||||
|
#expect(a == b)
|
||||||
|
#expect(a != c)
|
||||||
|
#expect(a.hashValue == b.hashValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
|
||||||
|
let name: IPCNotificationName = "com.example.test"
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameRawRepresentable() async throws {
|
||||||
|
let name = IPCNotificationName(rawValue: "com.example.test")
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payload Decoding Tests
|
||||||
|
|
||||||
|
@Test func decodePayloadFromNotification() async throws {
|
||||||
|
let payload = TestPayload(message: "hello", count: 42)
|
||||||
|
let userInfo: [String: Any] = ["message": "hello", "count": 42]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: nil)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForInvalidData() async throws {
|
||||||
|
let userInfo: [String: Any] = ["wrong": "data"]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Posting Tests
|
||||||
|
|
||||||
|
@Test func postWithoutPayloadDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithUserInfoDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh, userInfo: ["key": "value"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
|
||||||
|
let payload = TestPayload(message: "test", count: 1)
|
||||||
|
let result = IPC.post(.refresh, payload: payload)
|
||||||
|
#expect(result == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observer Tests
|
||||||
|
|
||||||
|
@Test func observerCleansUpOnDeinit() async throws {
|
||||||
|
// Verify that creating and releasing an observer doesn't crash.
|
||||||
|
// The token removes itself from the distributed center on deinit.
|
||||||
|
let token = IPC.observe(.refresh) { _ in }
|
||||||
|
withExtendedLifetime(token) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Refresh Command Config
|
||||||
|
|
||||||
|
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
||||||
|
let config = phbar.refresh.configuration
|
||||||
|
#expect(config.commandName == "refresh")
|
||||||
|
#expect(config.abstract == "Refresh the running status bar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHBlock Loading & Compute
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksFromTOML() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hello"
|
||||||
|
name = "greeting"
|
||||||
|
refresh = 5.0
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "echo world"
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks.count == 2)
|
||||||
|
#expect(blocks[0].command == "echo hello")
|
||||||
|
#expect(blocks[0].name == "greeting")
|
||||||
|
#expect(blocks[0].refresh == 5.0)
|
||||||
|
#expect(blocks[0].label == "")
|
||||||
|
#expect(blocks[1].name == nil)
|
||||||
|
#expect(blocks[1].refresh == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksRejectsInvalidTOML() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load(from: "not = valid = toml = =")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeReturnsCommandStdout() async throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let output = await blocks[0].compute()
|
||||||
|
|
||||||
|
#expect(output == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHBlock Refresh
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func updateSetsLabel() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
#expect(block.label == "")
|
||||||
|
|
||||||
|
await block.update()
|
||||||
|
|
||||||
|
#expect(block.label == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf hi"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
// Allow the one-shot update task to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label == "hi")
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshRepeatsAtInterval() async throws {
|
||||||
|
// A counter file lets us observe how many times the command ran.
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_test_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
|
||||||
|
let path = counter.path
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
refresh = 0.05
|
||||||
|
"""
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: toml)[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(250))
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
|
||||||
|
let count = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
|
||||||
|
// At ~20Hz over 250ms the command should have run more than once.
|
||||||
|
#expect(count >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHEvent
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventDecodesFromKnownStrings() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
events = ["volume", "network", "appearance", "power", "mpd"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks[0].events == [.volume, .network, .appearance, .power, .mpd])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventRejectsUnknownString() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
events = ["totally_made_up"]
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventsOptionalWhenOmitted() throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(blocks[0].events == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func defaultFactoryReturnsMPDSource() {
|
||||||
|
let source = PHEventRegistry.defaultFactory(.mpd)
|
||||||
|
#expect(source is MPDEventSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func mpdSourceIsIdempotentStartStop() {
|
||||||
|
// With MPD absent the source keeps trying to connect; ensure start/stop are
|
||||||
|
// safe and idempotent without a running daemon.
|
||||||
|
let source = MPDEventSource(host: "127.0.0.1", port: 1)
|
||||||
|
source.start(notify: {})
|
||||||
|
source.start(notify: {}) // second start is a no-op
|
||||||
|
source.stop()
|
||||||
|
source.stop() // second stop is a no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHEventRegistry
|
||||||
|
|
||||||
|
/// A no-op source that records its lifecycle and can be fired on demand.
|
||||||
|
@MainActor
|
||||||
|
private final class FakeEventSource: PHEventSource {
|
||||||
|
private(set) var startCount = 0
|
||||||
|
private(set) var stopCount = 0
|
||||||
|
private var notify: (@MainActor @Sendable () -> Void)?
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
startCount += 1
|
||||||
|
self.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCount += 1
|
||||||
|
notify = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fire() { notify?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
#expect(fake.startCount == 0)
|
||||||
|
#expect(fake.stopCount == 0)
|
||||||
|
|
||||||
|
var fired = 0
|
||||||
|
let s1 = registry.subscribe(.volume) { fired += 1 }
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: .volume) == 1)
|
||||||
|
|
||||||
|
// A second subscriber must reuse the already-running source.
|
||||||
|
let s2 = registry.subscribe(.volume) { fired += 1 }
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: .volume) == 2)
|
||||||
|
|
||||||
|
// One firing fans out to both subscribers.
|
||||||
|
fake.fire()
|
||||||
|
#expect(fired == 2)
|
||||||
|
|
||||||
|
// Cancelling one keeps the source alive for the other.
|
||||||
|
s1.cancel()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
#expect(fake.stopCount == 0)
|
||||||
|
#expect(registry.subscriberCount(for: .volume) == 1)
|
||||||
|
|
||||||
|
fake.fire()
|
||||||
|
#expect(fired == 3)
|
||||||
|
|
||||||
|
// Cancelling the last subscriber tears the source down.
|
||||||
|
s2.cancel()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
#expect(fake.stopCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: .volume) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
||||||
|
let volumeFake = FakeEventSource()
|
||||||
|
let networkFake = FakeEventSource()
|
||||||
|
let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource = { event in
|
||||||
|
switch event {
|
||||||
|
case .volume: return volumeFake
|
||||||
|
default: return networkFake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let registry = PHEventRegistry(factory: factory)
|
||||||
|
|
||||||
|
let v = registry.subscribe(.volume) {}
|
||||||
|
let n = registry.subscribe(.network) {}
|
||||||
|
|
||||||
|
#expect(volumeFake.startCount == 1)
|
||||||
|
#expect(networkFake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: .volume) == 1)
|
||||||
|
#expect(registry.subscriberCount(for: .network) == 1)
|
||||||
|
|
||||||
|
v.cancel()
|
||||||
|
n.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHBlock + Events
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockRefreshesOnEvent() async throws {
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_evt_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
let path = counter.path
|
||||||
|
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
events = ["volume"]
|
||||||
|
""")[0]
|
||||||
|
block.registry = registry
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
|
// Firing the event triggers a second refresh.
|
||||||
|
fake.fire()
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
|
||||||
|
|
||||||
|
// Stopping cancels the subscription and tears the source down.
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
|
||||||
|
#expect(fake.stopCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockCombinesIntervalAndEvents() async throws {
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_combo_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
let path = counter.path
|
||||||
|
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
refresh = 0.2
|
||||||
|
events = ["volume"]
|
||||||
|
""")[0]
|
||||||
|
block.registry = registry
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
|
||||||
|
let afterInitial = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
#expect(afterInitial == 1)
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
|
// Both an event and the interval can drive updates independently.
|
||||||
|
fake.fire()
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
|
||||||
|
let afterEvent = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
#expect(afterEvent >= 2)
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHController
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func controllerRefreshUpdatesAllBlocks() async throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf a"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "printf b"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let controller = PHController(screen: screen, blocks: blocks)
|
||||||
|
|
||||||
|
controller.refresh()
|
||||||
|
|
||||||
|
// Allow the per-block update tasks to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(blocks[0].label == "a")
|
||||||
|
#expect(blocks[1].label == "b")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user