Compare commits
48 Commits
a96efee4cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f70ea7f0f5
|
|||
|
4a74cf04c8
|
|||
|
3e9557586b
|
|||
|
f479499309
|
|||
|
30be4b2901
|
|||
|
ed01251ca5
|
|||
|
1867a4c0a6
|
|||
|
a894449d15
|
|||
|
a49f78cbb0
|
|||
|
1a2eddcd5d
|
|||
|
909d890837
|
|||
|
87070dd62f
|
|||
|
39457d8faf
|
|||
|
a963a19bf3
|
|||
|
aa241148ef
|
|||
|
07a05e5470
|
|||
|
94c83d74a4
|
|||
|
33c4cbe26e
|
|||
|
3c5222d800
|
|||
|
27fad1d67c
|
|||
|
9a7710d675
|
|||
|
f5e1ba1104
|
|||
|
77717c9654
|
|||
|
d4d1b7bec4
|
|||
|
b872659796
|
|||
|
0ff1664974
|
|||
|
19a8f09e5a
|
|||
|
6de06ac8e1
|
|||
|
24c53eb51d
|
|||
|
a6a7f80cce
|
|||
|
8315b8a6e3
|
|||
|
34921ca377
|
|||
|
6e258fdffa
|
|||
|
3da4c8e466
|
|||
|
70a170f544
|
|||
|
7c0551c662
|
|||
|
0edc4ca47a
|
|||
|
eebbe3cbb8
|
|||
|
2b1f94f220
|
|||
|
34fc5a4c0d
|
|||
|
289cc6eb61
|
|||
|
d8d5e38d33
|
|||
|
2d047d69b6
|
|||
|
a872ca1d6b
|
|||
|
d0b945574a
|
|||
|
0ed9e4dbf2
|
|||
|
2d6348c901
|
|||
|
7d281d1039
|
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 recognizer→main 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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo " $(date "+%a %d, %H:%M:%S")"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "phbar v$(phbar --version)"
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user