//! The app's own small state in its data folder: the project list and account labels (never secrets). Written //! atomically (temp file, then rename) so a crash never leaves half a file. use std::fs; use std::path::{Path, PathBuf}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::accounts::Account; use crate::workspace::{Overrides, Settings, Studio}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Project { pub id: String, /// Absolute folder that holds storebound.config.ts. pub path: String, /// Filled from the folder name when an old or hand-edited file has none. #[serde(default)] pub name: String, #[serde(default)] pub play_account: Option, #[serde(default)] pub apple_account: Option, #[serde(default)] pub added: String, } /// The state file format. Files without `version` are format 1 (desktop D1 to D3: projects and accounts, later /// studios, overrides and settings, all optional); `migrate` lifts them to this one. pub const VERSION: u32 = 2; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct State { #[serde(default)] pub version: u32, #[serde(default)] pub projects: Vec, #[serde(default)] pub accounts: Vec, #[serde(default)] pub studios: Vec, #[serde(default)] pub overrides: Overrides, #[serde(default)] pub settings: Settings, /// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start. #[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")] pub menu_pins: Vec, } impl Default for State { fn default() -> State { State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] } } } pub const FILE: &str = "state.json"; /// What `load_report` found besides the state: the format it migrated from, entries it had to drop, and where the /// original file was kept. Logged in host.log; nothing here is fatal. #[derive(Debug, Default, PartialEq, Eq)] pub struct LoadReport { pub migrated_from: Option, pub notes: Vec, pub backup: Option, } pub fn load(dir: &Path) -> State { load_report(dir).0 } /// Reads state.json and migrates it to VERSION. Never fails: an unreadable file is kept as a backup and the app starts /// empty; entries that do not parse are dropped one by one (and named in the report) instead of losing the whole file. /// A migrated file is written back in the new format after the original is copied to `state.json.v.bak`. pub fn load_report(dir: &Path) -> (State, LoadReport) { let mut report = LoadReport::default(); let path = dir.join(FILE); let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (State::default(), report), Err(e) => { report.notes.push(format!("state.json unreadable ({e})")); report.backup = keep_copy(dir, &format!("{FILE}.unreadable-{}", stamp())); return (State::default(), report); } }; let value: Value = match serde_json::from_str(&text) { Ok(v @ Value::Object(_)) => v, Ok(_) | Err(_) => { report.notes.push("state.json is not a JSON object: started empty".into()); report.backup = keep_copy(dir, &format!("{FILE}.unreadable-{}", stamp())); return (State::default(), report); } }; let (state, from, notes) = migrate(value); report.notes = notes; if from < VERSION { report.migrated_from = Some(from); report.backup = keep_copy(dir, &format!("{FILE}.v{from}.bak")); if let Err(e) = save(dir, &state) { report.notes.push(format!("migrated state not written: {e}")); } } else if from > VERSION { report.notes.push(format!("state.json is format {from}, newer than this app ({VERSION}): read what it understands")); } (state, report) } /// Lifts a state file of any known format to VERSION. Returns the state, the format it had, and notes on dropped /// entries. Pure: tests call it on the old formats directly. pub fn migrate(mut v: Value) -> (State, u32, Vec) { let from = v.get("version").and_then(Value::as_u64).map(|n| n.min(u32::MAX as u64) as u32).unwrap_or(1); let mut notes = Vec::new(); if from < 2 { migrate_1_to_2(&mut v); } let o = v.as_object().cloned().unwrap_or_default(); let mut state = State { version: VERSION.max(from), projects: list(&o, "projects", &mut notes), accounts: list(&o, "accounts", &mut notes), studios: list(&o, "studios", &mut notes), overrides: one(&o, "overrides", &mut notes), settings: one(&o, "settings", &mut notes), menu_pins: list(&o, "menuPins", &mut notes), }; // two entries with one id (hand-edited or merged files): the first one wins let mut seen = std::collections::HashSet::new(); state.projects.retain(|p| seen.insert(p.id.clone())); for p in &mut state.projects { if p.name.trim().is_empty() { p.name = Path::new(&p.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "App".into()); } } (state, from, notes) } /// Format 1 -> 2: `version` added; studios, overrides and settings (added in D3) written out with their defaults, and /// each account gets an explicit `studio` (null). Fields this app does not know are left alone. fn migrate_1_to_2(v: &mut Value) { let Some(o) = v.as_object_mut() else { return }; for k in ["projects", "accounts", "studios"] { if !o.get(k).is_some_and(Value::is_array) { o.insert(k.into(), Value::Array(vec![])); } } if !o.get("overrides").is_some_and(Value::is_object) { o.insert("overrides".into(), serde_json::to_value(Overrides::default()).unwrap_or(Value::Null)); } if !o.get("settings").is_some_and(Value::is_object) { o.insert("settings".into(), serde_json::to_value(Settings::default()).unwrap_or(Value::Null)); } if let Some(Value::Array(accs)) = o.get_mut("accounts") { for a in accs.iter_mut().filter_map(Value::as_object_mut) { a.entry("studio").or_insert(Value::Null); } } o.insert("version".into(), Value::from(2)); } fn list(o: &Map, key: &str, notes: &mut Vec) -> Vec { let Some(items) = o.get(key).and_then(Value::as_array) else { return vec![] }; items .iter() .enumerate() .filter_map(|(i, x)| match serde_json::from_value::(x.clone()) { Ok(t) => Some(t), Err(e) => { notes.push(format!("{key}[{i}] dropped: {e}")); None } }) .collect() } fn one(o: &Map, key: &str, notes: &mut Vec) -> T { match o.get(key) { None | Some(Value::Null) => T::default(), Some(x) => serde_json::from_value(x.clone()).unwrap_or_else(|e| { notes.push(format!("{key} reset to defaults: {e}")); T::default() }), } } /// Copies state.json to `name` in the same folder (an existing copy is kept). The copy's path, or None. fn keep_copy(dir: &Path, name: &str) -> Option { let to = dir.join(name); if to.exists() { return Some(to); } fs::copy(dir.join(FILE), &to).ok().map(|_| to) } /// Moves state.json aside as `state.json.reset-