From 27fad1d67cb2f88cc0fade76c132a71e83dcd3ec Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Sun, 12 Jul 2026 18:20:03 +0200 Subject: [PATCH] allow env variables expansion in config files --- Sources/phbar/AppDelegate.swift | 8 +- Sources/phbar/Models/PHBlock.swift | 4 + .../Models/PHBlock/PHBlock+Refresh.swift | 6 +- .../phbar/Models/PHConfig/PHConfig+Env.swift | 18 +++ .../Models/PHConfig/PHConfig+Loading.swift | 8 +- Sources/phbar/Models/PHConfig/config.toml | 14 ++ Sources/phbar/Models/PHEnvironment.swift | 76 +++++++++ .../Models/PHTheme/PHTheme+Loading.swift | 16 +- .../PHThemeStyle/PHThemeStyleColor.swift | 2 +- .../phbar/Models/PHTheme/PHThemeText.swift | 2 +- Sources/phbar/Views/BarController.swift | 3 +- Tests/phbarTests/PHEnvironmentTests.swift | 147 ++++++++++++++++++ 12 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 Sources/phbar/Models/PHConfig/PHConfig+Env.swift create mode 100644 Sources/phbar/Models/PHEnvironment.swift create mode 100644 Tests/phbarTests/PHEnvironmentTests.swift diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift index 017602a..a55e41e 100644 --- a/Sources/phbar/AppDelegate.swift +++ b/Sources/phbar/AppDelegate.swift @@ -16,10 +16,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) throws { super.init() + // The merged environment (process + `[env]`) is shared by every screen: + // scripts see it as their process environment, and themes expand `$VAR` + // references in colors/fonts against it. + let environment = PHEnvironment.process.merging(config.env) + for screen in screens { let name = screen.localizedName let index = NSScreen.screens.firstIndex(of: screen) - let theme = try PHTheme.load(config.theme(screenName: name, screenIndex: index)) + let theme = try PHTheme.load(config.theme(screenName: name, screenIndex: index), environment: environment) let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) controllers.append( @@ -28,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { screen: screen, theme: theme, blocks: blocks, + environment: environment, debug: debug ) ) diff --git a/Sources/phbar/Models/PHBlock.swift b/Sources/phbar/Models/PHBlock.swift index fbdd233..3f864c1 100644 --- a/Sources/phbar/Models/PHBlock.swift +++ b/Sources/phbar/Models/PHBlock.swift @@ -26,6 +26,10 @@ 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 diff --git a/Sources/phbar/Models/PHBlock/PHBlock+Refresh.swift b/Sources/phbar/Models/PHBlock/PHBlock+Refresh.swift index 98b35dc..1cbaf7c 100644 --- a/Sources/phbar/Models/PHBlock/PHBlock+Refresh.swift +++ b/Sources/phbar/Models/PHBlock/PHBlock+Refresh.swift @@ -82,6 +82,7 @@ extension PHBlock { guard kind == .text else { return nil } let command = command + let environment = self.environment let lines: [Substring]? = await Task.detached(priority: .userInitiated) { let process = Process() @@ -92,8 +93,9 @@ extension PHBlock { process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", command] - // Inherit the parent's environment (includes $PATH, etc.) - process.environment = ProcessInfo.processInfo.environment + // 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) diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Env.swift b/Sources/phbar/Models/PHConfig/PHConfig+Env.swift new file mode 100644 index 0000000..a31e601 --- /dev/null +++ b/Sources/phbar/Models/PHConfig/PHConfig+Env.swift @@ -0,0 +1,18 @@ +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 + ) + } +} diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift index 1729279..ea7c61e 100644 --- a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift +++ b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift @@ -46,8 +46,12 @@ extension PHConfig { static private func load(from contents: String) throws -> PHConfig { do { let decoder = TOMLDecoder() - let configFile = try decoder.decode(PHConfig.self, from: contents) - return configFile + 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() } catch { throw phbar.Error("Failed to parse config file", underlyingError: error) } diff --git a/Sources/phbar/Models/PHConfig/config.toml b/Sources/phbar/Models/PHConfig/config.toml index 17b9e6f..0d5e92c 100644 --- a/Sources/phbar/Models/PHConfig/config.toml +++ b/Sources/phbar/Models/PHConfig/config.toml @@ -21,3 +21,17 @@ blocks = "default" # 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" diff --git a/Sources/phbar/Models/PHEnvironment.swift b/Sources/phbar/Models/PHEnvironment.swift new file mode 100644 index 0000000..ef928cf --- /dev/null +++ b/Sources/phbar/Models/PHEnvironment.swift @@ -0,0 +1,76 @@ +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) + } +} diff --git a/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift index b05f792..2898765 100644 --- a/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift +++ b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift @@ -4,13 +4,16 @@ 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. - static func load(_ theme: String?) throws -> PHTheme { + /// + /// - 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") if FileManager.default.fileExists(atPath: url.relativePath) { - return try load(from: url) + return try load(from: url, environment: environment) } else { return try loadFromBundle() } @@ -27,18 +30,18 @@ extension PHTheme { else { throw phbar.Error("Failed to load theme file") } - return try load(from: defaultConfig) + 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) throws -> PHTheme { + static private func load(from url: URL, environment: PHEnvironment) 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") } - return try load(from: contents) + return try load(from: contents, environment: environment) } catch { throw phbar.Error("Failed to load theme file", underlyingError: error) } @@ -46,9 +49,10 @@ extension PHTheme { /// Load theme from a TOML string. /// If the content fails to parse, the execution is interrupted. - static private func load(from contents: String) throws -> PHTheme { + 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) } catch { throw phbar.Error("Failed to parse theme file", underlyingError: error) diff --git a/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift b/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift index 9117d22..ffe5511 100644 --- a/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift +++ b/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift @@ -3,7 +3,7 @@ import SwiftUI struct PHThemeStyleColor: Decodable, ShapeStyle { typealias Resolved = SwiftUI.Color - let color: String + @EnvExpanded var color: String let alpha: Double? var uiColor: SwiftUI.Color { diff --git a/Sources/phbar/Models/PHTheme/PHThemeText.swift b/Sources/phbar/Models/PHTheme/PHThemeText.swift index fc3912b..9a5cb50 100644 --- a/Sources/phbar/Models/PHTheme/PHThemeText.swift +++ b/Sources/phbar/Models/PHTheme/PHThemeText.swift @@ -3,7 +3,7 @@ import SwiftUI struct PHThemeText: Decodable { let name: String - let fontFamily: String + @EnvExpanded var fontFamily: String let size: Double let weight: PHThemeTextWeight let style: PHThemeTextStyle diff --git a/Sources/phbar/Views/BarController.swift b/Sources/phbar/Views/BarController.swift index 810499d..d0f45ea 100644 --- a/Sources/phbar/Views/BarController.swift +++ b/Sources/phbar/Views/BarController.swift @@ -16,7 +16,7 @@ final class BarController: ObservableObject { blocks.filter { $0.centered == true } } - init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool) { + init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], environment: PHEnvironment = .process, debug: Bool) { self.config = config self.screen = screen self.theme = theme @@ -26,6 +26,7 @@ final class BarController: ObservableObject { for block in blocks { block.text = text(for: block) block.style = style(for: block) + block.environment = environment block.debug = debug } } diff --git a/Tests/phbarTests/PHEnvironmentTests.swift b/Tests/phbarTests/PHEnvironmentTests.swift new file mode 100644 index 0000000..02285f8 --- /dev/null +++ b/Tests/phbarTests/PHEnvironmentTests.swift @@ -0,0 +1,147 @@ +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") +}