Files
phbar/Sources/phbar/Models/Event/README.md
T
2026-07-12 00:50:11 +02:00

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 = [...].
  • 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.

@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.

@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:

// 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:
    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.

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.

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 firingbroadcast runs every subscriber's handler on the main actor.
  • Last cancellationsource.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):

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 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-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

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:)), 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 defaultFactorynil → 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.