storebound/apps/desktop/src-tauri/src/store.rs
CRT 5236f002f1 desktop: tray menu panels and pinnable context menus
The tray's right-click menu is drawn by the app: one tree from tray.rs
(stable submenu keys), one frameless always-on-top menu-N window per
panel rendered by tray-menu.html with the design system, placed inside
the monitor's work area. A pure panel book keeps transient and pinned
panels apart; pinned panels (pin button, Ctrl+click, middle click) are
draggable, step aside from each other and are saved in state.json
(menuPins) and restored at start. tray_update pushes the tree to the
open panels, which update in place; closed panels are destroyed. The
native menu, rendered from the same tree, stays as the fallback and on
Linux. Only menu-* windows get the menu_* commands (menu.json).

In-app context menus get the same behaviour through a pure menu stack
(ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels
survive the next menu, are draggable and re-read their entries from
live sources so data changes update them in place.

Also drops a stray diff3 marker line from CHANGELOG.md.
2026-10-24 13:44:08 +08:00

252 lines
9.6 KiB
Rust

//! 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<String>,
#[serde(default)]
pub apple_account: Option<String>,
#[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<Project>,
#[serde(default)]
pub accounts: Vec<Account>,
#[serde(default)]
pub studios: Vec<Studio>,
#[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<crate::menu_panel::PinRecord>,
}
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<u32>,
pub notes: Vec<String>,
pub backup: Option<PathBuf>,
}
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<old>.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<String>) {
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<T: DeserializeOwned>(o: &Map<String, Value>, key: &str, notes: &mut Vec<String>) -> Vec<T> {
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::<T>(x.clone()) {
Ok(t) => Some(t),
Err(e) => {
notes.push(format!("{key}[{i}] dropped: {e}"));
None
}
})
.collect()
}
fn one<T: DeserializeOwned + Default>(o: &Map<String, Value>, key: &str, notes: &mut Vec<String>) -> 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<PathBuf> {
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-<time>.bak` (Reset saved state). The backup's path; None when there was
/// no file.
pub fn reset(dir: &Path) -> Result<Option<PathBuf>, String> {
let from = dir.join(FILE);
if !from.exists() {
return Ok(None);
}
let to = dir.join(format!("{FILE}.reset-{}.bak", stamp()));
fs::rename(&from, &to).map_err(|e| e.to_string())?;
Ok(Some(to))
}
/// File-name-safe UTC time: 20261011T101500Z.
fn stamp() -> String {
now_iso().replace(['-', ':'], "")
}
pub fn save(dir: &Path, state: &State) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let tmp: PathBuf = dir.join(format!("{FILE}.tmp"));
let mut state = state.clone();
state.version = state.version.max(VERSION);
let json = serde_json::to_string_pretty(&state).map_err(|e| e.to_string())?;
fs::write(&tmp, json + "\n").map_err(|e| e.to_string())?;
fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string())
}
/// Random id (hex) for projects and accounts.
pub fn new_id() -> Result<String, String> {
let mut b = [0u8; 8];
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
Ok(crate::confirm::hex(&b))
}
/// Current time as an RFC 3339 UTC string, without a date crate.
pub fn now_iso() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
iso_from_unix(secs)
}
pub fn iso_from_unix(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// civil-from-days (Howard Hinnant)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}