resolve config directory dynamically following a priority order

This commit is contained in:
2026-07-12 14:49:25 +02:00
parent 8315b8a6e3
commit a6a7f80cce
11 changed files with 133 additions and 40 deletions
@@ -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 `<configDirectory>/blocks/<name>.toml`. /// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
/// ///
/// - Parameter configDirectory: Override the lookup root (used by tests); /// - 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] { 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") let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
guard FileManager.default.fileExists(atPath: setFile.relativePath) else { guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
@@ -85,10 +85,10 @@ extension PHBlock {
let lines: [Substring]? = await Task.detached(priority: .userInitiated) { let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process() let process = Process()
// Commands run with the config root (~/.config/phbar) as their working // Commands run with the resolved config root (see `PHPaths`) as their
// directory, regardless of which block set they belong to, so relative // working directory, regardless of which block set they belong to, so
// paths in user scripts stay stable. // relative paths in user scripts stay stable.
process.currentDirectoryURL = Self.configDirectory process.currentDirectoryURL = PHPaths.configDirectory
process.executableURL = URL(fileURLWithPath: "/bin/bash") process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command] process.arguments = ["-c", command]
@@ -2,12 +2,11 @@ import Foundation
import TOML import TOML
extension PHConfig { extension PHConfig {
/// Load configuration from the default path (~/.config/phbar/config.toml). /// Load configuration from the resolved config directory
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal). /// (see `PHPaths`). If the file doesn't exist or fails to parse, the
/// bundled default is used.
static func load() throws -> PHConfig { static func load() throws -> PHConfig {
let url = FileManager.default.homeDirectoryForCurrentUser.appending( let url = PHPaths.configDirectory.appending(path: "config.toml")
path: ".config/phbar/config.toml"
)
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url) return try load(from: url)
+55
View File
@@ -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]
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import Foundation
/// Modeled as a `String`-backed value so it decodes straight from the TOML /// Modeled as a `String`-backed value so it decodes straight from the TOML
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled /// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
/// event sources**: every name resolves at runtime to an external recognizer /// event sources**: every name resolves at runtime to an external recognizer
/// loaded from `~/.config/phbar/events/<name>/` a `dlopen`'d library whose /// loaded from `<config>/events/<name>/` a `dlopen`'d library whose
/// root object conforms to `PHEventRecognizer`. To add an event, install a /// root object conforms to `PHEventRecognizer`. To add an event, install a
/// recognizer folder; no host code change is required. /// recognizer folder; no host code change is required.
struct PHEvent: Hashable, Sendable { struct PHEvent: Hashable, Sendable {
@@ -3,8 +3,8 @@ import Foundation
import phbarEvents import phbarEvents
/// Loads external event recognizers (shared libraries) from the user's config /// Loads external event recognizers (shared libraries) from the user's config
/// directory: `~/.config/phbar/events/<name>/event.toml` + the referenced /// directory (see `PHPaths`): `<config>/events/<name>/event.toml` + the
/// library. /// referenced library.
/// ///
/// Each event lives in its own folder. The manifest names the `.dylib` and, /// Each event lives in its own folder. The manifest names the `.dylib` and,
/// optionally, the factory symbol (default `phbar_event_create`). Libraries are /// optionally, the factory symbol (default `phbar_event_create`). Libraries are
@@ -31,7 +31,7 @@ final class PHEventLoader {
} }
static var defaultDirectory: URL { 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. /// Returns the recognizer for `name`, loading and caching it on first access.
@@ -2,7 +2,7 @@ import Foundation
import TOML import TOML
/// The manifest describing an external event recognizer, read from /// The manifest describing an external event recognizer, read from
/// `~/.config/phbar/events/<name>/event.toml`. /// `<config>/events/<name>/event.toml`.
/// ///
/// ```toml /// ```toml
/// library = "event.dylib" # required: shared library file name /// library = "event.dylib" # required: shared library file name
@@ -85,7 +85,7 @@ final class PHEventRegistry {
/// Resolves an event to its source. phbar ships no compiled sources: every /// Resolves an event to its source. phbar ships no compiled sources: every
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied /// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
/// recognizer from `~/.config/phbar/events/<name>/`. Returns `nil` if no /// recognizer from `<config>/events/<name>/`. Returns `nil` if no
/// recognizer is installed. /// recognizer is installed.
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? { static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil } guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
@@ -2,14 +2,12 @@ import Foundation
import TOML import TOML
extension PHTheme { extension PHTheme {
/// Load theme from the config directory (~/.config/phbar/themes/). /// Load theme from the resolved config directory (`PHPaths`/themes).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal). /// Falls back to the bundled theme when the file is absent or parsing fails.
static func load(_ theme: String?) throws -> PHTheme { static func load(_ theme: String?) throws -> PHTheme {
guard let theme else { return try loadFromBundle() } guard let theme else { return try loadFromBundle() }
let url = FileManager.default.homeDirectoryForCurrentUser.appending( let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
path: ".config/phbar/themes/\(theme).toml"
)
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url) return try load(from: url)
+3 -3
View File
@@ -4,9 +4,9 @@ import Foundation
/// ///
/// phbar ships a set of built-in recognizers (volume, network, appearance, /// phbar ships a set of built-in recognizers (volume, network, appearance,
/// power, mpd) compiled into the host. External recognizers are loaded at /// power, mpd) compiled into the host. External recognizers are loaded at
/// runtime as shared libraries from `~/.config/phbar/events/<name>/` and conform /// runtime as shared libraries from the events directory
/// to this same protocol, so the host drives built-in and external recognizers /// (`<config>/events/<name>/`) and conform to this same protocol, so the host
/// through one shape. /// drives built-in and external recognizers through one shape.
/// ///
/// Conform an `NSObject` subclass and export a zero-argument factory via /// Conform an `NSObject` subclass and export a zero-argument factory via
/// `@_cdecl(PHEventCreateSymbol)`: /// `@_cdecl(PHEventCreateSymbol)`:
+56 -7
View File
@@ -136,10 +136,10 @@ private struct TestPayload: Codable, Equatable {
@MainActor @MainActor
@Test func computeRunsInConfigDirectory() async throws { @Test func computeRunsInConfigDirectory() async throws {
// Block commands execute with the config root (~/.config/phbar) as their // Block commands execute with the resolved config root (see `PHPaths`) as
// working directory, so a block from any set resolves relative paths the // their working directory, so a block from any set resolves relative paths
// same way. Guards against the config dir being absent in sandboxed CIs. // the same way. Guards against the config dir being absent in sandboxed CIs.
guard FileManager.default.fileExists(atPath: PHBlock.configDirectory.path) else { return } guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
let blocks = try PHBlock.load(from: """ let blocks = try PHBlock.load(from: """
[[block]] [[block]]
@@ -148,9 +148,9 @@ private struct TestPayload: Codable, Equatable {
let output = await blocks[0].compute() let output = await blocks[0].compute()
// Resolve symlinks on both sides: `~/.config/phbar` is commonly a // Resolve symlinks on both sides: the config dir is commonly a symlink into
// symlink into a dotfiles repo, and `pwd` reports the physical path. // a dotfiles repo, and `pwd` reports the physical path.
let expected = PHBlock.configDirectory.resolvingSymlinksInPath().path let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
#expect(output == expected) #expect(output == expected)
} }
@@ -655,4 +655,53 @@ private func makeBlocksConfigDir() throws -> URL {
#expect(frame.height == 30) #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())
}