242 lines
7.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|