diff --git a/Package.swift b/Package.swift index 3a81337..15cae42 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,15 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .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( name: "phbarTests", diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift index 24786f7..0da8a48 100644 --- a/Sources/phbar/AppDelegate.swift +++ b/Sources/phbar/AppDelegate.swift @@ -4,20 +4,35 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { var barController: PHController! 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() - 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) { window = BarWindow(controller: barController) window.orderFront(nil) 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) { barController.stopAutoRefresh() + observers.removeAll() } } diff --git a/Sources/phbar/CLI/cli+refresh.swift b/Sources/phbar/CLI/cli+refresh.swift new file mode 100644 index 0000000..3c38516 --- /dev/null +++ b/Sources/phbar/CLI/cli+refresh.swift @@ -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) + } + } +} diff --git a/Sources/phbar/CLI/cli+start.swift b/Sources/phbar/CLI/cli+start.swift index aa5848c..7fe14c6 100644 --- a/Sources/phbar/CLI/cli+start.swift +++ b/Sources/phbar/CLI/cli+start.swift @@ -9,8 +9,13 @@ extension phbar { abstract: "Start the status bar." ) + @Flag(name: .long, help: "Display visual guides to help align elements.") + var debug: Bool = false + mutating func run() throws { + let config = try PHConfig.load() let screen = try targetScreen(monitor: 0) + let theme = try PHTheme.load("voltage") // NOTE: Make sure NSApp.run() runs in the main thread dispatchPrecondition(condition: .onQueue(.main)) @@ -18,7 +23,7 @@ extension phbar { try MainActor.assumeIsolated { 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 app.setActivationPolicy(.accessory) app.delegate = delegate diff --git a/Sources/phbar/Controllers/PHController.swift b/Sources/phbar/Controllers/PHController.swift index 1124e4b..54aa4e3 100644 --- a/Sources/phbar/Controllers/PHController.swift +++ b/Sources/phbar/Controllers/PHController.swift @@ -2,16 +2,55 @@ import SwiftUI @MainActor final class PHController: ObservableObject { + let config: PHConfig let screen: NSScreen + let theme: PHTheme + var debug: Bool = false { + didSet { + for block in blocks { + block.debug = debug + } + } + } @Published var blocks: [PHBlock] - init(screen: NSScreen, blocks: [PHBlock]) { - self.screen = screen - self.blocks = blocks + var arrangedBlocks: [PHBlock] { + blocks.filter { $0.centered != true } } - // 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. func startAutoRefresh() { for block in blocks { diff --git a/Sources/phbar/Events/PHEvent.swift b/Sources/phbar/Events/PHEvent.swift new file mode 100644 index 0000000..f3deca5 --- /dev/null +++ b/Sources/phbar/Events/PHEvent.swift @@ -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 + } +} diff --git a/Sources/phbar/Events/PHEventRegistry.swift b/Sources/phbar/Events/PHEventRegistry.swift new file mode 100644 index 0000000..5b9adc0 --- /dev/null +++ b/Sources/phbar/Events/PHEventRegistry.swift @@ -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() + } + } +} diff --git a/Sources/phbar/Events/PHEventSource.swift b/Sources/phbar/Events/PHEventSource.swift new file mode 100644 index 0000000..aff062c --- /dev/null +++ b/Sources/phbar/Events/PHEventSource.swift @@ -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() +} diff --git a/Sources/phbar/Events/PHEventSubscription.swift b/Sources/phbar/Events/PHEventSubscription.swift new file mode 100644 index 0000000..ea4b8c4 --- /dev/null +++ b/Sources/phbar/Events/PHEventSubscription.swift @@ -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?() } +} diff --git a/Sources/phbar/Events/Sources/AppearanceEventSource.swift b/Sources/phbar/Events/Sources/AppearanceEventSource.swift new file mode 100644 index 0000000..9d04154 --- /dev/null +++ b/Sources/phbar/Events/Sources/AppearanceEventSource.swift @@ -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 + } +} diff --git a/Sources/phbar/Events/Sources/MPDEventSource.swift b/Sources/phbar/Events/Sources/MPDEventSource.swift new file mode 100644 index 0000000..bd15be4 --- /dev/null +++ b/Sources/phbar/Events/Sources/MPDEventSource.swift @@ -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.. 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 + } +} diff --git a/Sources/phbar/Events/Sources/PowerEventSource.swift b/Sources/phbar/Events/Sources/PowerEventSource.swift new file mode 100644 index 0000000..a059ecc --- /dev/null +++ b/Sources/phbar/Events/Sources/PowerEventSource.swift @@ -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.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { box.notify() } +} diff --git a/Sources/phbar/Events/Sources/VolumeEventSource.swift b/Sources/phbar/Events/Sources/VolumeEventSource.swift new file mode 100644 index 0000000..21bf356 --- /dev/null +++ b/Sources/phbar/Events/Sources/VolumeEventSource.swift @@ -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.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.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, + _ clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData else { return noErr } + let listener = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + listener.fire(for: objectID) + return noErr +} + +private func phbarDefaultDeviceChanged( + _ objectID: AudioObjectID, + _ count: UInt32, + _ addresses: UnsafePointer, + _ clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData else { return noErr } + let listener = Unmanaged.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 + } +} diff --git a/Sources/phbar/Extensions/Color+Extensions.swift b/Sources/phbar/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..d638d27 --- /dev/null +++ b/Sources/phbar/Extensions/Color+Extensions.swift @@ -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) + } +} diff --git a/Sources/phbar/IPC/IPC.swift b/Sources/phbar/IPC/IPC.swift new file mode 100644 index 0000000..58dfce3 --- /dev/null +++ b/Sources/phbar/IPC/IPC.swift @@ -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(_ 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(_ 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( + _ 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) + } +} diff --git a/Sources/phbar/IPC/PHBar+Notifications.swift b/Sources/phbar/IPC/PHBar+Notifications.swift new file mode 100644 index 0000000..f4329ff --- /dev/null +++ b/Sources/phbar/IPC/PHBar+Notifications.swift @@ -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") +} \ No newline at end of file diff --git a/Sources/phbar/Models/Gesture/PHGesture.swift b/Sources/phbar/Models/Gesture/PHGesture.swift new file mode 100644 index 0000000..954224f --- /dev/null +++ b/Sources/phbar/Models/Gesture/PHGesture.swift @@ -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" +} diff --git a/Sources/phbar/Models/Gesture/PHGestureEvent.swift b/Sources/phbar/Models/Gesture/PHGestureEvent.swift new file mode 100644 index 0000000..50ed729 --- /dev/null +++ b/Sources/phbar/Models/Gesture/PHGestureEvent.swift @@ -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 + } + } +} diff --git a/Sources/phbar/Models/PHBlock.swift b/Sources/phbar/Models/PHBlock.swift index 7105468..1c693e3 100644 --- a/Sources/phbar/Models/PHBlock.swift +++ b/Sources/phbar/Models/PHBlock.swift @@ -8,75 +8,162 @@ final class PHBlock: ObservableObject, Decodable, Identifiable { let id = UUID() let command: String let name: String? - let style: String? + let styleName: String? + @Published var style: PHTheme.Style = .default 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. - private var refreshTask: Task? + /// System events that should trigger a refresh (e.g. `["volume", "network"]`). + /// `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? + + /// 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 { - case command, name, style, refresh + case command, name + case styleName = "style" + case refresh, events, centered } deinit { - refreshTask?.cancel() + intervalTask?.cancel() + subscriptions.forEach { $0.cancel() } + } +} + +// Kind + +extension PHBlock { + enum Kind: String { + case text, space } - // MARK: - Refresh + var kind: Kind { + if command.starts(with: "_space") { return .space } + return .text + } +} +// Refresh + +extension PHBlock { /// Start keeping the label up to date. /// - /// The label is always recomputed immediately. When `refresh` is set to a - /// positive number of seconds, it is then recomputed on that interval until - /// `stopAutoRefresh()` is called. With no interval set, only the initial - /// computation runs and later updates must be triggered manually via - /// `update()`. + /// Three independent triggers drive refreshes, and any combination works: + /// + /// 1. **Interval** — when `refresh` is a positive number of seconds, the + /// label is recomputed on that interval. + /// 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() { stopAutoRefresh() - guard let interval = refresh, interval > 0 else { - // No interval: compute once, rely on manual updates afterwards. - Task { await update() } - return - } + // Immediate refresh so the bar isn't blank until the first trigger fires. + scheduleUpdate() - let milliseconds = Int(interval * 1000) - refreshTask = Task { [weak self] in - while !Task.isCancelled { - await self?.update() - try? await Task.sleep(for: .milliseconds(milliseconds)) + // 1. Periodic refresh. + if let interval = refresh, interval > 0 { + let milliseconds = Int(interval * 1000) + intervalTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(milliseconds)) + if Task.isCancelled { break } + await self?.update() + } } } + + // 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 repeating auto-refresh, if any. + /// Cancel the interval task and all event subscriptions. func stopAutoRefresh() { - refreshTask?.cancel() - refreshTask = nil + intervalTask?.cancel() + 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 { 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 off the main actor so it can be awaited safely from SwiftUI views /// without blocking the UI. /// /// - 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 - return await Task.detached(priority: .userInitiated) { + + let lines: [Substring]? = await Task.detached(priority: .userInitiated) { let process = Process() + process.currentDirectoryURL = Self.configFile.deletingLastPathComponent() process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", command] // Inherit the parent's environment (includes $PATH, etc.) 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). let pipe = Pipe() @@ -86,12 +173,18 @@ final class PHBlock: ObservableObject, Decodable, Identifiable { try process.run() process.waitUntilExit() 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 { // Silently ignore failures - return "" + return nil } }.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). /// If the file doesn't exist or fails to parse, defaults are used (non-fatal). static func load() throws -> [PHBlock] { - let url = FileManager.default.homeDirectoryForCurrentUser.appending( - path: ".config/phbar/blocks.toml" - ) - - if FileManager.default.fileExists(atPath: url.relativePath) { - return try load(from: url) + if FileManager.default.fileExists(atPath: Self.configFile.relativePath) { + return try load(from: Self.configFile) } else { throw phbar.Error("Failed to load blocks file") } diff --git a/Sources/phbar/Models/PHConfig.swift b/Sources/phbar/Models/PHConfig.swift new file mode 100644 index 0000000..9abbf91 --- /dev/null +++ b/Sources/phbar/Models/PHConfig.swift @@ -0,0 +1,3 @@ +struct PHConfig: Decodable { + let text: Text +} diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift new file mode 100644 index 0000000..c6672bf --- /dev/null +++ b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift @@ -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) + } + } +} diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Text.swift b/Sources/phbar/Models/PHConfig/PHConfig+Text.swift new file mode 100644 index 0000000..354bc16 --- /dev/null +++ b/Sources/phbar/Models/PHConfig/PHConfig+Text.swift @@ -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 + } +} diff --git a/Sources/phbar/Models/PHTheme.swift b/Sources/phbar/Models/PHTheme.swift new file mode 100644 index 0000000..2f83b58 --- /dev/null +++ b/Sources/phbar/Models/PHTheme.swift @@ -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) + ) + }() + } +} diff --git a/Sources/phbar/Models/PHTheme/PHTheme+Color.swift b/Sources/phbar/Models/PHTheme/PHTheme+Color.swift new file mode 100644 index 0000000..ed37b04 --- /dev/null +++ b/Sources/phbar/Models/PHTheme/PHTheme+Color.swift @@ -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 + } + } +} diff --git a/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift new file mode 100644 index 0000000..a307e08 --- /dev/null +++ b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift @@ -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) + } + } +} diff --git a/Sources/phbar/Models/PHTheme/PHTheme+Padding.swift b/Sources/phbar/Models/PHTheme/PHTheme+Padding.swift new file mode 100644 index 0000000..f4fe549 --- /dev/null +++ b/Sources/phbar/Models/PHTheme/PHTheme+Padding.swift @@ -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) + } + } +} diff --git a/Sources/phbar/Themes/dracula.toml b/Sources/phbar/Themes/dracula.toml new file mode 100644 index 0000000..1c38ea7 --- /dev/null +++ b/Sources/phbar/Themes/dracula.toml @@ -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 } diff --git a/Sources/phbar/Themes/voltage.toml b/Sources/phbar/Themes/voltage.toml new file mode 100644 index 0000000..d4a5135 --- /dev/null +++ b/Sources/phbar/Themes/voltage.toml @@ -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 } diff --git a/Sources/phbar/Views/BarView.swift b/Sources/phbar/Views/BarView.swift index 8aa4aa6..386bde5 100644 --- a/Sources/phbar/Views/BarView.swift +++ b/Sources/phbar/Views/BarView.swift @@ -8,14 +8,31 @@ struct BarView: View { } var body: some View { - HStack(alignment: .center, spacing: 0) { - ForEach(bar.blocks) { block in - TextBlock(block: block) + ZStack { + HStack(alignment: .center, spacing: 0) { + ForEach(bar.arrangedBlocks) { block in + switch block.kind { + case .text: + 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) .ignoresSafeArea() .environmentObject(bar) diff --git a/Sources/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift index 71a3108..77be8f7 100644 --- a/Sources/phbar/Views/BarWindow.swift +++ b/Sources/phbar/Views/BarWindow.swift @@ -3,6 +3,8 @@ import SwiftUI final class BarWindow: NSPanel { let barController: PHController + + var backgroundView: BarWindowBackground! var barView: NSHostingView! init(controller: PHController) { @@ -20,9 +22,6 @@ final class BarWindow: NSPanel { self.isFloatingPanel = true self.level = .floating self.animationBehavior = .utilityWindow - self.isOpaque = false - self.backgroundColor = .clear - // self.hasShadow = pmenu.config.window.shadow self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] self.acceptsMouseMovedEvents = false self.isMovable = false @@ -33,11 +32,16 @@ final class BarWindow: NSPanel { self.isRestorable = false 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)) 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 } } diff --git a/Sources/phbar/Views/Blocks/SpaceBlock.swift b/Sources/phbar/Views/Blocks/SpaceBlock.swift index 08f656d..d831030 100644 --- a/Sources/phbar/Views/Blocks/SpaceBlock.swift +++ b/Sources/phbar/Views/Blocks/SpaceBlock.swift @@ -1,8 +1,17 @@ import SwiftUI 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 { Rectangle() - .fill(.blue) + .fill(block.style.background) + .frame(width: width) } } diff --git a/Sources/phbar/Views/Blocks/TextBlock.swift b/Sources/phbar/Views/Blocks/TextBlock.swift index 639303b..4698491 100644 --- a/Sources/phbar/Views/Blocks/TextBlock.swift +++ b/Sources/phbar/Views/Blocks/TextBlock.swift @@ -1,16 +1,49 @@ import SwiftUI struct TextBlock: View { + @EnvironmentObject private var bar: PHController @ObservedObject var block: PHBlock - var body: some View { - ZStack { - Rectangle() - .fill(.red) + @State var hovering = false - Text(block.label) - .foregroundColor(.white) + private var adjustOffset: CGAffineTransform { + 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) + .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() } - .fixedSize(horizontal: true, vertical: false) } } diff --git a/Sources/phbar/Views/Window/BarWindowBackground.swift b/Sources/phbar/Views/Window/BarWindowBackground.swift new file mode 100644 index 0000000..429075a --- /dev/null +++ b/Sources/phbar/Views/Window/BarWindowBackground.swift @@ -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 + } +} diff --git a/Sources/phbar/config.toml b/Sources/phbar/config.toml new file mode 100644 index 0000000..3d0ba84 --- /dev/null +++ b/Sources/phbar/config.toml @@ -0,0 +1,6 @@ +[text] +font = "Comic Code" +size = 14 +weight = "regular" +style = "normal" +offset = -0.5 diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift index a93123c..b9821dc 100644 --- a/Tests/phbarTests/phbarTests.swift +++ b/Tests/phbarTests/phbarTests.swift @@ -1,9 +1,431 @@ +import AppKit +import Foundation import Testing @testable import phbar -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing +// MARK: - Payload Types for Testing + +private struct TestPayload: Codable, Equatable { + 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") +} +