Files
phbar/Sources/phbar/Models/PHBlock.swift
T
2026-07-12 00:08:46 +02:00

242 lines
7.0 KiB
Swift

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: PHThemeStyle = .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)
}
}
}