full scope implementation

This commit is contained in:
2026-07-11 21:56:47 +02:00
parent 7d281d1039
commit 2d6348c901
36 changed files with 2138 additions and 66 deletions
+9 -1
View File
@@ -20,7 +20,15 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "TOML", package: "swift-toml"), .product(name: "TOML", package: "swift-toml"),
] ],
resources: [
.process("config.toml"),
.process("Themes/voltage.toml"),
// .process("Themes/catppuccin-mocha.toml"),
.process("Themes/dracula.toml"),
// .process("Themes/gruvbox.toml"),
// .process("Themes/tokyo-night.toml"),
],
), ),
.testTarget( .testTarget(
name: "phbarTests", name: "phbarTests",
+17 -2
View File
@@ -4,20 +4,35 @@ import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
var barController: PHController! var barController: PHController!
var window: BarWindow! var window: BarWindow!
private var observers: [IPCObserver] = []
init(screen: NSScreen, blocks: [PHBlock]) { init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool = false) {
super.init() super.init()
self.barController = PHController(screen: screen, blocks: blocks) self.barController = PHController(config: config, screen: screen, theme: theme, blocks: blocks)
barController.debug = debug
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
window = BarWindow(controller: barController) window = BarWindow(controller: barController)
window.orderFront(nil) window.orderFront(nil)
barController.startAutoRefresh() barController.startAutoRefresh()
// 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() {
barController.refresh()
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
barController.stopAutoRefresh() barController.stopAutoRefresh()
observers.removeAll()
} }
} }
+14
View File
@@ -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)
}
}
}
+6 -1
View File
@@ -9,8 +9,13 @@ extension phbar {
abstract: "Start the status bar." abstract: "Start the status bar."
) )
@Flag(name: .long, help: "Display visual guides to help align elements.")
var debug: Bool = false
mutating func run() throws { mutating func run() throws {
let config = try PHConfig.load()
let screen = try targetScreen(monitor: 0) let screen = try targetScreen(monitor: 0)
let theme = try PHTheme.load("voltage")
// NOTE: Make sure NSApp.run() runs in the main thread // NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main)) dispatchPrecondition(condition: .onQueue(.main))
@@ -18,7 +23,7 @@ extension phbar {
try MainActor.assumeIsolated { try MainActor.assumeIsolated {
let blocks = try PHBlock.load() let blocks = try PHBlock.load()
let delegate = AppDelegate(screen: screen, blocks: blocks) let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
let app = NSApplication.shared let app = NSApplication.shared
app.setActivationPolicy(.accessory) app.setActivationPolicy(.accessory)
app.delegate = delegate app.delegate = delegate
+43 -4
View File
@@ -2,16 +2,55 @@ import SwiftUI
@MainActor @MainActor
final class PHController: ObservableObject { final class PHController: ObservableObject {
let config: PHConfig
let screen: NSScreen let screen: NSScreen
let theme: PHTheme
var debug: Bool = false {
didSet {
for block in blocks {
block.debug = debug
}
}
}
@Published var blocks: [PHBlock] @Published var blocks: [PHBlock]
init(screen: NSScreen, blocks: [PHBlock]) { var arrangedBlocks: [PHBlock] {
self.screen = screen blocks.filter { $0.centered != true }
self.blocks = blocks
} }
// MARK: - Refresh var centeredBlocks: [PHBlock] {
blocks.filter { $0.centered == true }
}
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock]) {
self.config = config
self.screen = screen
self.theme = theme
self.blocks = blocks
for block in blocks {
block.style = style(for: block)
}
}
}
// Style
extension PHController {
func style(for block: PHBlock) -> PHTheme.Style {
guard let style = theme.styles.first(where: { $0.name == block.styleName }) else {
guard let defaultStyle = theme.styles.first(where: { $0.name == "default" }) else {
return PHTheme.Style.default
}
return defaultStyle
}
return style
}
}
// Refresh
extension PHController {
/// Start auto-refresh for every block. /// Start auto-refresh for every block.
func startAutoRefresh() { func startAutoRefresh() {
for block in blocks { for block in blocks {
+36
View File
@@ -0,0 +1,36 @@
import Foundation
/// A subscribable system event.
///
/// Declared as a `String`-backed enum so it decodes straight from the TOML
/// config (e.g. `events = ["volume", "network"]`) while staying typo-proof: an
/// unknown value fails to load instead of silently doing nothing.
///
/// To add a new event: add a case here, conform a `PHEventSource` to it in
/// `PHEventRegistry.defaultFactory`, and ship the source under `Events/Sources/`.
enum PHEvent: String, CaseIterable, Sendable {
/// System output volume or default output device changes (CoreAudio).
case volume
/// Network reachability / interface changes (Network framework).
case network
/// Light/Dark appearance changes (DistributedNotificationCenter).
case appearance
/// Power source changes AC plug/unplug, battery updates (IOKit).
case power
/// Music Player Daemon state changes playback, playlist, etc. (MPD `idle`).
case mpd
}
extension PHEvent: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
guard let value = Self(rawValue: raw) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "unknown event '\(raw)'; valid events: \(PHEvent.allCases.map(\.rawValue).joined(separator: ", "))"
))
}
self = value
}
}
@@ -0,0 +1,92 @@
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 {
slots[event] = Slot(source: factory(event))
}
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
}
}
/// Maps each event to its concrete adapter.
static func defaultFactory(_ event: PHEvent) -> any PHEventSource {
switch event {
case .volume: return VolumeEventSource()
case .network: return NetworkEventSource()
case .appearance: return AppearanceEventSource()
case .power: return PowerEventSource()
case .mpd: return MPDEventSource()
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import Foundation
/// A single adapter that observes one kind of system event.
///
/// The registry owns source lifecycles: a source is `start`ed on its first
/// subscriber and `stop`ped when the last one cancels. Conformers are
/// `@MainActor` for their mutable state, but the `notify` closure they are
/// handed is `@MainActor`-isolated and `Sendable`, so an adapter that receives
/// callbacks on a background queue (CoreAudio, Network) must hop to the main
/// actor before calling it.
@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,34 @@
import Foundation
/// Fires when the system appearance switches between Light and Dark mode.
///
/// `AppleInterfaceThemeChangedNotification` is a distributed notification posted
/// system-wide, so it reaches the bar even when it isn't frontmost.
@MainActor
final class AppearanceEventSource: PHEventSource {
static let notificationName = Notification.Name("AppleInterfaceThemeChangedNotification")
private var observer: NSObjectProtocol?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
observer = DistributedNotificationCenter.default().addObserver(
forName: Self.notificationName,
object: nil,
queue: .main
) { _ in
Task { @MainActor in notify() }
}
}
func stop() {
guard started else { return }
started = false
if let observer {
DistributedNotificationCenter.default().removeObserver(observer)
}
observer = nil
}
}
@@ -0,0 +1,182 @@
import Foundation
import Network
/// Fires whenever the Music Player Daemon (MPD) state changes playback
/// start/stop/seek, playlist edits, volume, options, etc.
///
/// Connects to MPD's TCP socket (default `127.0.0.1:6600`) and issues the
/// `idle` command, which blocks server-side until a subsystem changes. When MPD
/// replies with `changed: ` lines terminated by `OK`, the source fires `notify`
/// and re-enters idle. The specifics of *what* changed are intentionally
/// ignored: blocks only need to know that the music state changed so they can
/// recompute. If the connection drops or MPD is unreachable, the source
/// reconnects after a short delay for as long as it is started.
@MainActor
final class MPDEventSource: PHEventSource {
/// Seconds to wait before retrying a failed/dropped connection.
static let reconnectDelay: Duration = .seconds(1)
/// The `idle` command, newline-terminated, as sent over the wire.
static let idleCommand = Data("idle\n".utf8)
private let host: NWEndpoint.Host
private let port: NWEndpoint.Port
private let queue = DispatchQueue(label: "phbar.mpd", qos: .utility)
private var started = false
/// Bumped on every `start`/`stop` so a reconnect scheduled by a torn-down
/// generation bails out instead of racing a fresh `start`.
private var generation = 0
private var connection: NWConnection?
private var notify: (@MainActor @Sendable () -> Void)?
/// Accumulates partial lines across `receive` callbacks (TCP is a stream).
private var buffer = Data()
init(host: String = "127.0.0.1", port: UInt16 = 6600) {
self.host = NWEndpoint.Host(host)
self.port = NWEndpoint.Port(rawValue: port) ?? .any
}
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
self.notify = notify
buffer.removeAll()
openConnection()
}
func stop() {
guard started else { return }
started = false
generation += 1
connection?.cancel()
connection = nil
notify = nil
buffer.removeAll()
}
// MARK: - Connection lifecycle
private func openConnection() {
guard started else { return }
let connection = NWConnection(host: host, port: port, using: .tcp)
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor [weak self] in
self?.handle(state: state, connection: connection)
}
}
connection.start(queue: queue)
self.connection = connection
}
private func handle(state: NWConnection.State, connection: NWConnection) {
// Only react for the currently-active connection; an old connection
// winding down after a reconnect must not trigger another one.
guard self.connection === connection else { return }
switch state {
case .ready:
beginReadLoop(on: connection)
case .failed, .cancelled:
self.connection = nil
buffer.removeAll()
if started {
scheduleReconnect()
}
default:
break
}
}
/// Drop the current connection; the state handler drives the reconnect.
private func fail(connection: NWConnection) {
guard self.connection === connection else { return }
connection.cancel()
}
private func scheduleReconnect() {
let gen = generation
Task { [weak self] in
try? await Task.sleep(for: Self.reconnectDelay)
guard let self, self.started, self.generation == gen, !Task.isCancelled else { return }
self.openConnection()
}
}
// MARK: - MPD idle protocol
/// Reads lines from `connection` until it fails or closes. `onLine` runs on
/// the main actor for each complete line.
private func readLines(
on connection: NWConnection,
_ onLine: @escaping @MainActor (String) -> Void
) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in
Task { @MainActor [weak self] in
guard let self, self.started else { return }
if error != nil || isComplete {
self.fail(connection: connection)
return
}
if let content, !content.isEmpty {
self.buffer.append(content)
}
while let line = self.buffer.popLine() {
onLine(line)
}
self.readLines(on: connection, onLine)
}
}
}
/// The MPD greeting (e.g. `OK MPD 0.23.5`) arrives first; once it's consumed
/// the source enters the idle loop, re-sending `idle` after every `OK`.
private func beginReadLoop(on connection: NWConnection) {
var consumedGreeting = false
readLines(on: connection) { [weak self] line in
guard let self, self.started else { return }
if !consumedGreeting {
consumedGreeting = true
self.sendIdle(on: connection)
return
}
// `OK` ends an idle response fire and re-arm. `changed: ` and
// `ACK ` lines are ignored; only the fact of a change matters.
if line == "OK" {
self.fire()
self.sendIdle(on: connection)
}
}
}
private func sendIdle(on connection: NWConnection) {
guard started else { return }
connection.send(content: Self.idleCommand, completion: .contentProcessed { [weak self] error in
Task { @MainActor [weak self] in
guard let self, self.started else { return }
if error != nil {
self.fail(connection: connection)
}
}
})
}
private func fire() {
notify?()
}
}
// MARK: - Line buffering
private extension Data {
/// Removes and returns the first `\n`-terminated line (without the
/// terminator and any trailing `\r`), or `nil` if no complete line is buffered.
mutating func popLine() -> String? {
guard let newline = firstIndex(of: UInt8(ascii: "\n")) else { return nil }
var line = self[startIndex..<newline]
if line.last == UInt8(ascii: "\r") {
line = line.dropLast()
}
let string = String(data: line, encoding: .utf8) ?? ""
removeSubrange(startIndex...newline)
return string
}
}
@@ -0,0 +1,30 @@
import Foundation
import Network
/// Fires whenever the system's network path changes (Wi-Fi up/down, interface
/// switch, reachability). Uses `NWPathMonitor`, which needs no special
/// permissions unlike reading the SSID via CoreWLAN.
@MainActor
final class NetworkEventSource: PHEventSource {
private var monitor: NWPathMonitor?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { _ in
Task { @MainActor in notify() }
}
monitor.start(queue: .global(qos: .utility))
self.monitor = monitor
}
func stop() {
guard started else { return }
started = false
monitor?.cancel()
monitor = nil
}
}
@@ -0,0 +1,53 @@
import Foundation
import IOKit.ps
/// Fires when the power source changes AC adapter plugged/unplugged, battery
/// level/charging state updates via IOKit's power-source run-loop source.
@MainActor
final class PowerEventSource: PHEventSource {
/// Holds the notify closure so the C callback can reach it without capturing.
fileprivate final class ContextBox: @unchecked Sendable {
let notify: @MainActor @Sendable () -> Void
init(notify: @escaping @MainActor @Sendable () -> Void) { self.notify = notify }
}
private var runLoopSource: CFRunLoopSource?
private var contextBox: ContextBox?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
let box = ContextBox(notify: notify)
contextBox = box
let source = IOPSNotificationCreateRunLoopSource(
phbarPowerSourceChanged,
Unmanaged.passUnretained(box).toOpaque()
).takeRetainedValue()
CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
runLoopSource = source
}
func stop() {
guard started else { return }
started = false
if let runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .defaultMode)
}
runLoopSource = nil
contextBox = nil
}
}
// MARK: - IOKit callback
//
// `IOPowerSourceCallbackType` is a C function pointer. The source is added to
// the main run loop, so this runs on the main thread safe to assume MainActor.
private func phbarPowerSourceChanged(_ context: UnsafeMutableRawPointer?) {
guard let context else { return }
let box = Unmanaged<PowerEventSource.ContextBox>.fromOpaque(context).takeUnretainedValue()
MainActor.assumeIsolated { box.notify() }
}
@@ -0,0 +1,197 @@
import AudioToolbox
import CoreAudio
import Foundation
/// Read access to the system output volume with change notifications.
///
/// Wraps the public CoreAudio HAL so callers can ignore C callbacks and property
/// addresses. This tracks the virtual master output volume (the value the macOS
/// volume slider controls) and re-attaches when the default device changes.
enum SystemAudioVolume {
/// Current default output volume in `0...1`, or `0` if unavailable.
static var current: Float {
guard let device = defaultDevice else { return 0 }
return volume(of: device)
}
/// The system's default output device, if any.
static var defaultDevice: AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var device = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device)
return status == noErr ? device : nil
}
/// Virtual master volume `[0, 1]` for a given output device.
static func volume(of device: AudioDeviceID) -> Float {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
var value = Float32(0)
var size = UInt32(MemoryLayout<Float32>.size)
let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value)
return status == noErr ? Float(value) : 0
}
/// Observe volume changes on the current default device. The handler is
/// invoked immediately with the current value, then whenever the volume or
/// the default device changes. Keep the returned `Listener` alive.
static func observe(_ handler: @escaping @Sendable (Float) -> Void) -> Listener {
let listener = Listener(handler: handler)
listener.activate()
return listener
}
// MARK: - Listener
/// A self-contained listener that re-attaches when the default device changes.
/// Not `@MainActor`: CoreAudio delivers callbacks on its own thread.
final class Listener {
private let handler: @Sendable (Float) -> Void
private let lock = NSLock()
private var observedDevice: AudioDeviceID?
private var systemListenerInstalled = false
init(handler: @escaping @Sendable (Float) -> Void) {
self.handler = handler
}
func activate() {
installSystemListener()
attach(to: SystemAudioVolume.defaultDevice)
}
private func installSystemListener() {
lock.lock()
defer { lock.unlock() }
guard !systemListenerInstalled else { return }
systemListenerInstalled = true
var address = Self.defaultDeviceAddress
AudioObjectAddPropertyListener(
AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
private func attach(to device: AudioDeviceID?) {
lock.lock()
let previous = observedDevice
observedDevice = device
lock.unlock()
if let previous {
var address = Self.volumeAddress
AudioObjectRemovePropertyListener(
previous, &address, phbarVolumeChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
guard let device else { return }
var address = Self.volumeAddress
AudioObjectAddPropertyListener(
device, &address, phbarVolumeChanged,
Unmanaged.passUnretained(self).toOpaque()
)
// Emit current value once on attach.
handler(SystemAudioVolume.volume(of: device))
}
/// Called from the CoreAudio thread when the volume property fires.
fileprivate func fire(for device: AudioDeviceID) {
handler(SystemAudioVolume.volume(of: device))
}
/// Called from the CoreAudio thread when the default device changes.
fileprivate func reattach() {
attach(to: SystemAudioVolume.defaultDevice)
}
deinit {
let context = Unmanaged.passUnretained(self).toOpaque()
if systemListenerInstalled {
var address = Self.defaultDeviceAddress
AudioObjectRemovePropertyListener(AudioObjectID(kAudioObjectSystemObject), &address, phbarDefaultDeviceChanged, context)
}
if let device = observedDevice {
var address = Self.volumeAddress
AudioObjectRemovePropertyListener(device, &address, phbarVolumeChanged, context)
}
}
static var defaultDeviceAddress: AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}
static var volumeAddress: AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
}
}
}
// MARK: - CoreAudio callbacks
//
// `AudioObjectPropertyListenerProc` is a C function pointer, so these must be
// non-capturing top-level functions. They recover `self` via the client data
// pointer passed alongside the listener.
private func phbarVolumeChanged(
_ objectID: AudioObjectID,
_ count: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
listener.fire(for: objectID)
return noErr
}
private func phbarDefaultDeviceChanged(
_ objectID: AudioObjectID,
_ count: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
let listener = Unmanaged<SystemAudioVolume.Listener>.fromOpaque(clientData).takeUnretainedValue()
listener.reattach()
return noErr
}
// MARK: - PHEventSource
@MainActor
final class VolumeEventSource: PHEventSource {
private var listener: SystemAudioVolume.Listener?
private var started = false
func start(notify: @escaping @MainActor @Sendable () -> Void) {
guard !started else { return }
started = true
listener = SystemAudioVolume.observe { _ in
Task { @MainActor in notify() }
}
}
func stop() {
guard started else { return }
started = false
listener = nil
}
}
@@ -0,0 +1,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)
}
}
+167
View File
@@ -0,0 +1,167 @@
import Foundation
// MARK: - Notification Name
/// A type-safe identifier for cross-process notifications.
///
/// Names should be reverse-DNS strings (e.g. `"com.paninihouse.phbar.refresh"`)
/// to avoid collisions with other applications using distributed notifications.
struct IPCNotificationName: Hashable, RawRepresentable, ExpressibleByStringLiteral {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
init(_ rawValue: String) { self.rawValue = rawValue }
init(stringLiteral value: String) { self.rawValue = value }
/// The Foundation name used by the underlying distributed center.
fileprivate var nsName: Notification.Name { Notification.Name(rawValue) }
}
// MARK: - Notification
/// A received cross-process notification with its optional payload.
struct IPCNotification {
let name: IPCNotificationName
let userInfo: [String: Any]?
init(name: IPCNotificationName, userInfo: [String: Any]? = nil) {
self.name = name
self.userInfo = userInfo
}
/// Decode a Codable value from the notification's `userInfo` payload.
///
/// Returns `nil` if `userInfo` is missing or cannot be decoded as `type`.
func decode<T: Decodable>(_ type: T.Type) -> T? {
guard let userInfo,
JSONSerialization.isValidJSONObject(userInfo),
let data = try? JSONSerialization.data(withJSONObject: userInfo) else {
return nil
}
return try? JSONDecoder().decode(type, from: data)
}
}
// MARK: - Notification Center
/// A thin layer over `DistributedNotificationCenter` for sending lightweight
/// cross-process signals between the `phbar` daemon and short-lived CLI commands.
///
/// Distributed notifications are delivered best-effort to processes running under
/// the same user. They are ideal for idempotent signals (e.g. "refresh"); do not
/// rely on them for critical, ordered, or large data transfer.
///
/// ## Usage
/// ```swift
/// // Post a bare signal
/// IPC.post(.refresh)
///
/// // Post with a Codable payload
/// IPC.post(.updateItem, payload: Item(title: "Hello", count: 3))
///
/// // Observe (token must be retained)
/// let token = IPC.observe(.refresh) { notification in
/// // ...
/// }
///
/// // Observe a typed payload
/// let token = IPC.observe(.updateItem, as: Item.self) { item in
/// // ...
/// }
/// ```
enum IPC {
/// The shared distributed notification center (one per user session).
static var center: DistributedNotificationCenter { .default() }
/// Post a signal with an optional property-list payload.
///
/// `userInfo` must contain only property-list types (`String`, `Number`,
/// `Date`, `Data`, `Array`, `Dictionary`); other values are dropped during
/// cross-process delivery.
static func post(_ name: IPCNotificationName, userInfo: [String: Any]? = nil) {
center.postNotificationName(
name.nsName,
object: nil,
userInfo: userInfo as [AnyHashable: Any]?,
deliverImmediately: true
)
}
/// Post a signal carrying a Codable payload.
///
/// The payload is JSON-encoded into the notification's `userInfo`.
/// Returns `false` if encoding fails.
@discardableResult
static func post<T: Encodable>(_ name: IPCNotificationName, payload: T) -> Bool {
guard let data = try? JSONEncoder().encode(payload),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
post(name, userInfo: dict)
return true
}
/// Observe a signal.
///
/// The handler is invoked on the main thread (distributed notifications are
/// delivered to the receiving process's main run loop).
///
/// - Returns: A token that must be retained while observing. Releasing it
/// automatically unregisters the observer.
static func observe(
_ name: IPCNotificationName,
handler: @escaping (IPCNotification) -> Void
) -> IPCObserver {
IPCObserver(center: center, name: name.nsName, handler: handler)
}
/// Observe a signal and decode its Codable payload.
///
/// `handler` is called only when the payload decodes successfully; malformed
/// or missing payloads are silently ignored.
static func observe<T: Decodable>(
_ name: IPCNotificationName,
as type: T.Type,
handler: @escaping (T) -> Void
) -> IPCObserver {
observe(name) { notification in
guard let payload: T = notification.decode(type) else { return }
handler(payload)
}
}
}
// MARK: - Observer
/// An observation token for a distributed notification.
///
/// Wraps the target/selector API of `DistributedNotificationCenter` so callers
/// can register a Swift closure. The notification center dispatches the selector
/// to this object; releasing the token removes the observer in `deinit`.
final class IPCObserver: NSObject {
private let center: DistributedNotificationCenter
private let name: Notification.Name
private let handler: (IPCNotification) -> Void
fileprivate init(
center: DistributedNotificationCenter,
name: Notification.Name,
handler: @escaping (IPCNotification) -> Void
) {
self.center = center
self.name = name
self.handler = handler
super.init()
center.addObserver(self, selector: #selector(handle(_:)), name: name, object: nil)
}
@objc private func handle(_ notification: Notification) {
let name = IPCNotificationName(notification.name.rawValue)
let userInfo = notification.userInfo as? [String: Any]
handler(IPCNotification(name: name, userInfo: userInfo))
}
deinit {
center.removeObserver(self, name: name, object: nil)
}
}
@@ -0,0 +1,8 @@
import Foundation
// MARK: - PHBar Notification Names
extension IPCNotificationName {
/// Sent by `phbar refresh` to request the running status bar to update.
static let refresh = IPCNotificationName("com.paninihouse.phbar.refresh")
}
@@ -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
}
}
}
+129 -36
View File
@@ -8,75 +8,162 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID() let id = UUID()
let command: String let command: String
let name: String? let name: String?
let style: String? let styleName: String?
@Published var style: PHTheme.Style = .default
let refresh: Double? let refresh: Double?
let centered: Bool?
var debug: Bool = false
@Published private(set) var label = "" var visible: Bool {
label != nil && !label!.isEmpty
}
/// The repeating refresh task, if any. /// System events that should trigger a refresh (e.g. `["volume", "network"]`).
private var refreshTask: Task<Void, Never>? /// `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 private(set) var label: String?
/// The repeating interval task, if any.
private var intervalTask: Task<Void, Never>?
/// Active event subscriptions, torn down in `stopAutoRefresh`.
private var subscriptions: [PHEventSubscription] = []
/// Guards against stacking concurrent updates during rapid event bursts
/// (e.g. dragging the volume slider fires many events in quick succession).
private var updateScheduled = false
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case command, name, style, refresh case command, name
case styleName = "style"
case refresh, events, centered
} }
deinit { deinit {
refreshTask?.cancel() intervalTask?.cancel()
subscriptions.forEach { $0.cancel() }
}
}
// Kind
extension PHBlock {
enum Kind: String {
case text, space
} }
// MARK: - Refresh var kind: Kind {
if command.starts(with: "_space") { return .space }
return .text
}
}
// Refresh
extension PHBlock {
/// Start keeping the label up to date. /// Start keeping the label up to date.
/// ///
/// The label is always recomputed immediately. When `refresh` is set to a /// Three independent triggers drive refreshes, and any combination works:
/// positive number of seconds, it is then recomputed on that interval until ///
/// `stopAutoRefresh()` is called. With no interval set, only the initial /// 1. **Interval** when `refresh` is a positive number of seconds, the
/// computation runs and later updates must be triggered manually via /// label is recomputed on that interval.
/// `update()`. /// 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() { func startAutoRefresh() {
stopAutoRefresh() stopAutoRefresh()
guard let interval = refresh, interval > 0 else { // Immediate refresh so the bar isn't blank until the first trigger fires.
// No interval: compute once, rely on manual updates afterwards. scheduleUpdate()
Task { await update() }
return
}
let milliseconds = Int(interval * 1000) // 1. Periodic refresh.
refreshTask = Task { [weak self] in if let interval = refresh, interval > 0 {
while !Task.isCancelled { let milliseconds = Int(interval * 1000)
await self?.update() intervalTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(milliseconds)) while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break }
await self?.update()
}
} }
} }
// 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?.scheduleUpdate()
}
subscriptions.append(subscription)
}
} }
/// Cancel the repeating auto-refresh, if any. /// Cancel the interval task and all event subscriptions.
func stopAutoRefresh() { func stopAutoRefresh() {
refreshTask?.cancel() intervalTask?.cancel()
refreshTask = nil intervalTask = nil
subscriptions.forEach { $0.cancel() }
subscriptions.removeAll()
} }
/// Recompute the label from `command`. Used by auto-refresh and manual updates. /// Recompute the label from `command`. Used by the interval loop, event
/// handlers, and manual updates.
func update() async { func update() async {
label = await compute() label = await compute()
} }
/// Request an update on the main actor, coalescing rapid bursts into a single
/// recomputation.
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
Task { [weak self] in
defer { self?.updateScheduled = false }
await self?.update()
}
}
func handleGesture(_ event: PHGestureEvent) {
Task { label = await compute(with: event) }
}
/// Runs the provided command and returns its stdout. /// Runs the provided command and returns its stdout.
/// ///
/// Runs off the main actor so it can be awaited safely from SwiftUI views /// Runs off the main actor so it can be awaited safely from SwiftUI views
/// without blocking the UI. /// without blocking the UI.
/// ///
/// - Returns: A non-optional String to use as the block label. /// - Returns: A non-optional String to use as the block label.
func compute() async -> String { func compute(with gestureEvent: PHGestureEvent? = nil) async -> String? {
guard kind == .text else { return nil }
let command = command let command = command
return await Task.detached(priority: .userInitiated) {
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process() let process = Process()
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
process.executableURL = URL(fileURLWithPath: "/bin/bash") process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command] process.arguments = ["-c", command]
// Inherit the parent's environment (includes $PATH, etc.) // Inherit the parent's environment (includes $PATH, etc.)
process.environment = ProcessInfo.processInfo.environment process.environment = ProcessInfo.processInfo.environment
if let gestureEvent {
let type = gestureEvent.type.rawValue
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
if let data = gestureEvent.data {
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
}
}
// Capture stdout via a pipe (otherwise standardOutput is always nil). // Capture stdout via a pipe (otherwise standardOutput is always nil).
let pipe = Pipe() let pipe = Pipe()
@@ -86,12 +173,18 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? "" let string = String(data: data, encoding: .utf8)
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
return lines
} catch { } catch {
// Silently ignore failures // Silently ignore failures
return "" return nil
} }
}.value }.value
guard let lines else { return nil }
if self.debug { for line in lines { print(line) } }
return lines.last?.description
} }
} }
@@ -106,15 +199,15 @@ extension PHBlock {
} }
} }
private nonisolated static var configFile: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml")
}
/// Load blocks from the default path (~/.config/phbar/blocks.toml). /// Load blocks from the default path (~/.config/phbar/blocks.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal). /// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> [PHBlock] { static func load() throws -> [PHBlock] {
let url = FileManager.default.homeDirectoryForCurrentUser.appending( if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
path: ".config/phbar/blocks.toml" return try load(from: Self.configFile)
)
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else { } else {
throw phbar.Error("Failed to load blocks file") throw phbar.Error("Failed to load blocks file")
} }
+3
View File
@@ -0,0 +1,3 @@
struct PHConfig: Decodable {
let text: Text
}
@@ -0,0 +1,49 @@
import Foundation
import TOML
extension PHConfig {
/// Load configuration from the default path (~/.config/pmenu/config.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> PHConfig {
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
path: ".config/phbar/config.toml")
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
guard
let defaultConfig = Bundle.module.url(
forResource: "config",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load config file")
}
return try load(from: defaultConfig)
}
}
/// Decode configuration from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(from url: URL) throws -> PHConfig {
guard let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8)
else {
throw phbar.Error("Failed to load config file")
}
return try load(from: contents)
}
/// Decode configuration from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static func load(from contents: String) throws -> PHConfig {
do {
let decoder = TOMLDecoder()
let configFile = try decoder.decode(PHConfig.self, from: contents)
return configFile
} catch {
throw phbar.Error("Failed to parse config file", underlyingError: error)
}
}
}
@@ -0,0 +1,162 @@
import AppKit
import SwiftUI
extension PHConfig {
struct Text: Decodable {
let fontFamily: String
let size: Double
let weight: Weight
let style: Style
let offset: Double?
enum CodingKeys: String, CodingKey {
case fontFamily = "font"
case size, weight, style, offset
}
}
}
// Font
extension PHConfig.Text {
/// 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: Font {
if let design {
return Font.system(size: size, weight: weight.uiWeight, design: design)
} else {
return Font.custom(fontFamily, fixedSize: size).weight(weight.uiWeight)
}
}
}
// Design
extension PHConfig.Text {
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
}
}
}
// Weight
extension PHConfig.Text {
enum Weight: 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
}
}
}
}
// Style
extension PHConfig.Text {
enum Style: String, Decodable {
case normal, italic
}
var italic: Bool {
style == .italic
}
}
+53
View File
@@ -0,0 +1,53 @@
struct PHTheme: Decodable {
static let `default` = "dracula"
static let bundled = [
"voltage",
"catppuccin-mocha",
"dracula",
"gruvbox",
"tokyo-night",
]
let window: Window
let styles: [Style]
enum CodingKeys: String, CodingKey {
case window
case styles = "style"
}
}
// Window
extension PHTheme {
struct Window: Decodable {
let hasShadow: Bool
let blur: Double
enum CodingKeys: String, CodingKey {
case hasShadow = "shadow"
case blur
}
}
}
// Style
extension PHTheme {
struct Style: Decodable {
let name: String
let foreground: PHTheme.Color
let background: PHTheme.Color
let padding: PHTheme.Padding
static let `default`: Self = {
Style(
name: "_default",
foreground: .init(color: "#000000", alpha: nil),
background: .init(color: "#ffffff", alpha: nil),
padding: .init(leading: 10, trailing: 10)
)
}()
}
}
@@ -0,0 +1,27 @@
import SwiftUI
extension PHTheme {
struct Color: Decodable, ShapeStyle {
typealias Resolved = SwiftUI.Color
let 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,58 @@
import Foundation
import TOML
extension PHTheme {
/// Load theme from the default path (~/.config/phbar/theme.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load(_ theme: String?) throws -> PHTheme {
let theme = theme ?? Self.default
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
path: ".config/phbar/themes/\(theme).toml"
)
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
return try load(bundled: theme)
}
}
/// Load a bundled theme.
/// If the theme doesn't exist or fails to parse, the execution is interrupted.
static func load(bundled theme: String) throws -> PHTheme {
guard
let defaultConfig = Bundle.module.url(
forResource: theme,
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load theme file")
}
return try load(from: defaultConfig)
}
/// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(from url: URL) throws -> PHTheme {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
throw phbar.Error("the file is empty")
}
return try load(from: contents)
} catch {
throw phbar.Error("Failed to load theme file", underlyingError: error)
}
}
/// Load theme from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static func load(from contents: String) throws -> PHTheme {
do {
let decoder = TOMLDecoder()
return try decoder.decode(PHTheme.self, from: contents)
} catch {
throw phbar.Error("Failed to parse theme file", underlyingError: error)
}
}
}
@@ -0,0 +1,12 @@
extension PHTheme {
struct Padding: Decodable {
let leading: Double
let trailing: Double
var total: Double { leading + trailing }
static var zero: Padding {
Self.init(leading: 0, trailing: 0)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# phbar theme file ~ Dracula
#
# Place at ~/.config/phbar/themes/dracula.toml
[window]
shadow = false
blur = 10.0
[[style]]
name = "default"
foreground = { color = "#f8f8f2" }
background = { color = "#000000", alpha = 0.5 }
padding = { leading = 0.0, trailing = 0.0 }
[[style]]
name = "tinted"
foreground = { color = "#282a36" }
background = { color = "#bd93f9" }
padding = { leading = 10.0, trailing = 10.0 }
+25
View File
@@ -0,0 +1,25 @@
# phbar theme file ~ Voltage
#
# Place at ~/.config/phbar/themes/voltage.toml
[window]
shadow = false
blur = 0.0
[[style]]
name = "default"
foreground = { color = "#aed3f3" }
background = { color = "#010408", alpha = 0.825 }
padding = { leading = 0.0, trailing = 0.0 }
[[style]]
name = "floating"
foreground = { color = "#aed3f3" }
background = { color = "#000000", alpha = 0 }
padding = { leading = 0.0, trailing = 0.0 }
[[style]]
name = "tinted"
foreground = { color = "#aed3f3" }
background = { color = "#0f304a" }
padding = { leading = 12.0, trailing = 12.0 }
+22 -5
View File
@@ -8,14 +8,31 @@ struct BarView: View {
} }
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 0) { ZStack {
ForEach(bar.blocks) { block in HStack(alignment: .center, spacing: 0) {
TextBlock(block: block) ForEach(bar.arrangedBlocks) { block in
switch block.kind {
case .text:
TextBlock(block: block)
case .space:
SpaceBlock(block: block)
}
}
} }
SpaceBlock() HStack(alignment: .center, spacing: 0) {
ForEach(bar.centeredBlocks) { block in
switch block.kind {
case .text:
TextBlock(block: block)
case .space:
SpaceBlock(block: block)
}
}
}
} }
.font(.custom("Comic Code", size: 14)) .font(bar.config.text.font)
.italic(bar.config.text.italic)
.frame(height: 30) .frame(height: 30)
.ignoresSafeArea() .ignoresSafeArea()
.environmentObject(bar) .environmentObject(bar)
+9 -5
View File
@@ -3,6 +3,8 @@ import SwiftUI
final class BarWindow: NSPanel { final class BarWindow: NSPanel {
let barController: PHController let barController: PHController
var backgroundView: BarWindowBackground!
var barView: NSHostingView<BarView>! var barView: NSHostingView<BarView>!
init(controller: PHController) { init(controller: PHController) {
@@ -20,9 +22,6 @@ final class BarWindow: NSPanel {
self.isFloatingPanel = true self.isFloatingPanel = true
self.level = .floating self.level = .floating
self.animationBehavior = .utilityWindow self.animationBehavior = .utilityWindow
self.isOpaque = false
self.backgroundColor = .clear
// self.hasShadow = pmenu.config.window.shadow
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
self.acceptsMouseMovedEvents = false self.acceptsMouseMovedEvents = false
self.isMovable = false self.isMovable = false
@@ -33,11 +32,16 @@ final class BarWindow: NSPanel {
self.isRestorable = false self.isRestorable = false
self.displaysWhenScreenProfileChanges = true self.displaysWhenScreenProfileChanges = true
self.backgroundView = BarWindowBackground(controller: barController)
backgroundView.autoresizingMask = [.width, .height]
backgroundView.frame = self.contentView?.bounds ?? .zero
self.barView = NSHostingView(rootView: BarView(controller: barController)) self.barView = NSHostingView(rootView: BarView(controller: barController))
barView.autoresizingMask = [.width, .height] barView.autoresizingMask = [.width, .height]
barView.frame = self.contentView?.bounds ?? .zero barView.frame = contentView?.bounds ?? .zero
self.backgroundView.addSubview(barView)
self.contentView = barView self.contentView = self.backgroundView
} }
} }
+10 -1
View File
@@ -1,8 +1,17 @@
import SwiftUI import SwiftUI
struct SpaceBlock: View { struct SpaceBlock: View {
@ObservedObject var block: PHBlock
var width: CGFloat? {
guard let arg = block.command.split(separator: " ").last else { return nil }
guard let width = Double(arg) else { return nil }
return CGFloat(width)
}
var body: some View { var body: some View {
Rectangle() Rectangle()
.fill(.blue) .fill(block.style.background)
.frame(width: width)
} }
} }
+40 -7
View File
@@ -1,16 +1,49 @@
import SwiftUI import SwiftUI
struct TextBlock: View { struct TextBlock: View {
@EnvironmentObject private var bar: PHController
@ObservedObject var block: PHBlock @ObservedObject var block: PHBlock
var body: some View { @State var hovering = false
ZStack {
Rectangle()
.fill(.red)
Text(block.label) private var adjustOffset: CGAffineTransform {
.foregroundColor(.white) guard let offset = bar.config.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) {
RoundedRectangle(cornerRadius: 0)
.fill(block.style.background)
Group {
Text(label)
.foregroundStyle(block.style.foreground)
.transformEffect(adjustOffset)
.border(bar.debug ? .blue : .clear)
}
.padding(.leading, block.style.padding.leading)
.padding(.trailing, block.style.padding.trailing)
}
.fixedSize(horizontal: true, vertical: false)
.border(bar.debug ? .red : .clear)
.onHover { hovering = $0 }
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown,
.otherMouseDown,
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent)
}
return event
}
}
} else {
EmptyView()
} }
.fixedSize(horizontal: true, vertical: false)
} }
} }
@@ -0,0 +1,108 @@
import AppKit
final class BarWindowBackground: NSView {
var hasShadow: Bool {
didSet { configureShadow() }
}
var blur: CGFloat {
didSet { configureBlur() }
}
init(controller: PHController) {
self.hasShadow = controller.theme.window.hasShadow
self.blur = controller.theme.window.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 else { return }
window.hasShadow = hasShadow
}
private func configureBlur() {
guard let window 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
}
}
+6
View File
@@ -0,0 +1,6 @@
[text]
font = "Comic Code"
size = 14
weight = "regular"
style = "normal"
offset = -0.5
+426 -4
View File
@@ -1,9 +1,431 @@
import AppKit
import Foundation
import Testing import Testing
@testable import phbar @testable import phbar
@Test func example() async throws { // MARK: - Payload Types for Testing
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation private struct TestPayload: Codable, Equatable {
// https://developer.apple.com/documentation/testing let message: String
let count: Int
} }
// MARK: - Notification Name Tests
@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 Tests
@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 Tests
@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 Tests
@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) {}
}
// MARK: - Refresh Command Config
@Test func refreshCommandConfigurationIsCorrect() async throws {
let config = phbar.refresh.configuration
#expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.")
}
// MARK: - PHBlock Loading & Compute
@MainActor
@Test func loadBlocksFromTOML() throws {
let toml = """
[[block]]
command = "echo hello"
name = "greeting"
refresh = 5.0
[[block]]
command = "echo 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 == "")
#expect(blocks[1].name == nil)
#expect(blocks[1].refresh == nil)
}
@MainActor
@Test func loadBlocksRejectsInvalidTOML() {
#expect(throws: (any Error).self) {
try PHBlock.load(from: "not = valid = toml = =")
}
}
@MainActor
@Test func computeReturnsCommandStdout() async throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf panini"
""")
let output = await blocks[0].compute()
#expect(output == "panini")
}
// MARK: - PHBlock Refresh
@MainActor
@Test func updateSetsLabel() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf panini"
""")[0]
#expect(block.label == "")
await block.update()
#expect(block.label == "panini")
}
@MainActor
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf 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"
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)
}
// MARK: - PHEvent
@MainActor
@Test func eventDecodesFromKnownStrings() throws {
let toml = """
[[block]]
command = "echo hi"
events = ["volume", "network", "appearance", "power", "mpd"]
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks[0].events == [.volume, .network, .appearance, .power, .mpd])
}
@MainActor
@Test func eventRejectsUnknownString() {
#expect(throws: (any Error).self) {
try PHBlock.load(from: """
[[block]]
command = "echo hi"
events = ["totally_made_up"]
""")
}
}
@MainActor
@Test func eventsOptionalWhenOmitted() throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
""")
#expect(blocks[0].events == nil)
}
@MainActor
@Test func defaultFactoryReturnsMPDSource() {
let source = PHEventRegistry.defaultFactory(.mpd)
#expect(source is MPDEventSource)
}
@MainActor
@Test func mpdSourceIsIdempotentStartStop() {
// With MPD absent the source keeps trying to connect; ensure start/stop are
// safe and idempotent without a running daemon.
let source = MPDEventSource(host: "127.0.0.1", port: 1)
source.start(notify: {})
source.start(notify: {}) // second start is a no-op
source.stop()
source.stop() // second stop is a no-op
}
// MARK: - PHEventRegistry
/// 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?() }
}
@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(.volume) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: .volume) == 1)
// A second subscriber must reuse the already-running source.
let s2 = registry.subscribe(.volume) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: .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: .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: .volume) == 0)
}
@MainActor
@Test func registryStartsSeparateSourcePerEvent() async throws {
let volumeFake = FakeEventSource()
let networkFake = FakeEventSource()
let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource = { event in
switch event {
case .volume: return volumeFake
default: return networkFake
}
}
let registry = PHEventRegistry(factory: factory)
let v = registry.subscribe(.volume) {}
let n = registry.subscribe(.network) {}
#expect(volumeFake.startCount == 1)
#expect(networkFake.startCount == 1)
#expect(registry.subscriberCount(for: .volume) == 1)
#expect(registry.subscriberCount(for: .network) == 1)
v.cancel()
n.cancel()
}
// MARK: - PHBlock + 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"
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"
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()
}
// MARK: - PHController
@MainActor
@Test func controllerRefreshUpdatesAllBlocks() async throws {
let screen = try #require(NSScreen.main)
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf a"
[[block]]
command = "printf b"
""")
let controller = PHController(screen: screen, blocks: blocks)
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")
}