Compare commits

...

2 Commits

Author SHA1 Message Date
tommaso 2d6348c901 full scope implementation 2026-07-11 21:56:47 +02:00
tommaso 7d281d1039 basic block structure and auto-refresh 2026-07-10 17:19:04 +02:00
38 changed files with 2502 additions and 19 deletions
+9 -1
View File
@@ -20,7 +20,15 @@ let package = Package(
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.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(
name: "phbarTests",
+38
View File
@@ -0,0 +1,38 @@
import AppKit
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var barController: PHController!
var window: BarWindow!
private var observers: [IPCObserver] = []
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool = false) {
super.init()
self.barController = PHController(config: config, screen: screen, theme: theme, blocks: blocks)
barController.debug = debug
}
func applicationDidFinishLaunching(_ notification: Notification) {
window = BarWindow(controller: barController)
window.orderFront(nil)
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) {
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)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import AppKit
import ArgumentParser
import Foundation
extension phbar {
struct start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar."
)
@Flag(name: .long, help: "Display visual guides to help align elements.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
let screen = try targetScreen(monitor: 0)
let theme = try PHTheme.load("voltage")
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
let blocks = try PHBlock.load()
let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
// Monitor
extension phbar.start {
private func targetScreen(monitor: Int) throws -> NSScreen {
let screens = NSScreen.screens
guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") }
if monitor >= 0, monitor < screens.count {
return screens[monitor]
}
// Default: screen containing the mouse cursor
let mouseLocation = NSEvent.mouseLocation
guard
let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) })
?? screens.first
else {
throw CleanExit.message("No monitor founded")
}
return screen
}
}
@@ -0,0 +1,75 @@
import SwiftUI
@MainActor
final class PHController: ObservableObject {
let config: PHConfig
let screen: NSScreen
let theme: PHTheme
var debug: Bool = false {
didSet {
for block in blocks {
block.debug = debug
}
}
}
@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, 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.
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 {
Task { await block.update() }
}
}
}
+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
}
}
}
+241
View File
@@ -0,0 +1,241 @@
import ArgumentParser
import Foundation
import SwiftUI
import TOML
@MainActor
final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID()
let command: String
let name: String?
let styleName: String?
@Published var style: PHTheme.Style = .default
let refresh: Double?
let centered: Bool?
var debug: Bool = false
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 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 {
case command, name
case styleName = "style"
case refresh, events, centered
}
deinit {
intervalTask?.cancel()
subscriptions.forEach { $0.cancel() }
}
}
// Kind
extension PHBlock {
enum Kind: String {
case text, space
}
var kind: Kind {
if command.starts(with: "_space") { return .space }
return .text
}
}
// Refresh
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.
scheduleUpdate()
// 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?.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 interval task and all event subscriptions.
func stopAutoRefresh() {
intervalTask?.cancel()
intervalTask = nil
subscriptions.forEach { $0.cancel() }
subscriptions.removeAll()
}
/// Recompute the label from `command`. Used by the interval loop, event
/// handlers, and manual updates.
func update() async {
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 off the main actor so it can be awaited safely from SwiftUI views
/// without blocking the UI.
///
/// - Returns: A non-optional String to use as the block label.
func compute(with gestureEvent: PHGestureEvent? = nil) async -> String? {
guard kind == .text else { return nil }
let command = command
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process()
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
// Inherit the parent's environment (includes $PATH, etc.)
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).
let pipe = Pipe()
process.standardOutput = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let string = String(data: data, encoding: .utf8)
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
return lines
} catch {
// Silently ignore failures
return nil
}
}.value
guard let lines else { return nil }
if self.debug { for line in lines { print(line) } }
return lines.last?.description
}
}
// Loading
extension PHBlock {
private struct Wrapper: Decodable {
let blocks: [PHBlock]
enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
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).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> [PHBlock] {
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
return try load(from: Self.configFile)
} else {
throw phbar.Error("Failed to load blocks file")
}
}
/// 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 -> [PHBlock] {
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 blocks 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 -> [PHBlock] {
do {
let decoder = TOMLDecoder()
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
return wrapper.blocks
} catch {
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
}
}
}
+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 }
+40
View File
@@ -0,0 +1,40 @@
import SwiftUI
struct BarView: View {
@StateObject private var bar: PHController
init(controller: PHController) {
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:
TextBlock(block: block)
case .space:
SpaceBlock(block: block)
}
}
}
HStack(alignment: .center, spacing: 0) {
ForEach(bar.centeredBlocks) { block in
switch block.kind {
case .text:
TextBlock(block: block)
case .space:
SpaceBlock(block: block)
}
}
}
}
.font(bar.config.text.font)
.italic(bar.config.text.italic)
.frame(height: 30)
.ignoresSafeArea()
.environmentObject(bar)
}
}
+58
View File
@@ -0,0 +1,58 @@
import AppKit
import SwiftUI
final class BarWindow: NSPanel {
let barController: PHController
var backgroundView: BarWindowBackground!
var barView: NSHostingView<BarView>!
init(controller: PHController) {
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 = BarWindowBackground(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 {
/// Calculate window frame and position.
static func computeFrame(from controller: PHController) -> NSRect {
let screenFrame = controller.screen.frame
let height: CGFloat = 30
let x = screenFrame.minX
let y = screenFrame.maxY - height
return NSRect(x: x, y: y, width: screenFrame.width, height: height)
}
}
@@ -0,0 +1,17 @@
import SwiftUI
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 {
Rectangle()
.fill(block.style.background)
.frame(width: width)
}
}
@@ -0,0 +1,49 @@
import SwiftUI
struct TextBlock: View {
@EnvironmentObject private var bar: PHController
@ObservedObject var block: PHBlock
@State var hovering = false
private var adjustOffset: CGAffineTransform {
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()
}
}
}
@@ -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
}
}
+62
View File
@@ -0,0 +1,62 @@
// 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: """
pmenu reads a list of newline-separated items from stdin and presents them to the user.
When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates.
Entering text will narrow the items to those matching the tokens in the input.
""",
version: "1.0.0",
subcommands: [start.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
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
[text]
font = "Comic Code"
size = 14
weight = "regular"
style = "normal"
offset = -0.5
-14
View File
@@ -1,14 +0,0 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
//
// Swift Argument Parser
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
import ArgumentParser
@main
struct phbar: ParsableCommand {
mutating func run() throws {
print("Hello, world!")
}
}
+426 -4
View File
@@ -1,9 +1,431 @@
import AppKit
import Foundation
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
// MARK: - Payload Types for Testing
private struct TestPayload: Codable, Equatable {
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")
}