19 KiB
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 = [...]. PHEventRegistrykeeps 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.
@MainActor
protocol PHEventSource: AnyObject {
func start(notify: @escaping @MainActor @Sendable () -> Void)
func stop()
}
notifyis main-actor-isolated andSendable. The adapter performs the recognizer→main hop so it is delivered in the right context.start/stopare idempotent (guard on astartedflag).
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.
@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
notifyis a plain() -> Voidcallable 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
NSObjectsubclass (@objcprotocol conformance is resolved through the Objective-C runtime atdlopentime). - 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:
// 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 Sendablenotify box — when a framework callback is@Sendableand would reject capturing a non-Sendablenotify, wrap it:Capture the box (Sendable) in thefinal class NotifyBox: @unchecked Sendable { let notify: () -> Void init(_ notify: @escaping () -> Void) { self.notify = notify } func fire() { notify() } }@Sendablehandler.- Serial-queue serialization +
@unchecked Sendableclass — when the recognizer has rich mutable state, run everything on a privateDispatchQueueand 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.
struct PHEvent: Hashable, Sendable, Decodable {
let rawValue: String
init(_ rawValue: String)
}
- Decodes from TOML via a single-value
Stringcontainer. Any string is accepted at decode time; resolution (installed recognizer vs error) happens later in the registry factory. PHBlock.eventsis[PHEvent]?.- Construct ad-hoc names with
PHEvent("bluetooth").
5. The registry — ref-counting & lifecycle
Sources/phbar/Events/PHEventRegistry.swift. @MainActor final class.
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;
startnot called again). - Each firing →
broadcastruns 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-opPHEventSubscription. The app does not crash.
defaultFactory resolves every name to an external recognizer via the loader
(no compiled cases):
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
// 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 toPHEventRecognizer, mark the class@objc(Name)so the ObjC class name is stable. - Retain
notifyfor as long asstart'd; release it instop. - 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-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
phbarEventstarget for the recognizer — not the whole host — so the dependency is lightweight.
6.3 Build & install
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.
# 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
# ~/.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).
# ~/.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:)), checksapiVersionif set. dlopen(..., RTLD_NOW | RTLD_LOCAL)the library,dlsymthe factory,unsafeBitCasttoPHEventCreate, calls it, casts the result toPHEventRecognizer.- Caches the recognizer per name for the process lifetime. Repeated
subscriptions reuse one instance. The
dlopenhandle is neverdlclose'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 todefaultFactory→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
.dylibtodlopen; declare.library(name:, type: .dynamic, …). - External recognizers must not manage actor hops. Call
notifyfrom wherever the framework calls you; the host'sPHExternalEventSourcehops to main. (The opposite is true for internalPHEventSource— see §3.) notifyownership. Retain for the recognizer's lifetime; release instop. Wrap it in a@unchecked Sendablebox 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 privateDispatchQueueand mark the class@unchecked Sendable(mpd).- Folder name == event name.
events/bluetooth/→events = ["bluetooth"]. - Quarantine / Gatekeeper. A downloaded
.dylibmay be quarantined;dlopenthen fails with adlerrordiagnostic. Locally-built libraries are unaffected. Notarize for public distribution. PHEventis an opaque string. Don't add an exhaustiveswitchover it; every name resolves to an installed recognizer at runtime.- Idempotent
start/stop. Required. Guard on astartedflag. - 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.
.dynamicproduct is mandatory. SwiftPM must emit a.dylibtodlopen; declare.library(name:, type: .dynamic, …)and match phbar's macOS deployment target (.macOS(.v15)), sincephbarEventsrequires it.