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.
252 lines
9.6 KiB
Rust
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)
|
|
}
|