diff --git a/Package.swift b/Package.swift index 15cae42..46627a4 100644 --- a/Package.swift +++ b/Package.swift @@ -8,18 +8,28 @@ let package = Package( platforms: [ .macOS(.v15) ], + products: [ + // The public SDK external event recognizers depend on. Ships the + // `PHEventRecognizer` protocol + factory-symbol contract, nothing else. + .library(name: "phbarEvents", targets: ["phbarEvents"]), + ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), .package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. + // The public event-recognizer SDK. External libraries depend on this + // product; the host target depends on it to bridge recognizers in. + .target( + name: "phbarEvents", + path: "Sources/phbarEvents" + ), .executableTarget( name: "phbar", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "TOML", package: "swift-toml"), + .target(name: "phbarEvents"), ], resources: [ .process("config.toml"), @@ -32,7 +42,7 @@ let package = Package( ), .testTarget( name: "phbarTests", - dependencies: ["phbar"] + dependencies: ["phbar", "phbarEvents"] ), ], swiftLanguageModes: [.v6] diff --git a/Sources/phbar/Events/Events.md b/Sources/phbar/Events/Events.md new file mode 100644 index 0000000..ac3b34c --- /dev/null +++ b/Sources/phbar/Events/Events.md @@ -0,0 +1,465 @@ +# phbar Events — Architecture & Usage Guide + +> Reference for the event/trigger subsystem. Written for LLM-assisted +> development: authoring external recognizers and maintaining the +> loader/registry. All signatures below are the **source of truth** and match +> the current code. + +phbar blocks refresh on three independent triggers (interval / events / +manual). This document covers the **events** path: how a string in +`blocks.toml` becomes a live, ref-counted listener `dlopen`'d from +`~/.config/phbar/events//`. phbar ships no compiled event sources — every +trigger is an external recognizer. + +--- + +## 1. The 30-second model + +``` +blocks.toml PHBlock PHEventRegistry PHEventLoader +──────────── ─────── ──────────────── ───────────────────────────── +events = ["volume", ──► .events:[PHEvent] ──► subscribe(event) ──► factory ──► dlopen + recognizer (from ~/.config/phbar/events//) + "bluetooth"] (ref-counted) └─► PHExternalEventSource + │ + source.start { broadcast(event) } + │ on every firing (main actor) + ┌─────────┴─────────┐ + ▼ ▼ + block.scheduleUpdate block.scheduleUpdate +``` + +- A block lists event names in `events = [...]`. +- `PHEventRegistry` keeps **exactly one** source alive per event for as long as + ≥1 block subscribes; the last cancellation tears it down. +- phbar ships **no compiled event sources** — every name resolves to an external + recognizer `dlopen`'d from `~/.config/phbar/events//`. + +--- + +## 2. The two contracts + +There are two event-source protocols. They are intentionally **not** the same +type — one is the host's internal Swift seam, the other is the ABI-stable +external SDK that recognizers conform to. + +### `PHEventSource` — host-internal seam + +`Sources/phbar/Events/PHEventSource.swift`. Pure Swift, `@MainActor`. This is +the registry's internal source type: the adapter (`PHExternalEventSource`) +conforms, and tests inject a fake conformer. **phbar ships no compiled +sources**, so no production code conforms to this beyond the adapter. + +```swift +@MainActor +protocol PHEventSource: AnyObject { + func start(notify: @escaping @MainActor @Sendable () -> Void) + func stop() +} +``` + +- `notify` is **main-actor-isolated and `Sendable`**. The adapter performs the + recognizer→main hop so it is delivered in the right context. +- `start`/`stop` are idempotent (guard on a `started` flag). + +### `PHEventRecognizer` — external SDK + +`Sources/phbarEvents/PHEventRecognizer.swift`. `@objc` protocol, **ABI-stable** +across Swift compiler versions (this is *why* it exists separately from +`PHEventSource`). External libraries depend on the `phbarEvents` product and +conform to this. + +```swift +@objc public protocol PHEventRecognizer: AnyObject { + func start(notify: @escaping () -> Void) + func stop() +} + +public typealias PHEventCreate = @convention(c) () -> AnyObject +public let PHEventCreateSymbol = "phbar_event_create" // default factory symbol +public let PHEventAPIVersion = 1 // bump on incompatible changes +``` + +- `notify` is a **plain `() -> Void` callable from any thread**. The host's + adapter (`PHExternalEventSource`) hops each call to the main actor for the + recognizer, so an external recognizer **never** manages actor hops itself. +- A recognizer must be an `NSObject` subclass (`@objc` protocol conformance is + resolved through the Objective-C runtime at `dlopen` time). +- The factory symbol is discovered via `dlsym`; default `"phbar_event_create"`. + +### Comparison + +| Aspect | `PHEventSource` (internal seam) | `PHEventRecognizer` (external SDK) | +|---|---|---| +| Module | `phbar` (internal) | `phbarEvents` (public product) | +| ABI | Swift (not stable across compilers) | Objective-C runtime (stable) | +| Threading | `notify` is `@MainActor`; adapter hops | `notify` is plain; **host hops for you** | +| Construction | Direct `init` (adapter/tests only) | `@_cdecl` C factory, `dlsym`'d | +| Can call system frameworks / SPI? | Yes | Yes (native, in-process) | +| Conformed by | `PHExternalEventSource`, test fakes | every installed recognizer | + +--- + +## 3. Threading model — read this before writing a recognizer + +The single most important detail, and the most common source of bugs. + +**All registry broadcast and block refresh happens on the main actor.** The +host's adapter performs the background→main hop for every recognizer: + +```swift +// PHExternalEventSource +func start(notify: @escaping @MainActor @Sendable () -> Void) { + recognizer.start { Task { @MainActor in notify() } } +} +``` + +So a recognizer is handed a **plain `notify`** and may call it **from any +thread** — the host marshals each firing onto the main actor. Recognizer authors +never touch actors. This keeps the `@objc` boundary simple and lets a +recognizer observe on whatever queue/thread its framework uses. + +State protection is the recognizer's responsibility. Two patterns cover common +needs: + +- **`@unchecked Sendable` notify box** — when a framework callback is + `@Sendable` and would reject capturing a non-Sendable `notify`, wrap it: + ```swift + final class NotifyBox: @unchecked Sendable { + let notify: () -> Void + init(_ notify: @escaping () -> Void) { self.notify = notify } + func fire() { notify() } + } + ``` + Capture the box (Sendable) in the `@Sendable` handler. +- **Serial-queue serialization + `@unchecked Sendable` class** — when the + recognizer has rich mutable state, run everything on a private `DispatchQueue` + and mark the class `@unchecked Sendable` (honest, because the queue serializes + all access). +- **Unmanaged context pointer for C callbacks** — when a C function pointer must + reach `notify`/state, pass a retained box through the client-data pointer + (IOKit power; CoreAudio volume listener). + +| Framework | Callback thread | Pattern used | +|---|---|---| +| CoreAudio (`AudioObjectAddPropertyListener`) | CoreAudio's own thread | `NotifyBox` + `Unmanaged` context in the listener | +| Network (`NWPathMonitor`) | monitor's dispatch queue | `NotifyBox` | +| DistributedNotificationCenter | the queue passed (`.main`) | `NotifyBox` | +| IOKit power (`CFRunLoopSource` on main) | main run loop | `Unmanaged` `ContextBox`; C callback calls `notify` directly | +| MPD socket (`NWConnection`) | connection's dispatch queue | serial-queue `@unchecked Sendable` class | + +--- + +## 4. `PHEvent` — the identity type + +`Sources/phbar/Events/PHEvent.swift`. A `String`-backed struct: the event name +is an opaque string resolved at runtime to an installed recognizer. There are +no compiled-in cases. + +```swift +struct PHEvent: Hashable, Sendable, Decodable { + let rawValue: String + init(_ rawValue: String) +} +``` + +- Decodes from TOML via a single-value `String` container. Any string is + accepted at decode time; resolution (installed recognizer vs error) happens + later in the registry factory. +- `PHBlock.events` is `[PHEvent]?`. +- Construct ad-hoc names with `PHEvent("bluetooth")`. + +--- + +## 5. The registry — ref-counting & lifecycle + +`Sources/phbar/Events/PHEventRegistry.swift`. `@MainActor final class`. + +```swift +init(factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = .defaultFactory) +@discardableResult +func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription +func subscriberCount(for event: PHEvent) -> Int +static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? +``` + +Lifecycle, per event, is fully automatic: + +- **First subscriber** → factory invoked → `source.start { broadcast(event) }`. +- **Additional subscribers** → reuse the running source (factory **not** called + again; `start` **not** called again). +- **Each firing** → `broadcast` runs every subscriber's handler on the main + actor. +- **Last cancellation** → `source.stop()` and the slot is dropped. +- **Factory returns `nil`** (unresolvable external event) → logs to stderr and + returns a no-op `PHEventSubscription`. The app does not crash. + +`defaultFactory` resolves every name to an external recognizer via the loader +(no compiled cases): + +```swift +guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil } +return PHExternalEventSource(recognizer: recognizer) +``` + +`PHEventSubscription` (`PHEventSubscription.swift`) is a cancellable handle; +`cancel()` is safe from any context and from `deinit` (the actual unregister is +hopped to the main actor). `PHBlock` cancels all subscriptions in +`stopAutoRefresh()` and in `deinit`. + +--- + +## 6. Writing an external event recognizer (end-to-end) + +External = a `.dylib` conforming to `PHEventRecognizer`, dropped into the user +config. It runs **in-process as native code** with full framework access — +CoreAudio, IOKit, Network, even private SPI via `dlsym` — exactly like any other +native code. + +### 6.1 The Swift recognizer + +```swift +// Sources/BluetoothRecognizer/BluetoothRecognizer.swift +import CoreBluetooth +import Foundation +import phbarEvents + +@objc(BluetoothRecognizer) +public final class BluetoothRecognizer: NSObject, PHEventRecognizer { + private var central: CBCentralManager? + private var notify: (() -> Void)? + + public func start(notify: @escaping () -> Void) { + self.notify = notify + // CoreBluetooth calls back on its own queue — that's fine: notify is + // a plain closure and the host hops it to the main actor for us. + central = CBCentralManager(delegate: self, queue: nil) + } + + public func stop() { + central = nil + notify = nil + } +} + +extension BluetoothRecognizer: CBCentralManagerDelegate { + public func centralManagerDidUpdateState(_ c: CBCentralManager) { + notify?() // call from any thread; the host marshals it. + } +} + +// Required C entry point. Symbol name must match the manifest (or the default +// PHEventCreateSymbol = "phbar_event_create"). +@_cdecl("phbar_event_create") +public func phbar_event_create() -> AnyObject { + BluetoothRecognizer() +} +``` + +Checklist: +- `import phbarEvents`. +- Subclass `NSObject`, conform to `PHEventRecognizer`, mark the class + `@objc(Name)` so the ObjC class name is stable. +- **Retain `notify`** for as long as `start`'d; release it in `stop`. +- Export a zero-arg factory via `@_cdecl`. +- Do **not** hop to the main actor yourself — the host does it. + +### 6.2 `Package.swift` for the recognizer + +The product **must be dynamic** (`type: .dynamic`) so SwiftPM emits a `.dylib` +that can be `dlopen`'d. + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "BluetoothRecognizer", + products: [ + .library(name: "BluetoothRecognizer", type: .dynamic, targets: ["BluetoothRecognizer"]), + ], + dependencies: [ + // phbar repo URL here; pin to a tagged release. + .package(url: "https://github.com//phbar", from: "x.y.z"), + ], + targets: [ + .target( + name: "BluetoothRecognizer", + dependencies: [.product(name: "phbarEvents", package: "phbar")] + ), + ] +) +``` + +> SwiftPM only compiles the tiny `phbarEvents` target for the recognizer — not +> the whole host — so the dependency is lightweight. + +### 6.3 Build & install + +```bash +swift build -c release +# The dylib name is derived from the product name: lib.dylib +install -D .build/release/libBluetoothRecognizer.dylib \ + ~/.config/phbar/events/bluetooth/libBluetoothRecognizer.dylib +``` + +### 6.4 The manifest — `event.toml` + +Lives **next to** the dylib, at `~/.config/phbar/events//event.toml`. + +```toml +# Required: shared library file name (within this folder). +library = "libBluetoothRecognizer.dylib" +# Optional: factory symbol. Defaults to "phbar_event_create". +symbol = "phbar_event_create" +# Optional: API version the library targets. Omit to skip the check. +# Must equal PHEventAPIVersion (currently 1) if present. +apiVersion = 1 +# Any additional keys are ignored by the host. A recognizer that wants +# configuration reads its own event.toml directly (it is native code with +# filesystem access) — see §6.6. +``` + +The folder name (``, here `bluetooth`) **is** the event name the user puts +in `blocks.toml`. The host reads only `library`/`symbol`/`apiVersion`; all other +keys are the recognizer's own (the `@objc` boundary carries no arguments). + +### 6.5 Use it + +```toml +# ~/.config/phbar/blocks.toml +[[block]] +command = "~/.config/phbar/scripts/battery.sh" +events = ["power", "bluetooth"] # 'bluetooth' resolves to the dylib above +``` + +That's the entire user surface. No new config syntax — an external event is just +a string. + +### 6.6 Configuring a recognizer (self-read manifest) + +A recognizer that wants configuration reads its **own** `event.toml` directly — +the host ignores keys beyond `library`/`symbol`/`apiVersion`, and the `@objc` +boundary carries no arguments. An `mpd` recognizer is a good template: it +locates its dylib via `dladdr` on its class metatype, reads `event.toml` from +that directory, and scans the `arguments` table for `host`/`port` (falling back +to `127.0.0.1:6600`). + +```toml +# ~/.config/phbar/events/mpd/event.toml +library = "libMPDRecognizer.dylib" +apiVersion = 1 +arguments = { host = "192.168.1.10", port = 6600 } +``` + +| Key | Type | Default | Notes | +|---|---|---|---| +| `host` | string | `127.0.0.1` | MPD TCP host. | +| `port` | int | `6600` | Recognizer clamps to `1...65535`. | + +The config travels with the library regardless of the folder name, because the +recognizer finds its `event.toml` next to its own dylib (`dladdr`), not by +hardcoding the event name. This keeps configuration **manifest-scoped**: one +config per installed trigger, shared by every subscriber (no per-block +override, by design). + +--- + +## 7. Loading semantics & error handling + +`Sources/phbar/Events/PHEventLoader.swift`. `@MainActor final class`, +`static let shared`. + +``` +recognizer(for name:) -> (any PHEventRecognizer)? +``` + +- Looks up `//event.toml` (default directory: + `~/.config/phbar/events`). +- Reads the manifest (`PHEventManifest.load(at:)`), checks `apiVersion` if set. +- `dlopen(..., RTLD_NOW | RTLD_LOCAL)` the library, `dlsym` the factory, + `unsafeBitCast` to `PHEventCreate`, calls it, casts the result to + `PHEventRecognizer`. +- **Caches** the recognizer per name for the process lifetime. Repeated + subscriptions reuse one instance. The `dlopen` handle is **never** `dlclose`'d + (the recognizer lives in it for as long as the host runs). +- On any failure it writes a single diagnostic to **stderr** and returns `nil`, + which propagates to `defaultFactory` → `nil` → the registry logs and returns a + no-op subscription. **Nothing throws, nothing crashes.** + +Failure modes and their diagnostics: + +| Problem | stderr message shape | +|---|---| +| Missing/invalid `event.toml` | `could not load event '': no readable event.toml in ` | +| `apiVersion` mismatch | `incompatible API version (host N, library M)` | +| Library file missing | `library '' not found` | +| `dlopen` fails (e.g. quarantine/signing) | `dlopen failed: ` | +| Factory symbol missing | `symbol '' not found: ` | +| Object not a `PHEventRecognizer` | `factory did not return a PHEventRecognizer` | + +--- + +## 8. Versioning + +`PHEventAPIVersion` (in `phbarEvents`) is the contract version. Bump it when +`PHEventRecognizer`, the factory signature, or the symbol contract changes in a +source-incompatible way. A library can declare the version it targets via +`apiVersion` in its manifest; if present and not equal to the host's, the host +refuses to load it. Omitting the field skips the check (treat as unversioned) — +fine for local/personal use, risky for distributed plugins. + +This protects against *contract* drift, **not** compiler-version ABI drift: the +`@objc` boundary is what makes a Swift-6-built dylib loadable in a Swift-7 host +and vice versa. Do not weaken the protocol to pure Swift without reintroducing an +ABI story. + +--- + +## 9. File map + +``` +Sources/phbarEvents/ +└── PHEventRecognizer.swift PUBLIC SDK: protocol, factory type, symbol, API version. + +Sources/phbar/Events/ +├── Events.md ← this file +├── PHEvent.swift String-backed event identity (opaque name → installed recognizer). +├── PHEventSource.swift INTERNAL @MainActor seam: adapter + test fakes conform (no compiled sources). +├── PHEventSubscription.swift Cancellable handle (cancel from any context/deinit). +├── PHEventRegistry.swift Ref-counted broker; subscribe/broadcast/teardown; defaultFactory (external-only). +├── PHEventLoader.swift dlopen + dlsym + cache for external recognizers. +├── PHEventManifest.swift event.toml decoder (library/symbol/apiVersion). +└── PHExternalEventSource.swift Adapter: PHEventRecognizer → @MainActor PHEventSource. +``` + +Dependency direction: `phbar` depends on `phbarEvents`; external recognizer +packages depend on `phbarEvents` only. The host never depends on recognizer +code. + +--- + +## 10. Gotchas + +- **Don't use a static library product for a recognizer.** SwiftPM must emit a + `.dylib` to `dlopen`; declare `.library(name:, type: .dynamic, …)`. +- **External recognizers must not manage actor hops.** Call `notify` from + wherever the framework calls you; the host's `PHExternalEventSource` hops to + main. (The opposite is true for internal `PHEventSource` — see §3.) +- **`notify` ownership.** Retain for the recognizer's lifetime; release in + `stop`. Wrap it in a `@unchecked Sendable` box when a framework callback is + `@Sendable` (volume/network/appearance) or a C function pointer must reach it + (power). For rich mutable state, serialize on a private `DispatchQueue` and + mark the class `@unchecked Sendable` (mpd). +- **Folder name == event name.** `events/bluetooth/` → `events = ["bluetooth"]`. +- **Quarantine / Gatekeeper.** A downloaded `.dylib` may be quarantined; + `dlopen` then fails with a `dlerror` diagnostic. Locally-built libraries are + unaffected. Notarize for public distribution. +- **`PHEvent` is an opaque string.** Don't add an exhaustive `switch` over it; + every name resolves to an installed recognizer at runtime. +- **Idempotent `start`/`stop`.** Required. Guard on a `started` flag. +- **One recognizer instance per event name, process-wide.** The loader caches + it; multiple blocks subscribing to the same event share one instance and one + underlying system listener. +- **`.dynamic` product is mandatory.** SwiftPM must emit a `.dylib` to `dlopen`; + declare `.library(name:, type: .dynamic, …)` and match phbar's macOS + deployment target (`.macOS(.v15)`), since `phbarEvents` requires it. diff --git a/Sources/phbar/Events/PHEvent.swift b/Sources/phbar/Events/PHEvent.swift index f3deca5..9916786 100644 --- a/Sources/phbar/Events/PHEvent.swift +++ b/Sources/phbar/Events/PHEvent.swift @@ -1,36 +1,24 @@ import Foundation -/// A subscribable system event. +/// A subscribable event that drives block refreshes. /// -/// 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 +/// Modeled as a `String`-backed value so it decodes straight from the TOML +/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled +/// event sources**: every name resolves at runtime to an external recognizer +/// loaded from `~/.config/phbar/events//` — a `dlopen`'d library whose +/// root object conforms to `PHEventRecognizer`. To add an event, install a +/// recognizer folder; no host code change is required. +struct PHEvent: Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { self.rawValue = rawValue } + /// Convenience initializer for ad-hoc event names. + init(_ rawValue: String) { self.rawValue = rawValue } } 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 + self.init(rawValue: try container.decode(String.self)) } } diff --git a/Sources/phbar/Events/PHEventLoader.swift b/Sources/phbar/Events/PHEventLoader.swift new file mode 100644 index 0000000..c228fff --- /dev/null +++ b/Sources/phbar/Events/PHEventLoader.swift @@ -0,0 +1,93 @@ +import Darwin +import Foundation +import phbarEvents + +/// Loads external event recognizers (shared libraries) from the user's config +/// directory: `~/.config/phbar/events//event.toml` + the referenced +/// library. +/// +/// Each event lives in its own folder. The manifest names the `.dylib` and, +/// optionally, the factory symbol (default `phbar_event_create`). Libraries are +/// `dlopen`'d once and cached for the lifetime of the process, so repeated +/// subscriptions reuse the same recognizer instance. A loaded library's handle +/// is intentionally never `dlclose`'d: the recognizer instance it vends lives in +/// it for as long as phbar runs. +@MainActor +final class PHEventLoader { + static let shared = PHEventLoader() + + /// Root directory scanned for external events. + let directory: URL + + private struct Loaded { + let handle: UnsafeMutableRawPointer? + let recognizer: any PHEventRecognizer + } + + private var cache: [String: Loaded] = [:] + + init(directory: URL? = nil) { + self.directory = directory ?? Self.defaultDirectory + } + + static var defaultDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/events") + } + + /// Returns the recognizer for `name`, loading and caching it on first access. + /// `nil` (with a stderr diagnostic) if no valid library is found. + func recognizer(for name: String) -> (any PHEventRecognizer)? { + if let cached = cache[name] { return cached.recognizer } + switch load(name: name) { + case .success(let loaded): + cache[name] = loaded + return loaded.recognizer + case .failure(let message): + fputs("phbar: could not load event '\(name)': \(message)\n", stderr) + return nil + } + } + + // MARK: - Loading + + private enum Outcome { + case success(Loaded) + case failure(String) + } + + private func load(name: String) -> Outcome { + let folder = directory.appending(path: name) + let manifest: PHEventManifest + do { + manifest = try PHEventManifest.load(at: folder) + } catch { + return .failure("no readable event.toml in \(folder.path)") + } + + if let api = manifest.apiVersion, api != PHEventAPIVersion { + return .failure("incompatible API version (host \(PHEventAPIVersion), library \(api))") + } + + let libraryPath = folder.appending(path: manifest.library).path + guard FileManager.default.fileExists(atPath: libraryPath) else { + return .failure("library '\(manifest.library)' not found") + } + + let handle = libraryPath.withCString { dlopen($0, RTLD_NOW | RTLD_LOCAL) } + guard let handle else { + return .failure("dlopen failed: \(dlerror().map { String(cString: $0) } ?? "unknown")") + } + + let symbol = manifest.symbol ?? PHEventCreateSymbol + guard let raw = symbol.withCString({ dlsym(handle, $0) }) else { + return .failure("symbol '\(symbol)' not found: \(dlerror().map { String(cString: $0) } ?? "unknown")") + } + + let factory = unsafeBitCast(raw, to: PHEventCreate.self) + let instance = factory() + guard let recognizer = instance as? any PHEventRecognizer else { + return .failure("factory did not return a PHEventRecognizer") + } + return .success(Loaded(handle: handle, recognizer: recognizer)) + } +} diff --git a/Sources/phbar/Events/PHEventManifest.swift b/Sources/phbar/Events/PHEventManifest.swift new file mode 100644 index 0000000..6af6d9c --- /dev/null +++ b/Sources/phbar/Events/PHEventManifest.swift @@ -0,0 +1,42 @@ +import Foundation +import TOML + +/// The manifest describing an external event recognizer, read from +/// `~/.config/phbar/events//event.toml`. +/// +/// ```toml +/// library = "event.dylib" # required: shared library file name +/// symbol = "phbar_event_create" # optional: factory symbol (default below) +/// apiVersion = 1 # optional: target PHEventAPIVersion +/// ``` +/// +/// Additional keys (e.g. an `arguments` table) are **ignored by the host**: the +/// `@objc` boundary carries no arguments. A recognizer that wants configuration +/// reads its own `event.toml` directly (it is native code with filesystem +/// access) — see the official `mpd` trigger for an example. +struct PHEventManifest: Decodable { + /// File name of the shared library within the event folder. + let library: String + /// `dlsym` symbol of the factory. Defaults to `PHEventCreateSymbol` when nil. + let symbol: String? + /// API version the library was built against. Omitted = unversioned. + let apiVersion: Int? + + /// Read and decode the manifest living in `folder/event.toml`. + static func load(at folder: URL) throws -> PHEventManifest { + let url = folder.appending(path: "event.toml") + let data = try Data(contentsOf: url) + guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { + throw PHEventManifestError.unreadable(url) + } + do { + return try TOMLDecoder().decode(PHEventManifest.self, from: contents) + } catch { + throw PHEventManifestError.unreadable(url) + } + } +} + +enum PHEventManifestError: Error { + case unreadable(URL) +} diff --git a/Sources/phbar/Events/PHEventRegistry.swift b/Sources/phbar/Events/PHEventRegistry.swift index 5b9adc0..2aed303 100644 --- a/Sources/phbar/Events/PHEventRegistry.swift +++ b/Sources/phbar/Events/PHEventRegistry.swift @@ -20,9 +20,9 @@ final class PHEventRegistry { } private var slots: [PHEvent: Slot] = [:] - private let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource + private let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? - init(factory: @escaping @MainActor @Sendable (PHEvent) -> any PHEventSource = PHEventRegistry.defaultFactory) { + init(factory: @escaping @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = PHEventRegistry.defaultFactory) { self.factory = factory } @@ -35,7 +35,11 @@ final class PHEventRegistry { @discardableResult func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription { if slots[event] == nil { - slots[event] = Slot(source: factory(event)) + guard let source = factory(event) else { + fputs("phbar: no event recognizer found for '\(event.rawValue)'\n", stderr) + return PHEventSubscription {} + } + slots[event] = Slot(source: source) } let id = UUID() @@ -79,14 +83,12 @@ final class PHEventRegistry { } } - /// 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() - } + /// Resolves an event to its source. phbar ships no compiled sources: every + /// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied + /// recognizer from `~/.config/phbar/events//`. Returns `nil` if no + /// recognizer is installed. + static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? { + guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil } + return PHExternalEventSource(recognizer: recognizer) } } diff --git a/Sources/phbar/Events/PHEventSource.swift b/Sources/phbar/Events/PHEventSource.swift index aff062c..e8070a3 100644 --- a/Sources/phbar/Events/PHEventSource.swift +++ b/Sources/phbar/Events/PHEventSource.swift @@ -1,13 +1,12 @@ import Foundation -/// A single adapter that observes one kind of system event. +/// The registry's internal source abstraction. /// -/// 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. +/// phbar ships no compiled event sources; every live source is an +/// `PHExternalEventSource` wrapping a `dlopen`'d `PHEventRecognizer`. This +/// protocol is the registry's main-actor-isolated seam: the adapter conforms, +/// and tests inject a fake conformer. The `notify` closure is `@MainActor`- +/// isolated and `Sendable`; the adapter performs the recognizer→main hop. @MainActor protocol PHEventSource: AnyObject { /// Begin observing. `notify` must be retained for as long as the source is diff --git a/Sources/phbar/Events/PHExternalEventSource.swift b/Sources/phbar/Events/PHExternalEventSource.swift new file mode 100644 index 0000000..a6f4889 --- /dev/null +++ b/Sources/phbar/Events/PHExternalEventSource.swift @@ -0,0 +1,24 @@ +import Foundation +import phbarEvents + +/// Bridges an externally-loaded `PHEventRecognizer` (a `dlopen`'d shared +/// library) to the host's `@MainActor` `PHEventSource` contract. +/// +/// The recognizer fires `notify` from any thread; this adapter hops each firing +/// onto the main actor so the registry's broadcast runs in the expected context. +@MainActor +final class PHExternalEventSource: PHEventSource { + private let recognizer: any PHEventRecognizer + + init(recognizer: any PHEventRecognizer) { + self.recognizer = recognizer + } + + func start(notify: @escaping @MainActor @Sendable () -> Void) { + recognizer.start { Task { @MainActor in notify() } } + } + + func stop() { + recognizer.stop() + } +} diff --git a/Sources/phbar/Events/Sources/AppearanceEventSource.swift b/Sources/phbar/Events/Sources/AppearanceEventSource.swift deleted file mode 100644 index 9d04154..0000000 --- a/Sources/phbar/Events/Sources/AppearanceEventSource.swift +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index bd15be4..0000000 --- a/Sources/phbar/Events/Sources/MPDEventSource.swift +++ /dev/null @@ -1,182 +0,0 @@ -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 deleted file mode 100644 index a059ecc..0000000 --- a/Sources/phbar/Events/Sources/PowerEventSource.swift +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 21bf356..0000000 --- a/Sources/phbar/Events/Sources/VolumeEventSource.swift +++ /dev/null @@ -1,197 +0,0 @@ -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/phbarEvents/PHEventRecognizer.swift b/Sources/phbarEvents/PHEventRecognizer.swift new file mode 100644 index 0000000..2a90a82 --- /dev/null +++ b/Sources/phbarEvents/PHEventRecognizer.swift @@ -0,0 +1,54 @@ +import Foundation + +/// The contract every phbar event recognizer conforms to. +/// +/// phbar ships a set of built-in recognizers (volume, network, appearance, +/// power, mpd) compiled into the host. External recognizers are loaded at +/// runtime as shared libraries from `~/.config/phbar/events//` and conform +/// to this same protocol, so the host drives built-in and external recognizers +/// through one shape. +/// +/// Conform an `NSObject` subclass and export a zero-argument factory via +/// `@_cdecl(PHEventCreateSymbol)`: +/// +/// ```swift +/// import phbarEvents +/// +/// @objc(MyVolumeRecognizer) +/// final class MyVolumeRecognizer: NSObject, PHEventRecognizer { +/// func start(notify: @escaping () -> Void) { /* observe, call notify() */ } +/// func stop() { /* tear down */ } +/// } +/// +/// @_cdecl("phbar_event_create") +/// public func phbar_event_create() -> AnyObject { MyVolumeRecognizer() } +/// ``` +/// +/// `notify` may be invoked from any thread; the host marshals each firing onto +/// the main actor, so a recognizer that receives callbacks on a background queue +/// (CoreAudio, Network, IOKit) may call it directly without hopping. +@objc public protocol PHEventRecognizer: AnyObject { + /// Begin observing. `notify` must be retained for as long as the recognizer + /// is started and is invoked whenever the observed state changes. + /// Idempotent: calling `start` while already started is a no-op. + func start(notify: @escaping () -> Void) + + /// Stop observing and release system resources. Idempotent. + func stop() +} + +/// The C entry point every external event library must export. +/// +/// Returns a retained instance of an `NSObject` subclass conforming to +/// `PHEventRecognizer`. Authors declare it with `@_cdecl(PHEventCreateSymbol)`. +public typealias PHEventCreate = @convention(c) () -> AnyObject + +/// The `dlsym` symbol the host looks up in an external event library when the +/// manifest omits one (`"phbar_event_create"`). +public let PHEventCreateSymbol = "phbar_event_create" + +/// Bumped whenever `PHEventRecognizer` changes in a source-incompatible way. +/// External libraries declare the version they target in their manifest +/// (`apiVersion`); the host refuses to load a mismatched library. Omitting the +/// field skips the check (treat the library as unversioned). +public let PHEventAPIVersion = 1 diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift index b9821dc..ea04dd5 100644 --- a/Tests/phbarTests/phbarTests.swift +++ b/Tests/phbarTests/phbarTests.swift @@ -189,7 +189,7 @@ private struct TestPayload: Codable, Equatable { block.stopAutoRefresh() - let count = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 // At ~20Hz over 250ms the command should have run more than once. #expect(count >= 2) @@ -207,18 +207,20 @@ private struct TestPayload: Codable, Equatable { let blocks = try PHBlock.load(from: toml) - #expect(blocks[0].events == [.volume, .network, .appearance, .power, .mpd]) + #expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")]) } @MainActor -@Test func eventRejectsUnknownString() { - #expect(throws: (any Error).self) { - try PHBlock.load(from: """ - [[block]] - command = "echo hi" - events = ["totally_made_up"] - """) - } +@Test func eventAcceptsArbitraryStringAsCustom() throws { + // Unknown strings no longer fail decoding: they become custom event names + // resolved at runtime against ~/.config/phbar/events//. + let blocks = try PHBlock.load(from: """ + [[block]] + command = "echo hi" + events = ["totally_made_up"] + """) + + #expect(blocks[0].events == [PHEvent("totally_made_up")]) } @MainActor @@ -232,20 +234,22 @@ private struct TestPayload: Codable, Equatable { } @MainActor -@Test func defaultFactoryReturnsMPDSource() { - let source = PHEventRegistry.defaultFactory(.mpd) - #expect(source is MPDEventSource) +@Test func defaultFactoryReturnsNilWithoutRecognizer() { + // No recognizer installed at a clean directory → factory yields nil. + let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory) + let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in + guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil } + return PHExternalEventSource(recognizer: recognizer) + } + #expect(factory(PHEvent("anything")) == nil) } @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 +@Test func eventLoaderReturnsNilForMissingEvent() { + // A clean directory has no event folders, so every name fails to load. + let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory) + let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)") + #expect(recognizer == nil) } // MARK: - PHEventRegistry @@ -279,14 +283,14 @@ private final class FakeEventSource: PHEventSource { #expect(fake.stopCount == 0) var fired = 0 - let s1 = registry.subscribe(.volume) { fired += 1 } + let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 } #expect(fake.startCount == 1) - #expect(registry.subscriberCount(for: .volume) == 1) + #expect(registry.subscriberCount(for: PHEvent("volume")) == 1) // A second subscriber must reuse the already-running source. - let s2 = registry.subscribe(.volume) { fired += 1 } + let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 } #expect(fake.startCount == 1) - #expect(registry.subscriberCount(for: .volume) == 2) + #expect(registry.subscriberCount(for: PHEvent("volume")) == 2) // One firing fans out to both subscribers. fake.fire() @@ -296,7 +300,7 @@ private final class FakeEventSource: PHEventSource { s1.cancel() try await Task.sleep(for: .milliseconds(20)) #expect(fake.stopCount == 0) - #expect(registry.subscriberCount(for: .volume) == 1) + #expect(registry.subscriberCount(for: PHEvent("volume")) == 1) fake.fire() #expect(fired == 3) @@ -305,28 +309,26 @@ private final class FakeEventSource: PHEventSource { s2.cancel() try await Task.sleep(for: .milliseconds(20)) #expect(fake.stopCount == 1) - #expect(registry.subscriberCount(for: .volume) == 0) + #expect(registry.subscriberCount(for: PHEvent("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 factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in + if event == PHEvent("volume") { return volumeFake } + return networkFake } let registry = PHEventRegistry(factory: factory) - let v = registry.subscribe(.volume) {} - let n = registry.subscribe(.network) {} + let v = registry.subscribe(PHEvent("volume")) {} + let n = registry.subscribe(PHEvent("network")) {} #expect(volumeFake.startCount == 1) #expect(networkFake.startCount == 1) - #expect(registry.subscriberCount(for: .volume) == 1) - #expect(registry.subscriberCount(for: .network) == 1) + #expect(registry.subscriberCount(for: PHEvent("volume")) == 1) + #expect(registry.subscriberCount(for: PHEvent("network")) == 1) v.cancel() n.cancel() @@ -354,14 +356,14 @@ private final class FakeEventSource: PHEventSource { block.startAutoRefresh() try await Task.sleep(for: .milliseconds(100)) - #expect(block.label.trimmingCharacters(in: .whitespacesAndNewlines) == "1") + #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") + #expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2") // Stopping cancels the subscription and tears the source down. block.stopAutoRefresh() @@ -391,7 +393,7 @@ private final class FakeEventSource: PHEventSource { block.startAutoRefresh() try await Task.sleep(for: .milliseconds(50)) - let afterInitial = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 #expect(afterInitial == 1) #expect(fake.startCount == 1) @@ -399,7 +401,7 @@ private final class FakeEventSource: PHEventSource { fake.fire() try await Task.sleep(for: .milliseconds(50)) - let afterEvent = Int(block.label.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 #expect(afterEvent >= 2) block.stopAutoRefresh() @@ -418,7 +420,9 @@ private final class FakeEventSource: PHEventSource { command = "printf b" """) - let controller = PHController(screen: screen, blocks: blocks) + let config = try PHConfig.load() + let theme = try PHTheme.load("voltage") + let controller = PHController(config: config, screen: screen, theme: theme, blocks: blocks) controller.refresh()