Compare commits
16 Commits
94c83d74a4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f70ea7f0f5
|
|||
|
4a74cf04c8
|
|||
|
3e9557586b
|
|||
|
f479499309
|
|||
|
30be4b2901
|
|||
|
ed01251ca5
|
|||
|
1867a4c0a6
|
|||
|
a894449d15
|
|||
|
a49f78cbb0
|
|||
|
1a2eddcd5d
|
|||
|
909d890837
|
|||
|
87070dd62f
|
|||
|
39457d8faf
|
|||
|
a963a19bf3
|
|||
|
aa241148ef
|
|||
|
07a05e5470
|
@@ -1,47 +1,69 @@
|
||||
# Makefile for phbar
|
||||
#
|
||||
# Usage: make <target>
|
||||
#
|
||||
# NOTE: Never invoke this Makefile with `sudo` (e.g. `sudo make install`).
|
||||
# Running `swift build` as root corrupts `.build/` with root-owned files that
|
||||
# break subsequent non-root builds (you get "invalid access to …/DerivedSources").
|
||||
# The `install`/`uninstall` targets elevate *only* the file copy, and only when
|
||||
# the destination is not user-writable, so `sudo` is never needed from the user.
|
||||
|
||||
BIN_DIR := $(HOME)/.local/bin
|
||||
# Install directory. Override with: make install BIN_DIR=/opt/homebrew/bin
|
||||
BIN_DIR ?= /usr/local/bin
|
||||
BUILD_DIR := .build/release
|
||||
BINARIES := phbar
|
||||
|
||||
.PHONY: all build install clean test run
|
||||
# Guard: any target that runs Swift must not run under sudo, otherwise it would
|
||||
# sprinkle root-owned files across .build/. It fails fast with a helpful message
|
||||
# instead of corrupting the build directory. Recursively-expanded (`=`) so that
|
||||
# the `$@` automatic variable resolves per-target at recipe time.
|
||||
guard_not_root = @if [ "$$(id -u)" = "0" ] && [ -n "$$SUDO_USER" ]; then \
|
||||
echo "❌ 'make $@' must not run under sudo (it would corrupt .build/)." >&2; \
|
||||
echo " Run 'make install' without sudo; it elevates only the copy step." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: all build install clean test run uninstall reinstall help
|
||||
|
||||
all: build
|
||||
|
||||
# Build all targets in release mode
|
||||
build:
|
||||
$(guard_not_root)
|
||||
swift build -c release
|
||||
|
||||
# Build and install binaries to ~/.local/bin
|
||||
# Build and install binaries. Elevates only the copy when BIN_DIR isn't writable.
|
||||
install: build
|
||||
@mkdir -p $(BIN_DIR)
|
||||
@for bin in $(BINARIES); do \
|
||||
cp -f $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
|
||||
done
|
||||
@if [ -w "$(BIN_DIR)" ]; then CP="cp -f"; else CP="sudo cp -f"; fi; \
|
||||
for bin in $(BINARIES); do \
|
||||
$$CP $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
|
||||
done
|
||||
@echo "✅ Installed $(BINARIES) to $(BIN_DIR)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
$(guard_not_root)
|
||||
swift package clean
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
$(guard_not_root)
|
||||
swift test
|
||||
|
||||
# Quick test: build, install, and verify
|
||||
run: install
|
||||
@echo "Verifying installation..."
|
||||
@for bin in $(BINARIES); do \
|
||||
which $$bin > /dev/null && echo "✓ $$bin: $(shell which $$bin)" || echo "✗ $$bin: not found"; \
|
||||
which $$bin > /dev/null && echo "✓ $$bin: $$($$bin --version 2>/dev/null | head -1)" || echo "✗ $$bin: not found"; \
|
||||
done
|
||||
|
||||
# Uninstall binaries from ~/.local/bin
|
||||
# Uninstall binaries. Elevates only the remove when BIN_DIR isn't writable.
|
||||
uninstall:
|
||||
@for bin in $(BINARIES); do \
|
||||
rm -f $(BIN_DIR)/$$bin; \
|
||||
done
|
||||
@if [ -w "$(BIN_DIR)" ]; then RM="rm -f"; else RM="sudo rm -f"; fi; \
|
||||
for bin in $(BINARIES); do \
|
||||
$$RM $(BIN_DIR)/$$bin; \
|
||||
done
|
||||
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
|
||||
|
||||
# Rebuild and reinstall (clean + install)
|
||||
@@ -51,9 +73,12 @@ reinstall: clean install
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make build - Build in release mode"
|
||||
@echo " make install - Build and install to ~/.local/bin"
|
||||
@echo " make install - Build and install to $(BIN_DIR)"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make run - Build, install, and verify"
|
||||
@echo " make uninstall - Remove binaries from ~/.local/bin"
|
||||
@echo " make uninstall - Remove binaries from $(BIN_DIR)"
|
||||
@echo " make reinstall - Clean and reinstall"
|
||||
@echo ""
|
||||
@echo "Override the install location with BIN_DIR, e.g.:"
|
||||
@echo " make install BIN_DIR=$(HOME)/.local/bin"
|
||||
|
||||
+1
-4
@@ -34,10 +34,7 @@ let package = Package(
|
||||
exclude: [
|
||||
"Models/PHEvent/README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Models/PHConfig/config.toml"),
|
||||
.process("Models/PHTheme/theme.toml"),
|
||||
]
|
||||
resources: [.copy("config")]
|
||||
),
|
||||
.testTarget(
|
||||
name: "phbarTests",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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()
|
||||
|
||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
try MainActor.assumeIsolated {
|
||||
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
|
||||
|
||||
let delegate = PHBarDelegate(config: config, debug: debug)
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
|
||||
throw ExitCode.success
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension PHBar {
|
||||
struct Install: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "install",
|
||||
abstract: "Start the status bar.",
|
||||
discussion: """
|
||||
Launch the bar as a non-stopping background process.
|
||||
This command is meant to be used for both testing and production.
|
||||
|
||||
When testing, you can provide the `--debug` flag to get visual
|
||||
guidance that helps you configure the blocks and a verbose output from
|
||||
the shell scripts to debug the behaviour.
|
||||
"""
|
||||
)
|
||||
|
||||
private var source: URL? {
|
||||
Bundle.module.url(forResource: "config", withExtension: "")
|
||||
}
|
||||
|
||||
private var destination: URL {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
|
||||
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
|
||||
let standardConfigDirectory = homeDirectory.appending(path: ".config")
|
||||
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
|
||||
in: .whitespaces)
|
||||
var candidateDirectory: URL
|
||||
|
||||
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
|
||||
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
|
||||
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
|
||||
candidateDirectory = standardConfigDirectory
|
||||
} else {
|
||||
candidateDirectory = homeDirectory
|
||||
}
|
||||
|
||||
return candidateDirectory.appending(path: "phbar")
|
||||
}
|
||||
|
||||
mutating func run() throws {
|
||||
do {
|
||||
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
try makeExecutable()
|
||||
} catch {
|
||||
throw PHBar.Error("Failed to create config directory", underlyingError: error)
|
||||
}
|
||||
|
||||
throw CleanExit.message("Config directory created at \(destination.relativePath)")
|
||||
}
|
||||
|
||||
private func makeExecutable() throws {
|
||||
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
|
||||
let scripts = try FileManager.default.contentsOfDirectory(
|
||||
atPath: scriptsDirectory.relativePath
|
||||
)
|
||||
|
||||
for script in scripts {
|
||||
let path = scriptsDirectory.appending(path: script)
|
||||
var attributes: [FileAttributeKey: Any] = [:]
|
||||
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
|
||||
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import ArgumentParser
|
||||
|
||||
extension phbar {
|
||||
struct refresh: ParsableCommand {
|
||||
extension PHBar {
|
||||
struct Refresh: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "refresh",
|
||||
abstract: "Refresh the running status bar."
|
||||
@@ -0,0 +1,44 @@
|
||||
import AppKit
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension PHBar {
|
||||
struct Start: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "start",
|
||||
abstract: "Start the status bar.",
|
||||
discussion: """
|
||||
Launch the bar as a non-stopping background process.
|
||||
This command is meant to be used for both testing and production.
|
||||
|
||||
When testing, you can provide the `--debug` flag to get visual
|
||||
guidance that helps you configure the blocks and a verbose output from
|
||||
the shell scripts to debug the behaviour.
|
||||
"""
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Display visual guides and print verbose output.")
|
||||
var debug: Bool = false
|
||||
|
||||
mutating func run() throws {
|
||||
let config = try PHConfig.load()
|
||||
let theme = try PHTheme.load(config.theme)
|
||||
let layouts = try PHLayout.load(["default", "mini"])
|
||||
|
||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
try MainActor.assumeIsolated {
|
||||
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
|
||||
|
||||
let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug)
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
|
||||
throw ExitCode.success
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
extension String {
|
||||
/// Expand `$VAR` and `${VAR}` references against `variables`.
|
||||
///
|
||||
/// Unresolved references are left intact rather than blanked, so a typo
|
||||
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
|
||||
/// vanishing.
|
||||
func expanded(from environment: [String: String]) -> String {
|
||||
var result = self
|
||||
|
||||
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
|
||||
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
|
||||
environment[String(match.1)] ?? String(match.0)
|
||||
}
|
||||
// `$VAR`
|
||||
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
|
||||
environment[String(match.1)] ?? String(match.0)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,19 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate {
|
||||
/// Convenience accessor over `windows`.
|
||||
var controllers: [BarController] { windows.map(\.barController) }
|
||||
|
||||
/// Resolves a controller for any screen from the current config. Swapped by
|
||||
/// `reloadAll()` so a live reload re-reads theme/blocks from disk.
|
||||
/// Resolves a controller for any screen from the current config.
|
||||
var factory: PHBarFactory
|
||||
|
||||
private var observers: [IPCObserver] = []
|
||||
private var screenObserver: NSObjectProtocol?
|
||||
|
||||
/// `config` is captured here (not re-read per screen) so all bars share one
|
||||
/// resolved config + environment for their lifetime. Live reload swaps the
|
||||
/// whole factory via `reloadAll()`.
|
||||
init(config: PHConfig, debug: Bool) {
|
||||
let environment = PHEnvironment.process.merging(config.env)
|
||||
self.factory = PHBarFactory(config: config, environment: environment, debug: debug)
|
||||
init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) {
|
||||
self.factory = PHBarFactory(
|
||||
config: config,
|
||||
theme: theme,
|
||||
layouts: layouts,
|
||||
debug: debug
|
||||
)
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ extension PHBarDelegate {
|
||||
do {
|
||||
controller = try factory.make(for: screen)
|
||||
} catch {
|
||||
stderr("skipping screen \(screen.localizedName): \(error)")
|
||||
stderr("skipping screen \(screen.localizedName): \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
let window = BarWindow(controller: controller)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
extension PHBarDelegate {
|
||||
// MARK: - Reload (entry points for a future `phbar reload`)
|
||||
|
||||
/// Re-read config from disk and rebuild every bar against it. Screens are
|
||||
/// kept; only theme/blocks/window are re-resolved. This is the global path
|
||||
/// for picking up config edits at runtime.
|
||||
func reloadAll() {
|
||||
let config: PHConfig
|
||||
do {
|
||||
config = try PHConfig.load()
|
||||
} catch {
|
||||
stderr("reload failed, could not read config: \(error)")
|
||||
return
|
||||
}
|
||||
factory = PHBarFactory(
|
||||
config: config,
|
||||
environment: PHEnvironment.process.merging(config.env),
|
||||
debug: factory.debug
|
||||
)
|
||||
rebuildAllBars()
|
||||
}
|
||||
|
||||
/// Rebuild a single monitor's bar against the current factory. Per-monitor
|
||||
/// path for picking up config edits at runtime. Returns `false` if no bar
|
||||
/// currently runs on `name` (or its reload failed to load).
|
||||
@discardableResult
|
||||
func reload(screenNamed name: String) -> Bool {
|
||||
guard let screen = window(named: name)?.barController.screen else { return false }
|
||||
removeBar(for: screen)
|
||||
return addBar(for: screen)
|
||||
}
|
||||
|
||||
/// Tear down every bar and rebuild against the current factory, preserving
|
||||
/// the set of screens.
|
||||
private func rebuildAllBars() {
|
||||
let screens = controllers.map(\.screen)
|
||||
for screen in screens {
|
||||
removeBar(for: screen)
|
||||
}
|
||||
for screen in screens {
|
||||
addBar(for: screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,30 @@
|
||||
import AppKit
|
||||
|
||||
/// Builds a `BarController` for a screen by resolving its theme, blocks, and
|
||||
/// window from the config.
|
||||
///
|
||||
/// Extracted from `AppDelegate` so the exact same resolution drives initial
|
||||
/// launch, hot-plugged monitors (`syncScreens`), and live reload
|
||||
/// (`reloadAll` / `reload(screenNamed:)`). Each screen's theme/blocks are
|
||||
/// resolved independently, so a failure on one screen can be skipped without
|
||||
/// taking down the others.
|
||||
/// Builds a `BarController` for a screen by resolving its layout
|
||||
/// and window from the config.
|
||||
@MainActor
|
||||
struct PHBarFactory {
|
||||
let config: PHConfig
|
||||
let environment: PHEnvironment
|
||||
let theme: PHTheme
|
||||
let layouts: [String: PHLayout]
|
||||
let debug: Bool
|
||||
|
||||
/// Resolve theme + blocks for `screen` and build its controller.
|
||||
/// Resolve layout for `screen` and build its controller.
|
||||
///
|
||||
/// - Parameter screen: The screen to build a bar for. Its localized name
|
||||
/// and index in `NSScreen.screens` select any per-monitor config override.
|
||||
/// - Throws: `PHTheme.load` / `PHBlock.load` errors for the resolved names.
|
||||
/// - Throws: `PHBlock.load` error for the resolved names.
|
||||
func make(for screen: NSScreen) throws -> BarController {
|
||||
let name = screen.localizedName
|
||||
let index = NSScreen.screens.firstIndex(of: screen)
|
||||
let theme = try PHTheme.load(
|
||||
config.theme(screenName: name, screenIndex: index),
|
||||
environment: environment
|
||||
)
|
||||
let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index))
|
||||
guard let layout = layouts[config.layout(screenName: name, screenIndex: index)] else {
|
||||
throw PHBar.Error("Layout `\(name)` not found.")
|
||||
}
|
||||
return BarController(
|
||||
config: config,
|
||||
screen: screen,
|
||||
theme: theme,
|
||||
blocks: blocks,
|
||||
environment: environment,
|
||||
layout: layout,
|
||||
debug: debug
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
let name: String
|
||||
let _command: String?
|
||||
let textName: String?
|
||||
@Published var text: PHThemeText = .default
|
||||
let styleName: String?
|
||||
@@ -14,8 +13,8 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||
let centered: Bool?
|
||||
var debug: Bool = false
|
||||
|
||||
var command: String {
|
||||
_command ?? PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
|
||||
nonisolated var command: String {
|
||||
PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
|
||||
}
|
||||
|
||||
var visible: Bool {
|
||||
@@ -26,10 +25,6 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||
/// `nil` when omitted from config.
|
||||
let events: [PHEvent]?
|
||||
|
||||
/// Variables made visible to block scripts. Defaults to phbar's process
|
||||
/// environment; `BarController` overlays the `[env]` section on top.
|
||||
var environment: PHEnvironment = .process
|
||||
|
||||
/// Event source registry used to subscribe to system events. Defaults to the
|
||||
/// shared instance; inject a custom one for testing.
|
||||
var registry: PHEventRegistry = .shared
|
||||
@@ -44,11 +39,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||
|
||||
/// Guards against stacking concurrent updates during rapid event bursts
|
||||
/// (e.g. dragging the volume slider fires many events in quick succession).
|
||||
var updateScheduled = false
|
||||
var resolveScheduled = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case _command = "command"
|
||||
case textName = "text"
|
||||
case styleName = "style"
|
||||
case refresh, events, centered
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
extension PHBlock {
|
||||
func resolve(with event: PHGestureEvent) {
|
||||
var environment = [String: String]()
|
||||
environment.updateValue(event.type.rawValue, forKey: PHGestureEvent.typeKey)
|
||||
if let data = event.data {
|
||||
environment.updateValue(data, forKey: PHGestureEvent.infoKey)
|
||||
}
|
||||
|
||||
resolve(merging: environment)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ extension PHBlock {
|
||||
stopAutoRefresh()
|
||||
|
||||
// Immediate refresh so the bar isn't blank until the first trigger fires.
|
||||
scheduleUpdate()
|
||||
resolve()
|
||||
|
||||
// 1. Periodic refresh.
|
||||
if let interval = refresh, interval > 0 {
|
||||
@@ -28,7 +28,7 @@ extension PHBlock {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(milliseconds))
|
||||
if Task.isCancelled { break }
|
||||
await self?.update()
|
||||
await self?.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ extension PHBlock {
|
||||
// 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()
|
||||
self?.resolve()
|
||||
}
|
||||
subscriptions.append(subscription)
|
||||
}
|
||||
@@ -50,80 +50,4 @@ extension PHBlock {
|
||||
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 environment = self.environment
|
||||
|
||||
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]
|
||||
|
||||
// Scripts inherit phbar's process environment with the `[env]` section
|
||||
// overlaid, so user-defined variables and PATH extensions are visible.
|
||||
process.environment = environment.variables
|
||||
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,118 @@
|
||||
import Foundation
|
||||
|
||||
extension PHBlock {
|
||||
/// Resolve the label by executing `command`.
|
||||
/// Used by the interval loop, event handlers, and manual updates.
|
||||
func resolve(merging environment: [String: String]? = nil) {
|
||||
guard canResolve() else { return }
|
||||
|
||||
guard !resolveScheduled else { return }
|
||||
resolveScheduled = true
|
||||
|
||||
Task(name: command, priority: .userInitiated) { [weak self] in
|
||||
defer { self?.resolveScheduled = false }
|
||||
|
||||
do {
|
||||
self?.label = try await self?.resolve(merging: environment)
|
||||
} catch {
|
||||
self?.printStandardError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the label by executing `command`.
|
||||
/// Used by the interval loop, event handlers, and manual updates.
|
||||
func resolve(merging environment: [String: String]? = nil) async {
|
||||
guard canResolve() else { return }
|
||||
|
||||
do {
|
||||
label = try await resolve(merging: environment)
|
||||
} catch {
|
||||
printStandardError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolve(merging environment: [String: String]?) async throws -> String? {
|
||||
let customEnvironment = environment
|
||||
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
if let customEnvironment {
|
||||
environment.merge(customEnvironment) { (_, new) in new }
|
||||
}
|
||||
|
||||
return try await resolve(with: environment)
|
||||
}
|
||||
|
||||
private func resolve(with environment: [String: String]) async throws -> String? {
|
||||
do {
|
||||
let (stdout, stderr) = try await resolve(command, with: environment)
|
||||
printStandardError(stderr)
|
||||
printStandardOutput(stdout)
|
||||
guard let lines = stdout?.split(whereSeparator: \.isNewline),
|
||||
let last = lines.last
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(last)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func canResolve() -> Bool {
|
||||
guard kind == .text else { return false }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func resolve(_ command: String, with environment: [String: String]) async throws -> (
|
||||
stdout: String?,
|
||||
stderr: String?
|
||||
) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.currentDirectoryURL = PHPaths.configDirectory
|
||||
process.environment = environment
|
||||
process.arguments = ["-c", command]
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
let stdout = String(data: stdoutData, encoding: .utf8)
|
||||
let stderr = String(data: stderrData, encoding: .utf8)
|
||||
|
||||
continuation.resume(returning: (stdout, stderr))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a message to standard output.
|
||||
/// Silenced unless in debug mode.
|
||||
///
|
||||
/// - Parameter message: the content of the message
|
||||
private func printStandardOutput(_ message: String?) {
|
||||
guard debug, let data = message?.data(using: .utf8) else { return }
|
||||
FileHandle.standardOutput.write(data)
|
||||
}
|
||||
|
||||
/// Print a message to standard error.
|
||||
/// Silenced unless in debug mode.
|
||||
///
|
||||
/// - Parameter message: the content of the message
|
||||
private func printStandardError(_ message: String?) {
|
||||
guard debug, let data = message?.data(using: .utf8) else { return }
|
||||
FileHandle.standardError.write(data)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
enum PHBlockKind: String {
|
||||
case text, space
|
||||
case text, space, divider
|
||||
}
|
||||
|
||||
extension PHBlock {
|
||||
var kind: PHBlockKind {
|
||||
if name.starts(with: "_space") { return .space }
|
||||
if name.starts(with: "_divider") { return .divider }
|
||||
return .text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
struct PHConfig: Decodable {
|
||||
let theme: String
|
||||
let window: String
|
||||
let blocks: String?
|
||||
let env: [String: String]?
|
||||
let layout: String
|
||||
let monitors: [String: PHConfigMonitorOverride]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme, window, blocks, env
|
||||
case theme, window, layout
|
||||
case monitors = "monitor"
|
||||
}
|
||||
|
||||
var layouts: Set<String> {
|
||||
var layouts = Set<String>()
|
||||
layouts.insert(layout)
|
||||
|
||||
guard let monitors else { return layouts }
|
||||
for monitor in monitors.values {
|
||||
if let layout = monitor.layout {
|
||||
layouts.insert(layout)
|
||||
}
|
||||
}
|
||||
|
||||
return layouts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension PHConfig {
|
||||
/// Return a copy with each `[env]` value expanded against `environment`
|
||||
/// (defaulting to the process environment).
|
||||
///
|
||||
/// Unresolved references are left intact (see `PHEnvironment.expand`).
|
||||
func resolvingEnv(in environment: PHEnvironment = .process) -> PHConfig {
|
||||
guard let env, !env.isEmpty else { return self }
|
||||
return PHConfig(
|
||||
theme: theme,
|
||||
window: window,
|
||||
blocks: blocks,
|
||||
env: env.mapValues(environment.expand),
|
||||
monitors: monitors
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,58 +2,41 @@ import Foundation
|
||||
import TOML
|
||||
|
||||
extension PHConfig {
|
||||
/// Load configuration from the resolved config directory
|
||||
/// (see `PHPaths`). If the file doesn't exist or fails to parse, the
|
||||
/// bundled default is used.
|
||||
/// Load the configuration from the resolved config directory (see `PHPaths`).
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static func load() throws -> PHConfig {
|
||||
let url = PHPaths.configDirectory.appending(path: "config.toml")
|
||||
let url = PHPaths.configFile
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||
return try load(from: url)
|
||||
} else {
|
||||
return try loadFromBundle()
|
||||
let config = url.lastPathComponent
|
||||
throw PHBar.Error(
|
||||
"""
|
||||
Configuration file not found.
|
||||
Make sure to have a file named `\(config)` inside the
|
||||
directory: `\(PHPaths.configDirectory.relativePath)`
|
||||
|
||||
Tip: If you've never used phbar before, run the `phbar install` command
|
||||
to automatically generate the required configuration files.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bundled configuration.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static private func loadFromBundle() throws -> PHConfig {
|
||||
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.
|
||||
/// Load the configuration from the file at URL.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static private 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 private func load(from contents: String) throws -> PHConfig {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let contents = String(data: data, encoding: .utf8) else {
|
||||
throw PHBar.Error("content is not UTF8 encoded.")
|
||||
}
|
||||
let decoder = TOMLDecoder()
|
||||
let config = try decoder.decode(PHConfig.self, from: contents)
|
||||
// Expand `$VAR` references in `[env]` values against the process
|
||||
// environment (e.g. `PATH = "$PATH:/opt/homebrew/bin"`). Themes are
|
||||
// decoded against the merged result later, so their `$VAR` references
|
||||
// resolve the same set.
|
||||
return config.resolvingEnv()
|
||||
let configFile = contents.expanded(from: ProcessInfo.processInfo.environment)
|
||||
return try decoder.decode(PHConfig.self, from: configFile)
|
||||
} catch {
|
||||
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
||||
throw PHBar.Error("Configuration file not readable", underlyingError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,20 @@ import Foundation
|
||||
/// A per-monitor entry from the config's `[monitor]` table.
|
||||
///
|
||||
/// Either field is optional; an unset field inherits the matching global
|
||||
/// (`theme`, `window`, or `blocks`) so a monitor can override just one of them.
|
||||
/// (`window` or `layout`) so a monitor can override just one of them.
|
||||
struct PHConfigMonitorOverride: Decodable {
|
||||
let theme: String?
|
||||
let window: String?
|
||||
let blocks: String?
|
||||
let layout: String?
|
||||
|
||||
init(theme: String? = nil, window: String? = nil, blocks: String? = nil) {
|
||||
self.theme = theme
|
||||
init(window: String? = nil, layout: String? = nil) {
|
||||
self.window = window
|
||||
self.blocks = blocks
|
||||
self.layout = layout
|
||||
}
|
||||
}
|
||||
|
||||
extension PHConfig {
|
||||
/// The per-monitor override matching the given screen identity, if any.
|
||||
///
|
||||
/// This is the single entry point for per-monitor resolution: the
|
||||
/// `theme`/`window`/`blocks` helpers below all delegate to it. Kept free of
|
||||
/// AppKit so the resolution logic is testable without an `NSScreen`.
|
||||
/// Precedence: monitor name → monitor index.
|
||||
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
|
||||
guard let monitors else { return nil }
|
||||
@@ -30,21 +25,15 @@ extension PHConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Theme name for a screen: the override's `theme` if set, otherwise the
|
||||
/// global `theme`.
|
||||
func theme(screenName: String, screenIndex: Int?) -> String {
|
||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.theme ?? theme
|
||||
}
|
||||
|
||||
/// Window definition name for a screen: the override's `window` if set,
|
||||
/// Window name for a screen: the override's `window` if set,
|
||||
/// otherwise the global `window`.
|
||||
func window(screenName: String, screenIndex: Int?) -> String {
|
||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
|
||||
}
|
||||
|
||||
/// Block-set name for a screen: the override's `blocks` if set, otherwise
|
||||
/// the global `blocks` (defaulting to `"default"`).
|
||||
func blocks(screenName: String, screenIndex: Int?) -> String {
|
||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default")
|
||||
/// Layout name for a screen: the override's `layout` if set,
|
||||
/// otherwise the global `layout`.
|
||||
func layout(screenName: String, screenIndex: Int?) -> String {
|
||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Default window and block set for every monitor.
|
||||
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
|
||||
# - window: a [[window]] definition from the theme file
|
||||
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml
|
||||
theme = "default"
|
||||
window = "default"
|
||||
blocks = "default"
|
||||
|
||||
# Optional: override the theme, window, and/or block set per monitor.
|
||||
# - quoted numeric key → NSScreen index
|
||||
# - string key → NSScreen.localizedName (stable across replugs)
|
||||
# Precedence: monitor name → monitor index → the globals above.
|
||||
# Either field may be omitted to inherit its global value.
|
||||
#
|
||||
# [monitor."1"]
|
||||
# theme = "default"
|
||||
# window = "bottom"
|
||||
# blocks = "laptop"
|
||||
#
|
||||
# [monitor."DELL U2723QE"]
|
||||
# theme = "external"
|
||||
# window = "clock"
|
||||
# blocks = "external"
|
||||
|
||||
# Optional: environment variables passed to block scripts when they run.
|
||||
# - scripts always inherit phbar's own process environment (e.g. $PATH)
|
||||
# - entries listed here take precedence, overriding any inherited value
|
||||
# that shares the same name
|
||||
# - `$VAR` / `${VAR}` references in a value expand against the inherited
|
||||
# environment at load time, so you can extend variables (e.g.
|
||||
# `PATH = "$PATH:/opt/homebrew/bin"`) or pull in colors/fonts used by a
|
||||
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
|
||||
#
|
||||
# [env]
|
||||
# TEST = "ciao"
|
||||
# TEST_2 = "hello world"
|
||||
# PATH = "$PATH:/opt/homebrew/bin"
|
||||
@@ -1,76 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// A resolved set of environment variables with `$VAR` expansion support.
|
||||
///
|
||||
/// Two concerns meet here:
|
||||
///
|
||||
/// 1. **Scripts** — block commands run with these variables in their
|
||||
/// environment, built by layering the `[env]` section on top of phbar's own
|
||||
/// process environment (see `PHBlock.compute`).
|
||||
/// 2. **Config templating** — `@EnvExpanded` fields (e.g. theme colors, fonts)
|
||||
/// expand `$VAR` references against a `PHEnvironment` injected through
|
||||
/// `TOMLDecoder.userInfo` at decode time.
|
||||
///
|
||||
/// The environment is therefore resolved once (process → merged with `[env]`)
|
||||
/// and reused for both, so a variable defined in `[env]` is a single source of
|
||||
/// truth visible to scripts and themes alike.
|
||||
struct PHEnvironment: Sendable {
|
||||
let variables: [String: String]
|
||||
|
||||
/// phbar's own process environment.
|
||||
static let process = PHEnvironment(variables: ProcessInfo.processInfo.environment)
|
||||
|
||||
/// `CodingUserInfoKey` used to thread a `PHEnvironment` into `Decodable`
|
||||
/// types (and the `@EnvExpanded` wrapper) without global state.
|
||||
static let userInfoKey = CodingUserInfoKey(rawValue: "phbar.environment")!
|
||||
|
||||
/// Layer `overrides` (typically the `[env]` section) on top of this set.
|
||||
/// Existing values with the same name are replaced.
|
||||
func merging(_ overrides: [String: String]?) -> PHEnvironment {
|
||||
guard let overrides, !overrides.isEmpty else { return self }
|
||||
return PHEnvironment(variables: variables.merging(overrides) { _, override in override })
|
||||
}
|
||||
|
||||
/// Expand `$VAR` and `${VAR}` references against `variables`.
|
||||
///
|
||||
/// Unresolved references are left intact rather than blanked, so a typo
|
||||
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
|
||||
/// vanishing.
|
||||
func expand(_ value: String) -> String {
|
||||
var result = value
|
||||
|
||||
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
|
||||
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
|
||||
variables[String(match.1)] ?? String(match.0)
|
||||
}
|
||||
// `$VAR`
|
||||
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
|
||||
variables[String(match.1)] ?? String(match.0)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in property wrapper that expands `$VAR` / `${VAR}` references in a
|
||||
/// decoded string against the `PHEnvironment` carried in `decoder.userInfo`
|
||||
/// (falling back to the process environment when none is injected).
|
||||
///
|
||||
/// Apply only where config templating is wanted — never on `command`, whose
|
||||
/// `$`-references belong to the shell at run time, not to phbar at load time.
|
||||
@propertyWrapper
|
||||
struct EnvExpanded: Decodable, Equatable {
|
||||
private let value: String
|
||||
|
||||
var wrappedValue: String { value }
|
||||
|
||||
init(wrappedValue: String) {
|
||||
self.value = wrappedValue
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
let environment = (decoder.userInfo[PHEnvironment.userInfoKey] as? PHEnvironment) ?? .process
|
||||
self.value = environment.expand(raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
struct PHLayout: Decodable {
|
||||
let blocks: [PHBlock]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case blocks = "block"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import TOML
|
||||
|
||||
extension PHLayout {
|
||||
/// Load the specified layouts from the resolved config directory (see `PHPaths`).
|
||||
/// If any of theme doesn't exist or fails to parse, the execution is interrupted.
|
||||
static func load(_ layouts: Set<String>) throws -> [String: PHLayout] {
|
||||
var dictionary = [String: PHLayout]()
|
||||
|
||||
for layout in layouts {
|
||||
dictionary.updateValue(try load(layout), forKey: layout)
|
||||
}
|
||||
|
||||
return dictionary
|
||||
}
|
||||
|
||||
/// Load a layout from the resolved config directory (see `PHPaths`).
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static func load(_ layout: String) throws -> PHLayout {
|
||||
let url = PHPaths.layoutsDirectory.appending(path: "\(layout).toml")
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||
return try load(from: url)
|
||||
} else {
|
||||
let layout = url.lastPathComponent
|
||||
throw PHBar.Error(
|
||||
"""
|
||||
Layout file not found.
|
||||
Make sure to have a file named `\(layout)` inside the
|
||||
directory: `\(PHPaths.layoutsDirectory.relativePath)`
|
||||
|
||||
Tip: If you've never used phbar before, run `phbar generate config`
|
||||
to automatically generate the required configuration files.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a layout from the file at URL.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static private func load(from url: URL) throws -> PHLayout {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let contents = String(data: data, encoding: .utf8) else {
|
||||
throw PHBar.Error("content is not UTF8 encoded.")
|
||||
}
|
||||
let decoder = TOMLDecoder()
|
||||
let layoutFile = contents.expanded(from: ProcessInfo.processInfo.environment)
|
||||
return try decoder.decode(PHLayout.self, from: layoutFile)
|
||||
} catch {
|
||||
let layout = url.lastPathComponent
|
||||
throw PHBar.Error("Layout file `\(layout)` not readable", underlyingError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,21 @@ enum PHPaths {
|
||||
/// The resolved configuration directory (computed once, cached).
|
||||
static let configDirectory: URL = resolveConfigDirectory()
|
||||
|
||||
/// The resolved config file (computed once, cached).
|
||||
static let configFile: URL = {
|
||||
configDirectory.appending(path: "config.toml")
|
||||
}()
|
||||
|
||||
/// The resolved themes directory (computed once, cached).
|
||||
static let themesDirectory: URL = {
|
||||
configDirectory.appending(path: "themes")
|
||||
}()
|
||||
|
||||
/// The resolved layouts directory (computed once, cached).
|
||||
static let layoutsDirectory: URL = {
|
||||
configDirectory.appending(path: "layouts")
|
||||
}()
|
||||
|
||||
/// Ordered candidate directories, priority high → low.
|
||||
///
|
||||
/// - Parameter environment: Override the environment lookup (used by tests);
|
||||
|
||||
@@ -2,60 +2,42 @@ import Foundation
|
||||
import TOML
|
||||
|
||||
extension PHTheme {
|
||||
/// Load theme from the resolved config directory (`PHPaths`/themes).
|
||||
/// Falls back to the bundled theme when the file is absent or parsing fails.
|
||||
///
|
||||
/// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded`
|
||||
/// fields (colors, fonts); defaults to the process environment.
|
||||
static func load(_ theme: String?, environment: PHEnvironment = .process) throws -> PHTheme {
|
||||
guard let theme else { return try loadFromBundle() }
|
||||
|
||||
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
|
||||
/// Load a theme from the resolved config directory (see `PHPaths`).
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static func load(_ theme: String) throws -> PHTheme {
|
||||
let url = PHPaths.themesDirectory.appending(path: "\(theme).toml")
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||
return try load(from: url, environment: environment)
|
||||
return try load(from: url)
|
||||
} else {
|
||||
return try loadFromBundle()
|
||||
let theme = url.lastPathComponent
|
||||
throw PHBar.Error(
|
||||
"""
|
||||
Theme file not found.
|
||||
Make sure to have a file named `\(theme)` inside the
|
||||
directory: `\(PHPaths.themesDirectory.relativePath)`
|
||||
|
||||
Tip: If you've never used phbar before, run the `phbar install` command
|
||||
to automatically generate the required configuration files.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bundled theme.
|
||||
/// Load a theme from the file at URL.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static private func loadFromBundle() 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, environment: .process)
|
||||
}
|
||||
|
||||
/// Load theme from a file at URL.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
|
||||
static private 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")
|
||||
guard let contents = String(data: data, encoding: .utf8) else {
|
||||
throw PHBar.Error("content is not UTF8 encoded.")
|
||||
}
|
||||
return try load(from: contents, environment: environment)
|
||||
} 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 private func load(from contents: String, environment: PHEnvironment) throws -> PHTheme {
|
||||
do {
|
||||
let decoder = TOMLDecoder()
|
||||
decoder.userInfo[PHEnvironment.userInfoKey] = environment
|
||||
return try decoder.decode(PHTheme.self, from: contents)
|
||||
let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
|
||||
return try decoder.decode(PHTheme.self, from: themeFile)
|
||||
} catch {
|
||||
throw phbar.Error("Failed to parse theme file", underlyingError: error)
|
||||
let theme = url.lastPathComponent
|
||||
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ struct PHThemeStyle: Decodable {
|
||||
name: "_default",
|
||||
foreground: .init(color: "#000000", alpha: nil),
|
||||
background: .init(color: "#ffffff", alpha: nil),
|
||||
padding: .init(leading: 12, trailing: 12),
|
||||
padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
|
||||
corner: nil
|
||||
)
|
||||
}()
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
struct PHThemeStyleColor: Decodable, ShapeStyle {
|
||||
typealias Resolved = SwiftUI.Color
|
||||
|
||||
@EnvExpanded var color: String
|
||||
var color: String
|
||||
let alpha: Double?
|
||||
|
||||
var uiColor: SwiftUI.Color {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
struct PHThemeStylePadding: Decodable {
|
||||
let top: Double?
|
||||
let bottom: Double?
|
||||
let leading: Double?
|
||||
let trailing: Double?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case top
|
||||
case bottom
|
||||
case leading = "left"
|
||||
case trailing = "right"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
|
||||
struct PHThemeText: Decodable {
|
||||
let name: String
|
||||
@EnvExpanded var fontFamily: String
|
||||
var fontFamily: String
|
||||
let size: Double
|
||||
let weight: PHThemeTextWeight
|
||||
let style: PHThemeTextStyle
|
||||
|
||||
@@ -8,21 +8,42 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct phbar: ParsableCommand {
|
||||
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.
|
||||
phbar renders a status bar on every visible monitor. Each status bar
|
||||
is dynamically built from the given theme, window configuration,
|
||||
and block set. Thanks to the modularity of phbar, each one of these
|
||||
elements can be overwritten for any given monitor.
|
||||
|
||||
phbar relies on blocks to determine what is visible on a bar and how
|
||||
it should look. Everything in phbar is a block, not just texts but
|
||||
also spaces and dividers. Each block can have a different visual style,
|
||||
refresh conditions, and content source. The content of a block, if any,
|
||||
is computed by calling a shell script, giving you the maximum flexibility.
|
||||
|
||||
Run `phbar install` to create the required config directory filled with
|
||||
a template to start with. The directory will be created in one of these
|
||||
locations in order of priority: $XDG_CONFIG_HOME/phbar, ~/.config/phbar,
|
||||
or ~/.phbar.
|
||||
|
||||
Edit `<config-directory>/blocks/default.toml` to customize the block set used
|
||||
by default and `<config-directory>/themes/default.toml` to customize the look
|
||||
and feel of the bar. Check out the full documentation to learn more about all
|
||||
the possible customisations.
|
||||
|
||||
Run `phbar start` to launch the bar and verify the appearance and behaviour.
|
||||
Once you're ready to go to production, you can create a launch agent that uses
|
||||
the same command to start the bar automatically at login.
|
||||
""",
|
||||
version: "1.0.0",
|
||||
subcommands: [start.self]
|
||||
subcommands: [Start.self, Install.self]
|
||||
)
|
||||
}
|
||||
|
||||
extension phbar {
|
||||
extension PHBar {
|
||||
struct Error: LocalizedError {
|
||||
let message: String
|
||||
let underlyingError: Swift.Error?
|
||||
@@ -16,17 +16,16 @@ final class BarController: ObservableObject {
|
||||
blocks.filter { $0.centered == true }
|
||||
}
|
||||
|
||||
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], environment: PHEnvironment = .process, debug: Bool) {
|
||||
init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
|
||||
self.config = config
|
||||
self.screen = screen
|
||||
self.theme = theme
|
||||
self.window = Self.window(for: config, screen: screen, from: theme)
|
||||
self.blocks = blocks
|
||||
self.blocks = layout.blocks
|
||||
|
||||
for block in blocks {
|
||||
block.text = text(for: block)
|
||||
block.style = style(for: block)
|
||||
block.environment = environment
|
||||
block.debug = debug
|
||||
}
|
||||
}
|
||||
@@ -101,7 +100,7 @@ extension BarController {
|
||||
/// Each block updates concurrently; the view refreshes as results arrive.
|
||||
func refresh() {
|
||||
for block in blocks {
|
||||
Task { await block.update() }
|
||||
block.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ struct BarView: View {
|
||||
ForEach(bar.arrangedBlocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
TextBlockView(block: block)
|
||||
TextView(block: block)
|
||||
case .space:
|
||||
SpaceBlockView(block: block)
|
||||
SpaceView(block: block)
|
||||
case .divider:
|
||||
DividerView(block: block)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +26,11 @@ struct BarView: View {
|
||||
ForEach(bar.centeredBlocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
TextBlockView(block: block)
|
||||
TextView(block: block)
|
||||
case .space:
|
||||
SpaceBlockView(block: block)
|
||||
SpaceView(block: block)
|
||||
case .divider:
|
||||
DividerView(block: block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DividerView: View {
|
||||
@ObservedObject var block: PHBlock
|
||||
|
||||
var width: CGFloat {
|
||||
guard let arg = block.name.split(separator: " ").last else { return 1 }
|
||||
guard let width = Double(arg) else { return 1 }
|
||||
return CGFloat(width)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.foregroundStyle(block.style.background?.uiColor ?? .clear)
|
||||
|
||||
Group {
|
||||
if let corner = block.style.corner {
|
||||
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
|
||||
} else {
|
||||
Rectangle()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
|
||||
.frame(width: width)
|
||||
.padding(.trailing, block.style.padding?.trailing ?? 0)
|
||||
.padding(.leading, block.style.padding?.leading ?? 0)
|
||||
.padding(.top, block.style.padding?.top ?? 0)
|
||||
.padding(.bottom, block.style.padding?.bottom ?? 0)
|
||||
.border(block.debug ? .blue : .clear)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.border(block.debug ? .red : .clear)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SpaceBlockView: View {
|
||||
struct SpaceView: View {
|
||||
@ObservedObject var block: PHBlock
|
||||
|
||||
var width: CGFloat? {
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TextBlockView: View {
|
||||
struct TextView: View {
|
||||
@EnvironmentObject private var bar: BarController
|
||||
@ObservedObject var block: PHBlock
|
||||
|
||||
@@ -33,6 +33,8 @@ struct TextBlockView: View {
|
||||
}
|
||||
.padding(.trailing, block.style.padding?.trailing ?? 0)
|
||||
.padding(.leading, block.style.padding?.leading ?? 0)
|
||||
.padding(.top, block.style.padding?.top ?? 0)
|
||||
.padding(.bottom, block.style.padding?.bottom ?? 0)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.border(block.debug ? .red : .clear)
|
||||
@@ -45,7 +47,7 @@ struct TextBlockView: View {
|
||||
.scrollWheel,
|
||||
]) { event in
|
||||
if hovering, let gestureEvent = PHGestureEvent.from(event) {
|
||||
block.handleGesture(gestureEvent)
|
||||
block.resolve(with: gestureEvent)
|
||||
}
|
||||
return event
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Default window and block set for every monitor.
|
||||
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
|
||||
# - window: a [[window]] definition from the theme file
|
||||
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
|
||||
theme = "default"
|
||||
window = "default"
|
||||
layout = "default"
|
||||
|
||||
# Optional: override the window and/or layout per monitor.
|
||||
# - quoted numeric key → NSScreen index
|
||||
# - string key → NSScreen.localizedName (stable across replugs)
|
||||
# Precedence: monitor name → monitor index → the globals above.
|
||||
# Either field may be omitted to inherit its global value.
|
||||
#
|
||||
# [monitor."1"]
|
||||
# window = "bottom"
|
||||
# layout = "laptop"
|
||||
#
|
||||
# [monitor."DELL U2723QE"]
|
||||
# window = "clock"
|
||||
# layout = "external"
|
||||
@@ -0,0 +1,31 @@
|
||||
[[block]]
|
||||
name = "version"
|
||||
style = "elevated"
|
||||
|
||||
[[block]]
|
||||
name = "_space 14"
|
||||
|
||||
[[block]]
|
||||
name = "attribution"
|
||||
command = 'echo "by Panini House"'
|
||||
text = "italic"
|
||||
|
||||
[[block]]
|
||||
name = "_space"
|
||||
|
||||
[[block]]
|
||||
name = "gesture"
|
||||
|
||||
[[block]]
|
||||
name = "_space 14"
|
||||
|
||||
[[block]]
|
||||
name = "clock"
|
||||
refresh = 1
|
||||
style = "elevated"
|
||||
|
||||
[[block]]
|
||||
name = "greetings"
|
||||
command = 'echo " Hi $USER", welcome to phbar!'
|
||||
text = "italic"
|
||||
centered = true
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo " $(date "+%a %d, %H:%M:%S")"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
text="Click me"
|
||||
|
||||
case $GESTURE in
|
||||
"left_mouse_down") text="That's left mouse button!" ;;
|
||||
"right_mouse_down") text="That's right mouse button!" ;;
|
||||
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
|
||||
"scroll_wheel") text="Yes, you can even scroll!" ;;
|
||||
esac
|
||||
|
||||
echo " $text"
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "phbar v$(phbar --version)"
|
||||
@@ -8,39 +8,29 @@ anchor = "top"
|
||||
height = 30
|
||||
width = "100%"
|
||||
|
||||
[[window]]
|
||||
name = "bottom"
|
||||
anchor = "bottom"
|
||||
height = 30
|
||||
width = "100%"
|
||||
|
||||
[[text]]
|
||||
name = "default"
|
||||
font = "Comic Code"
|
||||
font = "monospace"
|
||||
size = 14
|
||||
weight = "regular"
|
||||
style = "normal"
|
||||
offset = -0.5
|
||||
offset = -1
|
||||
|
||||
[[text]]
|
||||
name = "italic"
|
||||
font = "Comic Code"
|
||||
font = "monospace"
|
||||
size = 14
|
||||
weight = "regular"
|
||||
style = "italic"
|
||||
offset = -0.5
|
||||
offset = -1
|
||||
|
||||
[[style]]
|
||||
name = "default"
|
||||
foreground = { color = "#aed3f3" }
|
||||
background = { color = "#010408", alpha = 0.825 }
|
||||
background = { color = "#010408" }
|
||||
|
||||
[[style]]
|
||||
name = "floating"
|
||||
foreground = { color = "#aed3f3" }
|
||||
|
||||
[[style]]
|
||||
name = "tinted"
|
||||
name = "elevated"
|
||||
foreground = { color = "#aed3f3" }
|
||||
background = { color = "#0f304a" }
|
||||
padding = { left = 12.0, right = 12.0 }
|
||||
@@ -7,7 +7,7 @@ import Testing
|
||||
// MARK: - Refresh command
|
||||
|
||||
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
||||
let config = phbar.refresh.configuration
|
||||
let config = PHBar.Refresh.configuration
|
||||
#expect(config.commandName == "refresh")
|
||||
#expect(config.abstract == "Refresh the running status bar.")
|
||||
}
|
||||
|
||||
@@ -28,19 +28,19 @@ private func makeConfig(
|
||||
}
|
||||
|
||||
@Test func windowMatchesByIndex() {
|
||||
let config = makeConfig(monitors: ["1": .init(window: "bottom", blocks: nil)])
|
||||
let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)])
|
||||
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
|
||||
}
|
||||
|
||||
@Test func windowMatchesByName() {
|
||||
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)])
|
||||
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)])
|
||||
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
|
||||
}
|
||||
|
||||
@Test func windowPrefersNameOverIndex() {
|
||||
let config = makeConfig(monitors: [
|
||||
"0": .init(window: "byIndex", blocks: nil),
|
||||
"DELL": .init(window: "byName", blocks: nil),
|
||||
"0": .init(window: "byIndex", layout: nil),
|
||||
"DELL": .init(window: "byName", layout: nil),
|
||||
])
|
||||
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
|
||||
}
|
||||
@@ -48,17 +48,17 @@ private func makeConfig(
|
||||
// MARK: blocks
|
||||
|
||||
@Test func blocksDefaultsToDefault() {
|
||||
#expect(makeConfig().blocks(screenName: "S", screenIndex: 0) == "default")
|
||||
#expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
|
||||
}
|
||||
|
||||
@Test func blocksUsesGlobalWhenNoOverride() {
|
||||
let config = makeConfig(blocks: "main")
|
||||
#expect(config.blocks(screenName: "S", screenIndex: 0) == "main")
|
||||
#expect(config.layout(screenName: "S", screenIndex: 0) == "main")
|
||||
}
|
||||
|
||||
@Test func blocksOverrideBeatsGlobal() {
|
||||
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, blocks: "alt")])
|
||||
#expect(config.blocks(screenName: "S", screenIndex: 1) == "alt")
|
||||
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
|
||||
#expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
|
||||
}
|
||||
|
||||
// MARK: theme
|
||||
@@ -77,10 +77,10 @@ private func makeConfig(
|
||||
|
||||
@Test func overrideInheritsUnsetFieldsFromGlobal() {
|
||||
// An override that sets only `blocks` still inherits the global window/theme.
|
||||
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, blocks: "laptop")])
|
||||
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")])
|
||||
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
|
||||
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
|
||||
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
|
||||
#expect(config.layout(screenName: "S", screenIndex: 1) == "laptop")
|
||||
}
|
||||
|
||||
@Test func themeWindowAndBlocksResolveIndependently() {
|
||||
@@ -107,6 +107,6 @@ private func makeConfig(
|
||||
}
|
||||
|
||||
@Test func monitorOverrideReturnsNilForUnmatchedScreen() {
|
||||
let config = makeConfig(monitors: ["DELL": .init(window: "clock", blocks: nil)])
|
||||
let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)])
|
||||
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TOML
|
||||
|
||||
@testable import phbar
|
||||
|
||||
// MARK: - expand
|
||||
|
||||
@Test func envExpandDollarBraceForm() {
|
||||
let env = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||
#expect(env.expand("${ACCENT}") == "#ff0000")
|
||||
}
|
||||
|
||||
@Test func envExpandDollarForm() {
|
||||
let env = PHEnvironment(variables: ["FOO": "bar"])
|
||||
#expect(env.expand("$FOO") == "bar")
|
||||
}
|
||||
|
||||
@Test func envExpandsEmbeddedReferences() {
|
||||
let env = PHEnvironment(variables: ["HOME": "/Users/tom", "BIN": "/opt/bin"])
|
||||
#expect(env.expand("$HOME/bin:${BIN}/extra") == "/Users/tom/bin:/opt/bin/extra")
|
||||
}
|
||||
|
||||
@Test func envExpandLeavesUnresolvedIntact() {
|
||||
// Unresolved references stay literal instead of being blanked, so a typo
|
||||
// remains visible.
|
||||
let env = PHEnvironment(variables: [:])
|
||||
#expect(env.expand("$MISSING and ${ALSO_MISSING}") == "$MISSING and ${ALSO_MISSING}")
|
||||
}
|
||||
|
||||
@Test func envExpandIgnoresNonIdentifierDollar() {
|
||||
let env = PHEnvironment(variables: ["FOO": "bar"])
|
||||
// A `$` not followed by an identifier name is left untouched.
|
||||
#expect(env.expand("cost: $5 and $FOO") == "cost: $5 and bar")
|
||||
}
|
||||
|
||||
// MARK: - merging
|
||||
|
||||
@Test func envMergingOverridesExistingKeys() {
|
||||
let env = PHEnvironment(variables: ["PATH": "/usr/bin", "HOME": "/u"])
|
||||
let merged = env.merging(["PATH": "/usr/bin:/extra"])
|
||||
#expect(merged.variables["PATH"] == "/usr/bin:/extra")
|
||||
#expect(merged.variables["HOME"] == "/u")
|
||||
}
|
||||
|
||||
@Test func envMergingNilReturnsSameVariables() {
|
||||
let env = PHEnvironment(variables: ["A": "1"])
|
||||
#expect(env.merging(nil).variables == ["A": "1"])
|
||||
}
|
||||
|
||||
// MARK: - [env] value expansion (config load)
|
||||
|
||||
private func makeConfig(env: [String: String]?) -> PHConfig {
|
||||
PHConfig(theme: "default", window: "default", blocks: nil, env: env, monitors: nil)
|
||||
}
|
||||
|
||||
@Test func resolvingEnvExpandsValuesAgainstGivenEnvironment() {
|
||||
// `PATH = "$PATH:/extra"` extends an inherited variable.
|
||||
let config = makeConfig(env: [
|
||||
"PATH": "$PATH:/opt/phbar/bin",
|
||||
"WORKDIR": "${TMPDIR}/phbar",
|
||||
"PLAIN": "literal",
|
||||
])
|
||||
let resolved = config.resolvingEnv(in: PHEnvironment(variables: [
|
||||
"PATH": "/usr/bin",
|
||||
"TMPDIR": "/var/tmp",
|
||||
]))
|
||||
|
||||
#expect(resolved.env?["PATH"] == "/usr/bin:/opt/phbar/bin")
|
||||
#expect(resolved.env?["WORKDIR"] == "/var/tmp/phbar")
|
||||
#expect(resolved.env?["PLAIN"] == "literal")
|
||||
}
|
||||
|
||||
@Test func resolvingEnvDefaultsToProcessEnvironment() {
|
||||
// `$TMPDIR` is set in phbar's launch environment, so this also exercises
|
||||
// real process-env expansion end to end.
|
||||
guard let tmpdir = ProcessInfo.processInfo.environment["TMPDIR"] else { return }
|
||||
let config = makeConfig(env: ["OUT": "$TMPDIR"])
|
||||
let resolved = config.resolvingEnv()
|
||||
#expect(resolved.env?["OUT"] == tmpdir)
|
||||
}
|
||||
|
||||
@Test func resolvingEnvLeavesConfigUntouchedWhenEnvAbsent() {
|
||||
let config = makeConfig(env: nil)
|
||||
#expect(config.resolvingEnv(in: .process).env == nil)
|
||||
}
|
||||
|
||||
// MARK: - @EnvExpanded wrapper
|
||||
|
||||
/// Stand-in Decodable that mirrors how a theme field opts into expansion.
|
||||
private struct Color: Decodable {
|
||||
@EnvExpanded var hex: String
|
||||
}
|
||||
|
||||
@Test func envExpandedResolvesAgainstInjectedEnvironment() throws {
|
||||
let decoder = TOMLDecoder()
|
||||
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||
|
||||
let color = try decoder.decode(Color.self, from: #"hex = "$ACCENT""#)
|
||||
|
||||
#expect(color.hex == "#ff0000")
|
||||
}
|
||||
|
||||
@Test func envExpandedFallsBackToLaunchEnvironment() throws {
|
||||
// Without injected userInfo the wrapper falls back to phbar's launch
|
||||
// environment (`ProcessInfo`), which is a snapshot taken at process start.
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return }
|
||||
|
||||
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$PATH""#)
|
||||
|
||||
#expect(color.hex == path)
|
||||
}
|
||||
|
||||
@Test func envExpandedLeavesUnresolvedIntact() throws {
|
||||
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$NEVER_SET""#)
|
||||
|
||||
#expect(color.hex == "$NEVER_SET")
|
||||
}
|
||||
|
||||
// MARK: - Theme integration
|
||||
|
||||
@Test func themeStyleColorExpandsEnvReference() throws {
|
||||
let decoder = TOMLDecoder()
|
||||
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||
|
||||
let style = try decoder.decode(PHThemeStyleColor.self, from: #"color = "$ACCENT""#)
|
||||
|
||||
#expect(style.color == "#ff0000")
|
||||
}
|
||||
|
||||
@Test func themeTextFontExpandsEnvReference() throws {
|
||||
let decoder = TOMLDecoder()
|
||||
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["FONT": "Comic Code"])
|
||||
|
||||
let text = try decoder.decode(
|
||||
PHThemeText.self,
|
||||
from: """
|
||||
name = "default"
|
||||
font = "$FONT"
|
||||
size = 14
|
||||
weight = "regular"
|
||||
style = "normal"
|
||||
"""
|
||||
)
|
||||
|
||||
#expect(text.fontFamily == "Comic Code")
|
||||
}
|
||||
Reference in New Issue
Block a user