resolve config directory dynamically following a priority order
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)`:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user