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}