Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2d6348c901
|
|||
|
7d281d1039
|
+9
-1
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArgumentParser
|
||||
|
||||
extension phbar {
|
||||
struct refresh: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "refresh",
|
||||
abstract: "Refresh the running status bar."
|
||||
)
|
||||
|
||||
mutating func run() throws {
|
||||
IPC.post(.refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[text]
|
||||
font = "Comic Code"
|
||||
size = 14
|
||||
weight = "regular"
|
||||
style = "normal"
|
||||
offset = -0.5
|
||||
@@ -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!")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user