Compare commits

...

48 Commits

Author SHA1 Message Date
tommaso f70ea7f0f5 remove hard-coded layout names 2026-07-14 00:36:43 +02:00
tommaso 4a74cf04c8 block running make as sudo 2026-07-14 00:35:49 +02:00
tommaso 3e9557586b greatly simplify environment variables handling 2026-07-13 23:46:08 +02:00
tommaso f479499309 refactor block resolve and refresh 2026-07-13 23:05:08 +02:00
tommaso 30be4b2901 wrap blocks around a layout concept 2026-07-13 21:08:04 +02:00
tommaso ed01251ca5 update Makefile 2026-07-13 16:53:48 +02:00
tommaso 1867a4c0a6 convert bundled defaults into an installable configuration template 2026-07-13 16:44:50 +02:00
tommaso a894449d15 improve error messages 2026-07-13 16:43:52 +02:00
tommaso a49f78cbb0 make theme persistent across monitors 2026-07-13 16:43:05 +02:00
tommaso 1a2eddcd5d refactor CLI 2026-07-13 11:43:37 +02:00
tommaso 909d890837 improve cli documentation 2026-07-13 11:41:25 +02:00
tommaso 87070dd62f improve divider 2026-07-12 21:39:29 +02:00
tommaso 39457d8faf add support for corner radius in dividers 2026-07-12 21:31:09 +02:00
tommaso a963a19bf3 add new divider block kind 2026-07-12 21:29:57 +02:00
tommaso aa241148ef add support for vertical padding in styles 2026-07-12 21:29:42 +02:00
tommaso 07a05e5470 rename views 2026-07-12 21:11:55 +02:00
tommaso 94c83d74a4 format code 2026-07-12 21:09:03 +02:00
tommaso 33c4cbe26e cleanup delegate 2026-07-12 19:57:33 +02:00
tommaso 3c5222d800 refresh the bars on screen changes 2026-07-12 19:51:42 +02:00
tommaso 27fad1d67c allow env variables expansion in config files 2026-07-12 18:20:03 +02:00
tommaso 9a7710d675 make name the core block info 2026-07-12 18:19:41 +02:00
tommaso f5e1ba1104 add support for corner radius 2026-07-12 17:05:29 +02:00
tommaso 77717c9654 rename leading and trailing externally in left and right 2026-07-12 16:50:18 +02:00
tommaso d4d1b7bec4 cleanup tests 2026-07-12 16:16:46 +02:00
tommaso b872659796 cleanup code 2026-07-12 16:16:35 +02:00
tommaso 0ff1664974 make Theme monitor dependent 2026-07-12 15:59:04 +02:00
tommaso 19a8f09e5a fix an issue with horizontal window margins not respected 2026-07-12 15:23:22 +02:00
tommaso 6de06ac8e1 respect the actual window height 2026-07-12 15:12:13 +02:00
tommaso 24c53eb51d move files around 2026-07-12 15:06:51 +02:00
tommaso a6a7f80cce resolve config directory dynamically following a priority order 2026-07-12 14:49:25 +02:00
tommaso 8315b8a6e3 make bar and blocks monitor scoped 2026-07-12 12:56:35 +02:00
tommaso 34921ca377 move window definition to theme and make it dynamic 2026-07-12 11:58:01 +02:00
tommaso 6e258fdffa cleanup code 2026-07-12 11:39:58 +02:00
tommaso 3da4c8e466 make styles optional in theme 2026-07-12 11:36:27 +02:00
tommaso 70a170f544 move text definition to theme and make it dynamic 2026-07-12 11:34:52 +02:00
tommaso 7c0551c662 make colors and padding optional 2026-07-12 11:16:54 +02:00
tommaso 0edc4ca47a add Makefile 2026-07-12 11:14:49 +02:00
tommaso eebbe3cbb8 cleanup Event 2026-07-12 00:50:11 +02:00
tommaso 2b1f94f220 cleanup Views 2026-07-12 00:47:26 +02:00
tommaso 34fc5a4c0d cleanup Block 2026-07-12 00:38:52 +02:00
tommaso 289cc6eb61 cleanup Config 2026-07-12 00:37:51 +02:00
tommaso d8d5e38d33 cleanup Theme 2026-07-12 00:37:28 +02:00
tommaso 2d047d69b6 cleanup Config 2026-07-12 00:25:06 +02:00
tommaso a872ca1d6b improve code style 2026-07-12 00:24:34 +02:00
tommaso d0b945574a cleanup Theme 2026-07-12 00:08:46 +02:00
tommaso 0ed9e4dbf2 make events external dylibs 2026-07-11 23:49:25 +02:00
tommaso 2d6348c901 full scope implementation 2026-07-11 21:56:47 +02:00
tommaso 7d281d1039 basic block structure and auto-refresh 2026-07-10 17:19:04 +02:00
74 changed files with 4103 additions and 27 deletions
+84
View File
@@ -0,0 +1,84 @@
# Makefile for phbar
#
# Usage: make <target>
#
# NOTE: Never invoke this Makefile with `sudo` (e.g. `sudo make install`).
# Running `swift build` as root corrupts `.build/` with root-owned files that
# break subsequent non-root builds (you get "invalid access to …/DerivedSources").
# The `install`/`uninstall` targets elevate *only* the file copy, and only when
# the destination is not user-writable, so `sudo` is never needed from the user.
# Install directory. Override with: make install BIN_DIR=/opt/homebrew/bin
BIN_DIR ?= /usr/local/bin
BUILD_DIR := .build/release
BINARIES := phbar
# Guard: any target that runs Swift must not run under sudo, otherwise it would
# sprinkle root-owned files across .build/. It fails fast with a helpful message
# instead of corrupting the build directory. Recursively-expanded (`=`) so that
# the `$@` automatic variable resolves per-target at recipe time.
guard_not_root = @if [ "$$(id -u)" = "0" ] && [ -n "$$SUDO_USER" ]; then \
echo "❌ 'make $@' must not run under sudo (it would corrupt .build/)." >&2; \
echo " Run 'make install' without sudo; it elevates only the copy step." >&2; \
exit 1; \
fi
.PHONY: all build install clean test run uninstall reinstall help
all: build
# Build all targets in release mode
build:
$(guard_not_root)
swift build -c release
# Build and install binaries. Elevates only the copy when BIN_DIR isn't writable.
install: build
@mkdir -p $(BIN_DIR)
@if [ -w "$(BIN_DIR)" ]; then CP="cp -f"; else CP="sudo cp -f"; fi; \
for bin in $(BINARIES); do \
$$CP $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
done
@echo "✅ Installed $(BINARIES) to $(BIN_DIR)"
# Clean build artifacts
clean:
$(guard_not_root)
swift package clean
# Run tests
test:
$(guard_not_root)
swift test
# Quick test: build, install, and verify
run: install
@echo "Verifying installation..."
@for bin in $(BINARIES); do \
which $$bin > /dev/null && echo "$$bin: $$($$bin --version 2>/dev/null | head -1)" || echo "$$bin: not found"; \
done
# Uninstall binaries. Elevates only the remove when BIN_DIR isn't writable.
uninstall:
@if [ -w "$(BIN_DIR)" ]; then RM="rm -f"; else RM="sudo rm -f"; fi; \
for bin in $(BINARIES); do \
$$RM $(BIN_DIR)/$$bin; \
done
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
# Rebuild and reinstall (clean + install)
reinstall: clean install
# Show help
help:
@echo "Available targets:"
@echo " make build - Build in release mode"
@echo " make install - Build and install to $(BIN_DIR)"
@echo " make clean - Clean build artifacts"
@echo " make test - Run tests"
@echo " make run - Build, install, and verify"
@echo " make uninstall - Remove binaries from $(BIN_DIR)"
@echo " make reinstall - Clean and reinstall"
@echo ""
@echo "Override the install location with BIN_DIR, e.g.:"
@echo " make install BIN_DIR=$(HOME)/.local/bin"
+18 -4
View File
@@ -8,23 +8,37 @@ let package = Package(
platforms: [
.macOS(.v15)
],
products: [
// The public SDK external event recognizers depend on. Ships the
// `PHEventRecognizer` protocol + factory-symbol contract, nothing else.
.library(name: "phbarEvents", targets: ["phbarEvents"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
.package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
// The public event-recognizer SDK. External libraries depend on this
// product; the host target depends on it to bridge recognizers in.
.target(
name: "phbarEvents",
path: "Sources/phbarEvents"
),
.executableTarget(
name: "phbar",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "TOML", package: "swift-toml"),
]
.target(name: "phbarEvents"),
],
exclude: [
"Models/PHEvent/README.md"
],
resources: [.copy("config")]
),
.testTarget(
name: "phbarTests",
dependencies: ["phbar"]
dependencies: ["phbar", "phbarEvents"]
),
],
swiftLanguageModes: [.v6]
@@ -0,0 +1,69 @@
import ArgumentParser
import Foundation
extension PHBar {
struct Install: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
private var source: URL? {
Bundle.module.url(forResource: "config", withExtension: "")
}
private var destination: URL {
let environment = ProcessInfo.processInfo.environment
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
let standardConfigDirectory = homeDirectory.appending(path: ".config")
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
in: .whitespaces)
var candidateDirectory: URL
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
candidateDirectory = standardConfigDirectory
} else {
candidateDirectory = homeDirectory
}
return candidateDirectory.appending(path: "phbar")
}
mutating func run() throws {
do {
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
try FileManager.default.copyItem(at: source, to: destination)
try makeExecutable()
} catch {
throw PHBar.Error("Failed to create config directory", underlyingError: error)
}
throw CleanExit.message("Config directory created at \(destination.relativePath)")
}
private func makeExecutable() throws {
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
let scripts = try FileManager.default.contentsOfDirectory(
atPath: scriptsDirectory.relativePath
)
for script in scripts {
let path = scriptsDirectory.appending(path: script)
var attributes: [FileAttributeKey: Any] = [:]
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
}
}
}
}
@@ -0,0 +1,14 @@
import ArgumentParser
extension PHBar {
struct Refresh: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "refresh",
abstract: "Refresh the running status bar."
)
mutating func run() throws {
IPC.post(.refresh)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import AppKit
import ArgumentParser
import Foundation
extension PHBar {
struct Start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
@Flag(name: .long, help: "Display visual guides and print verbose output.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
let theme = try PHTheme.load(config.theme)
let layouts = try PHLayout.load(["default", "mini"])
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
@@ -0,0 +1,17 @@
import SwiftUI
extension SwiftUI.Color {
init(hex: String, opacity: Double?) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let red = Double((rgb >> 16) & 0xFF) / 255.0
let green = Double((rgb >> 8) & 0xFF) / 255.0
let blue = Double(rgb & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue, opacity: opacity ?? 1.0)
}
}
@@ -0,0 +1,21 @@
extension String {
/// Expand `$VAR` and `${VAR}` references against `variables`.
///
/// Unresolved references are left intact rather than blanked, so a typo
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
/// vanishing.
func expanded(from environment: [String: String]) -> String {
var result = self
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
environment[String(match.1)] ?? String(match.0)
}
// `$VAR`
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
environment[String(match.1)] ?? String(match.0)
}
return result
}
}
+167
View File
@@ -0,0 +1,167 @@
import Foundation
// MARK: - Notification Name
/// A type-safe identifier for cross-process notifications.
///
/// Names should be reverse-DNS strings (e.g. `"com.paninihouse.phbar.refresh"`)
/// to avoid collisions with other applications using distributed notifications.
struct IPCNotificationName: Hashable, RawRepresentable, ExpressibleByStringLiteral {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
init(_ rawValue: String) { self.rawValue = rawValue }
init(stringLiteral value: String) { self.rawValue = value }
/// The Foundation name used by the underlying distributed center.
fileprivate var nsName: Notification.Name { Notification.Name(rawValue) }
}
// MARK: - Notification
/// A received cross-process notification with its optional payload.
struct IPCNotification {
let name: IPCNotificationName
let userInfo: [String: Any]?
init(name: IPCNotificationName, userInfo: [String: Any]? = nil) {
self.name = name
self.userInfo = userInfo
}
/// Decode a Codable value from the notification's `userInfo` payload.
///
/// Returns `nil` if `userInfo` is missing or cannot be decoded as `type`.
func decode<T: Decodable>(_ type: T.Type) -> T? {
guard let userInfo,
JSONSerialization.isValidJSONObject(userInfo),
let data = try? JSONSerialization.data(withJSONObject: userInfo) else {
return nil
}
return try? JSONDecoder().decode(type, from: data)
}
}
// MARK: - Notification Center
/// A thin layer over `DistributedNotificationCenter` for sending lightweight
/// cross-process signals between the `phbar` daemon and short-lived CLI commands.
///
/// Distributed notifications are delivered best-effort to processes running under
/// the same user. They are ideal for idempotent signals (e.g. "refresh"); do not
/// rely on them for critical, ordered, or large data transfer.
///
/// ## Usage
/// ```swift
/// // Post a bare signal
/// IPC.post(.refresh)
///
/// // Post with a Codable payload
/// IPC.post(.updateItem, payload: Item(title: "Hello", count: 3))
///
/// // Observe (token must be retained)
/// let token = IPC.observe(.refresh) { notification in
/// // ...
/// }
///
/// // Observe a typed payload
/// let token = IPC.observe(.updateItem, as: Item.self) { item in
/// // ...
/// }
/// ```
enum IPC {
/// The shared distributed notification center (one per user session).
static var center: DistributedNotificationCenter { .default() }
/// Post a signal with an optional property-list payload.
///
/// `userInfo` must contain only property-list types (`String`, `Number`,
/// `Date`, `Data`, `Array`, `Dictionary`); other values are dropped during
/// cross-process delivery.
static func post(_ name: IPCNotificationName, userInfo: [String: Any]? = nil) {
center.postNotificationName(
name.nsName,
object: nil,
userInfo: userInfo as [AnyHashable: Any]?,
deliverImmediately: true
)
}
/// Post a signal carrying a Codable payload.
///
/// The payload is JSON-encoded into the notification's `userInfo`.
/// Returns `false` if encoding fails.
@discardableResult
static func post<T: Encodable>(_ name: IPCNotificationName, payload: T) -> Bool {
guard let data = try? JSONEncoder().encode(payload),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
post(name, userInfo: dict)
return true
}
/// Observe a signal.
///
/// The handler is invoked on the main thread (distributed notifications are
/// delivered to the receiving process's main run loop).
///
/// - Returns: A token that must be retained while observing. Releasing it
/// automatically unregisters the observer.
static func observe(
_ name: IPCNotificationName,
handler: @escaping (IPCNotification) -> Void
) -> IPCObserver {
IPCObserver(center: center, name: name.nsName, handler: handler)
}
/// Observe a signal and decode its Codable payload.
///
/// `handler` is called only when the payload decodes successfully; malformed
/// or missing payloads are silently ignored.
static func observe<T: Decodable>(
_ name: IPCNotificationName,
as type: T.Type,
handler: @escaping (T) -> Void
) -> IPCObserver {
observe(name) { notification in
guard let payload: T = notification.decode(type) else { return }
handler(payload)
}
}
}
// MARK: - Observer
/// An observation token for a distributed notification.
///
/// Wraps the target/selector API of `DistributedNotificationCenter` so callers
/// can register a Swift closure. The notification center dispatches the selector
/// to this object; releasing the token removes the observer in `deinit`.
final class IPCObserver: NSObject {
private let center: DistributedNotificationCenter
private let name: Notification.Name
private let handler: (IPCNotification) -> Void
fileprivate init(
center: DistributedNotificationCenter,
name: Notification.Name,
handler: @escaping (IPCNotification) -> Void
) {
self.center = center
self.name = name
self.handler = handler
super.init()
center.addObserver(self, selector: #selector(handle(_:)), name: name, object: nil)
}
@objc private func handle(_ notification: Notification) {
let name = IPCNotificationName(notification.name.rawValue)
let userInfo = notification.userInfo as? [String: Any]
handler(IPCNotification(name: name, userInfo: userInfo))
}
deinit {
center.removeObserver(self, name: name, object: nil)
}
}
@@ -0,0 +1,8 @@
import Foundation
// MARK: - PHBar Notification Names
extension IPCNotificationName {
/// Sent by `phbar refresh` to request the running status bar to update.
static let refresh = IPCNotificationName("com.paninihouse.phbar.refresh")
}
+69
View File
@@ -0,0 +1,69 @@
import AppKit
@MainActor
final class PHBarDelegate: NSObject, NSApplicationDelegate {
/// One window per attached screen the single source of truth. The matching
/// controllers are reached through `windows[i].barController`.
var windows: [BarWindow] = []
/// Convenience accessor over `windows`.
var controllers: [BarController] { windows.map(\.barController) }
/// Resolves a controller for any screen from the current config.
var factory: PHBarFactory
private var observers: [IPCObserver] = []
private var screenObserver: NSObjectProtocol?
init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) {
self.factory = PHBarFactory(
config: config,
theme: theme,
layouts: layouts,
debug: debug
)
super.init()
}
func applicationDidFinishLaunching(_ notification: Notification) {
syncScreens()
// React to monitor connect/disconnect and resolution changes. macOS
// delivers a single app-level notification for any of these; `syncScreens`
// diffs so unchanged bars are left untouched.
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.syncScreens() }
}
// Listen for refresh notifications from `phbar refresh`.
let token = IPC.observe(.refresh) { [weak self] _ in
Task { @MainActor in self?.refresh() }
}
observers.append(token)
}
func refresh() {
for controller in controllers {
controller.refresh()
}
}
func applicationWillTerminate(_ notification: Notification) {
for controller in controllers {
controller.stopAutoRefresh()
}
observers.removeAll()
if let screenObserver {
NotificationCenter.default.removeObserver(screenObserver)
self.screenObserver = nil
}
}
func stderr(_ message: String) {
FileHandle.standardError.write(Data("phbar: \(message)\n".utf8))
}
}
@@ -0,0 +1,42 @@
import AppKit
extension PHBarDelegate {
/// Build, show, and start a bar for `screen`. Returns `false` (and prints to
/// stderr) if the screen's theme or blocks fail to load, so one bad screen
/// can't take down the others.
@discardableResult
func addBar(for screen: NSScreen) -> Bool {
let controller: BarController
do {
controller = try factory.make(for: screen)
} catch {
stderr("skipping screen \(screen.localizedName): \(error.localizedDescription)")
return false
}
let window = BarWindow(controller: controller)
window.orderFront(nil)
windows.append(window)
controller.startAutoRefresh()
return true
}
/// Stop refresh, hide, and drop the bar running on `screen` (if any).
func removeBar(for screen: NSScreen) {
guard let index = windows.firstIndex(where: { $0.barController.screen === screen }) else {
return
}
windows[index].barController.stopAutoRefresh()
windows[index].orderOut(nil)
windows.remove(at: index)
}
/// The window currently running on `screen`, if any.
func window(for screen: NSScreen) -> BarWindow? {
windows.first { $0.barController.screen === screen }
}
/// The window currently running on the monitor named `name`, if any.
func window(named name: String) -> BarWindow? {
windows.first { $0.barController.screen.localizedName == name }
}
}
@@ -0,0 +1,28 @@
import AppKit
extension PHBarDelegate {
/// Reconcile live bars against `screens`: tear down bars whose screen is
/// gone, recompute frames for screens whose geometry may have changed, and
/// add bars for newly attached screens.
///
/// A screen whose theme or blocks fail to load is skipped (with a stderr
/// message) rather than aborting the rest.
///
/// - Parameter screens: Defaults to `NSScreen.screens`; injected for tests.
func syncScreens(screens: [NSScreen] = NSScreen.screens) {
let plan = PHScreenSync.diff(
current: screens,
occupied: controllers.map(\.screen)
)
for screen in plan.removed {
removeBar(for: screen)
}
for screen in plan.kept {
window(for: screen)?.recomputeFrame()
}
for screen in plan.added {
addBar(for: screen)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import AppKit
/// Builds a `BarController` for a screen by resolving its layout
/// and window from the config.
@MainActor
struct PHBarFactory {
let config: PHConfig
let theme: PHTheme
let layouts: [String: PHLayout]
let debug: Bool
/// Resolve layout for `screen` and build its controller.
///
/// - Parameter screen: The screen to build a bar for. Its localized name
/// and index in `NSScreen.screens` select any per-monitor config override.
/// - Throws: `PHBlock.load` error for the resolved names.
func make(for screen: NSScreen) throws -> BarController {
let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen)
guard let layout = layouts[config.layout(screenName: name, screenIndex: index)] else {
throw PHBar.Error("Layout `\(name)` not found.")
}
return BarController(
config: config,
screen: screen,
theme: theme,
layout: layout,
debug: debug
)
}
}
+55
View File
@@ -0,0 +1,55 @@
import Foundation
@MainActor
final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID()
let name: String
let textName: String?
@Published var text: PHThemeText = .default
let styleName: String?
@Published var style: PHThemeStyle = .default
let refresh: Double?
let centered: Bool?
var debug: Bool = false
nonisolated var command: String {
PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
}
var visible: Bool {
label != nil && !label!.isEmpty
}
/// System events that should trigger a refresh (e.g. `["volume", "network"]`).
/// `nil` when omitted from config.
let events: [PHEvent]?
/// Event source registry used to subscribe to system events. Defaults to the
/// shared instance; inject a custom one for testing.
var registry: PHEventRegistry = .shared
@Published var label: String?
/// The repeating interval task, if any.
var intervalTask: Task<Void, Never>?
/// Active event subscriptions, torn down in `stopAutoRefresh`.
var subscriptions: [PHEventSubscription] = []
/// Guards against stacking concurrent updates during rapid event bursts
/// (e.g. dragging the volume slider fires many events in quick succession).
var resolveScheduled = false
private enum CodingKeys: String, CodingKey {
case name
case textName = "text"
case styleName = "style"
case refresh, events, centered
}
deinit {
intervalTask?.cancel()
subscriptions.forEach { $0.cancel() }
}
}
@@ -0,0 +1,11 @@
extension PHBlock {
func resolve(with event: PHGestureEvent) {
var environment = [String: String]()
environment.updateValue(event.type.rawValue, forKey: PHGestureEvent.typeKey)
if let data = event.data {
environment.updateValue(data, forKey: PHGestureEvent.infoKey)
}
resolve(merging: environment)
}
}
@@ -0,0 +1,53 @@
import Foundation
extension PHBlock {
/// Start keeping the label up to date.
///
/// Three independent triggers drive refreshes, and any combination works:
///
/// 1. **Interval** when `refresh` is a positive number of seconds, the
/// label is recomputed on that interval.
/// 2. **Events** each name in `events` subscribes to a system event source
/// (volume, network, appearance, power). Sources are activated lazily by
/// the registry: a listener runs only while at least one block subscribes
/// to it, so unused events cost nothing.
/// 3. **Manual** `update()`, `PHController.refresh()`, or `phbar refresh`.
///
/// The label is always recomputed once immediately on start, then again on
/// any of the triggers above until `stopAutoRefresh()` is called.
func startAutoRefresh() {
stopAutoRefresh()
// Immediate refresh so the bar isn't blank until the first trigger fires.
resolve()
// 1. Periodic refresh.
if let interval = refresh, interval > 0 {
let milliseconds = Int(interval * 1000)
intervalTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break }
await self?.resolve()
}
}
}
// 2. Event-driven refresh. The registry lazily activates each underlying
// system listener only while at least one block subscribes to it.
for event in (events ?? []) {
let subscription = registry.subscribe(event) { [weak self] in
self?.resolve()
}
subscriptions.append(subscription)
}
}
/// Cancel the interval task and all event subscriptions.
func stopAutoRefresh() {
intervalTask?.cancel()
intervalTask = nil
subscriptions.forEach { $0.cancel() }
subscriptions.removeAll()
}
}
@@ -0,0 +1,118 @@
import Foundation
extension PHBlock {
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) {
guard canResolve() else { return }
guard !resolveScheduled else { return }
resolveScheduled = true
Task(name: command, priority: .userInitiated) { [weak self] in
defer { self?.resolveScheduled = false }
do {
self?.label = try await self?.resolve(merging: environment)
} catch {
self?.printStandardError(error.localizedDescription)
}
}
}
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) async {
guard canResolve() else { return }
do {
label = try await resolve(merging: environment)
} catch {
printStandardError(error.localizedDescription)
}
}
private func resolve(merging environment: [String: String]?) async throws -> String? {
let customEnvironment = environment
var environment = ProcessInfo.processInfo.environment
if let customEnvironment {
environment.merge(customEnvironment) { (_, new) in new }
}
return try await resolve(with: environment)
}
private func resolve(with environment: [String: String]) async throws -> String? {
do {
let (stdout, stderr) = try await resolve(command, with: environment)
printStandardError(stderr)
printStandardOutput(stdout)
guard let lines = stdout?.split(whereSeparator: \.isNewline),
let last = lines.last
else {
return nil
}
return String(last)
} catch {
throw error
}
}
private func canResolve() -> Bool {
guard kind == .text else { return false }
return true
}
private func resolve(_ command: String, with environment: [String: String]) async throws -> (
stdout: String?,
stderr: String?
) {
return try await withCheckedThrowingContinuation { continuation in
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.currentDirectoryURL = PHPaths.configDirectory
process.environment = environment
process.arguments = ["-c", command]
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: stdoutData, encoding: .utf8)
let stderr = String(data: stderrData, encoding: .utf8)
continuation.resume(returning: (stdout, stderr))
} catch {
continuation.resume(throwing: error)
}
}
}
/// Print a message to standard output.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardOutput(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardOutput.write(data)
}
/// Print a message to standard error.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardError(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardError.write(data)
}
}
@@ -0,0 +1,11 @@
enum PHBlockKind: String {
case text, space, divider
}
extension PHBlock {
var kind: PHBlockKind {
if name.starts(with: "_space") { return .space }
if name.starts(with: "_divider") { return .divider }
return .text
}
}
+25
View File
@@ -0,0 +1,25 @@
struct PHConfig: Decodable {
let theme: String
let window: String
let layout: String
let monitors: [String: PHConfigMonitorOverride]?
private enum CodingKeys: String, CodingKey {
case theme, window, layout
case monitors = "monitor"
}
var layouts: Set<String> {
var layouts = Set<String>()
layouts.insert(layout)
guard let monitors else { return layouts }
for monitor in monitors.values {
if let layout = monitor.layout {
layouts.insert(layout)
}
}
return layouts
}
}
@@ -0,0 +1,42 @@
import Foundation
import TOML
extension PHConfig {
/// Load the configuration from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load() throws -> PHConfig {
let url = PHPaths.configFile
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
let config = url.lastPathComponent
throw PHBar.Error(
"""
Configuration file not found.
Make sure to have a file named `\(config)` inside the
directory: `\(PHPaths.configDirectory.relativePath)`
Tip: If you've never used phbar before, run the `phbar install` command
to automatically generate the required configuration files.
"""
)
}
}
/// Load the configuration from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHConfig {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder()
let configFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHConfig.self, from: configFile)
} catch {
throw PHBar.Error("Configuration file not readable", underlyingError: error)
}
}
}
@@ -0,0 +1,39 @@
import Foundation
/// A per-monitor entry from the config's `[monitor]` table.
///
/// Either field is optional; an unset field inherits the matching global
/// (`window` or `layout`) so a monitor can override just one of them.
struct PHConfigMonitorOverride: Decodable {
let window: String?
let layout: String?
init(window: String? = nil, layout: String? = nil) {
self.window = window
self.layout = layout
}
}
extension PHConfig {
/// The per-monitor override matching the given screen identity, if any.
///
/// Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
guard let monitors else { return nil }
if let byName = monitors[screenName] { return byName }
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
return nil
}
/// Window name for a screen: the override's `window` if set,
/// otherwise the global `window`.
func window(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
}
/// Layout name for a screen: the override's `layout` if set,
/// otherwise the global `layout`.
func layout(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
}
}
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// A subscribable event that drives block refreshes.
///
/// Modeled as a `String`-backed value so it decodes straight from the TOML
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
/// event sources**: every name resolves at runtime to an external recognizer
/// loaded from `<config>/events/<name>/` a `dlopen`'d library whose
/// root object conforms to `PHEventRecognizer`. To add an event, install a
/// recognizer folder; no host code change is required.
struct PHEvent: Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
/// Convenience initializer for ad-hoc event names.
init(_ rawValue: String) { self.rawValue = rawValue }
}
extension PHEvent: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.init(rawValue: try container.decode(String.self))
}
}
@@ -0,0 +1,93 @@
import Darwin
import Foundation
import phbarEvents
/// Loads external event recognizers (shared libraries) from the user's config
/// directory (see `PHPaths`): `<config>/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 {
PHPaths.configDirectory.appending(path: "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>/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)
}
@@ -0,0 +1,94 @@
import Foundation
/// Lazily-activated, ref-counted broker for system events.
///
/// Blocks never talk to `CoreAudio` / `Network` / `IOKit` directly. Instead they
/// `subscribe(_:)` to a `PHEvent` and receive a main-actor callback whenever it
/// fires. The registry keeps exactly one `PHEventSource` alive per event for as
/// long as at least one subscriber exists, so an event that no block cares about
/// costs nothing.
///
/// The shared instance is used app-wide; tests inject a registry built with a
/// custom `factory` (typically returning a fake source).
@MainActor
final class PHEventRegistry {
static let shared = PHEventRegistry()
private struct Slot {
let source: any PHEventSource
var subscribers: [UUID: @MainActor () -> Void] = [:]
}
private var slots: [PHEvent: Slot] = [:]
private let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)?
init(factory: @escaping @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = PHEventRegistry.defaultFactory) {
self.factory = factory
}
/// Subscribe to `event`.
///
/// - Parameters:
/// - handler: Invoked on the main actor every time the event fires.
/// - Returns: A cancellable. The underlying listener is started on the first
/// subscriber and stopped once the last one cancels.
@discardableResult
func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription {
if slots[event] == nil {
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 wasEmpty = slots[event]?.subscribers.isEmpty ?? true
slots[event]?.subscribers[id] = handler
if wasEmpty {
slots[event]?.source.start { [weak self] in
self?.broadcast(event)
}
}
return PHEventSubscription { [weak self] in
guard let self else { return }
Task { @MainActor in self.unsubscribe(event, id: id) }
}
}
/// Number of active subscribers for `event` (handy for tests/debugging).
func subscriberCount(for event: PHEvent) -> Int {
slots[event]?.subscribers.count ?? 0
}
/// Forward one firing to every current subscriber. Called on the main actor.
private func broadcast(_ event: PHEvent) {
guard let slot = slots[event] else { return }
for handler in slot.subscribers.values {
handler()
}
}
/// Remove one subscriber, stopping and dropping the source when none remain.
private func unsubscribe(_ event: PHEvent, id: UUID) {
guard var slot = slots[event] else { return }
slot.subscribers[id] = nil
if slot.subscribers.isEmpty {
slot.source.stop()
slots[event] = nil
} else {
slots[event] = slot
}
}
/// Resolves an event to its source. phbar ships no compiled sources: every
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
/// recognizer from `<config>/events/<name>/`. Returns `nil` if no
/// recognizer is installed.
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
return PHExternalEventSource(recognizer: recognizer)
}
}
@@ -0,0 +1,19 @@
import Foundation
/// The registry's internal source abstraction.
///
/// phbar ships no compiled event sources; every live source is an
/// `PHExternalEventSource` wrapping a `dlopen`'d `PHEventRecognizer`. This
/// protocol is the registry's main-actor-isolated seam: the adapter conforms,
/// and tests inject a fake conformer. The `notify` closure is `@MainActor`-
/// isolated and `Sendable`; the adapter performs the recognizermain hop.
@MainActor
protocol PHEventSource: AnyObject {
/// Begin observing. `notify` must be retained for as long as the source is
/// started and is invoked on the main actor for every state change.
/// Idempotent: calling `start` while already started is a no-op.
func start(notify: @escaping @MainActor @Sendable () -> Void)
/// Stop observing and release system resources. Idempotent.
func stop()
}
@@ -0,0 +1,25 @@
import Foundation
/// A cancellable handle returned by `PHEventRegistry.subscribe(_:)`.
///
/// Cancel it (e.g. in `PHBlock.stopAutoRefresh`) to decrement the event's
/// subscriber count; the registry tears the underlying listener down when the
/// last subscriber for an event cancels. `cancel` is safe to call from any
/// context and from `deinit`; the actual unregistration is performed on the
/// main actor.
final class PHEventSubscription: @unchecked Sendable {
private var cancellation: (@Sendable () -> Void)?
init(_ cancellation: @escaping @Sendable () -> Void) {
self.cancellation = cancellation
}
/// Stop the subscription. Safe to call more than once.
func cancel() {
let cancellation = cancellation
self.cancellation = nil
cancellation?()
}
deinit { cancellation?() }
}
@@ -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()
}
}
+465
View File
@@ -0,0 +1,465 @@
# phbar Events — Architecture & Usage Guide
> Reference for the event/trigger subsystem. Written for LLM-assisted
> development: authoring external recognizers and maintaining the
> loader/registry. All signatures below are the **source of truth** and match
> the current code.
phbar blocks refresh on three independent triggers (interval / events /
manual). This document covers the **events** path: how a string in
`blocks.toml` becomes a live, ref-counted listener `dlopen`'d from
`~/.config/phbar/events/<name>/`. phbar ships no compiled event sources — every
trigger is an external recognizer.
---
## 1. The 30-second model
```
blocks.toml PHBlock PHEventRegistry PHEventLoader
──────────── ─────── ──────────────── ─────────────────────────────
events = ["volume", ──► .events:[PHEvent] ──► subscribe(event) ──► factory ──► dlopen + recognizer (from ~/.config/phbar/events/<name>/)
"bluetooth"] (ref-counted) └─► PHExternalEventSource
source.start { broadcast(event) }
│ on every firing (main actor)
┌─────────┴─────────┐
▼ ▼
block.scheduleUpdate block.scheduleUpdate
```
- A block lists event names in `events = [...]`.
- `PHEventRegistry` keeps **exactly one** source alive per event for as long as
≥1 block subscribes; the last cancellation tears it down.
- phbar ships **no compiled event sources** — every name resolves to an external
recognizer `dlopen`'d from `~/.config/phbar/events/<name>/`.
---
## 2. The two contracts
There are two event-source protocols. They are intentionally **not** the same
type — one is the host's internal Swift seam, the other is the ABI-stable
external SDK that recognizers conform to.
### `PHEventSource` — host-internal seam
`Sources/phbar/Events/PHEventSource.swift`. Pure Swift, `@MainActor`. This is
the registry's internal source type: the adapter (`PHExternalEventSource`)
conforms, and tests inject a fake conformer. **phbar ships no compiled
sources**, so no production code conforms to this beyond the adapter.
```swift
@MainActor
protocol PHEventSource: AnyObject {
func start(notify: @escaping @MainActor @Sendable () -> Void)
func stop()
}
```
- `notify` is **main-actor-isolated and `Sendable`**. The adapter performs the
recognizer→main hop so it is delivered in the right context.
- `start`/`stop` are idempotent (guard on a `started` flag).
### `PHEventRecognizer` — external SDK
`Sources/phbarEvents/PHEventRecognizer.swift`. `@objc` protocol, **ABI-stable**
across Swift compiler versions (this is *why* it exists separately from
`PHEventSource`). External libraries depend on the `phbarEvents` product and
conform to this.
```swift
@objc public protocol PHEventRecognizer: AnyObject {
func start(notify: @escaping () -> Void)
func stop()
}
public typealias PHEventCreate = @convention(c) () -> AnyObject
public let PHEventCreateSymbol = "phbar_event_create" // default factory symbol
public let PHEventAPIVersion = 1 // bump on incompatible changes
```
- `notify` is a **plain `() -> Void` callable from any thread**. The host's
adapter (`PHExternalEventSource`) hops each call to the main actor for the
recognizer, so an external recognizer **never** manages actor hops itself.
- A recognizer must be an `NSObject` subclass (`@objc` protocol conformance is
resolved through the Objective-C runtime at `dlopen` time).
- The factory symbol is discovered via `dlsym`; default `"phbar_event_create"`.
### Comparison
| Aspect | `PHEventSource` (internal seam) | `PHEventRecognizer` (external SDK) |
|---|---|---|
| Module | `phbar` (internal) | `phbarEvents` (public product) |
| ABI | Swift (not stable across compilers) | Objective-C runtime (stable) |
| Threading | `notify` is `@MainActor`; adapter hops | `notify` is plain; **host hops for you** |
| Construction | Direct `init` (adapter/tests only) | `@_cdecl` C factory, `dlsym`'d |
| Can call system frameworks / SPI? | Yes | Yes (native, in-process) |
| Conformed by | `PHExternalEventSource`, test fakes | every installed recognizer |
---
## 3. Threading model — read this before writing a recognizer
The single most important detail, and the most common source of bugs.
**All registry broadcast and block refresh happens on the main actor.** The
host's adapter performs the background→main hop for every recognizer:
```swift
// PHExternalEventSource
func start(notify: @escaping @MainActor @Sendable () -> Void) {
recognizer.start { Task { @MainActor in notify() } }
}
```
So a recognizer is handed a **plain `notify`** and may call it **from any
thread** — the host marshals each firing onto the main actor. Recognizer authors
never touch actors. This keeps the `@objc` boundary simple and lets a
recognizer observe on whatever queue/thread its framework uses.
State protection is the recognizer's responsibility. Two patterns cover common
needs:
- **`@unchecked Sendable` notify box** — when a framework callback is
`@Sendable` and would reject capturing a non-Sendable `notify`, wrap it:
```swift
final class NotifyBox: @unchecked Sendable {
let notify: () -> Void
init(_ notify: @escaping () -> Void) { self.notify = notify }
func fire() { notify() }
}
```
Capture the box (Sendable) in the `@Sendable` handler.
- **Serial-queue serialization + `@unchecked Sendable` class** — when the
recognizer has rich mutable state, run everything on a private `DispatchQueue`
and mark the class `@unchecked Sendable` (honest, because the queue serializes
all access).
- **Unmanaged context pointer for C callbacks** — when a C function pointer must
reach `notify`/state, pass a retained box through the client-data pointer
(IOKit power; CoreAudio volume listener).
| Framework | Callback thread | Pattern used |
|---|---|---|
| CoreAudio (`AudioObjectAddPropertyListener`) | CoreAudio's own thread | `NotifyBox` + `Unmanaged` context in the listener |
| Network (`NWPathMonitor`) | monitor's dispatch queue | `NotifyBox` |
| DistributedNotificationCenter | the queue passed (`.main`) | `NotifyBox` |
| IOKit power (`CFRunLoopSource` on main) | main run loop | `Unmanaged` `ContextBox`; C callback calls `notify` directly |
| MPD socket (`NWConnection`) | connection's dispatch queue | serial-queue `@unchecked Sendable` class |
---
## 4. `PHEvent` — the identity type
`Sources/phbar/Events/PHEvent.swift`. A `String`-backed struct: the event name
is an opaque string resolved at runtime to an installed recognizer. There are
no compiled-in cases.
```swift
struct PHEvent: Hashable, Sendable, Decodable {
let rawValue: String
init(_ rawValue: String)
}
```
- Decodes from TOML via a single-value `String` container. Any string is
accepted at decode time; resolution (installed recognizer vs error) happens
later in the registry factory.
- `PHBlock.events` is `[PHEvent]?`.
- Construct ad-hoc names with `PHEvent("bluetooth")`.
---
## 5. The registry — ref-counting & lifecycle
`Sources/phbar/Events/PHEventRegistry.swift`. `@MainActor final class`.
```swift
init(factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = .defaultFactory)
@discardableResult
func subscribe(_ event: PHEvent, handler: @escaping @MainActor () -> Void) -> PHEventSubscription
func subscriberCount(for event: PHEvent) -> Int
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)?
```
Lifecycle, per event, is fully automatic:
- **First subscriber** → factory invoked → `source.start { broadcast(event) }`.
- **Additional subscribers** → reuse the running source (factory **not** called
again; `start` **not** called again).
- **Each firing** → `broadcast` runs every subscriber's handler on the main
actor.
- **Last cancellation** → `source.stop()` and the slot is dropped.
- **Factory returns `nil`** (unresolvable external event) → logs to stderr and
returns a no-op `PHEventSubscription`. The app does not crash.
`defaultFactory` resolves every name to an external recognizer via the loader
(no compiled cases):
```swift
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
return PHExternalEventSource(recognizer: recognizer)
```
`PHEventSubscription` (`PHEventSubscription.swift`) is a cancellable handle;
`cancel()` is safe from any context and from `deinit` (the actual unregister is
hopped to the main actor). `PHBlock` cancels all subscriptions in
`stopAutoRefresh()` and in `deinit`.
---
## 6. Writing an external event recognizer (end-to-end)
External = a `.dylib` conforming to `PHEventRecognizer`, dropped into the user
config. It runs **in-process as native code** with full framework access —
CoreAudio, IOKit, Network, even private SPI via `dlsym` — exactly like any other
native code.
### 6.1 The Swift recognizer
```swift
// Sources/BluetoothRecognizer/BluetoothRecognizer.swift
import CoreBluetooth
import Foundation
import phbarEvents
@objc(BluetoothRecognizer)
public final class BluetoothRecognizer: NSObject, PHEventRecognizer {
private var central: CBCentralManager?
private var notify: (() -> Void)?
public func start(notify: @escaping () -> Void) {
self.notify = notify
// CoreBluetooth calls back on its own queue — that's fine: notify is
// a plain closure and the host hops it to the main actor for us.
central = CBCentralManager(delegate: self, queue: nil)
}
public func stop() {
central = nil
notify = nil
}
}
extension BluetoothRecognizer: CBCentralManagerDelegate {
public func centralManagerDidUpdateState(_ c: CBCentralManager) {
notify?() // call from any thread; the host marshals it.
}
}
// Required C entry point. Symbol name must match the manifest (or the default
// PHEventCreateSymbol = "phbar_event_create").
@_cdecl("phbar_event_create")
public func phbar_event_create() -> AnyObject {
BluetoothRecognizer()
}
```
Checklist:
- `import phbarEvents`.
- Subclass `NSObject`, conform to `PHEventRecognizer`, mark the class
`@objc(Name)` so the ObjC class name is stable.
- **Retain `notify`** for as long as `start`'d; release it in `stop`.
- Export a zero-arg factory via `@_cdecl`.
- Do **not** hop to the main actor yourself — the host does it.
### 6.2 `Package.swift` for the recognizer
The product **must be dynamic** (`type: .dynamic`) so SwiftPM emits a `.dylib`
that can be `dlopen`'d.
```swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "BluetoothRecognizer",
products: [
.library(name: "BluetoothRecognizer", type: .dynamic, targets: ["BluetoothRecognizer"]),
],
dependencies: [
// phbar repo URL here; pin to a tagged release.
.package(url: "https://github.com/<owner>/phbar", from: "x.y.z"),
],
targets: [
.target(
name: "BluetoothRecognizer",
dependencies: [.product(name: "phbarEvents", package: "phbar")]
),
]
)
```
> SwiftPM only compiles the tiny `phbarEvents` target for the recognizer — not
> the whole host — so the dependency is lightweight.
### 6.3 Build & install
```bash
swift build -c release
# The dylib name is derived from the product name: lib<Name>.dylib
install -D .build/release/libBluetoothRecognizer.dylib \
~/.config/phbar/events/bluetooth/libBluetoothRecognizer.dylib
```
### 6.4 The manifest — `event.toml`
Lives **next to** the dylib, at `~/.config/phbar/events/<name>/event.toml`.
```toml
# Required: shared library file name (within this folder).
library = "libBluetoothRecognizer.dylib"
# Optional: factory symbol. Defaults to "phbar_event_create".
symbol = "phbar_event_create"
# Optional: API version the library targets. Omit to skip the check.
# Must equal PHEventAPIVersion (currently 1) if present.
apiVersion = 1
# Any additional keys are ignored by the host. A recognizer that wants
# configuration reads its own event.toml directly (it is native code with
# filesystem access) — see §6.6.
```
The folder name (`<name>`, here `bluetooth`) **is** the event name the user puts
in `blocks.toml`. The host reads only `library`/`symbol`/`apiVersion`; all other
keys are the recognizer's own (the `@objc` boundary carries no arguments).
### 6.5 Use it
```toml
# ~/.config/phbar/blocks.toml
[[block]]
command = "~/.config/phbar/scripts/battery.sh"
events = ["power", "bluetooth"] # 'bluetooth' resolves to the dylib above
```
That's the entire user surface. No new config syntax — an external event is just
a string.
### 6.6 Configuring a recognizer (self-read manifest)
A recognizer that wants configuration reads its **own** `event.toml` directly —
the host ignores keys beyond `library`/`symbol`/`apiVersion`, and the `@objc`
boundary carries no arguments. An `mpd` recognizer is a good template: it
locates its dylib via `dladdr` on its class metatype, reads `event.toml` from
that directory, and scans the `arguments` table for `host`/`port` (falling back
to `127.0.0.1:6600`).
```toml
# ~/.config/phbar/events/mpd/event.toml
library = "libMPDRecognizer.dylib"
apiVersion = 1
arguments = { host = "192.168.1.10", port = 6600 }
```
| Key | Type | Default | Notes |
|---|---|---|---|
| `host` | string | `127.0.0.1` | MPD TCP host. |
| `port` | int | `6600` | Recognizer clamps to `1...65535`. |
The config travels with the library regardless of the folder name, because the
recognizer finds its `event.toml` next to its own dylib (`dladdr`), not by
hardcoding the event name. This keeps configuration **manifest-scoped**: one
config per installed trigger, shared by every subscriber (no per-block
override, by design).
---
## 7. Loading semantics & error handling
`Sources/phbar/Events/PHEventLoader.swift`. `@MainActor final class`,
`static let shared`.
```
recognizer(for name:) -> (any PHEventRecognizer)?
```
- Looks up `<directory>/<name>/event.toml` (default directory:
`~/.config/phbar/events`).
- Reads the manifest (`PHEventManifest.load(at:)`), checks `apiVersion` if set.
- `dlopen(..., RTLD_NOW | RTLD_LOCAL)` the library, `dlsym` the factory,
`unsafeBitCast` to `PHEventCreate`, calls it, casts the result to
`PHEventRecognizer`.
- **Caches** the recognizer per name for the process lifetime. Repeated
subscriptions reuse one instance. The `dlopen` handle is **never** `dlclose`'d
(the recognizer lives in it for as long as the host runs).
- On any failure it writes a single diagnostic to **stderr** and returns `nil`,
which propagates to `defaultFactory` → `nil` → the registry logs and returns a
no-op subscription. **Nothing throws, nothing crashes.**
Failure modes and their diagnostics:
| Problem | stderr message shape |
|---|---|
| Missing/invalid `event.toml` | `could not load event '<name>': no readable event.toml in <path>` |
| `apiVersion` mismatch | `incompatible API version (host N, library M)` |
| Library file missing | `library '<file>' not found` |
| `dlopen` fails (e.g. quarantine/signing) | `dlopen failed: <dlerror>` |
| Factory symbol missing | `symbol '<sym>' not found: <dlerror>` |
| Object not a `PHEventRecognizer` | `factory did not return a PHEventRecognizer` |
---
## 8. Versioning
`PHEventAPIVersion` (in `phbarEvents`) is the contract version. Bump it when
`PHEventRecognizer`, the factory signature, or the symbol contract changes in a
source-incompatible way. A library can declare the version it targets via
`apiVersion` in its manifest; if present and not equal to the host's, the host
refuses to load it. Omitting the field skips the check (treat as unversioned) —
fine for local/personal use, risky for distributed plugins.
This protects against *contract* drift, **not** compiler-version ABI drift: the
`@objc` boundary is what makes a Swift-6-built dylib loadable in a Swift-7 host
and vice versa. Do not weaken the protocol to pure Swift without reintroducing an
ABI story.
---
## 9. File map
```
Sources/phbarEvents/
└── PHEventRecognizer.swift PUBLIC SDK: protocol, factory type, symbol, API version.
Sources/phbar/Events/
├── Events.md ← this file
├── PHEvent.swift String-backed event identity (opaque name → installed recognizer).
├── PHEventSource.swift INTERNAL @MainActor seam: adapter + test fakes conform (no compiled sources).
├── PHEventSubscription.swift Cancellable handle (cancel from any context/deinit).
├── PHEventRegistry.swift Ref-counted broker; subscribe/broadcast/teardown; defaultFactory (external-only).
├── PHEventLoader.swift dlopen + dlsym + cache for external recognizers.
├── PHEventManifest.swift event.toml decoder (library/symbol/apiVersion).
└── PHExternalEventSource.swift Adapter: PHEventRecognizer → @MainActor PHEventSource.
```
Dependency direction: `phbar` depends on `phbarEvents`; external recognizer
packages depend on `phbarEvents` only. The host never depends on recognizer
code.
---
## 10. Gotchas
- **Don't use a static library product for a recognizer.** SwiftPM must emit a
`.dylib` to `dlopen`; declare `.library(name:, type: .dynamic, …)`.
- **External recognizers must not manage actor hops.** Call `notify` from
wherever the framework calls you; the host's `PHExternalEventSource` hops to
main. (The opposite is true for internal `PHEventSource` — see §3.)
- **`notify` ownership.** Retain for the recognizer's lifetime; release in
`stop`. Wrap it in a `@unchecked Sendable` box when a framework callback is
`@Sendable` (volume/network/appearance) or a C function pointer must reach it
(power). For rich mutable state, serialize on a private `DispatchQueue` and
mark the class `@unchecked Sendable` (mpd).
- **Folder name == event name.** `events/bluetooth/` → `events = ["bluetooth"]`.
- **Quarantine / Gatekeeper.** A downloaded `.dylib` may be quarantined;
`dlopen` then fails with a `dlerror` diagnostic. Locally-built libraries are
unaffected. Notarize for public distribution.
- **`PHEvent` is an opaque string.** Don't add an exhaustive `switch` over it;
every name resolves to an installed recognizer at runtime.
- **Idempotent `start`/`stop`.** Required. Guard on a `started` flag.
- **One recognizer instance per event name, process-wide.** The loader caches
it; multiple blocks subscribing to the same event share one instance and one
underlying system listener.
- **`.dynamic` product is mandatory.** SwiftPM must emit a `.dylib` to `dlopen`;
declare `.library(name:, type: .dynamic, …)` and match phbar's macOS
deployment target (`.macOS(.v15)`), since `phbarEvents` requires it.
+6
View File
@@ -0,0 +1,6 @@
enum PHGesture: String {
case leftMouseDown = "left_mouse_down"
case rightMouseDown = "right_mouse_down"
case otherMouseDown = "other_mouse_down"
case scrollWheel = "scroll_wheel"
}
@@ -0,0 +1,24 @@
import AppKit
struct PHGestureEvent {
static let typeKey = "GESTURE"
static let infoKey = "GESTURE_INFO"
let type: PHGesture
let data: String?
static func from(_ event: NSEvent) -> PHGestureEvent? {
switch event.type {
case .leftMouseDown:
return .init(type: .leftMouseDown, data: nil)
case .rightMouseDown:
return .init(type: .rightMouseDown, data: nil)
case .otherMouseDown:
return .init(type: .otherMouseDown, data: String(event.buttonNumber))
case .scrollWheel:
return .init(type: .scrollWheel, data: "\(event.scrollingDeltaX) \(event.scrollingDeltaY)")
default:
return nil
}
}
}
+7
View File
@@ -0,0 +1,7 @@
struct PHLayout: Decodable {
let blocks: [PHBlock]
private enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
@@ -0,0 +1,55 @@
import Foundation
import TOML
extension PHLayout {
/// Load the specified layouts from the resolved config directory (see `PHPaths`).
/// If any of theme doesn't exist or fails to parse, the execution is interrupted.
static func load(_ layouts: Set<String>) throws -> [String: PHLayout] {
var dictionary = [String: PHLayout]()
for layout in layouts {
dictionary.updateValue(try load(layout), forKey: layout)
}
return dictionary
}
/// Load a layout from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(_ layout: String) throws -> PHLayout {
let url = PHPaths.layoutsDirectory.appending(path: "\(layout).toml")
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
let layout = url.lastPathComponent
throw PHBar.Error(
"""
Layout file not found.
Make sure to have a file named `\(layout)` inside the
directory: `\(PHPaths.layoutsDirectory.relativePath)`
Tip: If you've never used phbar before, run `phbar generate config`
to automatically generate the required configuration files.
"""
)
}
}
/// Load a layout from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHLayout {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder()
let layoutFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHLayout.self, from: layoutFile)
} catch {
let layout = url.lastPathComponent
throw PHBar.Error("Layout file `\(layout)` not readable", underlyingError: error)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
/// Resolves phbar's configuration directory.
///
/// Searches these locations in priority order and returns the first that
/// exists on disk:
///
/// 1. `$XDG_CONFIG_HOME/phbar` only when `XDG_CONFIG_HOME` is set to an
/// absolute path (a relative/empty value is ignored, per the XDG spec).
/// 2. `~/.config/phbar`
/// 3. `~/.phbar`
///
/// If none exist yet (fresh install), the highest-priority candidate is
/// returned so loaders and block commands have a stable root to resolve into.
enum PHPaths {
/// The resolved configuration directory (computed once, cached).
static let configDirectory: URL = resolveConfigDirectory()
/// The resolved config file (computed once, cached).
static let configFile: URL = {
configDirectory.appending(path: "config.toml")
}()
/// The resolved themes directory (computed once, cached).
static let themesDirectory: URL = {
configDirectory.appending(path: "themes")
}()
/// The resolved layouts directory (computed once, cached).
static let layoutsDirectory: URL = {
configDirectory.appending(path: "layouts")
}()
/// Ordered candidate directories, priority high low.
///
/// - Parameter environment: Override the environment lookup (used by tests);
/// defaults to the current process environment.
static func configDirectoryCandidates(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [URL] {
let home = FileManager.default.homeDirectoryForCurrentUser
var candidates: [URL] = []
if let xdg = environment["XDG_CONFIG_HOME"] {
let trimmed = xdg.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("/") {
candidates.append(URL(fileURLWithPath: trimmed).appending(path: "phbar"))
}
}
candidates.append(home.appending(path: ".config/phbar"))
candidates.append(home.appending(path: ".phbar"))
return candidates
}
/// First existing candidate, or the highest-priority one if none exist.
///
/// - Parameter environment: Override the environment lookup (used by tests);
/// defaults to the current process environment.
static func resolveConfigDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> URL {
let candidates = configDirectoryCandidates(environment: environment)
for candidate in candidates {
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
}
return candidates[0]
}
}
+31
View File
@@ -0,0 +1,31 @@
import AppKit
/// Which screens to keep, remove, or add when reconciling live bars against the
/// current `NSScreen.screens`.
///
/// Pure and identity-based (it never reads `NSScreen` properties), so it can be
/// unit-tested with any `[NSScreen]` without driving real window lifecycle.
enum PHScreenSync {
struct Diff: Equatable {
/// Screens that already have a bar and are still attached. Their window
/// frame is recomputed since geometry may have changed.
let kept: [NSScreen]
/// Screens whose bar must be torn down (monitor disconnected).
let removed: [NSScreen]
/// Screens with no bar yet that need one (monitor connected).
let added: [NSScreen]
}
/// Compare the screens now attached (`current`) against the screens currently
/// occupied by bars (`occupied`). A disconnected monitor reappears as a new
/// `NSScreen` instance, so it is reported as remove + add, never a keep.
static func diff(current: [NSScreen], occupied: [NSScreen]) -> Diff {
let currentIDs = Set(current.map(ObjectIdentifier.init))
let occupiedIDs = Set(occupied.map(ObjectIdentifier.init))
return Diff(
kept: occupied.filter { currentIDs.contains(ObjectIdentifier($0)) },
removed: occupied.filter { !currentIDs.contains(ObjectIdentifier($0)) },
added: current.filter { !occupiedIDs.contains(ObjectIdentifier($0)) }
)
}
}
+11
View File
@@ -0,0 +1,11 @@
struct PHTheme: Decodable {
let windows: [PHThemeWindow]?
let texts: [PHThemeText]?
let styles: [PHThemeStyle]?
private enum CodingKeys: String, CodingKey {
case windows = "window"
case texts = "text"
case styles = "style"
}
}
@@ -0,0 +1,43 @@
import Foundation
import TOML
extension PHTheme {
/// Load a theme from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(_ theme: String) throws -> PHTheme {
let url = PHPaths.themesDirectory.appending(path: "\(theme).toml")
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
let theme = url.lastPathComponent
throw PHBar.Error(
"""
Theme file not found.
Make sure to have a file named `\(theme)` inside the
directory: `\(PHPaths.themesDirectory.relativePath)`
Tip: If you've never used phbar before, run the `phbar install` command
to automatically generate the required configuration files.
"""
)
}
}
/// Load a theme from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHTheme {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder()
let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHTheme.self, from: themeFile)
} catch {
let theme = url.lastPathComponent
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
}
}
}
@@ -0,0 +1,17 @@
struct PHThemeStyle: Decodable {
let name: String
let foreground: PHThemeStyleColor?
let background: PHThemeStyleColor?
let padding: PHThemeStylePadding?
let corner: PHThemeStyleCorner?
static let `default`: Self = {
PHThemeStyle(
name: "_default",
foreground: .init(color: "#000000", alpha: nil),
background: .init(color: "#ffffff", alpha: nil),
padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
corner: nil
)
}()
}
@@ -0,0 +1,25 @@
import SwiftUI
struct PHThemeStyleColor: Decodable, ShapeStyle {
typealias Resolved = SwiftUI.Color
var color: String
let alpha: Double?
var uiColor: SwiftUI.Color {
SwiftUI.Color(hex: color, opacity: alpha)
}
var nsColor: NSColor? {
guard let cgColor else { return nil }
return NSColor(cgColor: cgColor)
}
var cgColor: CGColor? {
uiColor.cgColor
}
func resolve(in environment: EnvironmentValues) -> SwiftUI.Color {
uiColor
}
}
@@ -0,0 +1,26 @@
import SwiftUI
struct PHThemeStyleCorner: Decodable {
let radius: Double
private let _style: PHThemeStyleCornerStyle?
var style: SwiftUI.RoundedCornerStyle {
switch _style {
case .circular:
return .circular
case .continuous:
return .continuous
default:
return .circular
}
}
private enum CodingKeys: String, CodingKey {
case radius
case _style = "style"
}
}
enum PHThemeStyleCornerStyle: String, Decodable {
case circular, continuous
}
@@ -0,0 +1,13 @@
struct PHThemeStylePadding: Decodable {
let top: Double?
let bottom: Double?
let leading: Double?
let trailing: Double?
private enum CodingKeys: String, CodingKey {
case top
case bottom
case leading = "left"
case trailing = "right"
}
}
@@ -0,0 +1,99 @@
import AppKit
import SwiftUI
struct PHThemeText: Decodable {
let name: String
var fontFamily: String
let size: Double
let weight: PHThemeTextWeight
let style: PHThemeTextStyle
let offset: Double?
private enum CodingKeys: String, CodingKey {
case name
case fontFamily = "font"
case size, weight, style, offset
}
static let `default`: Self = {
PHThemeText(
name: "_default",
fontFamily: "monospace",
size: 14.0,
weight: .regular,
style: .normal,
offset: nil
)
}()
}
// Design
extension PHThemeText {
var design: SwiftUI.Font.Design? {
switch fontFamily {
case "sans":
return .default
case "monospace":
return .monospaced
case "serif":
return .serif
default:
return nil
}
}
var systemDesign: NSFontDescriptor.SystemDesign? {
switch fontFamily {
case "sans":
return .default
case "monospace":
return .monospaced
case "serif":
return .serif
default:
return nil
}
}
}
// Font
extension PHThemeText {
/// Construct an `NSFont` from the typeface settings.
///
/// Falls back to the system font if parsing fails.
var nsFont: NSFont {
if let systemDesign {
let baseFont = NSFont.systemFont(ofSize: size, weight: weight.nsWeight)
let descriptor =
baseFont.fontDescriptor.withDesign(systemDesign)
?? baseFont.fontDescriptor
return NSFont(descriptor: descriptor, size: size)
?? baseFont
} else {
let descriptor = NSFontDescriptor.init(fontAttributes: [
.family: fontFamily,
.traits: [
NSFontDescriptor.TraitKey.weight: weight.nsWeight
],
])
return NSFont(descriptor: descriptor, size: size)
?? NSFont(name: fontFamily, size: size)
?? NSFont.systemFont(ofSize: size, weight: weight.nsWeight)
}
}
/// Construct a `Font` from the typeface settings.
///
/// Falls back to the system font if parsing fails.
var font: SwiftUI.Font {
if let design {
return Font.system(size: size, weight: weight.uiWeight, design: design)
} else {
return Font.custom(fontFamily, fixedSize: size).weight(weight.uiWeight)
}
}
}
@@ -0,0 +1,9 @@
enum PHThemeTextStyle: String, Decodable {
case normal, italic
}
extension PHThemeText {
var italic: Bool {
style == .italic
}
}
@@ -0,0 +1,60 @@
import AppKit
import SwiftUI
enum PHThemeTextWeight: String, Decodable {
case thin
case ultraLight = "ultralight"
case light
case regular
case medium
case semiBold = "semibold"
case bold
case heavy
case black
var uiWeight: SwiftUI.Font.Weight {
switch self {
case .thin:
return .thin
case .ultraLight:
return .ultraLight
case .light:
return .light
case .regular:
return .regular
case .medium:
return .medium
case .semiBold:
return .semibold
case .bold:
return .bold
case .heavy:
return .heavy
case .black:
return .black
}
}
var nsWeight: NSFont.Weight {
switch self {
case .thin:
return .thin
case .ultraLight:
return .ultraLight
case .light:
return .light
case .regular:
return .regular
case .medium:
return .medium
case .semiBold:
return .semibold
case .bold:
return .bold
case .heavy:
return .heavy
case .black:
return .black
}
}
}
@@ -0,0 +1,33 @@
import Foundation
struct PHThemeWindow: Decodable {
let name: String
var hasShadow: Bool? = false
var blur: Double? = 0
/// Window height in points.
var height: Double? = 30
/// Window width: a point value or a percentage of the screen width.
var width: PHThemeWindowDimension? = .percentage(1.0)
/// Semantic placement within the screen.
var anchor: PHThemeWindowAnchor? = .top
/// Insets from the anchored edges.
var margin: PHThemeWindowMargin? = .init()
/// Absolute origin. Overrides `anchor` and `margin` when set.
var origin: PHThemeWindowPoint?
private enum CodingKeys: String, CodingKey {
case name
case hasShadow = "shadow"
case blur
case height, width, anchor, margin, origin
}
static let `default`: Self = {
PHThemeWindow(
name: "_default",
hasShadow: false,
blur: 0.0
)
}()
}
@@ -0,0 +1,60 @@
import CoreGraphics
import Foundation
/// Where a window is placed within its screen.
///
/// Single-edge anchors (`top`, `bottom`, `leading`, `trailing`) pin to that
/// edge and center along the opposite axis. Corner anchors pin to two edges.
/// `center` centers the window on both axes.
enum PHThemeWindowAnchor: String, Decodable {
case top, bottom, leading, trailing
case topLeading = "top-left"
case topTrailing = "top-right"
case bottomLeading = "bottom-left"
case bottomTrailing = "bottom-right"
case center
}
extension PHThemeWindowAnchor {
/// Compute the top-left origin (in screen coordinates) of a window of `size`
/// placed against this anchor within `rect`.
///
/// `rect` is the content rectangle the window is placed within typically the
/// screen inset by the window's margin (see `PHThemeWindowMargin.inset(of:)`).
/// Single-edge anchors pin to that edge and center along the opposite axis;
/// corner anchors pin to two edges; `.center` centers on both axes.
func origin(in rect: CGRect, size: CGSize) -> CGPoint {
// Default: centered on both axes within the rect.
var point = CGPoint(
x: rect.midX - size.width / 2,
y: rect.midY - size.height / 2
)
switch self {
case .top:
point.y = rect.maxY - size.height
case .bottom:
point.y = rect.minY
case .leading:
point.x = rect.minX
case .trailing:
point.x = rect.maxX - size.width
case .topLeading:
point.x = rect.minX
point.y = rect.maxY - size.height
case .topTrailing:
point.x = rect.maxX - size.width
point.y = rect.maxY - size.height
case .bottomLeading:
point.x = rect.minX
point.y = rect.minY
case .bottomTrailing:
point.x = rect.maxX - size.width
point.y = rect.minY
case .center:
break
}
return point
}
}
@@ -0,0 +1,48 @@
import CoreGraphics
import Foundation
/// A length expressed either in points or as a percentage of the available
/// space (e.g. the screen width).
///
/// Decoded from either a number (`width = 400`) or a percentage string
/// (`width = "100%"`).
enum PHThemeWindowDimension: Decodable, Equatable {
case points(Double)
case percentage(Double)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// TOML encodes integers and floats distinctly, so accept both.
if let double = try? container.decode(Double.self) {
self = .points(double)
return
}
if let int = try? container.decode(Int.self) {
self = .points(Double(int))
return
}
if let string = try? container.decode(String.self) {
let trimmed = string.trimmingCharacters(in: .whitespaces)
if trimmed.hasSuffix("%"), let value = Double(trimmed.dropLast()) {
self = .percentage(value / 100.0)
return
}
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: #"Expected a number or a percentage string like "100%""#
)
}
/// Resolve the dimension against a total length.
func resolve(against length: CGFloat) -> CGFloat {
switch self {
case .points(let value):
return CGFloat(value)
case .percentage(let ratio):
return length * CGFloat(ratio)
}
}
}
@@ -0,0 +1,50 @@
import CoreGraphics
import Foundation
/// Insets (in points) from the screen edges, defining the content rectangle a
/// window is sized and placed within.
///
/// Margins compose with `width`/`height`: a `width = "100%"` window spans the
/// space *between* the leading/trailing margins rather than the full screen.
/// Unspecified edges are left untouched (treated as 0).
struct PHThemeWindowMargin: Decodable, Equatable {
let top: Double?
let bottom: Double?
let leading: Double?
let trailing: Double?
private enum CodingKeys: String, CodingKey {
case top
case bottom
case leading = "left"
case trailing = "right"
}
init(
top: Double? = nil,
bottom: Double? = nil,
leading: Double? = nil,
trailing: Double? = nil
) {
self.top = top
self.bottom = bottom
self.leading = leading
self.trailing = trailing
}
/// Returns `rect` inset by the specified edges; unspecified edges are left
/// untouched (treated as 0). Uses AppKit's coordinate system (origin at the
/// bottom-left, y increasing upward): `top` reduces the max-Y edge.
func inset(of rect: CGRect) -> CGRect {
let l = leading ?? 0
let r = trailing ?? 0
let t = top ?? 0
let b = bottom ?? 0
return CGRect(
x: rect.minX + l,
y: rect.minY + b,
width: rect.width - l - r,
height: rect.height - t - b
)
}
}
@@ -0,0 +1,10 @@
import Foundation
/// An absolute screen-space origin.
///
/// When set on a window, it overrides anchor/margin placement entirely the
/// window appears at exactly these coordinates.
struct PHThemeWindowPoint: Decodable, Equatable {
let x: Double
let y: Double
}
+83
View File
@@ -0,0 +1,83 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
//
// Swift Argument Parser
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
import ArgumentParser
import Foundation
@main
struct PHBar: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "phbar",
abstract: "Modular status bar for macOS.",
discussion: """
phbar renders a status bar on every visible monitor. Each status bar
is dynamically built from the given theme, window configuration,
and block set. Thanks to the modularity of phbar, each one of these
elements can be overwritten for any given monitor.
phbar relies on blocks to determine what is visible on a bar and how
it should look. Everything in phbar is a block, not just texts but
also spaces and dividers. Each block can have a different visual style,
refresh conditions, and content source. The content of a block, if any,
is computed by calling a shell script, giving you the maximum flexibility.
Run `phbar install` to create the required config directory filled with
a template to start with. The directory will be created in one of these
locations in order of priority: $XDG_CONFIG_HOME/phbar, ~/.config/phbar,
or ~/.phbar.
Edit `<config-directory>/blocks/default.toml` to customize the block set used
by default and `<config-directory>/themes/default.toml` to customize the look
and feel of the bar. Check out the full documentation to learn more about all
the possible customisations.
Run `phbar start` to launch the bar and verify the appearance and behaviour.
Once you're ready to go to production, you can create a launch agent that uses
the same command to start the bar automatically at login.
""",
version: "1.0.0",
subcommands: [Start.self, Install.self]
)
}
extension PHBar {
struct Error: LocalizedError {
let message: String
let underlyingError: Swift.Error?
init(_ message: String, underlyingError: Swift.Error? = nil) {
self.message = message
self.underlyingError = underlyingError
}
var errorDescription: String? {
guard let underlyingError else { return message }
if let decodingError = underlyingError as? DecodingError {
return "\(message): \(Self.describe(decodingError))"
}
return "\(message): \(underlyingError.localizedDescription)"
}
private static func describe(_ error: DecodingError) -> String {
switch error {
case .keyNotFound(let key, let context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
return "missing key '\(key.stringValue)' at \(path.isEmpty ? "root" : path)"
case .typeMismatch(let type, let context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
return "type mismatch for \(type) at \(path)"
case .valueNotFound(let type, let context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
return "missing value of type \(type) at \(path)"
case .dataCorrupted(let context):
return "corrupted data → \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import AppKit
final class BackgroundView: NSView {
var hasShadow: Bool? = false {
didSet { configureShadow() }
}
var blur: CGFloat? = 0 {
didSet { configureBlur() }
}
init(controller: BarController) {
if let hasShadow = controller.window.hasShadow { self.hasShadow = hasShadow }
if let blur = controller.window.blur { self.blur = blur }
super.init(frame: .zero)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
wantsLayer = true
configureShadow()
configureBlur()
}
// Blur is applied to the *window*, not the layer, so we need a window
// to exist first. Re-apply whenever the view moves to a (possibly new) window.
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
configureShadow()
configureBlur()
}
private func configureShadow() {
guard let window, let hasShadow else { return }
window.hasShadow = hasShadow
}
private func configureBlur() {
guard let window, let blur else { return }
// Window must be non-opaque for the desktop/other windows behind it
// to be visible at all, let alone blurred.
window.isOpaque = false
// Near-zero (not fully .clear) alpha keeps the window eligible for
// compositor blur on some macOS versions; fully transparent windows
// are sometimes excluded from the blur pass. This mirrors Kitty's
// approach (NSColor(white: 0, alpha: 0.001) vs .clear).
if blur > 0 {
window.backgroundColor = NSColor(white: 0, alpha: 0.001)
} else {
window.backgroundColor = .clear
}
let radius = Int32(clamping: Int(blur.rounded()))
let applied = CGSPrivate.applyBlur(to: window, radius: radius)
if !applied {
// Private symbols unavailable/renamed on this macOS version.
// Fails silently rather than crashing; falls back to no blur.
#if DEBUG
print("BackgroundView: failed to apply private window blur (radius: \(radius))")
#endif
}
}
}
// HACK: - Private CoreGraphics Services blur API
// This mirrors what Kitty terminal does on macOS: dlopen/dlsym the private
// CGSSetWindowBackgroundBlurRadius symbol at runtime rather than linking
// against it directly. This is UNDOCUMENTED, PRIVATE API:
// - Works reliably in practice (Kitty ships it to a huge user base)
// - Can break on future macOS updates with no notice
// - Will get an app rejected from the Mac App Store
private enum CGSPrivate {
typealias ConnectionFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias BlurFn = @convention(c) (UnsafeMutableRawPointer?, Int, Int32) -> OSStatus
static let getConnection: ConnectionFn? = {
guard
let handle = dlopen(
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_NOW),
let sym = dlsym(handle, "CGSDefaultConnectionForThread")
else { return nil }
return unsafeBitCast(sym, to: ConnectionFn.self)
}()
static let setBlurRadius: BlurFn? = {
guard
let handle = dlopen(
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_NOW),
let sym = dlsym(handle, "CGSSetWindowBackgroundBlurRadius")
else { return nil }
return unsafeBitCast(sym, to: BlurFn.self)
}()
/// Returns true if the private symbols were resolved and the call was issued.
@MainActor
@discardableResult
static func applyBlur(to window: NSWindow, radius: Int32) -> Bool {
guard let getConnection, let setBlurRadius else { return false }
let connection = getConnection()
let status = setBlurRadius(connection, window.windowNumber, radius)
return status == noErr
}
}
+106
View File
@@ -0,0 +1,106 @@
import SwiftUI
@MainActor
final class BarController: ObservableObject {
let config: PHConfig
let screen: NSScreen
let theme: PHTheme
let window: PHThemeWindow
@Published var blocks: [PHBlock]
var arrangedBlocks: [PHBlock] {
blocks.filter { $0.centered != true }
}
var centeredBlocks: [PHBlock] {
blocks.filter { $0.centered == true }
}
init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
self.config = config
self.screen = screen
self.theme = theme
self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = layout.blocks
for block in blocks {
block.text = text(for: block)
block.style = style(for: block)
block.debug = debug
}
}
}
// Theming
extension BarController {
/// Resolve the window definition for a given screen, applying any
/// per-monitor override from the config. Selection lives on `PHConfig`
/// (see `window(screenName:screenIndex:)`); this looks the name up in the
/// theme, falling back to "default".
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
let name = config.window(
screenName: screen.localizedName,
screenIndex: NSScreen.screens.firstIndex(of: screen)
)
return resolve(window: name, from: theme)
}
/// Look up a window definition by name, falling back to "default" then the
/// built-in default.
static func resolve(window name: String, from theme: PHTheme) -> PHThemeWindow {
guard let window = theme.windows?.first(where: { $0.name == name }) else {
guard let defaultWindow = theme.windows?.first(where: { $0.name == "default" }) else {
return PHThemeWindow.default
}
return defaultWindow
}
return window
}
func text(for block: PHBlock) -> PHThemeText {
guard let text = theme.texts?.first(where: { $0.name == block.textName }) else {
guard let defaultText = theme.texts?.first(where: { $0.name == "default" }) else {
return PHThemeText.default
}
return defaultText
}
return text
}
func style(for block: PHBlock) -> PHThemeStyle {
guard let style = theme.styles?.first(where: { $0.name == block.styleName }) else {
guard let defaultStyle = theme.styles?.first(where: { $0.name == "default" }) else {
return PHThemeStyle.default
}
return defaultStyle
}
return style
}
}
// Refresh
extension BarController {
/// Start auto-refresh for every block.
func startAutoRefresh() {
for block in blocks {
block.startAutoRefresh()
}
}
/// Stop auto-refresh for every block.
func stopAutoRefresh() {
for block in blocks {
block.stopAutoRefresh()
}
}
/// Force every block to recompute its label immediately (manual refresh).
/// Each block updates concurrently; the view refreshes as results arrive.
func refresh() {
for block in blocks {
block.resolve()
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import SwiftUI
struct BarView: View {
@StateObject private var bar: BarController
init(controller: BarController) {
self._bar = StateObject(wrappedValue: controller)
}
var body: some View {
ZStack {
HStack(alignment: .center, spacing: 0) {
ForEach(bar.arrangedBlocks) { block in
switch block.kind {
case .text:
TextView(block: block)
case .space:
SpaceView(block: block)
case .divider:
DividerView(block: block)
}
}
}
HStack(alignment: .center, spacing: 0) {
ForEach(bar.centeredBlocks) { block in
switch block.kind {
case .text:
TextView(block: block)
case .space:
SpaceView(block: block)
case .divider:
DividerView(block: block)
}
}
}
}
.frame(height: bar.window.height ?? 30)
.ignoresSafeArea()
.environmentObject(bar)
}
}
+93
View File
@@ -0,0 +1,93 @@
import AppKit
import SwiftUI
final class BarWindow: NSPanel {
let barController: BarController
var backgroundView: BackgroundView!
var barView: NSHostingView<BarView>!
init(controller: BarController) {
self.barController = controller
super.init(
contentRect: Self.computeFrame(from: barController),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
// self.delegate = self
self.title = "PMenu"
self.isFloatingPanel = true
self.level = .floating
self.animationBehavior = .utilityWindow
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
self.acceptsMouseMovedEvents = false
self.isMovable = false
self.ignoresMouseEvents = false
self.hidesOnDeactivate = false
self.titleVisibility = .hidden
self.titlebarAppearsTransparent = true
self.isRestorable = false
self.displaysWhenScreenProfileChanges = true
self.backgroundView = BackgroundView(controller: barController)
backgroundView.autoresizingMask = [.width, .height]
backgroundView.frame = self.contentView?.bounds ?? .zero
self.barView = NSHostingView(rootView: BarView(controller: barController))
barView.autoresizingMask = [.width, .height]
barView.frame = contentView?.bounds ?? .zero
self.backgroundView.addSubview(barView)
self.contentView = self.backgroundView
}
}
extension BarWindow {
/// Recompute and apply the frame from the controller's screen and theme.
/// Used when a screen's geometry changes (resolution, layout) so the bar
/// stays correctly anchored without rebuilding the whole window.
func recomputeFrame() {
setFrame(Self.computeFrame(from: barController), display: true)
}
/// Calculate window frame and position from the theme window definition,
/// resolved against the controller's screen.
static func computeFrame(from controller: BarController) -> NSRect {
let screenFrame = controller.screen.frame
let themeWindow = controller.window
// Absolute origin bypasses the anchor/margin system entirely: width
// resolves against the full screen and the window sits at the exact
// coordinates given.
if let origin = themeWindow.origin {
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: screenFrame.width),
height: CGFloat(themeWindow.height ?? 30)
)
return NSRect(
x: CGFloat(origin.x),
y: CGFloat(origin.y),
width: size.width,
height: size.height
)
}
// Margins define a content rectangle within the screen: the window is
// sized against it (so `width = "100%"` spans only between the margins,
// not the full screen) and placed by the anchor inside it.
let margin = themeWindow.margin ?? .init()
let contentRect = margin.inset(of: screenFrame)
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: contentRect.width),
height: CGFloat(themeWindow.height ?? 30)
)
let anchor = themeWindow.anchor ?? .top
return NSRect(
origin: anchor.origin(in: contentRect, size: size),
size: size
)
}
}
+35
View File
@@ -0,0 +1,35 @@
import SwiftUI
struct DividerView: View {
@ObservedObject var block: PHBlock
var width: CGFloat {
guard let arg = block.name.split(separator: " ").last else { return 1 }
guard let width = Double(arg) else { return 1 }
return CGFloat(width)
}
var body: some View {
ZStack {
Rectangle()
.foregroundStyle(block.style.background?.uiColor ?? .clear)
Group {
if let corner = block.style.corner {
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
} else {
Rectangle()
}
}
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
.frame(width: width)
.padding(.trailing, block.style.padding?.trailing ?? 0)
.padding(.leading, block.style.padding?.leading ?? 0)
.padding(.top, block.style.padding?.top ?? 0)
.padding(.bottom, block.style.padding?.bottom ?? 0)
.border(block.debug ? .blue : .clear)
}
.fixedSize(horizontal: true, vertical: false)
.border(block.debug ? .red : .clear)
}
}
+17
View File
@@ -0,0 +1,17 @@
import SwiftUI
struct SpaceView: View {
@ObservedObject var block: PHBlock
var width: CGFloat? {
guard let arg = block.name.split(separator: " ").last else { return nil }
guard let width = Double(arg) else { return nil }
return CGFloat(width)
}
var body: some View {
Rectangle()
.fill(block.style.background?.uiColor ?? .clear)
.frame(width: width)
}
}
+59
View File
@@ -0,0 +1,59 @@
import SwiftUI
struct TextView: View {
@EnvironmentObject private var bar: BarController
@ObservedObject var block: PHBlock
@State var hovering = false
private var adjustOffset: CGAffineTransform {
guard let offset = block.text.offset else { return .init() }
return .init(translationX: 0, y: offset)
}
var body: some View {
if let label = block.label, !label.isEmpty {
ZStack(alignment: .center) {
Group {
if let corner = block.style.corner {
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
} else {
Rectangle()
}
}
.foregroundStyle(block.style.background?.uiColor ?? .clear)
Group {
Text(label)
.font(block.text.font)
.italic(block.text.italic)
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
.transformEffect(adjustOffset)
.border(block.debug ? .blue : .clear)
}
.padding(.trailing, block.style.padding?.trailing ?? 0)
.padding(.leading, block.style.padding?.leading ?? 0)
.padding(.top, block.style.padding?.top ?? 0)
.padding(.bottom, block.style.padding?.bottom ?? 0)
}
.fixedSize(horizontal: true, vertical: false)
.border(block.debug ? .red : .clear)
.onHover { hovering = $0 }
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown,
.otherMouseDown,
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.resolve(with: gestureEvent)
}
return event
}
}
} else {
EmptyView()
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - window: a [[window]] definition from the theme file
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default"
window = "default"
layout = "default"
# Optional: override the window and/or layout per monitor.
# - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value.
#
# [monitor."1"]
# window = "bottom"
# layout = "laptop"
#
# [monitor."DELL U2723QE"]
# window = "clock"
# layout = "external"
+31
View File
@@ -0,0 +1,31 @@
[[block]]
name = "version"
style = "elevated"
[[block]]
name = "_space 14"
[[block]]
name = "attribution"
command = 'echo "by Panini House"'
text = "italic"
[[block]]
name = "_space"
[[block]]
name = "gesture"
[[block]]
name = "_space 14"
[[block]]
name = "clock"
refresh = 1
style = "elevated"
[[block]]
name = "greetings"
command = 'echo "􀟱 Hi $USER", welcome to phbar!'
text = "italic"
centered = true
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "􀐬 $(date "+%a %d, %H:%M:%S")"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
text="Click me"
case $GESTURE in
"left_mouse_down") text="That's left mouse button!" ;;
"right_mouse_down") text="That's right mouse button!" ;;
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
"scroll_wheel") text="Yes, you can even scroll!" ;;
esac
echo "􀭆 $text"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "phbar v$(phbar --version)"
+36
View File
@@ -0,0 +1,36 @@
# phbar theme file
#
# Place at ~/.config/phbar/themes/default.toml
[[window]]
name = "default"
anchor = "top"
height = 30
width = "100%"
[[text]]
name = "default"
font = "monospace"
size = 14
weight = "regular"
style = "normal"
offset = -1
[[text]]
name = "italic"
font = "monospace"
size = 14
weight = "regular"
style = "italic"
offset = -1
[[style]]
name = "default"
foreground = { color = "#aed3f3" }
background = { color = "#010408" }
[[style]]
name = "elevated"
foreground = { color = "#aed3f3" }
background = { color = "#0f304a" }
padding = { left = 12.0, right = 12.0 }
-14
View File
@@ -1,14 +0,0 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
//
// Swift Argument Parser
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
import ArgumentParser
@main
struct phbar: ParsableCommand {
mutating func run() throws {
print("Hello, world!")
}
}
@@ -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 the events directory
/// (`<config>/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
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import Foundation
import Testing
@testable import phbar
@MainActor
struct AppDelegateTests {
// MARK: - ScreenSync.diff
@Test func diffReportsAddedWhenNothingOccupied() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [screen], occupied: [])
#expect(plan.added == [screen])
#expect(plan.removed.isEmpty)
#expect(plan.kept.isEmpty)
}
@Test func diffReportsRemovedWhenScreenGone() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [], occupied: [screen])
#expect(plan.removed == [screen])
#expect(plan.added.isEmpty)
#expect(plan.kept.isEmpty)
}
@Test func diffKeepsScreenStillAttached() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [screen], occupied: [screen])
#expect(plan.kept == [screen])
#expect(plan.added.isEmpty)
#expect(plan.removed.isEmpty)
}
/// A disconnected monitor reappears as a *new* `NSScreen` instance, so the
/// same physical display must be reported as remove + add, never a keep.
@Test func diffTreatsDistinctInstancesAsRemoveThenAdd() throws {
let main = try #require(NSScreen.main)
guard let other = NSScreen.screens.first(where: { $0 !== main }) else {
// Single-monitor environment: nothing to contrast against. Verify
// identity semantics hold for the same object re-presented instead.
let kept = PHScreenSync.diff(current: [main], occupied: [main])
#expect(kept.kept == [main])
return
}
let plan = PHScreenSync.diff(current: [other], occupied: [main])
#expect(plan.removed == [main])
#expect(plan.added == [other])
#expect(plan.kept.isEmpty)
}
@Test func diffIgnoresOrdering() throws {
let main = try #require(NSScreen.main)
guard let other = NSScreen.screens.first(where: { $0 !== main }) else {
// Single-monitor environment: ordering is trivially stable.
return
}
let plan = PHScreenSync.diff(current: [other, main], occupied: [main, other])
#expect(Set(plan.kept.map(ObjectIdentifier.init)) == Set([main, other].map(ObjectIdentifier.init)))
#expect(plan.added.isEmpty)
#expect(plan.removed.isEmpty)
}
// MARK: - BarControllerFactory
@Test func factoryBuildsControllerForScreen() throws {
let screen = try #require(NSScreen.main)
let config = try PHConfig.load()
let factory = PHBarFactory(config: config, environment: .process, debug: false)
let controller = try factory.make(for: screen)
#expect(controller.screen === screen)
#expect(controller.blocks.isEmpty == false)
}
}
+41
View File
@@ -0,0 +1,41 @@
import AppKit
import Foundation
import Testing
@testable import phbar
// MARK: - Refresh command
@Test func refreshCommandConfigurationIsCorrect() async throws {
let config = PHBar.Refresh.configuration
#expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.")
}
// MARK: - BarController refresh
@MainActor
@Test func controllerRefreshUpdatesAllBlocks() async throws {
let screen = try #require(NSScreen.main)
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf a"
name = "a"
[[block]]
command = "printf b"
name = "b"
""")
let config = try PHConfig.load()
let theme = try PHTheme.load("voltage")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
controller.refresh()
// Allow the per-block update tasks to run.
try await Task.sleep(for: .milliseconds(100))
#expect(blocks[0].label == "a")
#expect(blocks[1].label == "b")
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Payload Types for Testing
private struct TestPayload: Codable, Equatable {
let message: String
let count: Int
}
// MARK: - Notification Name
@Test func notificationNameIsHashable() async throws {
let a = IPCNotificationName("com.example.test")
let b = IPCNotificationName("com.example.test")
let c = IPCNotificationName("com.example.other")
#expect(a == b)
#expect(a != c)
#expect(a.hashValue == b.hashValue)
}
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
let name: IPCNotificationName = "com.example.test"
#expect(name.rawValue == "com.example.test")
}
@Test func notificationNameRawRepresentable() async throws {
let name = IPCNotificationName(rawValue: "com.example.test")
#expect(name.rawValue == "com.example.test")
}
// MARK: - Payload Decoding
@Test func decodePayloadFromNotification() async throws {
let payload = TestPayload(message: "hello", count: 42)
let userInfo: [String: Any] = ["message": "hello", "count": 42]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == payload)
}
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
let notification = IPCNotification(name: .refresh, userInfo: nil)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
@Test func decodePayloadReturnsNilForInvalidData() async throws {
let userInfo: [String: Any] = ["wrong": "data"]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
// MARK: - Posting
@Test func postWithoutPayloadDoesNotCrash() async throws {
IPC.post(.refresh)
}
@Test func postWithUserInfoDoesNotCrash() async throws {
IPC.post(.refresh, userInfo: ["key": "value"])
}
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
let payload = TestPayload(message: "test", count: 1)
let result = IPC.post(.refresh, payload: payload)
#expect(result == true)
}
// MARK: - Observer
@Test func observerCleansUpOnDeinit() async throws {
// Verify that creating and releasing an observer doesn't crash.
// The token removes itself from the distributed center on deinit.
let token = IPC.observe(.refresh) { _ in }
withExtendedLifetime(token) {}
}
+189
View File
@@ -0,0 +1,189 @@
import AppKit
import Foundation
import Testing
@testable import phbar
// MARK: - Loading
@MainActor
@Test func loadBlocksFromTOML() throws {
let toml = """
[[block]]
command = "echo hello"
name = "greeting"
refresh = 5.0
[[block]]
command = "echo world"
name = "world"
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks.count == 2)
#expect(blocks[0].command == "echo hello")
#expect(blocks[0].name == "greeting")
#expect(blocks[0].refresh == 5.0)
#expect(blocks[0].label == nil)
#expect(blocks[1].name == "world")
#expect(blocks[1].refresh == nil)
}
@MainActor
@Test func loadBlocksRejectsInvalidTOML() {
#expect(throws: (any Error).self) {
try PHBlock.load(from: "not = valid = toml = =")
}
}
// MARK: - Named set loading
private func makeBlocksConfigDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@MainActor
@Test func loadSetReadsNamedFile() throws {
let dir = try makeBlocksConfigDir()
defer { try? FileManager.default.removeItem(at: dir) }
let blocksDir = dir.appending(path: "blocks")
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
try Data("""
[[block]]
command = "echo hi"
name = "greeting"
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
let blocks = try PHBlock.load("laptop", in: dir)
#expect(blocks.count == 1)
#expect(blocks[0].name == "greeting")
}
@MainActor
@Test func loadSetThrowsForMissingSet() {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
#expect(throws: (any Error).self) {
try PHBlock.load("nope", in: dir)
}
}
// MARK: - Compute
@MainActor
@Test func computeSeesBlockEnvironment() async throws {
// The `[env]` section (overlaid by `BarController`) must reach the script's
// process environment this is the wiring the `env` config documents.
let block = try PHBlock.load(from: """
[[block]]
name = "env"
command = "echo $PHBAR_TEST_VAR"
""")[0]
block.environment = PHEnvironment(variables: ["PHBAR_TEST_VAR": "panini"])
let output = await block.compute()
#expect(output == "panini")
}
@MainActor
@Test func computeReturnsCommandStdout() async throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf panini"
name = "panini"
""")
let output = await blocks[0].compute()
#expect(output == "panini")
}
@MainActor
@Test func computeRunsInConfigDirectory() async throws {
// Block commands execute with the resolved config root (see `PHPaths`) as
// their working directory, so a block from any set resolves relative paths
// the same way. Guards against the config dir being absent in sandboxed CIs.
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
let blocks = try PHBlock.load(from: """
[[block]]
command = "pwd"
name = "pwd"
""")
let output = await blocks[0].compute()
// Resolve symlinks on both sides: the config dir is commonly a symlink into
// a dotfiles repo, and `pwd` reports the physical path.
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
#expect(output == expected)
}
// MARK: - Auto-refresh
@MainActor
@Test func updateSetsLabel() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf panini"
name = "panini"
""")[0]
#expect(block.label == nil)
await block.update()
#expect(block.label == "panini")
}
@MainActor
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf hi"
name = "hi"
""")[0]
block.startAutoRefresh()
// Allow the one-shot update task to run.
try await Task.sleep(for: .milliseconds(100))
#expect(block.label == "hi")
block.stopAutoRefresh()
}
@MainActor
@Test func startAutoRefreshRepeatsAtInterval() async throws {
// A counter file lets us observe how many times the command ran.
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_test_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let toml = """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "counter"
refresh = 0.05
"""
let block = try PHBlock.load(from: toml)[0]
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(250))
block.stopAutoRefresh()
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
// At ~20Hz over 250ms the command should have run more than once.
#expect(count >= 2)
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Per-monitor resolution
//
// `PHConfig` exposes one resolution API: `theme`/`window`/`blocks`/
// `monitorOverride`, each taking a pure `(screenName, screenIndex)` pair.
// Precedence for every field: override-by-name override-by-index global.
/// Build a `PHConfig` with sensible defaults so each test only spells out the
/// fields it cares about.
private func makeConfig(
theme: String = "default",
window: String = "default",
blocks: String? = nil,
monitors: [String: PHConfigMonitorOverride]? = nil
) -> PHConfig {
PHConfig(theme: theme, window: window, blocks: blocks, env: nil, monitors: monitors)
}
// MARK: window
@Test func windowFallsBackToGlobal() {
let config = makeConfig(window: "top")
#expect(config.window(screenName: "Built-in", screenIndex: 0) == "top")
}
@Test func windowMatchesByIndex() {
let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)])
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
}
@Test func windowMatchesByName() {
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)])
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
}
@Test func windowPrefersNameOverIndex() {
let config = makeConfig(monitors: [
"0": .init(window: "byIndex", layout: nil),
"DELL": .init(window: "byName", layout: nil),
])
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
}
// MARK: blocks
@Test func blocksDefaultsToDefault() {
#expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
}
@Test func blocksUsesGlobalWhenNoOverride() {
let config = makeConfig(blocks: "main")
#expect(config.layout(screenName: "S", screenIndex: 0) == "main")
}
@Test func blocksOverrideBeatsGlobal() {
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
#expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
}
// MARK: theme
@Test func themeFallsBackToGlobal() {
let config = makeConfig(theme: "voltage")
#expect(config.theme(screenName: "S", screenIndex: 0) == "voltage")
}
@Test func themeOverrideBeatsGlobal() {
let config = makeConfig(theme: "voltage", monitors: ["1": .init(theme: "mono", window: nil, blocks: nil)])
#expect(config.theme(screenName: "S", screenIndex: 1) == "mono")
}
// MARK: independence
@Test func overrideInheritsUnsetFieldsFromGlobal() {
// An override that sets only `blocks` still inherits the global window/theme.
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")])
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
#expect(config.layout(screenName: "S", screenIndex: 1) == "laptop")
}
@Test func themeWindowAndBlocksResolveIndependently() {
let config = makeConfig(
theme: "voltage", window: "top", blocks: "main",
monitors: [
"DELL": .init(theme: "mono", window: "clock", blocks: nil), // inherits blocks "main"
"1": .init(theme: nil, window: nil, blocks: "laptop"), // inherits theme/window
]
)
#expect(config.theme(screenName: "DELL", screenIndex: 0) == "mono")
#expect(config.window(screenName: "DELL", screenIndex: 0) == "clock")
#expect(config.blocks(screenName: "DELL", screenIndex: 0) == "main")
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
}
// MARK: monitorOverride
@Test func monitorOverrideReturnsNilWhenTableAbsent() {
#expect(makeConfig().monitorOverride(screenName: "S", screenIndex: 0) == nil)
}
@Test func monitorOverrideReturnsNilForUnmatchedScreen() {
let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)])
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
}
+225
View File
@@ -0,0 +1,225 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Fake event source
/// A no-op source that records its lifecycle and can be fired on demand.
@MainActor
private final class FakeEventSource: PHEventSource {
private(set) var startCount = 0
private(set) var stopCount = 0
private var notify: (@MainActor @Sendable () -> Void)?
func start(notify: @escaping @MainActor @Sendable () -> Void) {
startCount += 1
self.notify = notify
}
func stop() {
stopCount += 1
notify = nil
}
func fire() { notify?() }
}
// MARK: - Event decoding
@MainActor
@Test func eventDecodesFromKnownStrings() throws {
let toml = """
[[block]]
command = "echo hi"
name = "a"
events = ["volume", "network", "appearance", "power", "mpd"]
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
}
@MainActor
@Test func eventAcceptsArbitraryStringAsCustom() throws {
// Unknown strings become custom event names resolved at runtime against
// `<config>/events/<name>/`.
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
name = "b"
events = ["totally_made_up"]
""")
#expect(blocks[0].events == [PHEvent("totally_made_up")])
}
@MainActor
@Test func eventsOptionalWhenOmitted() throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
name = "c"
""")
#expect(blocks[0].events == nil)
}
// MARK: - Event loader
@MainActor
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
// No recognizer installed at a clean directory factory yields nil.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
return PHExternalEventSource(recognizer: recognizer)
}
#expect(factory(PHEvent("anything")) == nil)
}
@MainActor
@Test func eventLoaderReturnsNilForMissingEvent() {
// A clean directory has no event folders, so every name fails to load.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
#expect(recognizer == nil)
}
// MARK: - Event registry
@MainActor
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
#expect(fake.startCount == 0)
#expect(fake.stopCount == 0)
var fired = 0
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
// A second subscriber must reuse the already-running source.
let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
// One firing fans out to both subscribers.
fake.fire()
#expect(fired == 2)
// Cancelling one keeps the source alive for the other.
s1.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 0)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
fake.fire()
#expect(fired == 3)
// Cancelling the last subscriber tears the source down.
s2.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
}
@MainActor
@Test func registryStartsSeparateSourcePerEvent() async throws {
let volumeFake = FakeEventSource()
let networkFake = FakeEventSource()
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
if event == PHEvent("volume") { return volumeFake }
return networkFake
}
let registry = PHEventRegistry(factory: factory)
let v = registry.subscribe(PHEvent("volume")) {}
let n = registry.subscribe(PHEvent("network")) {}
#expect(volumeFake.startCount == 1)
#expect(networkFake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
v.cancel()
n.cancel()
}
// MARK: - Block + events
@MainActor
@Test func blockRefreshesOnEvent() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_evt_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "d"
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
#expect(fake.startCount == 1)
// Firing the event triggers a second refresh.
fake.fire()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
// Stopping cancels the subscription and tears the source down.
block.stopAutoRefresh()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
}
@MainActor
@Test func blockCombinesIntervalAndEvents() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_combo_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "e"
refresh = 0.2
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(50))
let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterInitial == 1)
#expect(fake.startCount == 1)
// Both an event and the interval can drive updates independently.
fake.fire()
try await Task.sleep(for: .milliseconds(50))
let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterEvent >= 2)
block.stopAutoRefresh()
}
+58
View File
@@ -0,0 +1,58 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Config directory cascade
//
// `PHPaths` resolves the config root in priority order:
// 1. $XDG_CONFIG_HOME/phbar (only for an absolute XDG path)
// 2. ~/.config/phbar
// 3. ~/.phbar
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
#expect(candidates.count == 3)
#expect(candidates[0].path == "/custom/xdg/phbar")
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
#expect(candidates[2].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitRelativeXdg() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
#expect(candidates.count == 2)
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
#expect(candidates[1].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitEmptyXdg() {
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
#expect(candidates.count == 2)
}
@Test func pathsCandidatesOmitXdgWhenUnset() {
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
#expect(candidates.count == 2)
}
@Test func pathsResolvePicksFirstExistingCandidate() throws {
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
// it sorts first in the candidate list.
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
let xdgConfig = xdgRoot.appending(path: "phbar")
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: xdgRoot) }
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
#expect(resolved == xdgConfig)
}
@Test func pathsConfigDirectoryMatchesResolve() {
// The cached static agrees with a fresh resolve against the live process env.
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
}
+166
View File
@@ -0,0 +1,166 @@
import AppKit
import CoreGraphics
import Foundation
import Testing
@testable import phbar
// MARK: - PHThemeWindowDimension
private struct DimensionWrapper: Decodable {
let v: PHThemeWindowDimension
}
@Test func dimensionDecodesPoints() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
}
@Test func dimensionDecodesPercentage() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
}
@Test func dimensionRejectsGarbage() {
let decoder = JSONDecoder()
#expect(throws: (any Error).self) {
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
}
}
@Test func dimensionResolvesAgainstLength() {
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
}
// MARK: - PHThemeWindowAnchor geometry
@Test func anchorPlacesAtTopEdge() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
#expect(origin == CGPoint(x: 0, y: 770))
}
@Test func anchorRespectsTopMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 0, y: 760))
}
@Test func anchorPlacesBottomLeadingCorner() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 12, y: 8))
}
@Test func anchorTrailingCentersVertically() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersWithinContentRect() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
// `.center` centers within the inset rect: a top margin lifts the center,
// it doesn't ignore the margin.
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
// rect: y [0, 700], midY = 350 350 20 = 330
#expect(origin == CGPoint(x: 400, y: 330))
}
// MARK: - PHThemeWindowMargin.inset
@Test func marginInsetAppliesAllEdges() {
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
let inset = PHThemeWindowMargin(top: 10, bottom: 20, leading: 30, trailing: 40).inset(of: rect)
#expect(inset == CGRect(x: 30, y: 20, width: 930, height: 770))
}
@Test func marginInsetLeavesUnspecifiedEdgesUntouched() {
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
// Only leading set; top/bottom/trailing are left as-is (treated as 0).
let inset = PHThemeWindowMargin(leading: 50).inset(of: rect)
#expect(inset == CGRect(x: 50, y: 0, width: 950, height: 800))
}
// MARK: - BarWindow.computeFrame
@MainActor
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
let screen = try #require(NSScreen.main)
// A window that omits geometry exercises computeFrame's defaults:
// width 100%, top anchor, no margin a full-screen top bar. Built locally
// (rather than loading the bundled theme) so the test tracks the engine's
// defaults, not the theme file's editorial margins.
let theme = PHTheme(
windows: [PHThemeWindow(name: "default")],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == screen.frame.width)
#expect(frame.minX == screen.frame.minX)
#expect(frame.minY == screen.frame.maxY - 30)
}
@MainActor
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
let screen = try #require(NSScreen.main)
let theme = PHTheme(
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.origin == CGPoint(x: 100, y: 200))
#expect(frame.height == 30)
}
@MainActor
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
// Regression: `width = "100%"` previously ignored leading/trailing margins
// and spanned edge to edge. Margins now inset the content rectangle, so the
// bar spans only between them.
let screen = try #require(NSScreen.main)
let sf = screen.frame
let theme = PHTheme(
windows: [PHThemeWindow(
name: "inset",
width: .percentage(1.0),
anchor: .top,
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
)],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == sf.width - 40)
#expect(frame.minX == sf.minX + 20)
#expect(frame.maxX == sf.maxX - 20)
}
-9
View File
@@ -1,9 +0,0 @@
import Testing
@testable import phbar
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}