allow env variables expansion in config files

This commit is contained in:
2026-07-12 18:20:03 +02:00
parent 9a7710d675
commit 27fad1d67c
12 changed files with 290 additions and 14 deletions
+7 -1
View File
@@ -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
)
)
+4
View File
@@ -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
@@ -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)
@@ -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
)
}
}
@@ -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)
}
+14
View File
@@ -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"
+76
View File
@@ -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)
}
}
@@ -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)
@@ -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 {
@@ -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
+2 -1
View File
@@ -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
}
}