diff --git a/Sources/phbar/Commands/PHBar+Start.swift b/Sources/phbar/Commands/PHBar+Start.swift index 306787c..71040c1 100644 --- a/Sources/phbar/Commands/PHBar+Start.swift +++ b/Sources/phbar/Commands/PHBar+Start.swift @@ -22,8 +22,7 @@ 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 diff --git a/Sources/phbar/Extensions/String+Extensions.swift b/Sources/phbar/Extensions/String+Extensions.swift new file mode 100644 index 0000000..1abef7b --- /dev/null +++ b/Sources/phbar/Extensions/String+Extensions.swift @@ -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 + } +} diff --git a/Sources/phbar/Models/PHBarDelegate.swift b/Sources/phbar/Models/PHBarDelegate.swift index 629af3f..c5dc93a 100644 --- a/Sources/phbar/Models/PHBarDelegate.swift +++ b/Sources/phbar/Models/PHBarDelegate.swift @@ -16,12 +16,10 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate { private var screenObserver: NSObjectProtocol? init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) { - let environment = PHEnvironment.process.merging(config.env) self.factory = PHBarFactory( config: config, theme: theme, layouts: layouts, - environment: environment, debug: debug ) super.init() diff --git a/Sources/phbar/Models/PHBarFactory.swift b/Sources/phbar/Models/PHBarFactory.swift index 74e72fc..1398c5e 100644 --- a/Sources/phbar/Models/PHBarFactory.swift +++ b/Sources/phbar/Models/PHBarFactory.swift @@ -7,7 +7,6 @@ struct PHBarFactory { let config: PHConfig let theme: PHTheme let layouts: [String: PHLayout] - let environment: PHEnvironment let debug: Bool /// Resolve layout for `screen` and build its controller. @@ -26,7 +25,6 @@ struct PHBarFactory { screen: screen, theme: theme, layout: layout, - environment: environment, debug: debug ) } diff --git a/Sources/phbar/Models/PHBlock.swift b/Sources/phbar/Models/PHBlock.swift index eda9ff7..464eaad 100644 --- a/Sources/phbar/Models/PHBlock.swift +++ b/Sources/phbar/Models/PHBlock.swift @@ -25,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 diff --git a/Sources/phbar/Models/PHConfig.swift b/Sources/phbar/Models/PHConfig.swift index dc47cec..48ac90f 100644 --- a/Sources/phbar/Models/PHConfig.swift +++ b/Sources/phbar/Models/PHConfig.swift @@ -2,11 +2,10 @@ struct PHConfig: Decodable { let theme: String let window: String let layout: String - let env: [String: String]? let monitors: [String: PHConfigMonitorOverride]? private enum CodingKeys: String, CodingKey { - case theme, window, layout, env + case theme, window, layout case monitors = "monitor" } } diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Env.swift b/Sources/phbar/Models/PHConfig/PHConfig+Env.swift deleted file mode 100644 index cfc171b..0000000 --- a/Sources/phbar/Models/PHConfig/PHConfig+Env.swift +++ /dev/null @@ -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, - layout: layout, - 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 b49f033..2d111bf 100644 --- a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift +++ b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift @@ -2,19 +2,20 @@ import Foundation import TOML extension PHConfig { - /// Load configuration from the resolved config directory (see `PHPaths`). + /// 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. @@ -23,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) } } } diff --git a/Sources/phbar/Models/PHEnvironment.swift b/Sources/phbar/Models/PHEnvironment.swift deleted file mode 100644 index ef928cf..0000000 --- a/Sources/phbar/Models/PHEnvironment.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift b/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift index 9f52aad..5dbf4c5 100644 --- a/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift +++ b/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift @@ -45,7 +45,8 @@ extension PHLayout { throw PHBar.Error("content is not UTF8 encoded.") } let decoder = TOMLDecoder() - return try decoder.decode(PHLayout.self, from: contents) + 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) diff --git a/Sources/phbar/Models/PHPaths.swift b/Sources/phbar/Models/PHPaths.swift index 7f12b9d..57abd57 100644 --- a/Sources/phbar/Models/PHPaths.swift +++ b/Sources/phbar/Models/PHPaths.swift @@ -16,9 +16,19 @@ 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 = { - resolveConfigDirectory().appending(path: "layouts") + configDirectory.appending(path: "layouts") }() /// Ordered candidate directories, priority high → low. diff --git a/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift index 80586bd..1d91a95 100644 --- a/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift +++ b/Sources/phbar/Models/PHTheme/PHTheme+Loading.swift @@ -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) } } } diff --git a/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift b/Sources/phbar/Models/PHTheme/PHThemeStyle/PHThemeStyleColor.swift index ffe5511..8a9700b 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 - @EnvExpanded var color: String + 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 9a5cb50..c2ebd41 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 - @EnvExpanded var fontFamily: String + 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 b5ecc8a..9e05a92 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, layout: PHLayout, 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 @@ -26,7 +26,6 @@ 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/Sources/phbar/config/config.toml b/Sources/phbar/config/config.toml index 79274d9..e3a35db 100644 --- a/Sources/phbar/config/config.toml +++ b/Sources/phbar/config/config.toml @@ -19,16 +19,3 @@ layout = "default" # [monitor."DELL U2723QE"] # window = "clock" # layout = "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" diff --git a/Tests/phbarTests/PHEnvironmentTests.swift b/Tests/phbarTests/PHEnvironmentTests.swift deleted file mode 100644 index 02285f8..0000000 --- a/Tests/phbarTests/PHEnvironmentTests.swift +++ /dev/null @@ -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") -}