Skip to main content

openlogi_core/
paths.rs

1//! Per-OS application directories, following the XDG Base Directory spec on
2//! **every** platform — including macOS, so configuration lives at the
3//! familiar `~/.config/openlogi/` rather than macOS's
4//! `~/Library/Application Support/`.
5//!
6//! | kind   | env override        | default                       |
7//! |--------|---------------------|-------------------------------|
8//! | config | `$XDG_CONFIG_HOME`  | `~/.config/openlogi`          |
9//! | data   | `$XDG_DATA_HOME`    | `~/.local/share/openlogi`     |
10//!
11//! On Windows `$HOME` falls back to `%USERPROFILE%`, so paths resolve to
12//! `%USERPROFILE%\.config\openlogi` etc.
13//!
14//! **Decision (#347):** the Windows location is final, not best-effort.
15//! XDG-on-every-platform is this module's deliberate design — macOS also
16//! skips its native `~/Library/Application Support` — and Windows follows
17//! the same rule rather than `%APPDATA%`. Recorded before the agent first
18//! shipped in Windows artifacts, because moving it afterwards would strand
19//! every existing user's `config.toml` and the agent's first-run state.
20
21//! Local packaged macOS builds stamped with dev-channel identifiers use the
22//! same layout under an `openlogi-dev` app directory.
23
24use std::path::PathBuf;
25use std::sync::OnceLock;
26
27use etcetera::{BaseStrategy, base_strategy::Xdg};
28use thiserror::Error;
29
30/// Production subdirectory created under each XDG base directory.
31///
32/// Public because the dev tooling has to name the same directories from
33/// outside a running app — `xtask macos dev-bundle` remembers the developer's
34/// codesigning certificate under [`DEV_APP_DIR`], where `cargo clean` cannot
35/// reach it.
36pub const APP_DIR: &str = "openlogi";
37/// Local macOS dev builds use a separate profile so development agents
38/// cannot take over the installed app's socket, lock, config, or asset cache.
39pub const DEV_APP_DIR: &str = "openlogi-dev";
40
41/// Failure resolving the per-user base directories.
42#[derive(Debug, Error)]
43pub enum PathsError {
44    /// No home directory could be determined for the current user, so none
45    /// of the XDG bases resolve.
46    #[error("could not resolve a home directory for the current user")]
47    HomeNotFound,
48}
49
50fn xdg() -> Result<Xdg, PathsError> {
51    Xdg::new().map_err(|_| PathsError::HomeNotFound)
52}
53
54fn app_dir() -> &'static str {
55    static IS_DEV_PROFILE: OnceLock<bool> = OnceLock::new();
56    if *IS_DEV_PROFILE.get_or_init(is_dev_profile) {
57        DEV_APP_DIR
58    } else {
59        APP_DIR
60    }
61}
62
63fn is_dev_profile() -> bool {
64    match std::env::var("OPENLOGI_PROFILE") {
65        Ok(value) if value == "dev" => return true,
66        Ok(value) if matches!(value.as_str(), "prod" | "production") => return false,
67        _ => {}
68    }
69
70    #[cfg(target_os = "macos")]
71    {
72        if let Some(identifier) = current_bundle_identifier() {
73            return crate::brand::is_dev_id(&identifier);
74        }
75    }
76
77    false
78}
79
80#[cfg(target_os = "macos")]
81fn current_bundle_identifier() -> Option<String> {
82    let exe = std::env::current_exe().ok()?;
83    for ancestor in exe.ancestors() {
84        if !ancestor
85            .extension()
86            .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
87        {
88            continue;
89        }
90
91        let info = ancestor.join("Contents/Info.plist");
92        let Ok(plist) = plist::Value::from_file(info) else {
93            continue;
94        };
95        let Some(identifier) = plist
96            .as_dictionary()
97            .and_then(|dictionary| dictionary.get("CFBundleIdentifier"))
98            .and_then(plist::Value::as_string)
99        else {
100            continue;
101        };
102        return Some(identifier.to_owned());
103    }
104
105    None
106}
107
108/// The current user's home directory.
109///
110/// The plain home, not an XDG base — for callers placing files under
111/// OS-native locations (e.g. macOS `~/Library/LaunchAgents`).
112pub fn home_dir() -> Result<PathBuf, PathsError> {
113    Ok(xdg()?.home_dir().to_path_buf())
114}
115
116/// The raw XDG config home directory (without the `openlogi` subdirectory).
117///
118/// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`.
119/// Useful when placing files that belong to other apps under the same base
120/// (e.g. systemd user units at `$XDG_CONFIG_HOME/systemd/user/`).
121pub fn xdg_config_home() -> Result<PathBuf, PathsError> {
122    Ok(xdg()?.config_dir())
123}
124
125/// Directory holding the user's `config.toml`.
126///
127/// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`.
128/// Local macOS dev builds use `openlogi-dev` instead.
129pub fn config_dir() -> Result<PathBuf, PathsError> {
130    Ok(xdg_config_home()?.join(app_dir()))
131}
132
133/// Full path to the user config file.
134pub fn config_path() -> Result<PathBuf, PathsError> {
135    Ok(config_dir()?.join("config.toml"))
136}
137
138/// Directory for downloaded application data; the device-render asset cache
139/// lives under `data_dir()/assets`.
140///
141/// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`.
142/// Local macOS dev builds use `openlogi-dev` instead.
143pub fn data_dir() -> Result<PathBuf, PathsError> {
144    Ok(xdg()?.data_dir().join(app_dir()))
145}
146
147/// Directory for runtime sockets — the background agent's IPC endpoint.
148pub fn runtime_dir() -> Result<PathBuf, PathsError> {
149    let xdg = xdg()?;
150    Ok(xdg.runtime_dir().map_or_else(
151        || xdg.config_dir().join(app_dir()),
152        |dir| dir.join(app_dir()),
153    ))
154}
155
156/// Path to the background agent's Unix-domain IPC socket: the GUI connects here
157/// to reach the agent that owns device I/O.
158pub fn agent_socket_path() -> Result<PathBuf, PathsError> {
159    Ok(runtime_dir()?.join("agent.sock"))
160}
161
162#[cfg(test)]
163#[cfg(unix)]
164#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn config_dir_keeps_openlogi_under_xdg_config_home() {
170        assert!(config_dir().expect("config dir").ends_with("openlogi"));
171    }
172
173    #[test]
174    fn data_dir_keeps_openlogi_under_xdg_data_home() {
175        assert!(data_dir().expect("data dir").ends_with("openlogi"));
176    }
177
178    #[test]
179    fn runtime_dir_keeps_openlogi_suffix() {
180        assert!(runtime_dir().expect("runtime dir").ends_with("openlogi"));
181    }
182}