make events external dylibs

This commit is contained in:
2026-07-11 23:49:25 +02:00
parent 2d6348c901
commit 0ed9e4dbf2
15 changed files with 770 additions and 585 deletions
+465
View File
@@ -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/<name>/`. 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/<name>/)
"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/<name>/`.
---
## 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/<owner>/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<Name>.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/<name>/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 (`<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 `<directory>/<name>/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 '<name>': no readable event.toml in <path>` |
| `apiVersion` mismatch | `incompatible API version (host N, library M)` |
| Library file missing | `library '<file>' not found` |
| `dlopen` fails (e.g. quarantine/signing) | `dlopen failed: <dlerror>` |
| Factory symbol missing | `symbol '<sym>' not found: <dlerror>` |
| 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.
+14 -26
View File
@@ -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/<name>/` 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))
}
}
+93
View File
@@ -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/<name>/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))
}
}
@@ -0,0 +1,42 @@
import Foundation
import TOML
/// The manifest describing an external event recognizer, read from
/// `~/.config/phbar/events/<name>/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)
}
+14 -12
View File
@@ -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/<name>/`. 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)
}
}
+6 -7
View File
@@ -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 recognizermain hop.
@MainActor
protocol PHEventSource: AnyObject {
/// Begin observing. `notify` must be retained for as long as the source is
@@ -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()
}
}
@@ -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
}
}
@@ -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..<newline]
if line.last == UInt8(ascii: "\r") {
line = line.dropLast()
}
let string = String(data: line, encoding: .utf8) ?? ""
removeSubrange(startIndex...newline)
return string
}
}
@@ -1,30 +0,0 @@
import Foundation
import Network
/// Fires whenever the system's network path changes (Wi-Fi up/down, interface
/// switch, reachability). Uses `NWPathMonitor`, which needs no special
/// permissions unlike reading the SSID via CoreWLAN.
@MainActor
final class NetworkEventSource: PHEventSource {
private var monitor: NWPathMonitor?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { _ in
Task { @MainActor in notify() }
}
monitor.start(queue: .global(qos: .utility))
self.monitor = monitor
}
func stop() {
guard started else { return }
started = false
monitor?.cancel()
monitor = nil
}
}
@@ -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<PowerEventSource.ContextBox>.fromOpaque(context).takeUnretainedValue()
MainActor.assumeIsolated { box.notify() }
}
@@ -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<AudioDeviceID>.size)
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device)
return status == noErr ? device : nil
}
/// Virtual master volume `[0, 1]` for a given output device.
static func volume(of device: AudioDeviceID) -> Float {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
var value = Float32(0)
var size = UInt32(MemoryLayout<Float32>.size)
let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value)
return status == noErr ? Float(value) : 0
}
/// Observe volume changes on the current default device. The handler is
/// invoked immediately with the current value, then whenever the volume or
/// the default device changes. Keep the returned `Listener` alive.
static func observe(_ handler: @escaping @Sendable (Float) -> Void) -> Listener {
let listener = Listener(handler: handler)
listener.activate()
return listener
}
// MARK: - Listener
/// A self-contained listener that re-attaches when the default device changes.
/// Not `@MainActor`: CoreAudio delivers callbacks on its own thread.
final class Listener {
private let handler: @Sendable (Float) -> Void
private let lock = NSLock()
private var observedDevice: AudioDeviceID?
private var systemListenerInstalled = false
init(handler: @escaping @Sendable (Float) -> Void) {
self.handler = handler
}
func activate() {
installSystemListener()
attach(to: SystemAudioVolume.defaultDevice)
}
private func installSystemListener() {
lock.lock()
defer { lock.unlock() }
guard !systemListenerInstalled else { return }
systemListenerInstalled = true
var address = Self.defaultDeviceAddress
AudioObjectAddPropertyListener(
AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
private func attach(to device: AudioDeviceID?) {
lock.lock()
let previous = observedDevice
observedDevice = device
lock.unlock()
if let previous {
var address = Self.volumeAddress
AudioObjectRemovePropertyListener(
previous, &address, phbarVolumeChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
guard let device else { return }
var address = Self.volumeAddress
AudioObjectAddPropertyListener(
device, &address, phbarVolumeChanged,
Unmanaged.passUnretained(self).toOpaque()
)
// Emit current value once on attach.
handler(SystemAudioVolume.volume(of: device))
}
/// Called from the CoreAudio thread when the volume property fires.
fileprivate func fire(for device: AudioDeviceID) {
handler(SystemAudioVolume.volume(of: device))
}
/// Called from the CoreAudio thread when the default device changes.
fileprivate func reattach() {
attach(to: SystemAudioVolume.defaultDevice)
}
deinit {
let context = Unmanaged.passUnretained(self).toOpaque()
if systemListenerInstalled {
var address = Self.defaultDeviceAddress
AudioObjectRemovePropertyListener(AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged, context)
}
if let device = observedDevice {
var address = Self.volumeAddress
AudioObjectRemovePropertyListener(device, &address, phbarVolumeChanged, context)
}
}
static var defaultDeviceAddress: AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}
static var volumeAddress: AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
}
}
}
// MARK: - CoreAudio callbacks
//
// `AudioObjectPropertyListenerProc` is a C function pointer, so these must be
// non-capturing top-level functions. They recover `self` via the client data
// pointer passed alongside the listener.
private func phbarVolumeChanged(
_ objectID: AudioObjectID,
_ count: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
listener.fire(for: objectID)
return noErr
}
private func phbarDefaultDeviceChanged(
_ objectID: AudioObjectID,
_ count: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
listener.reattach()
return noErr
}
// MARK: - PHEventSource
@MainActor
final class VolumeEventSource: PHEventSource {
private var listener: SystemAudioVolume.Listener?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
listener = SystemAudioVolume.observe { _ in
Task { @MainActor in notify() }
}
}
func stop() {
guard started else { return }
started = false
listener = nil
}
}
@@ -0,0 +1,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/<name>/` 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