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