Compare commits

...

6 Commits

Author SHA1 Message Date
tommaso f70ea7f0f5 remove hard-coded layout names 2026-07-14 00:36:43 +02:00
tommaso 4a74cf04c8 block running make as sudo 2026-07-14 00:35:49 +02:00
tommaso 3e9557586b greatly simplify environment variables handling 2026-07-13 23:46:08 +02:00
tommaso f479499309 refactor block resolve and refresh 2026-07-13 23:05:08 +02:00
tommaso 30be4b2901 wrap blocks around a layout concept 2026-07-13 21:08:04 +02:00
tommaso ed01251ca5 update Makefile 2026-07-13 16:53:48 +02:00
28 changed files with 363 additions and 563 deletions
+38 -13
View File
@@ -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"
+3 -3
View File
@@ -22,8 +22,8 @@ extension PHBar {
mutating func run() throws {
let config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
let theme = try PHTheme.load(config.theme, environment: environment)
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))
@@ -31,7 +31,7 @@ extension PHBar {
try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = PHBarDelegate(config: config, theme: theme, debug: debug)
let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
@@ -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
}
}
+3 -8
View File
@@ -9,22 +9,17 @@ 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, theme: PHTheme, debug: Bool) {
let environment = PHEnvironment.process.merging(config.env)
init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) {
self.factory = PHBarFactory(
config: config,
theme: theme,
environment: environment,
layouts: layouts,
debug: debug
)
super.init()
@@ -1,48 +0,0 @@
extension PHBarDelegate {
// MARK: - Reload (entry points for a future `phbar reload`)
/// Re-read config and theme from disk and rebuild every bar against them.
/// 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
let theme: PHTheme
do {
config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
theme = try PHTheme.load(config.theme, environment: environment)
} catch {
stderr("reload failed, could not read config: \(error)")
return
}
factory = PHBarFactory(
config: config,
theme: theme,
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)
}
}
}
+8 -7
View File
@@ -1,15 +1,15 @@
import AppKit
/// Builds a `BarController` for a screen by resolving its blocks and
/// window from the config.
/// Builds a `BarController` for a screen by resolving its layout
/// and window from the config.
@MainActor
struct PHBarFactory {
let config: PHConfig
let theme: PHTheme
let environment: PHEnvironment
let layouts: [String: PHLayout]
let debug: Bool
/// Resolve 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.
@@ -17,13 +17,14 @@ struct PHBarFactory {
func make(for screen: NSScreen) throws -> BarController {
let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen)
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
)
}
+3 -9
View File
@@ -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)
}
}
+16 -3
View File
@@ -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,20 +2,20 @@ 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 execution is interrupted.
/// 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 {
let config = url.lastPathComponent
throw PHBar.Error(
"""
Configuration file not found.
Make sure to have a file named `config.toml` inside the
directory: `\(url.deletingLastPathComponent().relativePath)`
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.
@@ -24,31 +24,19 @@ extension PHConfig {
}
}
/// 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), !contents.isEmpty
else {
throw PHBar.Error("Configuration file not readable or empty.")
}
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("Configuration file not valid", underlyingError: error)
throw PHBar.Error("Configuration file not readable", underlyingError: error)
}
}
}
@@ -3,23 +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 window: String?
let blocks: String?
let layout: String?
init(window: String? = nil, blocks: String? = nil) {
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 }
@@ -28,15 +25,15 @@ extension PHConfig {
return nil
}
/// 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
}
}
-76
View File
@@ -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)
}
}
+7
View File
@@ -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)
}
}
}
+15
View File
@@ -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,22 +2,20 @@ import Foundation
import TOML
extension PHTheme {
/// Load theme from the resolved config directory (`PHPaths`/themes).
/// Load a theme from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
///
/// - 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 {
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
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 {
let theme = url.lastPathComponent
throw PHBar.Error(
"""
Theme file not found.
Make sure to have a file named `\(theme).toml` inside the
directory: `\(url.deletingLastPathComponent().relativePath)`
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.
@@ -26,27 +24,20 @@ extension PHTheme {
}
}
/// Load theme from a file at URL.
/// 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 load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
guard let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8), !contents.isEmpty
else {
throw PHBar.Error("Theme file not readable or empty.")
}
return try load(from: contents, environment: environment)
}
/// 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 {
static private func load(from url: URL) throws -> PHTheme {
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()
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("Theme file not valid", underlyingError: error)
let theme = url.lastPathComponent
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
}
}
}
@@ -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 {
@@ -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
+3 -4
View File
@@ -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()
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ struct TextView: View {
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent)
block.resolve(with: gestureEvent)
}
return event
}
+5 -18
View File
@@ -1,12 +1,12 @@
# 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
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default"
window = "default"
blocks = "default"
layout = "default"
# Optional: override the window and/or block set per monitor.
# 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.
@@ -14,21 +14,8 @@ blocks = "default"
#
# [monitor."1"]
# window = "bottom"
# blocks = "laptop"
# layout = "laptop"
#
# [monitor."DELL U2723QE"]
# 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]
# PATH = "$PATH:/opt/homebrew/bin"
# MY_CUSTOM_VAR = "hello world"
# layout = "external"
+11 -11
View File
@@ -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)
}
-147
View File
@@ -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")
}