move files around
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import TOML
|
||||
|
||||
extension PHBlock {
|
||||
private struct Wrapper: Decodable {
|
||||
let blocks: [PHBlock]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case blocks = "block"
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
|
||||
///
|
||||
/// - Parameter configDirectory: Override the lookup root (used by tests);
|
||||
/// defaults to the resolved config directory (see `PHPaths`).
|
||||
static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] {
|
||||
let base = configDirectory ?? PHPaths.configDirectory
|
||||
|
||||
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
|
||||
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
|
||||
throw phbar.Error("Failed to load blocks '\(blocks)'")
|
||||
}
|
||||
return try load(from: setFile)
|
||||
}
|
||||
|
||||
/// 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,127 @@
|
||||
import Foundation
|
||||
|
||||
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()
|
||||
// Commands run with the resolved config root (see `PHPaths`) as their
|
||||
// working directory, regardless of which block set they belong to, so
|
||||
// relative paths in user scripts stay stable.
|
||||
process.currentDirectoryURL = PHPaths.configDirectory
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
enum PHBlockKind: String {
|
||||
case text, space
|
||||
}
|
||||
|
||||
extension PHBlock {
|
||||
var kind: PHBlockKind {
|
||||
if command.starts(with: "_space") { return .space }
|
||||
return .text
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user