Skip to main content

zwire_host/
store.rs

1//! Per-app persistent state under the OS application-data directory.
2//!
3//! The base dir matches the C++ HUD colour patch (`base::DIR_APP_DATA`) and
4//! `scripts/state-dir.sh`, so the `hud-scheme` the host writes is the exact file
5//! the compiled colour mixer reads — no split-brain across two locations:
6//!   * macOS   `~/Library/Application Support/com.menketechnologies.zwire`
7//!   * Windows `%APPDATA%\zwire`
8//!   * other   `${XDG_CONFIG_HOME:-~/.config}/zwire`
9//!
10//! On macOS the zwire folder is the bundle identifier (matching the .app's
11//! CFBundleIdentifier and `scripts/state-dir.sh`); the other platforms keep the
12//! short `zwire` name.
13//!
14//! `$ZWIRE_STATE` overrides the whole path (same contract as the launcher).
15//!
16//! Two layers live here:
17//!   * a generic namespaced key/value store (`kv_*`) any app can use, at
18//!     `<state>/kv/<key>.json`;
19//!   * the zwire scheme + UI files (`hud-scheme`, `hud-ui.json`) that the HUD
20//!     protocol reads and writes.
21use serde_json::{json, Value};
22use std::path::{Path, PathBuf};
23
24/// Colour schemes the zwire HUD accepts. Writes of anything else are rejected so
25/// a typo can't poison the file the compiled colour mixer reads.
26pub const SCHEMES: &[&str] = &[
27    "cyberpunk",
28    "midnight",
29    "matrix",
30    "ember",
31    "arctic",
32    "crimson",
33    "toxic",
34    "vapor",
35];
36
37/// The user's home directory, falling back to `.` so we never panic on a
38/// stripped environment. `USERPROFILE` covers Windows.
39pub fn home() -> PathBuf {
40    let h = std::env::var("HOME")
41        .or_else(|_| std::env::var("USERPROFILE"))
42        .unwrap_or_else(|_| ".".into());
43    PathBuf::from(h)
44}
45
46/// Expand a leading `~` / `~/` to the home directory; otherwise pass through.
47pub fn expand(p: &str) -> PathBuf {
48    if p == "~" {
49        return home();
50    }
51    if let Some(rest) = p.strip_prefix("~/") {
52        return home().join(rest);
53    }
54    PathBuf::from(p)
55}
56
57/// Restrict a caller-supplied name to a safe filesystem token (alnum plus
58/// `-`, `_`, `.`), so an `app`/`key` can never escape its directory.
59fn sanitize(name: &str, fallback: &str) -> String {
60    let s: String = name
61        .chars()
62        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
63        .collect();
64    let s = s.trim_matches('.').to_string();
65    if s.is_empty() {
66        fallback.to_string()
67    } else {
68        s
69    }
70}
71
72/// Platform base directory for persistent app state, mirroring the C++ HUD
73/// patch (`base::DIR_APP_DATA`) and `scripts/state-dir.sh`:
74///   * macOS   `~/Library/Application Support`
75///   * Windows `%APPDATA%` (Roaming)
76///   * other   `${XDG_CONFIG_HOME:-~/.config}`
77fn state_base() -> PathBuf {
78    #[cfg(target_os = "macos")]
79    {
80        home().join("Library").join("Application Support")
81    }
82    #[cfg(windows)]
83    {
84        std::env::var("APPDATA")
85            .ok()
86            .filter(|s| !s.is_empty())
87            .map(PathBuf::from)
88            .unwrap_or_else(|| home().join("AppData").join("Roaming"))
89    }
90    #[cfg(not(any(target_os = "macos", windows)))]
91    {
92        std::env::var("XDG_CONFIG_HOME")
93            .ok()
94            .filter(|s| !s.is_empty())
95            .map(PathBuf::from)
96            .unwrap_or_else(|| home().join(".config"))
97    }
98}
99
100/// On-disk folder name for the zwire browser's own state. On macOS this is the
101/// bundle identifier (matching the .app's `CFBundleIdentifier`, the convention
102/// that Application Support dirs are named by reverse-DNS id, and
103/// `scripts/state-dir.sh`); the other platforms keep the short `zwire` name.
104#[cfg(target_os = "macos")]
105const ZWIRE_DIRNAME: &str = "com.menketechnologies.zwire";
106#[cfg(not(target_os = "macos"))]
107const ZWIRE_DIRNAME: &str = "zwire";
108
109/// The base directory for an app's state, created on demand. `app` empty or
110/// missing resolves to the zwire app. For the `zwire` app, `$ZWIRE_STATE`
111/// overrides the whole path (the launcher/native-host contract) and otherwise
112/// the folder is [`ZWIRE_DIRNAME`], keeping the host, the C++ colour mixer, and
113/// the shell scripts pointed at one directory. Any other `app` gets its own
114/// `<app>` sub-folder for the generic kv store.
115pub fn app_dir(app: &str) -> PathBuf {
116    let name = sanitize(app, "zwire");
117    let d = if name == "zwire" {
118        match std::env::var("ZWIRE_STATE") {
119            Ok(s) if !s.is_empty() => PathBuf::from(s),
120            _ => state_base().join(ZWIRE_DIRNAME),
121        }
122    } else {
123        state_base().join(&name)
124    };
125    let _ = std::fs::create_dir_all(&d);
126    d
127}
128
129/// Atomically replace a file: write a sibling `.tmp` then rename over the target
130/// so a reader never observes a half-written file.
131fn write_atomic(path: &Path, bytes: &[u8]) -> bool {
132    let tmp = path.with_extension("tmp");
133    if let Some(parent) = path.parent() {
134        let _ = std::fs::create_dir_all(parent);
135    }
136    if std::fs::write(&tmp, bytes).is_ok() {
137        return std::fs::rename(&tmp, path).is_ok();
138    }
139    false
140}
141
142/* ---- generic key/value store ---- */
143
144fn kv_path(app: &str, key: &str) -> PathBuf {
145    app_dir(app)
146        .join("kv")
147        .join(format!("{}.json", sanitize(key, "default")))
148}
149
150/// Read a stored value, or `null` when the key is absent or unreadable.
151pub fn kv_get(app: &str, key: &str) -> Value {
152    std::fs::read_to_string(kv_path(app, key))
153        .ok()
154        .and_then(|s| serde_json::from_str(&s).ok())
155        .unwrap_or(Value::Null)
156}
157
158/// Replace a key's value wholesale.
159pub fn kv_set(app: &str, key: &str, value: &Value) -> bool {
160    serde_json::to_vec(value)
161        .map(|b| write_atomic(&kv_path(app, key), &b))
162        .unwrap_or(false)
163}
164
165/// Shallow-merge an object into a key (top-level keys only); returns the merged
166/// value. Non-object stored values are overwritten by `partial`.
167pub fn kv_merge(app: &str, key: &str, partial: &Value) -> Value {
168    let mut cur = kv_get(app, key);
169    match (cur.as_object_mut(), partial.as_object()) {
170        (Some(c), Some(p)) => {
171            for (k, v) in p {
172                c.insert(k.clone(), v.clone());
173            }
174        }
175        _ => cur = partial.clone(),
176    }
177    kv_set(app, key, &cur);
178    cur
179}
180
181/// Delete a key. Returns `true` if the file is gone afterwards (including if it
182/// never existed).
183pub fn kv_del(app: &str, key: &str) -> bool {
184    let p = kv_path(app, key);
185    !p.exists() || std::fs::remove_file(&p).is_ok()
186}
187
188/// List the keys stored for an app.
189pub fn kv_keys(app: &str) -> Vec<String> {
190    let dir = app_dir(app).join("kv");
191    let mut keys = Vec::new();
192    if let Ok(rd) = std::fs::read_dir(dir) {
193        for e in rd.flatten() {
194            let name = e.file_name().to_string_lossy().to_string();
195            if let Some(k) = name.strip_suffix(".json") {
196                keys.push(k.to_string());
197            }
198        }
199    }
200    keys.sort();
201    keys
202}
203
204/* ---- legacy zwire scheme + ui ---- */
205
206// ─────────────────────── shared fleet-wide theme ───────────────────────
207// The colour scheme + light/fx prefs (and user-defined custom schemes) live in
208// ONE app-independent file, `~/.zwire/global.toml`, so EVERY zwire-host client —
209// the browser HUD/newtab/zpwrchrome, Audio-Haxor, zemacs, zpwr-daw, the whole
210// fleet — reads and writes the same theme. `d` is the shared theme dir
211// (`theme_dir()`); the legacy per-app `hud-scheme`/`hud-ui.json` split is gone.
212//
213//   [theme]
214//   scheme = "midnight"
215//   [theme.ui]
216//   light = false
217//   scanlines = true
218//   [schemes.mytheme]        # custom colourschemes, human-editable
219//   "--bg-primary" = "#0a0d16"
220
221/// The shared theme directory (`~/.zwire`, overridable via `$ZWIRE_GLOBAL_DIR`).
222/// App-independent on purpose: this is the fleet's single theme location.
223pub fn theme_dir() -> PathBuf {
224    let d = match std::env::var("ZWIRE_GLOBAL_DIR") {
225        Ok(s) if !s.is_empty() => PathBuf::from(s),
226        _ => home().join(".zwire"),
227    };
228    let _ = std::fs::create_dir_all(&d);
229    d
230}
231
232fn global_path(d: &Path) -> PathBuf {
233    d.join("global.toml")
234}
235
236/// Load `global.toml` as a TOML table (empty table when absent/unparseable).
237fn load_global(d: &Path) -> toml::Value {
238    std::fs::read_to_string(global_path(d))
239        .ok()
240        .and_then(|s| s.parse::<toml::Value>().ok())
241        .filter(|v| v.is_table())
242        .unwrap_or_else(|| toml::Value::Table(Default::default()))
243}
244
245fn save_global(d: &Path, v: &toml::Value) {
246    if let Ok(s) = toml::to_string_pretty(v) {
247        write_atomic(&global_path(d), s.as_bytes());
248    }
249}
250
251/// Set `root[path…] = val`, creating intermediate tables. `root` must be a table.
252fn set_path(root: &mut toml::Value, path: &[&str], val: toml::Value) {
253    fn go(tbl: &mut toml::map::Map<String, toml::Value>, path: &[&str], val: toml::Value) {
254        if path.len() == 1 {
255            tbl.insert(path[0].to_string(), val);
256            return;
257        }
258        let e = tbl
259            .entry(path[0].to_string())
260            .or_insert_with(|| toml::Value::Table(Default::default()));
261        if !e.is_table() {
262            *e = toml::Value::Table(Default::default());
263        }
264        go(e.as_table_mut().unwrap(), &path[1..], val);
265    }
266    if let Some(tbl) = root.as_table_mut() {
267        go(tbl, path, val);
268    }
269}
270
271fn ui_from(root: &toml::Value) -> Value {
272    root.get("theme")
273        .and_then(|t| t.get("ui"))
274        .and_then(|u| serde_json::to_value(u).ok())
275        .filter(|v| v.is_object())
276        .unwrap_or_else(|| json!({}))
277}
278
279/// Current colour scheme (`[theme].scheme`), defaulting to `cyberpunk`.
280pub fn current_scheme(d: &Path) -> String {
281    load_global(d)
282        .get("theme")
283        .and_then(|t| t.get("scheme"))
284        .and_then(|s| s.as_str())
285        .filter(|s| !s.is_empty())
286        .map(|s| s.to_string())
287        .unwrap_or_else(|| "cyberpunk".into())
288}
289
290/// Serialize the read-modify-write of `global.toml` across ALL host processes
291/// (Chrome spawns one per sendNativeMessage / connectNative). Without this, a
292/// concurrent `{scheme}` + `{ui}` write both `load_global` the OLD file and the
293/// later `save_global` clobbers the earlier — the sporadic "picked scheme
294/// reverts to the old one" bug. An advisory exclusive lock on a sidecar file
295/// (auto-released on process exit, so no stale locks) makes each RMW atomic.
296fn with_global_lock<F: FnOnce()>(d: &Path, f: F) {
297    let _ = std::fs::create_dir_all(d);
298    match std::fs::OpenOptions::new()
299        .create(true)
300        .write(true)
301        .truncate(false)
302        .open(d.join("global.toml.lock"))
303    {
304        Ok(lock) => {
305            let _ = lock.lock(); // blocks until we hold the exclusive lock
306            f();
307            let _ = lock.unlock();
308        }
309        Err(_) => f(), // lock unavailable — best-effort write rather than drop it
310    }
311}
312
313/// Persist the colour scheme (caller validates against [`SCHEMES`] or a custom
314/// `[schemes.*]`). Preserves the rest of `global.toml` (ui prefs, custom schemes).
315pub fn write_scheme(d: &Path, s: &str) {
316    with_global_lock(d, || {
317        let mut root = load_global(d);
318        set_path(
319            &mut root,
320            &["theme", "scheme"],
321            toml::Value::String(s.to_string()),
322        );
323        save_global(d, &root);
324    });
325    // Also emit a plain `hud-scheme` text file beside global.toml. The native C++
326    // browser chrome reads the scheme with a tiny FilePathWatcher; giving it a
327    // one-line text projection means Chromium needs no TOML parser. `global.toml`
328    // stays the single source of truth (one writer), so the two never drift.
329    write_atomic(&d.join("hud-scheme"), format!("{s}\n").as_bytes());
330    // Refresh the hud-light projection too, so a browser started after a scheme
331    // change (but before any light toggle) still sees the current light state.
332    let light = current_ui(d)
333        .get("light")
334        .and_then(|v| v.as_bool())
335        .unwrap_or(false);
336    write_hud_light(d, light);
337    // Transitional: also write the legacy per-app location (`<app-data>/zwire/
338    // hud-scheme`) that a browser built BEFORE the ~/.zwire C++ patch reads, so
339    // window-chrome colouring keeps working until that browser is rebuilt.
340    // Harmless afterwards (nothing reads it). Remove once every build is current.
341    write_atomic(
342        &app_dir("zwire").join("hud-scheme"),
343        format!("{s}\n").as_bytes(),
344    );
345}
346
347/// Current light/fx UI-preference object (`[theme.ui]`; empty when unset).
348pub fn current_ui(d: &Path) -> Value {
349    ui_from(&load_global(d))
350}
351
352/// Shallow-merge `partial` into `[theme.ui]` and persist; returns the merged
353/// object. Preserves scheme + custom schemes.
354pub fn write_ui(d: &Path, partial: &Value) -> Value {
355    let mut ui = json!({});
356    with_global_lock(d, || {
357        let mut root = load_global(d);
358        ui = ui_from(&root);
359        if let (Some(c), Some(p)) = (ui.as_object_mut(), partial.as_object()) {
360            for (k, v) in p {
361                c.insert(k.clone(), v.clone());
362            }
363        }
364        let ui_toml =
365            toml::Value::try_from(&ui).unwrap_or_else(|_| toml::Value::Table(Default::default()));
366        set_path(&mut root, &["theme", "ui"], ui_toml);
367        save_global(d, &root);
368    });
369    write_hud_light(
370        d,
371        ui.get("light").and_then(|v| v.as_bool()).unwrap_or(false),
372    );
373    ui
374}
375
376/// Plain `hud-light` text projection ("1"/"0") beside `hud-scheme`, so the native
377/// C++ chrome can follow light mode with a tiny FilePathWatcher (no TOML parse) —
378/// mirroring how `hud-scheme` projects the colour scheme.
379fn write_hud_light(d: &Path, light: bool) {
380    write_atomic(&d.join("hud-light"), if light { b"1\n" } else { b"0\n" });
381}