From a6a7f80cce78330a32901618512ba76d19baae74 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Sun, 12 Jul 2026 14:49:25 +0200 Subject: [PATCH] resolve config directory dynamically following a priority order --- .../Block/Extensions/PHBlock+Loading.swift | 12 +--- .../Block/Extensions/PHBlock+Refresh.swift | 8 +-- .../Config/Extensions/PHConfig+Loading.swift | 9 ++- Sources/phbar/Models/Config/PHPaths.swift | 55 ++++++++++++++++ Sources/phbar/Models/Event/PHEvent.swift | 2 +- .../phbar/Models/Event/PHEventLoader.swift | 6 +- .../phbar/Models/Event/PHEventManifest.swift | 2 +- .../phbar/Models/Event/PHEventRegistry.swift | 2 +- .../Theme/Extensions/PHTheme+Loading.swift | 8 +-- Sources/phbarEvents/PHEventRecognizer.swift | 6 +- Tests/phbarTests/phbarTests.swift | 63 ++++++++++++++++--- 11 files changed, 133 insertions(+), 40 deletions(-) create mode 100644 Sources/phbar/Models/Config/PHPaths.swift diff --git a/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift b/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift index a3c7e17..ba65557 100644 --- a/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift +++ b/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift @@ -10,20 +10,12 @@ extension PHBlock { } } - nonisolated static var configDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar") - } - - nonisolated static var blocksDirectory: URL { - Self.configDirectory.appending(path: "blocks") - } - /// Load a named block set from `/blocks/.toml`. /// /// - Parameter configDirectory: Override the lookup root (used by tests); - /// defaults to `~/.config/phbar`. + /// defaults to the resolved config directory (see `PHPaths`). static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] { - let base = configDirectory ?? Self.configDirectory + let base = configDirectory ?? PHPaths.configDirectory let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml") guard FileManager.default.fileExists(atPath: setFile.relativePath) else { diff --git a/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift b/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift index 4e249dc..98b35dc 100644 --- a/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift +++ b/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift @@ -85,10 +85,10 @@ extension PHBlock { let lines: [Substring]? = await Task.detached(priority: .userInitiated) { let process = Process() - // Commands run with the config root (~/.config/phbar) as their working - // directory, regardless of which block set they belong to, so relative - // paths in user scripts stay stable. - process.currentDirectoryURL = Self.configDirectory + // 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] diff --git a/Sources/phbar/Models/Config/Extensions/PHConfig+Loading.swift b/Sources/phbar/Models/Config/Extensions/PHConfig+Loading.swift index b61eaab..1729279 100644 --- a/Sources/phbar/Models/Config/Extensions/PHConfig+Loading.swift +++ b/Sources/phbar/Models/Config/Extensions/PHConfig+Loading.swift @@ -2,12 +2,11 @@ import Foundation import TOML extension PHConfig { - /// Load configuration from the default path (~/.config/phbar/config.toml). - /// If the file doesn't exist or fails to parse, defaults are used (non-fatal). + /// Load configuration from the resolved config directory + /// (see `PHPaths`). If the file doesn't exist or fails to parse, the + /// bundled default is used. static func load() throws -> PHConfig { - let url = FileManager.default.homeDirectoryForCurrentUser.appending( - path: ".config/phbar/config.toml" - ) + let url = PHPaths.configDirectory.appending(path: "config.toml") if FileManager.default.fileExists(atPath: url.relativePath) { return try load(from: url) diff --git a/Sources/phbar/Models/Config/PHPaths.swift b/Sources/phbar/Models/Config/PHPaths.swift new file mode 100644 index 0000000..3777d37 --- /dev/null +++ b/Sources/phbar/Models/Config/PHPaths.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Resolves phbar's configuration directory. +/// +/// Searches these locations in priority order and returns the first that +/// exists on disk: +/// +/// 1. `$XDG_CONFIG_HOME/phbar` — only when `XDG_CONFIG_HOME` is set to an +/// absolute path (a relative/empty value is ignored, per the XDG spec). +/// 2. `~/.config/phbar` +/// 3. `~/.phbar` +/// +/// If none exist yet (fresh install), the highest-priority candidate is +/// returned so loaders and block commands have a stable root to resolve into. +enum PHPaths { + /// The resolved configuration directory (computed once, cached). + static let configDirectory: URL = resolveConfigDirectory() + + /// Ordered candidate directories, priority high → low. + /// + /// - Parameter environment: Override the environment lookup (used by tests); + /// defaults to the current process environment. + static func configDirectoryCandidates( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + var candidates: [URL] = [] + + if let xdg = environment["XDG_CONFIG_HOME"] { + let trimmed = xdg.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("/") { + candidates.append(URL(fileURLWithPath: trimmed).appending(path: "phbar")) + } + } + candidates.append(home.appending(path: ".config/phbar")) + candidates.append(home.appending(path: ".phbar")) + return candidates + } + + /// First existing candidate, or the highest-priority one if none exist. + /// + /// - Parameter environment: Override the environment lookup (used by tests); + /// defaults to the current process environment. + static func resolveConfigDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + let candidates = configDirectoryCandidates(environment: environment) + for candidate in candidates { + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + return candidates[0] + } +} diff --git a/Sources/phbar/Models/Event/PHEvent.swift b/Sources/phbar/Models/Event/PHEvent.swift index 9916786..cfdb71f 100644 --- a/Sources/phbar/Models/Event/PHEvent.swift +++ b/Sources/phbar/Models/Event/PHEvent.swift @@ -5,7 +5,7 @@ import Foundation /// Modeled as a `String`-backed value so it decodes straight from the TOML /// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled /// event sources**: every name resolves at runtime to an external recognizer -/// loaded from `~/.config/phbar/events//` — a `dlopen`'d library whose +/// loaded from `/events//` — a `dlopen`'d library whose /// root object conforms to `PHEventRecognizer`. To add an event, install a /// recognizer folder; no host code change is required. struct PHEvent: Hashable, Sendable { diff --git a/Sources/phbar/Models/Event/PHEventLoader.swift b/Sources/phbar/Models/Event/PHEventLoader.swift index c228fff..8af1a7d 100644 --- a/Sources/phbar/Models/Event/PHEventLoader.swift +++ b/Sources/phbar/Models/Event/PHEventLoader.swift @@ -3,8 +3,8 @@ import Foundation import phbarEvents /// Loads external event recognizers (shared libraries) from the user's config -/// directory: `~/.config/phbar/events//event.toml` + the referenced -/// library. +/// directory (see `PHPaths`): `/events//event.toml` + the +/// referenced library. /// /// Each event lives in its own folder. The manifest names the `.dylib` and, /// optionally, the factory symbol (default `phbar_event_create`). Libraries are @@ -31,7 +31,7 @@ final class PHEventLoader { } static var defaultDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/events") + PHPaths.configDirectory.appending(path: "events") } /// Returns the recognizer for `name`, loading and caching it on first access. diff --git a/Sources/phbar/Models/Event/PHEventManifest.swift b/Sources/phbar/Models/Event/PHEventManifest.swift index 6af6d9c..f8efdaf 100644 --- a/Sources/phbar/Models/Event/PHEventManifest.swift +++ b/Sources/phbar/Models/Event/PHEventManifest.swift @@ -2,7 +2,7 @@ import Foundation import TOML /// The manifest describing an external event recognizer, read from -/// `~/.config/phbar/events//event.toml`. +/// `/events//event.toml`. /// /// ```toml /// library = "event.dylib" # required: shared library file name diff --git a/Sources/phbar/Models/Event/PHEventRegistry.swift b/Sources/phbar/Models/Event/PHEventRegistry.swift index 2aed303..e6d5bb1 100644 --- a/Sources/phbar/Models/Event/PHEventRegistry.swift +++ b/Sources/phbar/Models/Event/PHEventRegistry.swift @@ -85,7 +85,7 @@ final class PHEventRegistry { /// Resolves an event to its source. phbar ships no compiled sources: every /// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied - /// recognizer from `~/.config/phbar/events//`. Returns `nil` if no + /// recognizer from `/events//`. Returns `nil` if no /// recognizer is installed. static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? { guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil } diff --git a/Sources/phbar/Models/Theme/Extensions/PHTheme+Loading.swift b/Sources/phbar/Models/Theme/Extensions/PHTheme+Loading.swift index 8e517e7..b05f792 100644 --- a/Sources/phbar/Models/Theme/Extensions/PHTheme+Loading.swift +++ b/Sources/phbar/Models/Theme/Extensions/PHTheme+Loading.swift @@ -2,14 +2,12 @@ import Foundation import TOML extension PHTheme { - /// Load theme from the config directory (~/.config/phbar/themes/). - /// If the file doesn't exist or fails to parse, defaults are used (non-fatal). + /// 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 { guard let theme else { return try loadFromBundle() } - let url = FileManager.default.homeDirectoryForCurrentUser.appending( - path: ".config/phbar/themes/\(theme).toml" - ) + let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml") if FileManager.default.fileExists(atPath: url.relativePath) { return try load(from: url) diff --git a/Sources/phbarEvents/PHEventRecognizer.swift b/Sources/phbarEvents/PHEventRecognizer.swift index 2a90a82..1deab1f 100644 --- a/Sources/phbarEvents/PHEventRecognizer.swift +++ b/Sources/phbarEvents/PHEventRecognizer.swift @@ -4,9 +4,9 @@ import Foundation /// /// phbar ships a set of built-in recognizers (volume, network, appearance, /// power, mpd) compiled into the host. External recognizers are loaded at -/// runtime as shared libraries from `~/.config/phbar/events//` and conform -/// to this same protocol, so the host drives built-in and external recognizers -/// through one shape. +/// runtime as shared libraries from the events directory +/// (`/events//`) and conform to this same protocol, so the host +/// drives built-in and external recognizers through one shape. /// /// Conform an `NSObject` subclass and export a zero-argument factory via /// `@_cdecl(PHEventCreateSymbol)`: diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift index 3410898..a9f4bc6 100644 --- a/Tests/phbarTests/phbarTests.swift +++ b/Tests/phbarTests/phbarTests.swift @@ -136,10 +136,10 @@ private struct TestPayload: Codable, Equatable { @MainActor @Test func computeRunsInConfigDirectory() async throws { - // Block commands execute with the config root (~/.config/phbar) as their - // working directory, so a block from any set resolves relative paths the - // same way. Guards against the config dir being absent in sandboxed CIs. - guard FileManager.default.fileExists(atPath: PHBlock.configDirectory.path) else { return } + // Block commands execute with the resolved config root (see `PHPaths`) as + // their working directory, so a block from any set resolves relative paths + // the same way. Guards against the config dir being absent in sandboxed CIs. + guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return } let blocks = try PHBlock.load(from: """ [[block]] @@ -148,9 +148,9 @@ private struct TestPayload: Codable, Equatable { let output = await blocks[0].compute() - // Resolve symlinks on both sides: `~/.config/phbar` is commonly a - // symlink into a dotfiles repo, and `pwd` reports the physical path. - let expected = PHBlock.configDirectory.resolvingSymlinksInPath().path + // Resolve symlinks on both sides: the config dir is commonly a symlink into + // a dotfiles repo, and `pwd` reports the physical path. + let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path #expect(output == expected) } @@ -655,4 +655,53 @@ private func makeBlocksConfigDir() throws -> URL { #expect(frame.height == 30) } +// MARK: - PHPaths (config directory cascade) + +@Test func pathsCandidatesIncludeAbsoluteXdgFirst() { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"]) + + #expect(candidates.count == 3) + #expect(candidates[0].path == "/custom/xdg/phbar") + #expect(candidates[1].path == home.appending(path: ".config/phbar").path) + #expect(candidates[2].path == home.appending(path: ".phbar").path) +} + +@Test func pathsCandidatesOmitRelativeXdg() { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"]) + + #expect(candidates.count == 2) + #expect(candidates[0].path == home.appending(path: ".config/phbar").path) + #expect(candidates[1].path == home.appending(path: ".phbar").path) +} + +@Test func pathsCandidatesOmitEmptyXdg() { + let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""]) + #expect(candidates.count == 2) +} + +@Test func pathsCandidatesOmitXdgWhenUnset() { + let candidates = PHPaths.configDirectoryCandidates(environment: [:]) + #expect(candidates.count == 2) +} + +@Test func pathsResolvePicksFirstExistingCandidate() throws { + // An existing XDG-rooted dir takes priority over ~/.config/phbar because + // it sorts first in the candidate list. + let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)") + let xdgConfig = xdgRoot.appending(path: "phbar") + try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: xdgRoot) } + + let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path]) + + #expect(resolved == xdgConfig) +} + +@Test func pathsConfigDirectoryMatchesResolve() { + // The cached static agrees with a fresh resolve against the live process env. + #expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory()) +} +