make events external dylibs
This commit is contained in:
+13
-3
@@ -8,18 +8,28 @@ let package = Package(
|
|||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v15)
|
.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: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
|
.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"),
|
.package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// The public event-recognizer SDK. External libraries depend on this
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
// product; the host target depends on it to bridge recognizers in.
|
||||||
|
.target(
|
||||||
|
name: "phbarEvents",
|
||||||
|
path: "Sources/phbarEvents"
|
||||||
|
),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "phbar",
|
name: "phbar",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "TOML", package: "swift-toml"),
|
.product(name: "TOML", package: "swift-toml"),
|
||||||
|
.target(name: "phbarEvents"),
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("config.toml"),
|
.process("config.toml"),
|
||||||
@@ -32,7 +42,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "phbarTests",
|
name: "phbarTests",
|
||||||
dependencies: ["phbar"]
|
dependencies: ["phbar", "phbarEvents"]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v6]
|
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
|
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
|
/// Modeled as a `String`-backed value so it decodes straight from the TOML
|
||||||
/// config (e.g. `events = ["volume", "network"]`) while staying typo-proof: an
|
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
|
||||||
/// unknown value fails to load instead of silently doing nothing.
|
/// event sources**: every name resolves at runtime to an external recognizer
|
||||||
///
|
/// loaded from `~/.config/phbar/events/<name>/` — a `dlopen`'d library whose
|
||||||
/// To add a new event: add a case here, conform a `PHEventSource` to it in
|
/// root object conforms to `PHEventRecognizer`. To add an event, install a
|
||||||
/// `PHEventRegistry.defaultFactory`, and ship the source under `Events/Sources/`.
|
/// recognizer folder; no host code change is required.
|
||||||
enum PHEvent: String, CaseIterable, Sendable {
|
struct PHEvent: Hashable, Sendable {
|
||||||
/// System output volume or default output device changes (CoreAudio).
|
let rawValue: String
|
||||||
case volume
|
|
||||||
/// Network reachability / interface changes (Network framework).
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
case network
|
/// Convenience initializer for ad-hoc event names.
|
||||||
/// Light/Dark appearance changes (DistributedNotificationCenter).
|
init(_ rawValue: String) { self.rawValue = rawValue }
|
||||||
case appearance
|
|
||||||
/// Power source changes — AC plug/unplug, battery updates (IOKit).
|
|
||||||
case power
|
|
||||||
/// Music Player Daemon state changes — playback, playlist, etc. (MPD `idle`).
|
|
||||||
case mpd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PHEvent: Decodable {
|
extension PHEvent: Decodable {
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let raw = try container.decode(String.self)
|
self.init(rawValue: try container.decode(String.self))
|
||||||
guard let value = Self(rawValue: raw) else {
|
|
||||||
throw DecodingError.dataCorrupted(.init(
|
|
||||||
codingPath: decoder.codingPath,
|
|
||||||
debugDescription: "unknown event '\(raw)'; valid events: \(PHEvent.allCases.map(\.rawValue).joined(separator: ", "))"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
self = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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 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
|
self.factory = factory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,11 @@ final class PHEventRegistry {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription {
|
func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription {
|
||||||
if slots[event] == nil {
|
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()
|
let id = UUID()
|
||||||
@@ -79,14 +83,12 @@ final class PHEventRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps each event to its concrete adapter.
|
/// Resolves an event to its source. phbar ships no compiled sources: every
|
||||||
static func defaultFactory(_ event: PHEvent) -> any PHEventSource {
|
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
|
||||||
switch event {
|
/// recognizer from `~/.config/phbar/events/<name>/`. Returns `nil` if no
|
||||||
case .volume: return VolumeEventSource()
|
/// recognizer is installed.
|
||||||
case .network: return NetworkEventSource()
|
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
|
||||||
case .appearance: return AppearanceEventSource()
|
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
|
||||||
case .power: return PowerEventSource()
|
return PHExternalEventSource(recognizer: recognizer)
|
||||||
case .mpd: return MPDEventSource()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Foundation
|
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
|
/// phbar ships no compiled event sources; every live source is an
|
||||||
/// subscriber and `stop`ped when the last one cancels. Conformers are
|
/// `PHExternalEventSource` wrapping a `dlopen`'d `PHEventRecognizer`. This
|
||||||
/// `@MainActor` for their mutable state, but the `notify` closure they are
|
/// protocol is the registry's main-actor-isolated seam: the adapter conforms,
|
||||||
/// handed is `@MainActor`-isolated and `Sendable`, so an adapter that receives
|
/// and tests inject a fake conformer. The `notify` closure is `@MainActor`-
|
||||||
/// callbacks on a background queue (CoreAudio, Network) must hop to the main
|
/// isolated and `Sendable`; the adapter performs the recognizer→main hop.
|
||||||
/// actor before calling it.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol PHEventSource: AnyObject {
|
protocol PHEventSource: AnyObject {
|
||||||
/// Begin observing. `notify` must be retained for as long as the source is
|
/// 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()
|
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.
|
// At ~20Hz over 250ms the command should have run more than once.
|
||||||
#expect(count >= 2)
|
#expect(count >= 2)
|
||||||
@@ -207,18 +207,20 @@ private struct TestPayload: Codable, Equatable {
|
|||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
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
|
@MainActor
|
||||||
@Test func eventRejectsUnknownString() {
|
@Test func eventAcceptsArbitraryStringAsCustom() throws {
|
||||||
#expect(throws: (any Error).self) {
|
// Unknown strings no longer fail decoding: they become custom event names
|
||||||
try PHBlock.load(from: """
|
// resolved at runtime against ~/.config/phbar/events/<name>/.
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "echo hi"
|
command = "echo hi"
|
||||||
events = ["totally_made_up"]
|
events = ["totally_made_up"]
|
||||||
""")
|
""")
|
||||||
}
|
|
||||||
|
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -232,20 +234,22 @@ private struct TestPayload: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func defaultFactoryReturnsMPDSource() {
|
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
|
||||||
let source = PHEventRegistry.defaultFactory(.mpd)
|
// No recognizer installed at a clean directory → factory yields nil.
|
||||||
#expect(source is MPDEventSource)
|
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
|
@MainActor
|
||||||
@Test func mpdSourceIsIdempotentStartStop() {
|
@Test func eventLoaderReturnsNilForMissingEvent() {
|
||||||
// With MPD absent the source keeps trying to connect; ensure start/stop are
|
// A clean directory has no event folders, so every name fails to load.
|
||||||
// safe and idempotent without a running daemon.
|
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
||||||
let source = MPDEventSource(host: "127.0.0.1", port: 1)
|
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
|
||||||
source.start(notify: {})
|
#expect(recognizer == nil)
|
||||||
source.start(notify: {}) // second start is a no-op
|
|
||||||
source.stop()
|
|
||||||
source.stop() // second stop is a no-op
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PHEventRegistry
|
// MARK: - PHEventRegistry
|
||||||
@@ -279,14 +283,14 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
#expect(fake.stopCount == 0)
|
#expect(fake.stopCount == 0)
|
||||||
|
|
||||||
var fired = 0
|
var fired = 0
|
||||||
let s1 = registry.subscribe(.volume) { fired += 1 }
|
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
|
||||||
#expect(fake.startCount == 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.
|
// 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(fake.startCount == 1)
|
||||||
#expect(registry.subscriberCount(for: .volume) == 2)
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
|
||||||
|
|
||||||
// One firing fans out to both subscribers.
|
// One firing fans out to both subscribers.
|
||||||
fake.fire()
|
fake.fire()
|
||||||
@@ -296,7 +300,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
s1.cancel()
|
s1.cancel()
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
#expect(fake.stopCount == 0)
|
#expect(fake.stopCount == 0)
|
||||||
#expect(registry.subscriberCount(for: .volume) == 1)
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
||||||
|
|
||||||
fake.fire()
|
fake.fire()
|
||||||
#expect(fired == 3)
|
#expect(fired == 3)
|
||||||
@@ -305,28 +309,26 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
s2.cancel()
|
s2.cancel()
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
#expect(fake.stopCount == 1)
|
#expect(fake.stopCount == 1)
|
||||||
#expect(registry.subscriberCount(for: .volume) == 0)
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
||||||
let volumeFake = FakeEventSource()
|
let volumeFake = FakeEventSource()
|
||||||
let networkFake = FakeEventSource()
|
let networkFake = FakeEventSource()
|
||||||
let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource = { event in
|
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
||||||
switch event {
|
if event == PHEvent("volume") { return volumeFake }
|
||||||
case .volume: return volumeFake
|
return networkFake
|
||||||
default: return networkFake
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let registry = PHEventRegistry(factory: factory)
|
let registry = PHEventRegistry(factory: factory)
|
||||||
|
|
||||||
let v = registry.subscribe(.volume) {}
|
let v = registry.subscribe(PHEvent("volume")) {}
|
||||||
let n = registry.subscribe(.network) {}
|
let n = registry.subscribe(PHEvent("network")) {}
|
||||||
|
|
||||||
#expect(volumeFake.startCount == 1)
|
#expect(volumeFake.startCount == 1)
|
||||||
#expect(networkFake.startCount == 1)
|
#expect(networkFake.startCount == 1)
|
||||||
#expect(registry.subscriberCount(for: .volume) == 1)
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
||||||
#expect(registry.subscriberCount(for: .network) == 1)
|
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
|
||||||
|
|
||||||
v.cancel()
|
v.cancel()
|
||||||
n.cancel()
|
n.cancel()
|
||||||
@@ -354,14 +356,14 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
block.startAutoRefresh()
|
block.startAutoRefresh()
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
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)
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
// Firing the event triggers a second refresh.
|
// Firing the event triggers a second refresh.
|
||||||
fake.fire()
|
fake.fire()
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
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.
|
// Stopping cancels the subscription and tears the source down.
|
||||||
block.stopAutoRefresh()
|
block.stopAutoRefresh()
|
||||||
@@ -391,7 +393,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
block.startAutoRefresh()
|
block.startAutoRefresh()
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
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(afterInitial == 1)
|
||||||
#expect(fake.startCount == 1)
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
@@ -399,7 +401,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
fake.fire()
|
fake.fire()
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
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)
|
#expect(afterEvent >= 2)
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
block.stopAutoRefresh()
|
||||||
@@ -418,7 +420,9 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
command = "printf b"
|
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()
|
controller.refresh()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user