make events external dylibs
This commit is contained in:
+13
-3
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/<name>/.
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user