Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)

The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype
freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign
`constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle
stopped before React drew anything. The saved state was not the cause.

- build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines
  the property when the frozen prototype would refuse it
- error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a
  backup); public/boot.js shows the same panel when the bundle fails or the window stays empty
- logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated,
  home as ~, keys, tokens and e-mail addresses replaced
- state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry
  tolerant parsing, partial settings keep their fields, unreadable files kept aside
- folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them
- tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from
  the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci,
  the Forgejo CI workflow and the macOS release
This commit is contained in:
CRTkafa 2026-10-11 09:36:34 +08:00
parent ae2ffd8dbf
commit bd3ab20e1d
45 changed files with 2080 additions and 69 deletions

View file

@ -1,5 +1,5 @@
# bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck, # bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck,
# desktop i18n / typecheck / build, desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md). # desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md).
# Publishes nothing: site/ is still published by Jenkins. # Publishes nothing: site/ is still published by Jenkins.
name: ci name: ci
@ -52,5 +52,7 @@ jobs:
rustup component add clippy rustup component add clippy
bun --version && cargo --version bun --version && cargo --version
bun install --frozen-lockfile bun install --frozen-lockfile
- name: Headless Chromium (desktop boot check)
run: bunx playwright install --with-deps chromium
- name: bun run ci - name: bun run ci
run: bun run ci run: bun run ci

View file

@ -2,6 +2,18 @@
## Unreleased ## Unreleased
- Desktop: the signed release showed only its background colour and never started the sidecar. Cause: Tauri's
`freezePrototype` freezes `Object.prototype` before the bundle runs, and d3-color / d3-format (under the visx charts)
assign `constructor` / `toString` on plain objects while loading, which throws on a frozen prototype; the bundle
stopped before React drew anything. Saved state was not the cause. A Vite plugin (`apps/desktop/build/freeze-safe.ts`)
makes those assignments safe. So the window is never blank again: error boundaries for the whole window and each
screen and tab (Copy details, Reload, Reset saved state with a backup), `public/boot.js` for a bundle that fails to
load, `logs/host.log` and `logs/webview.log` in the app data folder (rotated, home as `~`, no keys or e-mail
addresses), a versioned `state.json` with an explicit migration from the D1-D3 format (original kept as
`state.json.v1.bak`; broken entries dropped one by one), "Folder not found" with Locate… / Remove for app folders
that are gone, and a boot check in CI and the macOS release (`tools/desktop-boot-check.ts`: the built window with the
release CSP and the frozen prototype on empty, D1 and D3 saved states).
- Icons: Phosphor replaces lucide (desktop: `@phosphor-icons/react` per-icon imports, "regular" weight, "fill" for the - Icons: Phosphor replaces lucide (desktop: `@phosphor-icons/react` per-icon imports, "regular" weight, "fill" for the
active nav item and notices; site and fixture art: Phosphor's own files). Store marks are now the real App Store and active nav item and notices; site and fixture art: Phosphor's own files). Store marks are now the real App Store and
Google Play logos from simple-icons, in the neutral text colour next to the store's name; the trademark note is in Google Play logos from simple-icons, in the neutral text colour next to the store's name; the trademark note is in

View file

@ -0,0 +1,68 @@
// Tauri's `freezePrototype: true` (tauri.conf.json) runs Object.freeze(Object.prototype) before any page script. In
// strict code, assigning a property that an object only inherits from the frozen Object.prototype (`constructor`,
// `toString`, ...) then throws "Cannot assign to read only property": the JavaScript "override mistake". d3-color and
// d3-format (under @visx/scale) do exactly that while their modules load, so the bundle stopped before React rendered
// anything and the release window showed only its background colour.
//
// This plugin rewrites every `x.constructor = v`, `x.toString = v`, ... in dependencies into a call that does the same
// assignment and, only when that assignment hits the frozen prototype, defines the property on `x` itself instead
// (Object.defineProperty is allowed there). The release boot check (tools/desktop-boot-check.ts) loads the built
// bundle with the prototype frozen, as Tauri does, and fails on any uncaught error.
import type { Plugin } from 'vite';
/** The writable data properties of Object.prototype that freezing turns read-only for every plain object. */
export const FROZEN_NAMES = ['constructor', 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable'] as const;
const QUICK = new RegExp(`\\.(?:${FROZEN_NAMES.join('|')})\\s*=(?!=)`);
export const HELPER = '__storeboundFreezeSafeSet';
// Appended (function declarations hoist), so a leading "use strict" directive stays first. Works in strict and sloppy
// code: when `k` is only inherited from a frozen (read-only) data property, define it on `o`; otherwise assign.
const HELPER_SRC = `\nfunction ${HELPER}(o, k, v) { if (o !== null && (typeof o === 'object' || typeof o === 'function') && !Object.prototype.hasOwnProperty.call(o, k)) { for (var p = Object.getPrototypeOf(o); p !== null; p = Object.getPrototypeOf(p)) { var d = Object.getOwnPropertyDescriptor(p, k); if (!d) continue; if ('value' in d && !d.writable) { Object.defineProperty(o, k, { value: v, writable: true, enumerable: true, configurable: true }); return v; } break; } } o[k] = v; return v; }\n`;
interface Node { type: string; start: number; end: number; [k: string]: unknown }
function walk(node: unknown, visit: (n: Node) => void): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { for (const n of node) walk(n, visit); return; }
const n = node as Node;
if (typeof n.type === 'string') visit(n);
for (const k of Object.keys(n)) if (k !== 'type' && k !== 'start' && k !== 'end') walk(n[k], visit);
}
/** Rewrites the assignments in one module's code; null when there is nothing to change. */
export function rewrite(code: string, parse: (code: string) => unknown): string | null {
if (!QUICK.test(code)) return null;
const edits: { start: number; end: number; text: (inner: (s: number, e: number) => string) => string }[] = [];
walk(parse(code), n => {
if (n.type !== 'AssignmentExpression' || n.operator !== '=') return;
const left = n.left as Node & { object: Node; property: Node & { name?: string }; computed: boolean; optional?: boolean };
if (left.type !== 'MemberExpression' || left.computed || left.object.type === 'Super') return;
if (!(FROZEN_NAMES as readonly string[]).includes(left.property.name ?? '')) return;
const right = n.right as Node;
edits.push({ start: n.start, end: n.end, text: inner => `${HELPER}(${inner(left.object.start, left.object.end)}, ${JSON.stringify(left.property.name)}, ${inner(right.start, right.end)})` });
});
if (!edits.length) return null;
// outermost edits only carry their inner text through `inner`, which applies nested edits recursively
edits.sort((a, b) => a.start - b.start || b.end - a.end);
const render = (s: number, e: number): string => {
let out = '', at = s;
for (const ed of edits) {
if (ed.start < at || ed.end > e || ed.start < s) continue;
out += code.slice(at, ed.start) + ed.text(render);
at = ed.end;
}
return out + code.slice(at, e);
};
return render(0, code.length) + HELPER_SRC;
}
export function freezeSafe(): Plugin {
return {
name: 'storebound-freeze-safe',
transform(code, id) {
const file = id.split('?')[0]!;
if (!/node_modules/.test(file) || !/\.(?:m?js|cjs)$/.test(file)) return null;
const out = rewrite(code, c => this.parse(c));
return out === null ? null : { code: out, map: null };
},
};
}

View file

@ -6,6 +6,8 @@
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>storebound</title> <title>storebound</title>
<!-- before the bundle: an error panel instead of an empty window if the bundle fails (public/boot.js) -->
<script src="/boot.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

132
apps/desktop/public/boot.js Normal file
View file

@ -0,0 +1,132 @@
// storebound desktop: the first script of the window (a plain script, loaded before the app bundle; CSP allows only
// files of the app). If the bundle fails to load or React never draws (or later unmounts after an error no boundary
// caught), the window would show nothing but its background colour. This shows a plain error panel instead, with
// Copy details, Reload and Reset saved state (keeps a backup), and sends the error to logs/webview.log through the
// app's `log_webview` command until src/lib/crash.ts takes over. No framework, no strings from the network.
(function () {
'use strict';
var errors = [];
var shown = false;
function invoke(cmd, args) {
try {
var i = window.__TAURI_INTERNALS__;
if (i && typeof i.invoke === 'function') return Promise.resolve(i.invoke(cmd, args || {})).catch(function () { return null; });
} catch (e) { /* no host */ }
return Promise.resolve(null);
}
function text(e) {
if (!e) return 'unknown error';
if (e instanceof Error) return e.name + ': ' + e.message;
if (typeof e === 'string') return e;
if (typeof e.message === 'string') return e.message;
try { return JSON.stringify(e); } catch (x) { return String(e); }
}
function root() { return document.getElementById('root'); }
function blank() { var r = root(); return !r || r.childElementCount === 0; }
function booted() { return document.documentElement.getAttribute('data-booted') === '1'; }
function record(where, e, stack) {
var msg = text(e);
var entry = where + ': ' + msg + (stack ? '\n' + stack : '');
errors.push(entry);
if (errors.length > 20) errors.shift();
// src/lib/crash.ts logs once the bundle is running; before that, this does
if (!window.__storeboundCrashHandlers) invoke('log_webview', { level: 'error', message: where + ': ' + msg, detail: stack || null });
// after start: at once; while starting, give React a moment (a non-fatal start error does not blank the window)
setTimeout(check, booted() ? 60 : 1500);
}
function el(tag, cls, txt) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (txt) n.textContent = txt;
return n;
}
function show(title, lead) {
if (shown) return;
shown = true;
var detail = 'storebound desktop · ' + (booted() ? 'after start' : 'during start') + ' · ' + new Date().toISOString() + '\n' +
(navigator.userAgent || '') + '\n\n' + (errors.length ? errors.join('\n\n') : 'No error was reported: the window stayed empty.');
var host = root() || document.body;
while (host.firstChild) host.removeChild(host.firstChild);
var wrap = el('div', 'crash crash-full');
wrap.id = 'boot-fallback';
wrap.setAttribute('role', 'alert');
wrap.setAttribute('data-crash', 'boot');
var box = el('div', 'crash-box');
box.appendChild(el('h2', '', title));
box.appendChild(el('p', '', lead));
box.appendChild(el('pre', 'crash-msg selectable', errors.length ? errors[errors.length - 1].split('\n')[0] : 'The window stayed empty.'));
var det = el('details');
det.appendChild(el('summary', '', 'Details'));
det.appendChild(el('pre', 'crash-detail selectable', detail));
box.appendChild(det);
var note = el('p', 'small muted');
note.setAttribute('role', 'status');
var actions = el('div', 'actions');
var copy = el('button', 'btn', 'Copy details');
copy.onclick = function () {
var done = function (ok) { note.textContent = ok ? 'Details copied.' : 'Could not copy: select the details and copy them by hand.'; };
try {
navigator.clipboard.writeText(detail).then(function () { done(true); }, function () { done(fallbackCopy(detail)); });
} catch (e) { done(fallbackCopy(detail)); }
};
var reload = el('button', 'btn btn-primary', 'Reload');
reload.onclick = function () { location.reload(); };
var reset = el('button', 'btn btn-ghost', 'Reset saved state (keeps a backup)');
reset.title = 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.';
reset.onclick = function () {
invoke('state_reset').then(function (backup) {
if (backup === null || backup === undefined) return;
note.textContent = 'Saved state reset. Backup: ' + (backup || '-') + '. Reloading…';
setTimeout(function () { location.reload(); }, 1200);
});
};
actions.appendChild(copy);
actions.appendChild(reload);
actions.appendChild(reset);
box.appendChild(actions);
box.appendChild(note);
box.appendChild(el('p', 'small muted', 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).'));
wrap.appendChild(box);
host.appendChild(wrap);
}
function fallbackCopy(s) {
try {
var ta = el('textarea', 'sr');
ta.value = s;
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
ta.remove();
return ok;
} catch (e) { return false; }
}
function check() {
if (shown || !blank() || !document.body) return;
if (booted()) show('storebound hit an error', 'The window was closed by an error. Your app folders, store keys and saved data are untouched. Reload usually fixes it; if it keeps happening, copy the details into a bug report.');
else if (errors.length) show('storebound could not start', 'The window failed while starting. Your app folders, store keys and saved data are untouched. Reload, or reset the saved state if it keeps happening, and copy the details into a bug report.');
}
window.addEventListener('error', function (ev) {
// a script or stylesheet that failed to load has no ev.error
var t = ev.target;
if (t && t !== window && (t.src || t.href)) { record('load', 'could not load ' + (t.src || t.href)); return; }
record('window.onerror', ev.error || ev.message, ev.error && ev.error.stack);
}, true);
window.addEventListener('unhandledrejection', function (ev) {
if (!booted()) record('unhandledrejection', ev.reason, ev.reason && ev.reason.stack);
});
// React's root reports an error no boundary caught (the tree is gone) here: src/main.tsx onUncaughtError
window.__storeboundBlank = function (message, stack) { record('react', message, stack); };
// nothing drawn and nothing reported after a while: still say so
window.addEventListener('load', function () {
setTimeout(function () {
if (!booted() && blank()) { errors.push('start: the window was still empty 15 s after loading'); invoke('log_webview', { level: 'error', message: 'start: the window was still empty 15 s after loading', detail: null }); show('storebound could not start', 'The window stayed empty while starting. Your app folders, store keys and saved data are untouched. Reload, or reset the saved state if it keeps happening, and copy the details into a bug report.'); }
}, 15000);
});
})();

View file

@ -49,6 +49,9 @@
"allow-project-import-fastlane", "allow-project-import-fastlane",
"allow-export-save", "allow-export-save",
"allow-price-sheet-pick", "allow-price-sheet-pick",
"allow-project-reveal" "allow-project-reveal",
"allow-project-locate",
"allow-state-reset",
"allow-log-webview"
] ]
} }

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-log-webview"
description = "Enables the log_webview command without any pre-configured scope."
commands.allow = ["log_webview"]
[[permission]]
identifier = "deny-log-webview"
description = "Denies the log_webview command without any pre-configured scope."
commands.deny = ["log_webview"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-project-locate"
description = "Enables the project_locate command without any pre-configured scope."
commands.allow = ["project_locate"]
[[permission]]
identifier = "deny-project-locate"
description = "Denies the project_locate command without any pre-configured scope."
commands.deny = ["project_locate"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-state-reset"
description = "Enables the state_reset command without any pre-configured scope."
commands.allow = ["state_reset"]
[[permission]]
identifier = "deny-state-reset"
description = "Denies the state_reset command without any pre-configured scope."
commands.deny = ["state_reset"]

View file

@ -321,10 +321,57 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result })) Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
} }
/// An app folder as the window sees it: `missing` when the folder is gone (moved, renamed, deleted, or an old
/// worktree), so the app shows "Folder not found" with Locate / Remove instead of reading it.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectView {
#[serde(flatten)]
pub project: Project,
pub missing: bool,
}
pub fn project_view(p: &Project) -> ProjectView {
ProjectView { project: p.clone(), missing: !Path::new(&p.path).is_dir() }
}
#[tauri::command] #[tauri::command]
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Project>> { pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<ProjectView>> {
guard(&window)?; guard(&window)?;
Ok(saved(&state)?.projects.clone()) Ok(saved(&state)?.projects.iter().map(project_view).collect())
}
/// "Locate…" for a folder that moved: native folder picker; the folder must hold a storebound.config.ts and not be in
/// the list already. Keeps the app's id, accounts, links and saved reads. None = cancelled.
#[tauri::command]
pub async fn project_locate(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String) -> Res<Option<ProjectView>> {
guard(&window)?;
if !saved(&state)?.projects.iter().any(|p| p.id == id) {
return Err(Fail::host("unknown project"));
}
let Some(dir) = pick_folder(&window, "Locate the app folder (with storebound.config.ts)").await? else { return Ok(None) };
if !has_config(&dir) {
return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display())));
}
let path = dir.to_string_lossy().to_string();
// the app's name from its config when the sidecar can read it; the old name otherwise
let name = match state.sidecar.get(&app).await {
Ok(r) => r.call("project.inspect", json!({ "project": path })).await.ok().and_then(|v| v["name"].as_str().map(str::to_string)),
Err(_) => None,
};
let mut st = saved(&state)?;
if st.projects.iter().any(|p| p.path == path && p.id != id) {
return Err(Fail::host("this folder is already in the list"));
}
let p = st.projects.iter_mut().find(|p| p.id == id).ok_or_else(|| Fail::host("unknown project"))?;
p.path = path;
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
p.name = n;
}
let view = project_view(p);
persist(&state, &st)?;
crate::logs::host("info", &format!("app folder located: {}", view.project.path));
Ok(Some(view))
} }
const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"]; const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"];
@ -671,7 +718,9 @@ pub async fn portfolio_status(window: WebviewWindow, app: AppHandle, state: Stat
let (projects, parallel) = { let (projects, parallel) = {
let st = saved(&state)?; let st = saved(&state)?;
let ignored: Vec<&str> = st.overrides.ignored.iter().filter_map(|i| i.strip_prefix("project:")).collect(); let ignored: Vec<&str> = st.overrides.ignored.iter().filter_map(|i| i.strip_prefix("project:")).collect();
(st.projects.iter().filter(|p| !ignored.contains(&p.id.as_str())).cloned().collect::<Vec<_>>(), st.settings.refresh.parallel.clamp(1, 8) as usize) // folders that are gone are skipped (the window shows them as "Folder not found")
let projects = st.projects.iter().filter(|p| !ignored.contains(&p.id.as_str()) && Path::new(&p.path).is_dir()).cloned().collect::<Vec<_>>();
(projects, st.settings.refresh.parallel.clamp(1, 8) as usize)
}; };
// store-only apps (live reads only): validated targets with their accounts' credentials // store-only apps (live reads only): validated targets with their accounts' credentials
let mut targets = Vec::new(); let mut targets = Vec::new();
@ -1026,6 +1075,66 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res
Ok(true) Ok(true)
} }
/// "Reset saved state" of the error screen: after a native confirmation, state.json is moved aside as
/// `state.json.reset-<time>.bak` (nothing is deleted: store keys stay in the keychain, saved reads stay) and the app
/// starts with an empty list. The backup's path (~), or null when cancelled.
#[tauri::command]
pub async fn state_reset(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Option<String>> {
guard(&window)?;
let w = window.clone();
let yes = tauri::async_runtime::spawn_blocking(move || {
w.app_handle()
.dialog()
.message("storebound forgets its list of app folders, accounts, studios, pairing and settings, and starts empty.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in the system keychain, and your app folders are not touched.")
.title("Reset saved state?")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("Reset and keep a backup".into(), "Cancel".into()))
.parent(&w)
.blocking_show()
})
.await
.map_err(|e| Fail::host(e.to_string()))?;
if !yes {
return Ok(None);
}
let mut st = saved(&state)?;
let backup = store::reset(&state.data_dir).map_err(Fail::host)?;
*st = Saved::default();
persist(&state, &st)?;
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
let shown = backup.map(|b| tilde(&b.to_string_lossy(), home.as_deref())).unwrap_or_default();
crate::logs::host("warn", &format!("saved state reset from the error screen; backup: {shown}"));
Ok(Some(shown))
}
/// Uncaught errors of the window (window.onerror, unhandled rejections, React error boundaries) into
/// `<app data>/logs/webview.log`. Sanitized there (home as ~, no keys, tokens or e-mail addresses), size-capped, and
/// limited to WEBVIEW_LOG_BURST entries a minute so a render loop cannot fill the disk.
#[tauri::command]
pub fn log_webview(window: WebviewWindow, level: String, message: String, detail: Option<String>) -> Res<()> {
guard(&window)?;
static BUDGET: Mutex<(u64, u32)> = Mutex::new((0, 0));
let minute = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() / 60).unwrap_or(0);
{
let mut b = BUDGET.lock().map_err(|_| Fail::host("log state poisoned"))?;
if b.0 != minute {
*b = (minute, 0);
}
b.1 += 1;
if b.1 > WEBVIEW_LOG_BURST {
return Ok(());
}
}
let text = match detail.filter(|d| !d.trim().is_empty()) {
Some(d) => format!("{message}\n{d}"),
None => message,
};
crate::logs::webview(&level, &text);
Ok(())
}
pub const WEBVIEW_LOG_BURST: u32 = 60;
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// Updates (Settings > Updates): signed bundles from storebound.dev, installed only after the owner's click. // Updates (Settings > Updates): signed bundles from storebound.dev, installed only after the owner's click.

View file

@ -42,4 +42,7 @@ pub const COMMANDS: &[&str] = &[
"export_save", "export_save",
"price_sheet_pick", "price_sheet_pick",
"project_reveal", "project_reveal",
"project_locate",
"state_reset",
"log_webview",
]; ];

View file

@ -5,6 +5,7 @@ pub mod cache;
pub mod commands; pub mod commands;
pub mod confirm; pub mod confirm;
pub mod downloads; pub mod downloads;
pub mod logs;
pub mod policy; pub mod policy;
pub mod selftest; pub mod selftest;
pub mod updates; pub mod updates;
@ -140,9 +141,22 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.setup(|app| { .setup(|app| {
let data_dir = app.path().app_data_dir()?; let data_dir = app.path().app_data_dir()?;
logs::init(&data_dir, app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()));
logs::panic_hook();
logs::host("info", &format!("storebound desktop {} starting ({} {}{})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH, if cfg!(debug_assertions) { ", debug" } else { "" }));
dev_seed(&data_dir); dev_seed(&data_dir);
let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?; let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?;
let saved = store::load(&data_dir); let (saved, report) = store::load_report(&data_dir);
logs::host("info", &format!("saved state: {} app folders ({} not found), {} accounts, {} studios, format {}", saved.projects.len(), saved.projects.iter().filter(|p| !std::path::Path::new(&p.path).is_dir()).count(), saved.accounts.len(), saved.studios.len(), saved.version));
if let Some(from) = report.migrated_from {
logs::host("info", &format!("saved state migrated from format {from} to {}", store::VERSION));
}
for n in &report.notes {
logs::host("warn", &format!("saved state: {n}"));
}
if let Some(b) = &report.backup {
logs::host("info", &format!("saved state: original kept as {}", b.display()));
}
let keep_days = saved.settings.cache.keep_days; let keep_days = saved.settings.cache.keep_days;
let state = commands::AppState { let state = commands::AppState {
sidecar: sidecar::Sidecar::default(), sidecar: sidecar::Sidecar::default(),
@ -225,6 +239,9 @@ pub fn run() {
commands::export_save, commands::export_save,
commands::price_sheet_pick, commands::price_sheet_pick,
commands::project_reveal, commands::project_reveal,
commands::project_locate,
commands::state_reset,
commands::log_webview,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("storebound desktop failed to start"); .expect("storebound desktop failed to start");

View file

@ -0,0 +1,213 @@
//! Local diagnostics in `<app data>/logs`: `host.log` (this process: start, saved state, sidecar, panics) and
//! `webview.log` (uncaught errors of the window, sent through the `log_webview` command). Each file is rotated at
//! MAX_BYTES (one older `.1.log` kept). Every line goes through `sanitize`: the home folder shows as `~`, and keys,
//! tokens, long secrets and e-mail addresses are replaced, so a log can be attached to a bug report as it is.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub const DIR: &str = "logs";
pub const HOST: &str = "host.log";
pub const WEBVIEW: &str = "webview.log";
pub const MAX_BYTES: u64 = 512 * 1024;
/// Longest single entry kept (the rest is cut).
pub const MAX_ENTRY: usize = 16 * 1024;
struct Logs {
dir: PathBuf,
home: Option<String>,
lock: Mutex<()>,
}
static LOGS: OnceLock<Logs> = OnceLock::new();
/// Sets the log folder once (the first call wins).
pub fn init(data_dir: &Path, home: Option<String>) {
let _ = LOGS.set(Logs { dir: data_dir.join(DIR), home, lock: Mutex::new(()) });
}
pub fn dir() -> Option<PathBuf> {
LOGS.get().map(|l| l.dir.clone())
}
/// One line in host.log (no-op before `init`).
pub fn host(level: &str, message: &str) {
write(HOST, "host", level, message);
}
/// One entry in webview.log.
pub fn webview(level: &str, message: &str) {
write(WEBVIEW, "webview", level, message);
}
fn write(file: &str, source: &str, level: &str, message: &str) {
let Some(l) = LOGS.get() else { return };
let _g = l.lock.lock();
let level = match level {
"error" | "warn" | "info" | "debug" => level,
_ => "info",
};
let text = sanitize(message, l.home.as_deref());
let line = entry(&crate::store::now_iso(), source, level, &text);
let _ = append(&l.dir, file, &line);
}
/// `<time> <level> [<source>] <text>`; continuation lines are indented.
pub fn entry(at: &str, source: &str, level: &str, text: &str) -> String {
let mut t: String = text.chars().take(MAX_ENTRY).collect();
if t.len() < text.len() {
t.push_str(" [cut]");
}
format!("{at} {level:<5} [{source}] {}\n", t.trim_end().replace('\n', "\n "))
}
/// Appends, rotating first when the file would pass MAX_BYTES.
pub fn append(dir: &Path, file: &str, line: &str) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
let path = dir.join(file);
if fs::metadata(&path).map(|m| m.len() + line.len() as u64 > MAX_BYTES).unwrap_or(false) {
let old = dir.join(file.replace(".log", ".1.log"));
let _ = fs::remove_file(&old);
fs::rename(&path, &old)?;
}
OpenOptions::new().create(true).append(true).open(&path)?.write_all(line.as_bytes())
}
const SECRET_KEYS: &[&str] = &["secret", "token", "password", "passwd", "private_key", "privatekey", "apikey", "api_key", "authorization", "credential", "p8"];
/// The home folder as `~`, then keys, tokens and e-mail addresses replaced. Control characters other than newline and
/// tab are dropped.
pub fn sanitize(text: &str, home: Option<&str>) -> String {
let mut s: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
if let Some(h) = home.map(|h| h.trim_end_matches(['/', '\\'])).filter(|h| h.len() > 1) {
s = s.replace(h, "~");
}
s = redact_pem(&s);
s = redact_assignments(&s);
redact_words(&s)
}
fn redact_pem(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find("-----BEGIN") {
out.push_str(&rest[..i]);
let after = &rest[i..];
match after.find("-----END").and_then(|e| after[e + 8..].find("-----").map(|k| e + 8 + k + 5)) {
Some(end) => {
out.push_str("[redacted key]");
rest = &after[end..];
}
None => {
out.push_str("[redacted key]");
rest = "";
}
}
}
out.push_str(rest);
out
}
/// `token=...`, `"password": "..."`, `Authorization: Bearer ...`: the value goes.
fn redact_assignments(s: &str) -> String {
let lower = s.to_ascii_lowercase();
let b = s.as_bytes();
let mut cut: Vec<(usize, usize)> = Vec::new();
for k in SECRET_KEYS {
let mut from = 0;
while let Some(i) = lower[from..].find(k) {
let mut j = i + from + k.len();
from = j;
// rest of the key name ("tokenId", "secret_key"), a closing quote, spaces, then ':' or '='
while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_' || b[j] == b'-') {
j += 1;
}
while j < b.len() && (b[j] == b'"' || b[j] == b'\'' || b[j] == b' ') {
j += 1;
}
if j >= b.len() || (b[j] != b':' && b[j] != b'=') {
continue;
}
j += 1;
while j < b.len() && (b[j] == b' ' || b[j] == b'"' || b[j] == b'\'') {
j += 1;
}
if lower[j..].starts_with("bearer ") || lower[j..].starts_with("basic ") {
j += lower[j..].find(' ').unwrap_or(0) + 1;
}
let start = j;
while j < b.len() && !matches!(b[j], b'"' | b'\'' | b',' | b'}' | b' ' | b'\n' | b'&' | b';') {
j += 1;
}
if j > start {
cut.push((start, j));
}
}
}
cut.sort();
let mut out = String::new();
let mut at = 0;
for (a, z) in cut {
if a < at {
continue;
}
out.push_str(&s[at..a]);
out.push_str("[redacted]");
at = z;
}
out.push_str(&s[at..]);
out
}
/// Long random-looking words (keys, JWTs, hashes of 32+ characters with letters and digits) and e-mail addresses.
fn redact_words(s: &str) -> String {
let is_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '/' | '=' | '.' | '@');
let mut out = String::with_capacity(s.len());
let mut word = String::new();
let flush = |w: &mut String, out: &mut String| {
if !w.is_empty() {
out.push_str(&judge(w));
w.clear();
}
};
for c in s.chars() {
if is_word(c) {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
fn judge(w: &str) -> String {
if let Some((user, domain)) = w.split_once('@') {
if !user.is_empty() && domain.contains('.') && !domain.starts_with('.') {
return "[email]".into();
}
}
let mixed = |p: &str| p.chars().any(|c| c.is_ascii_alphabetic()) && p.chars().any(|c| c.is_ascii_digit());
// a long base64 / hex run (may hold '/' or '+'): not a path, not a dotted name
if w.len() >= 40 && !w.starts_with(['/', '~']) && !w.contains('.') && mixed(w) {
return "[redacted]".into();
}
// paths and dotted names stay (file names in stacks); one long run of key-like characters inside them goes
for part in w.split(['/', '.']) {
if part.len() >= 32 && mixed(part) {
return w.replace(part, "[redacted]");
}
}
w.to_string()
}
/// Records panics in host.log (release builds abort right after).
pub fn panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
host("error", &format!("panic: {info}"));
prev(info);
}));
}

View file

@ -187,7 +187,14 @@ impl Sidecar {
return Ok(r.clone()); return Ok(r.clone());
} }
} }
let r = Arc::new(start(app).await?); let r = match start(app).await {
Ok(r) => Arc::new(r),
Err(e) => {
crate::logs::host("error", &format!("sidecar did not start: {}", e.message));
return Err(e);
}
};
crate::logs::host("info", &format!("sidecar started: storebound {} ({})", r.version, r.protocol));
*cur = Some(r.clone()); *cur = Some(r.clone());
Ok(r) Ok(r)
} }
@ -249,6 +256,9 @@ async fn start<R: Runtime>(app: &AppHandle<R>) -> Result<Running, Fail> {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut out: Vec<u8> = Vec::new(); let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new(); let mut err: Vec<u8> = Vec::new();
// the last stderr lines, written to host.log when the sidecar stops
let mut tail: std::collections::VecDeque<String> = std::collections::VecDeque::new();
let mut end = String::from("closed");
while let Some(ev) = rx.recv().await { while let Some(ev) = rx.recv().await {
match ev { match ev {
CommandEvent::Stdout(bytes) => { CommandEvent::Stdout(bytes) => {
@ -262,16 +272,30 @@ async fn start<R: Runtime>(app: &AppHandle<R>) -> Result<Running, Fail> {
err.extend_from_slice(&bytes); err.extend_from_slice(&bytes);
while let Some(nl) = err.iter().position(|b| *b == b'\n') { while let Some(nl) = err.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = err.drain(..=nl).collect(); let line: Vec<u8> = err.drain(..=nl).collect();
let text = String::from_utf8_lossy(&line).to_string();
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
eprint!("[sidecar] {}", String::from_utf8_lossy(&line)); eprint!("[sidecar] {text}");
} }
if tail.len() == 20 {
tail.pop_front();
}
tail.push_back(text.trim_end().to_string());
} }
} }
CommandEvent::Terminated(_) | CommandEvent::Error(_) => break, CommandEvent::Terminated(t) => {
end = format!("exit code {:?}, signal {:?}", t.code, t.signal);
break;
}
CommandEvent::Error(e) => {
end = format!("error: {e}");
break;
}
_ => {} _ => {}
} }
} }
a.store(false, Ordering::SeqCst); let expected = !a.swap(false, Ordering::SeqCst);
let tail: Vec<String> = tail.into_iter().collect();
crate::logs::host(if expected { "info" } else { "warn" }, &format!("sidecar stopped ({end}){}", if tail.is_empty() { String::new() } else { format!("\n{}", tail.join("\n")) }));
if let Ok(mut map) = p.lock() { if let Ok(mut map) = p.lock() {
for (_, tx) in map.drain() { for (_, tx) in map.drain() {
let _ = tx.send(Err(Fail::host("the storebound sidecar stopped"))); let _ = tx.send(Err(Fail::host("the storebound sidecar stopped")));

View file

@ -3,7 +3,9 @@
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::accounts::Account; use crate::accounts::Account;
use crate::workspace::{Overrides, Settings, Studio}; use crate::workspace::{Overrides, Settings, Studio};
@ -14,16 +16,25 @@ pub struct Project {
pub id: String, pub id: String,
/// Absolute folder that holds storebound.config.ts. /// Absolute folder that holds storebound.config.ts.
pub path: String, pub path: String,
/// Filled from the folder name when an old or hand-edited file has none.
#[serde(default)]
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub play_account: Option<String>, pub play_account: Option<String>,
#[serde(default)] #[serde(default)]
pub apple_account: Option<String>, pub apple_account: Option<String>,
#[serde(default)]
pub added: String, pub added: String,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] /// 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 { pub struct State {
#[serde(default)]
pub version: u32,
#[serde(default)] #[serde(default)]
pub projects: Vec<Project>, pub projects: Vec<Project>,
#[serde(default)] #[serde(default)]
@ -36,16 +47,172 @@ pub struct State {
pub settings: Settings, pub settings: Settings,
} }
impl Default for State {
fn default() -> State {
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() }
}
}
pub const FILE: &str = "state.json"; 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 { pub fn load(dir: &Path) -> State {
fs::read_to_string(dir.join(FILE)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() 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),
};
// 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> { pub fn save(dir: &Path, state: &State) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| e.to_string())?; fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let tmp: PathBuf = dir.join(format!("{FILE}.tmp")); let tmp: PathBuf = dir.join(format!("{FILE}.tmp"));
let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; 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::write(&tmp, json + "\n").map_err(|e| e.to_string())?;
fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string()) fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string())
} }

View file

@ -242,7 +242,7 @@ pub fn check_overrides(o: &Overrides) -> Result<(), String> {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct RefreshSettings { pub struct RefreshSettings {
/// Minutes between automatic store reads; 0 = off. /// Minutes between automatic store reads; 0 = off.
pub auto_minutes: u32, pub auto_minutes: u32,
@ -254,14 +254,14 @@ pub struct RefreshSettings {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct CacheSettings { pub struct CacheSettings {
/// Saved reads older than this many days are dropped; 0 = keep. /// Saved reads older than this many days are dropped; 0 = keep.
pub keep_days: u32, pub keep_days: u32,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct NotifySettings { pub struct NotifySettings {
pub review_state: bool, pub review_state: bool,
pub low_reviews: bool, pub low_reviews: bool,
@ -269,7 +269,7 @@ pub struct NotifySettings {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct Settings { pub struct Settings {
pub refresh: RefreshSettings, pub refresh: RefreshSettings,
pub cache: CacheSettings, pub cache: CacheSettings,
@ -278,14 +278,29 @@ pub struct Settings {
pub channel: String, pub channel: String,
} }
// Every settings struct fills a missing field with its default (`#[serde(default)]`), so a file written by an older
// build that lacks a newer setting keeps the rest.
impl Default for RefreshSettings {
fn default() -> RefreshSettings {
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true }
}
}
impl Default for CacheSettings {
fn default() -> CacheSettings {
CacheSettings { keep_days: 30 }
}
}
impl Default for NotifySettings {
fn default() -> NotifySettings {
NotifySettings { review_state: true, low_reviews: true, crash: true }
}
}
impl Default for Settings { impl Default for Settings {
fn default() -> Settings { fn default() -> Settings {
Settings { Settings { refresh: RefreshSettings::default(), cache: CacheSettings::default(), notify: NotifySettings::default(), channel: "stable".into() }
refresh: RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true },
cache: CacheSettings { keep_days: 30 },
notify: NotifySettings { review_state: true, low_reviews: true, crash: true },
channel: "stable".into(),
}
} }
} }

View file

@ -0,0 +1,201 @@
//! Saved state: old formats migrate to the current one (with a backup of the original), damaged files and entries
//! never take the rest down, missing folders are reported as such, Reset keeps a backup, and the logs stay clean.
use serde_json::{json, Value};
use storebound_desktop::commands::project_view;
use storebound_desktop::logs;
use storebound_desktop::store::{self, State, VERSION};
use storebound_desktop::workspace::{Overrides, Settings};
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json");
fn write(dir: &std::path::Path, text: &str) {
std::fs::write(dir.join(store::FILE), text).unwrap();
}
#[test]
fn d1_state_with_missing_folders_migrates_to_the_current_format() {
let (st, from, notes) = store::migrate(serde_json::from_str(D1).unwrap());
assert_eq!((from, st.version), (1, VERSION));
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(st.projects.len(), 2);
assert_eq!(st.projects[0].name, "Prism Puddle");
assert!(st.accounts.is_empty() && st.studios.is_empty());
assert_eq!((st.overrides.clone(), st.settings.clone()), (Overrides::default(), Settings::default()));
// the folders are gone: the window gets `missing: true` for each, nothing fails
for p in &st.projects {
let v = serde_json::to_value(project_view(p)).unwrap();
assert_eq!(v["missing"], true);
assert_eq!(v["path"], p.path.as_str());
assert!(v.get("playAccount").is_some(), "camelCase, flattened");
}
// on disk: migrated once, the original kept as state.json.v1.bak, the new file carries the version
let dir = tempfile::tempdir().unwrap();
write(dir.path(), D1);
let (loaded, report) = store::load_report(dir.path());
assert_eq!(loaded, st);
assert_eq!(report.migrated_from, Some(1));
let bak = dir.path().join("state.json.v1.bak");
assert_eq!(report.backup.as_deref(), Some(bak.as_path()));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), D1);
let saved: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
assert_eq!(saved["version"], VERSION);
assert_eq!(saved["settings"]["cache"]["keepDays"], 30);
let (again, report) = store::load_report(dir.path());
assert_eq!(again, st);
assert_eq!(report.migrated_from, None, "migrates only once");
}
#[test]
fn d3_state_without_version_keeps_everything() {
let (st, from, notes) = store::migrate(serde_json::from_str(D3).unwrap());
assert_eq!(from, 1);
assert!(notes.is_empty(), "{notes:?}");
assert_eq!((st.projects.len(), st.accounts.len(), st.studios.len()), (2, 3, 2));
assert_eq!(st.accounts[0].studio.as_deref(), Some("s1"));
assert_eq!(st.accounts[2].studio, None);
assert_eq!(st.studios[1].logo, None);
assert_eq!(st.overrides.ignored, vec!["account:00112233445566ff".to_string()]);
assert_eq!(st.overrides.app_studio[0].studio, "s2");
}
#[test]
fn empty_and_missing_files_start_empty() {
let (st, from, _) = store::migrate(serde_json::from_str(EMPTY).unwrap());
assert_eq!(from, 1);
assert_eq!(st, State::default());
let dir = tempfile::tempdir().unwrap();
assert_eq!(store::load_report(dir.path()), (State::default(), store::LoadReport::default()));
assert!(!dir.path().join(store::FILE).exists(), "nothing written for a fresh install");
}
#[test]
fn partial_settings_keep_what_is_there() {
let (st, from, notes) = store::migrate(json!({ "version": 2, "settings": { "refresh": { "autoMinutes": 15 }, "channel": "beta" } }));
assert_eq!(from, 2);
assert!(notes.is_empty(), "{notes:?}");
assert_eq!((st.settings.refresh.auto_minutes, st.settings.refresh.parallel, st.settings.cache.keep_days, st.settings.channel.as_str()), (15, 4, 30, "beta"));
assert!(st.settings.notify.crash);
}
#[test]
fn bad_entries_are_dropped_one_by_one() {
let (st, _, notes) = store::migrate(json!({
"projects": [
{ "id": "a", "path": "/x/a", "name": "A", "added": "2026-10-01T00:00:00Z" },
{ "id": 7 },
{ "id": "b", "path": "/x/Beta" },
{ "id": "a", "path": "/x/dup", "name": "Dup" }
],
"accounts": [{ "id": "0123456789abcdef", "label": "P", "kind": "play", "created": "2026-10-01T00:00:00Z" }, { "kind": "windows-phone" }],
"studios": "not a list",
"overrides": { "ignored": "nope" },
"settings": null
}));
assert_eq!(st.projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["A", "Beta"], "no name: the folder name; duplicate id: first wins");
assert_eq!(st.accounts.len(), 1);
assert!(st.studios.is_empty());
assert_eq!(st.overrides, Overrides::default());
assert_eq!(st.settings, Settings::default());
assert_eq!(notes.len(), 3, "{notes:?}");
assert!(notes[0].starts_with("projects[1] dropped"));
}
#[test]
fn unreadable_files_are_kept_aside_and_newer_formats_read() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "{broken");
let (st, report) = store::load_report(dir.path());
assert_eq!(st, State::default());
let bak = report.backup.expect("a copy of the unreadable file");
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.unreadable-"));
assert_eq!(std::fs::read_to_string(bak).unwrap(), "{broken");
write(dir.path(), "[1,2]");
assert_eq!(store::load(dir.path()), State::default());
let (st, from, _) = store::migrate(json!({ "version": 99, "projects": [{ "id": "a", "path": "/x", "name": "A", "added": "" }], "future": true }));
assert_eq!((from, st.version, st.projects.len()), (99, 99, 1));
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &json!({ "version": 99 }).to_string());
let (_, report) = store::load_report(dir.path());
assert!(report.notes[0].contains("newer"), "{:?}", report.notes);
assert_eq!(report.migrated_from, None);
}
#[test]
fn reset_moves_the_file_aside() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(store::reset(dir.path()).unwrap(), None);
write(dir.path(), D1);
let bak = store::reset(dir.path()).unwrap().unwrap();
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.reset-"));
assert_eq!(std::fs::read_to_string(bak).unwrap(), D1);
assert!(!dir.path().join(store::FILE).exists());
assert_eq!(store::load(dir.path()), State::default());
}
#[test]
fn saved_files_always_carry_the_version() {
let dir = tempfile::tempdir().unwrap();
store::save(dir.path(), &State { version: 0, ..State::default() }).unwrap();
let v: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
assert_eq!(v["version"], VERSION);
}
#[test]
fn logs_hide_home_keys_tokens_and_emails() {
let home = Some("/Users/example");
let s = logs::sanitize("TypeError at /Users/example/Library/x.js:1\n\tat f", home);
assert_eq!(s, "TypeError at ~/Library/x.js:1\n\tat f");
let pem = "key: -----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49\n-----END PRIVATE KEY----- done";
assert_eq!(logs::sanitize(pem, None), "key: [redacted key] done");
let s = logs::sanitize(r#"{"password": "hunter22", "token":"abc.def", "name":"ok"} Authorization: Bearer xyz123 url?api_key=K1&x=1"#, None);
assert!(!s.contains("hunter22") && !s.contains("abc.def") && !s.contains("xyz123") && !s.contains("K1&"), "{s}");
assert!(s.contains(r#""name":"ok""#), "{s}");
let s = logs::sanitize("tester person@example.com saw eyJhbGciOiJFUzI1NiJ9abcdef1234567890ABCDEF and 0123456789abcdef0123456789abcdef01", None);
assert!(!s.contains("person@example.com") && !s.contains("eyJhbGci") && !s.contains("0123456789abcdef0123"), "{s}");
assert!(s.contains("[email]"));
// ordinary paths, file names and error text stay readable
let ok = "at qg (tauri://localhost/assets/index-DXUigr_F.js:49:175893) Cannot read properties of undefined (reading 'map')";
assert_eq!(logs::sanitize(ok, home), ok);
assert_eq!(logs::sanitize("a\u{7}b\u{1b}[31m", None), "ab[31m");
}
#[test]
fn log_entries_are_one_block_and_rotate() {
let e = logs::entry("2026-10-11T00:00:00Z", "webview", "error", "boom\nat f\n");
assert_eq!(e, "2026-10-11T00:00:00Z error [webview] boom\n at f\n");
let long = "x".repeat(logs::MAX_ENTRY + 10);
assert!(logs::entry("t", "host", "info", &long).ends_with(" [cut]\n"));
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("host.log"), vec![b'a'; logs::MAX_BYTES as usize]).unwrap();
logs::append(dir.path(), "host.log", "new\n").unwrap();
assert_eq!(std::fs::read_to_string(dir.path().join("host.log")).unwrap(), "new\n");
assert_eq!(std::fs::metadata(dir.path().join("host.1.log")).unwrap().len(), logs::MAX_BYTES);
}
/// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written
/// next to it as `<name>.host.json`. tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
#[test]
fn host_answers_for_the_fixtures_are_current() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
for (name, text) in [("empty", EMPTY), ("d1-missing-folders", D1), ("d3-full", D3)] {
let (st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
assert!(notes.is_empty(), "{name}: {notes:?}");
let host = json!({
"projects_list": st.projects,
"accounts_list": st.accounts,
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
});
let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap());
let file = dir.join(format!("{name}.host.json"));
if std::env::var_os("STOREBOUND_UPDATE_FIXTURES").is_some() {
std::fs::write(&file, &want).unwrap();
}
let have = std::fs::read_to_string(&file).unwrap_or_default();
assert!(have == want, "{} is stale: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state", file.display());
}
}

View file

@ -21,6 +21,7 @@ import { Icon, type IconName, Mark } from './ui/icons';
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit'; import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
import { type Command, Palette } from './ui/Palette'; import { type Command, Palette } from './ui/Palette';
import { ExportMenu, useExportItems, useRunExport } from './ui/Exports'; import { ExportMenu, useExportItems, useRunExport } from './ui/Exports';
import { ErrorBoundary } from './ui/ErrorBoundary';
import { Toasts } from './ui/Toasts'; import { Toasts } from './ui/Toasts';
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
@ -139,7 +140,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
const appItem = (a: UnifiedApp) => { const appItem = (a: UnifiedApp) => {
const e = d.statusFor(a); const e = d.statusFor(a);
const bad = (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); const missing = !!a.project?.missing;
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
return ( return (
<div role="listitem" key={a.key}> <div role="listitem" key={a.key}>
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })} <button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
@ -147,7 +149,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
<AppIcon name={a.name} src={d.iconFor(a)} /> <AppIcon name={a.name} src={d.iconFor(a)} />
<span className="grow">{a.name}</span> <span className="grow">{a.name}</span>
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>} {!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
{bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>} {missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
</button> </button>
</div> </div>
); );
@ -220,19 +223,22 @@ export function App({ onTheme }: { onTheme: () => void }) {
<ExportMenu /> <ExportMenu />
</header> </header>
<main className="content" id="content" tabIndex={-1}> <main className="content" id="content" tabIndex={-1}>
{route.screen === 'portfolio' && <Portfolio />} {/* one boundary per screen (keyed, so moving to another screen starts fresh): a failing screen keeps the shell */}
{route.screen === 'app' && app && <AppView key={app.key} app={app} tab={tabs.includes(route.tab) ? route.tab : 'releases'} />} <ErrorBoundary key={route.screen === 'app' ? `app:${route.appId}` : route.screen} where={`screen ${route.screen}`}>
{route.screen === 'apps' && <AppFolders />} {route.screen === 'portfolio' && <Portfolio />}
{route.screen === 'accounts' && <Accounts focus={route.section} />} {route.screen === 'app' && app && <AppView key={app.key} app={app} tab={tabs.includes(route.tab) ? route.tab : 'releases'} />}
{route.screen === 'ignored' && <Ignored />} {route.screen === 'apps' && <AppFolders />}
{route.screen === 'dependencies' && <Dependencies />} {route.screen === 'accounts' && <Accounts focus={route.section} />}
{route.screen === 'settings' && <Settings onTheme={onTheme} section={route.section} />} {route.screen === 'ignored' && <Ignored />}
{route.screen === 'dependencies' && <Dependencies />}
{route.screen === 'settings' && <Settings onTheme={onTheme} section={route.section} />}
</ErrorBoundary>
</main> </main>
</div> </div>
</div> </div>
{palette && <Palette commands={commands} onClose={() => setPalette(false)} />} {palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
{welcome && <Onboarding onClose={closeWelcome} />} {welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
<Toasts /> <ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
</NavCtx.Provider> </NavCtx.Provider>
); );
} }

View file

@ -18,7 +18,7 @@ export type { ToolStatus } from '../../../cli/lib/tools';
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[] } export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[] }
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null } export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */ /** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string } export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean }
export type AccountKind = 'play' | 'apple'; export type AccountKind = 'play' | 'apple';
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null } export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null }
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const; export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
@ -126,6 +126,12 @@ export const api = {
pickPriceSheet: (projectId: string) => invoke<string | null>('price_sheet_pick', { projectId }), pickPriceSheet: (projectId: string) => invoke<string | null>('price_sheet_pick', { projectId }),
/** Shows a path of an app folder in the file manager, or opens a folder / the gallery page. */ /** Shows a path of an app folder in the file manager, or opens a folder / the gallery page. */
reveal: (projectId: string, rel = '', open = false) => invoke<void>('project_reveal', { projectId, rel, open }), reveal: (projectId: string, rel = '', open = false) => invoke<void>('project_reveal', { projectId, rel, open }),
/** "Locate…" for a folder that is gone: native folder picker; the same app with its new path, or null. */
locateProject: (id: string) => invoke<Project | null>('project_locate', { id }),
/** Native confirmation, then state.json is moved aside (a backup) and the app starts empty. The backup path (~) or null. */
resetState: () => invoke<string | null>('state_reset'),
/** One entry in <app data>/logs/webview.log (sanitized and rate-limited by the host). */
logWebview: (level: 'error' | 'warn' | 'info', message: string, detail?: string) => invoke<void>('log_webview', { level, message, detail: detail ?? null }),
}; };
export type ExportKind = 'csv' | 'md' | 'json' | 'txt'; export type ExportKind = 'csv' | 'md' | 'json' | 'txt';

View file

@ -510,6 +510,9 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
case 'export_save': await sleep(200); return `~/Downloads/${String(a.name)}.${String(a.kind)}`; case 'export_save': await sleep(200); return `~/Downloads/${String(a.name)}.${String(a.kind)}`;
case 'price_sheet_pick': return `${HOME}/Downloads/prices-autumn.csv`; case 'price_sheet_pick': return `${HOME}/Downloads/prices-autumn.csv`;
case 'project_reveal': return null; case 'project_reveal': return null;
case 'project_locate': await sleep(200); return null;
case 'state_reset': return null;
case 'log_webview': console.info('[webview.log]', a.level, a.message); return null;
case 'project_remove': projects = projects.filter(p => p.id !== a.id); return null; case 'project_remove': projects = projects.filter(p => p.id !== a.id); return null;
case 'project_set_accounts': { const p = projects.find(x => x.id === a.id)!; p.playAccount = (a.play as string | null) ?? null; p.appleAccount = (a.apple as string | null) ?? null; return p; } case 'project_set_accounts': { const p = projects.find(x => x.id === a.id)!; p.playAccount = (a.play as string | null) ?? null; p.appleAccount = (a.apple as string | null) ?? null; return p; }
case 'account_add_play': case 'account_add_apple': return null; case 'account_add_play': case 'account_add_apple': return null;

View file

@ -1225,4 +1225,28 @@ export const en = {
'wz.keysInto': 'New keys go into {name}.', 'wz.keysInto': 'New keys go into {name}.',
'wz.keysNoStudio': 'New keys stay without a studio.', 'wz.keysNoStudio': 'New keys stay without a studio.',
'wz.change': 'Change', 'wz.change': 'Change',
'crash.titleApp': 'storebound hit an error',
'crash.titleScreen': 'This screen hit an error',
'crash.leadApp': 'The window could not be shown. Your app folders, store keys and saved data are untouched. Reload usually fixes it; if it keeps happening, copy the details into a bug report.',
'crash.leadScreen': 'The rest of the app still works. Try again, reload the window, or copy the details into a bug report.',
'crash.details': 'Details',
'crash.copy': 'Copy details',
'crash.copied': 'Details copied.',
'crash.copyFailed': 'Could not copy: select the details and copy them by hand.',
'crash.retry': 'Try again',
'crash.reload': 'Reload',
'crash.reset': 'Reset saved state (keeps a backup)',
'crash.resetHint': 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.',
'crash.resetDone': 'Saved state reset. Backup: {path}. Reloading…',
'crash.logNote': 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).',
'folder.missing': 'Folder not found.',
'folder.missingText': 'storebound cannot find this app folder at {path}. It may have been moved, renamed or deleted. Locate it to keep its accounts and saved reads, or remove it from the list (no files are touched).',
'folder.missingShort': 'Folder not found',
'folder.locate': 'Locate…',
'folder.locateHint': 'Pick the folder that now holds this app\'s storebound.config.ts',
'folder.located': '{name} found',
'folder.locateFailed': 'Could not use that folder',
'folder.remove': 'Remove from list',
'folder.removed': '{name} removed from the list',
} as const; } as const;

View file

@ -1227,4 +1227,28 @@ export const tr: Record<keyof typeof en, string> = {
'wz.keysInto': 'Yeni anahtarlar {name} stüdyosuna gider.', 'wz.keysInto': 'Yeni anahtarlar {name} stüdyosuna gider.',
'wz.keysNoStudio': 'Yeni anahtarlar stüdyosuz kalır.', 'wz.keysNoStudio': 'Yeni anahtarlar stüdyosuz kalır.',
'wz.change': 'Değiştir', 'wz.change': 'Değiştir',
'crash.titleApp': 'storebound bir hatayla karşılaştı',
'crash.titleScreen': 'Bu ekran bir hatayla karşılaştı',
'crash.leadApp': 'Pencere gösterilemedi. Uygulama klasörleriniz, mağaza anahtarlarınız ve kayıtlı verileriniz olduğu gibi duruyor. Yeniden yüklemek genellikle düzeltir; tekrarlarsa ayrıntıları bir hata bildirimine kopyalayın.',
'crash.leadScreen': 'Uygulamanın geri kalanı çalışıyor. Yeniden deneyin, pencereyi yeniden yükleyin ya da ayrıntıları bir hata bildirimine kopyalayın.',
'crash.details': 'Ayrıntılar',
'crash.copy': 'Ayrıntıları kopyala',
'crash.copied': 'Ayrıntılar kopyalandı.',
'crash.copyFailed': 'Kopyalanamadı: ayrıntıları seçip elle kopyalayın.',
'crash.retry': 'Yeniden dene',
'crash.reload': 'Yeniden yükle',
'crash.reset': 'Kayıtlı durumu sıfırla (yedek tutulur)',
'crash.resetHint': 'Boş bir listeyle başlar. Kayıtlı durum bir yedek dosyada saklanır, mağaza anahtarları anahtar zincirinde kalır, uygulama klasörlerine dokunulmaz.',
'crash.resetDone': 'Kayıtlı durum sıfırlandı. Yedek: {path}. Yeniden yükleniyor…',
'crash.logNote': 'Uygulama veri klasöründeki logs/webview.log dosyasına da yazıldı (ana klasör ~ olarak, anahtarlar olmadan).',
'folder.missing': 'Klasör bulunamadı.',
'folder.missingText': 'storebound bu uygulama klasörünü {path} konumunda bulamıyor. Taşınmış, yeniden adlandırılmış ya da silinmiş olabilir. Hesaplarını ve kayıtlı okumalarını korumak için yerini gösterin ya da listeden kaldırın (hiçbir dosyaya dokunulmaz).',
'folder.missingShort': 'Klasör bulunamadı',
'folder.locate': 'Yerini göster…',
'folder.locateHint': 'Bu uygulamanın storebound.config.ts dosyasının artık bulunduğu klasörü seçin',
'folder.located': '{name} bulundu',
'folder.locateFailed': 'Bu klasör kullanılamadı',
'folder.remove': 'Listeden kaldır',
'folder.removed': '{name} listeden kaldırıldı',
}; };

View file

@ -0,0 +1,77 @@
// Uncaught errors of the window: one place that turns them into a readable report, sends it to
// <app data>/logs/webview.log (the host sanitizes again and rate-limits), and offers it for "Copy details".
// public/boot.js covers the time before React runs (a bundle that fails to load); from the first render on, React's
// root error callbacks, the error boundaries and the window handlers below report here.
import { api } from '../api';
export interface CrashReport { message: string; detail: string }
let home: string | null = null;
let version = '';
/** Shown as "~" in reports, like everywhere in the window. */
export function setCrashContext(h: string | null, v: string): void { home = h; version = v; }
const tilde = (s: string): string => {
const h = home?.replace(/[\\/]+$/, '');
return h && h.length > 1 ? s.split(h).join('~') : s;
};
export function errorMessage(e: unknown): string {
if (e instanceof Error) return `${e.name}: ${e.message}`;
if (typeof e === 'string') return e;
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message: unknown }).message === 'string') return (e as { message: string }).message;
try { return JSON.stringify(e) ?? String(e); } catch { return String(e); }
}
/** Message plus a details block (where, version, time, stack, React component stack) with the home folder as ~. */
export function describe(where: string, e: unknown, componentStack?: string | null): CrashReport {
const message = tilde(errorMessage(e));
const lines = [
`storebound desktop ${version || '?'} · ${where}`,
`time: ${new Date().toISOString()}`,
'',
message,
];
const stack = e instanceof Error && e.stack ? e.stack : '';
if (stack) lines.push('', stack.trimEnd());
if (componentStack) lines.push('', 'component stack:', componentStack.trim());
return { message, detail: tilde(lines.join('\n')) };
}
const recent = new Map<string, number>();
/** Logs one report (the same message at most once every 10 s). Never throws. */
export function report(where: string, e: unknown, componentStack?: string | null, level: 'error' | 'warn' = 'error'): CrashReport {
const r = describe(where, e, componentStack);
const key = `${where}|${r.message}`;
const now = Date.now();
if ((recent.get(key) ?? 0) + 10_000 < now) {
recent.set(key, now);
if (recent.size > 100) recent.clear();
try { api.logWebview(level, `${where}: ${r.message}`, r.detail).catch(() => {}); } catch { /* no host (tests, a browser) */ }
}
return r;
}
/** window.onerror and unhandled promise rejections, after React took over from public/boot.js. */
export function installGlobalHandlers(): void {
// public/boot.js stops logging on its own from here
(window as { __storeboundCrashHandlers?: boolean }).__storeboundCrashHandlers = true;
window.addEventListener('error', ev => { report('window.onerror', ev.error ?? ev.message); });
window.addEventListener('unhandledrejection', ev => { report('unhandledrejection', ev.reason, null, 'warn'); });
}
/** Clipboard first, then the old selection way (the webview may refuse clipboard access). True when copied. */
export async function copyText(text: string): Promise<boolean> {
try { await navigator.clipboard.writeText(text); return true; } catch { /* fall through */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.className = 'sr';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch { return false; }
}

View file

@ -0,0 +1,68 @@
// What the host sends, made complete for the screens. The Rust host already fills defaults (store.rs migrates old
// state files), but the window must not depend on that: a field an older or newer host leaves out, or a value of the
// wrong type, becomes its default here instead of a "cannot read properties of undefined" at render time.
import { type Account, DEFAULT_SETTINGS, type Project, type Settings, STUDIO_COLORS, type Studio, type Workspace } from '../api';
import { NO_OVERRIDES, type Overrides } from './identity';
type Rec = Record<string, unknown>;
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : d);
const strOrNull = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const folderName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
export function normalizeProjects(v: unknown): Project[] {
const seen = new Set<string>();
return arr(v).filter(isRec).filter(p => typeof p.id === 'string' && typeof p.path === 'string' && !seen.has(p.id) && !!seen.add(p.id)).map(p => {
const path = p.path as string;
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true };
});
}
export function normalizeAccounts(v: unknown): Account[] {
return arr(v).filter(isRec).filter(a => typeof a.id === 'string' && (a.kind === 'play' || a.kind === 'apple'))
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio) }));
}
function studios(v: unknown): Studio[] {
return arr(v).filter(isRec).filter(s => typeof s.id === 'string').map(s => ({
id: s.id as string,
name: str(s.name) || (s.id as string),
color: (STUDIO_COLORS as readonly string[]).includes(str(s.color)) ? (s.color as Studio['color']) : 'slate',
logo: typeof s.logo === 'string' && s.logo.startsWith('data:image/') ? s.logo : null,
created: str(s.created),
}));
}
function overrides(v: unknown): Overrides {
if (!isRec(v)) return NO_OVERRIDES;
const pairs = (x: unknown) => arr(x).filter(isRec).filter(p => typeof p.ios === 'string' && typeof p.android === 'string').map(p => ({ ios: p.ios as string, android: p.android as string }));
return {
pairs: pairs(v.pairs),
unpairs: pairs(v.unpairs),
links: arr(v.links).filter(isRec).filter(l => typeof l.key === 'string' && typeof l.project === 'string').map(l => ({ key: l.key as string, project: l.project as string })),
appStudio: arr(v.appStudio).filter(isRec).filter(l => typeof l.key === 'string' && typeof l.studio === 'string').map(l => ({ key: l.key as string, studio: l.studio as string })),
ignored: arr(v.ignored).filter((x): x is string => typeof x === 'string'),
};
}
function pick<T>(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; }
const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x);
const isBool = (x: unknown) => typeof x === 'boolean';
export function normalizeSettings(v: unknown): Settings {
const d = DEFAULT_SETTINGS;
if (!isRec(v)) return d;
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
return {
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool) },
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
channel: v.channel === 'beta' ? 'beta' : 'stable',
};
}
export function normalizeWorkspace(v: unknown): Workspace {
const w = isRec(v) ? v : {};
return { studios: studios(w.studios), overrides: overrides(w.overrides), settings: normalizeSettings(w.settings) };
}

View file

@ -5,7 +5,9 @@ import { devSwitch } from './dev/switches';
import { setLang } from './i18n'; import { setLang } from './i18n';
import { applyPrefs, loadPrefs, type Prefs, resolveLang, savePrefs } from './prefs'; import { applyPrefs, loadPrefs, type Prefs, resolveLang, savePrefs } from './prefs';
import { PrefsContext } from './prefs-ctx'; import { PrefsContext } from './prefs-ctx';
import { installGlobalHandlers, report } from './lib/crash';
import { DataProvider } from './store'; import { DataProvider } from './store';
import { ErrorBoundary } from './ui/ErrorBoundary';
import { ExportProvider } from './ui/Exports'; import { ExportProvider } from './ui/Exports';
import './styles.css'; import './styles.css';
@ -25,6 +27,8 @@ function Root() {
const lang = resolveLang(prefs.lang, systemLang()); const lang = resolveLang(prefs.lang, systemLang());
setLang(lang); setLang(lang);
useEffect(() => { applyPrefs(prefs, document.documentElement, systemLang()); }, [prefs]); useEffect(() => { applyPrefs(prefs, document.documentElement, systemLang()); }, [prefs]);
// React has the window now: public/boot.js stops watching for a blank start
useEffect(() => { document.documentElement.dataset.booted = '1'; }, []);
const update = useCallback((p: Prefs) => { setPrefs(p); savePrefs(p); }, []); const update = useCallback((p: Prefs) => { setPrefs(p); savePrefs(p); }, []);
const onTheme = useCallback(() => { const onTheme = useCallback(() => {
const dark = prefs.theme === 'dark' || (prefs.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); const dark = prefs.theme === 'dark' || (prefs.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
@ -34,4 +38,12 @@ function Root() {
return <PrefsContext.Provider value={{ prefs, update }}><DataProvider><ExportProvider><App key={lang} onTheme={onTheme} /></ExportProvider></DataProvider></PrefsContext.Provider>; return <PrefsContext.Provider value={{ prefs, update }}><DataProvider><ExportProvider><App key={lang} onTheme={onTheme} /></ExportProvider></DataProvider></PrefsContext.Provider>;
} }
createRoot(document.getElementById('root')!).render(<StrictMode><Root /></StrictMode>); installGlobalHandlers();
createRoot(document.getElementById('root')!, {
// errors no boundary caught (a boundary's own fallback failing): logged; public/boot.js shows its plain panel
onUncaughtError: (e, info) => {
const r = report('react (uncaught)', e, info.componentStack);
(window as { __storeboundBlank?: (m: string, s?: string) => void }).__storeboundBlank?.(r.message, r.detail);
},
onRecoverableError: (e, info) => { report('react (recovered)', e, info.componentStack, 'warn'); },
}).render(<StrictMode><ErrorBoundary where="app" full><Root /></ErrorBoundary></StrictMode>);

View file

@ -8,12 +8,18 @@ import { shortPath, tildeText } from '../paths';
import { useNav } from '../nav'; import { useNav } from '../nav';
import { useData } from '../store'; import { useData } from '../store';
import { Icon, StoreGlyph } from '../ui/icons'; import { Icon, StoreGlyph } from '../ui/icons';
import { useFolderActions } from '../ui/FolderMissing';
import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit';
import { checkState } from './Portfolio'; import { checkState } from './Portfolio';
type Profile = 'app' | 'game' | 'subscription-app'; type Profile = 'app' | 'game' | 'subscription-app';
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string } interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
function LocateButton({ project }: { project: Project }) {
const { busy, locate } = useFolderActions(project);
return <button className="btn btn-sm btn-primary" onClick={locate} disabled={busy} title={t('folder.locateHint')}><Icon name="search" />{t('folder.locate')}</button>;
}
export function AppFolders() { export function AppFolders() {
const d = useData(); const d = useData();
const nav = useNav(); const nav = useNav();
@ -88,7 +94,7 @@ export function AppFolders() {
<div className="list"> <div className="list">
{d.projects.map(p => { {d.projects.map(p => {
const e = d.statusOf(p); const e = d.statusOf(p);
const st = checkState(e); const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
return ( return (
<div className="li" key={p.id} style={{ alignItems: 'center' }}> <div className="li" key={p.id} style={{ alignItems: 'center' }}>
<AppIcon name={p.name} src={d.icons[p.id]} /> <AppIcon name={p.name} src={d.icons[p.id]} />
@ -113,10 +119,12 @@ export function AppFolders() {
</select></label> </select></label>
</div> </div>
<div className="actions" style={{ flexWrap: 'nowrap' }}> <div className="actions" style={{ flexWrap: 'nowrap' }}>
<button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: p.id, tab: 'releases' })} title={t('folders.openApp')}><Icon name="arrowRight" />{t('folders.open')}</button> {p.missing ? <LocateButton project={p} /> : <>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => api.reveal(p.id).catch(e => setError(errorText(e)))} aria-label={t('folders.reveal')} title={t('folders.reveal')}><Icon name="eye" /></button> <button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: p.id, tab: 'releases' })} title={t('folders.openApp')}><Icon name="arrowRight" />{t('folders.open')}</button>
<button className="btn btn-sm" onClick={() => check(p)} disabled={!!busy}><Icon name="shield" />{t('folders.check')}</button> <button className="btn btn-sm btn-ghost btn-icon" onClick={() => api.reveal(p.id).catch(e => setError(errorText(e)))} aria-label={t('folders.reveal')} title={t('folders.reveal')}><Icon name="eye" /></button>
<button className="btn btn-sm" onClick={() => gallery(p)} disabled={!!busy} title={t('folders.galleryHint')}><Icon name="image" />{t('folders.gallery')}</button> <button className="btn btn-sm" onClick={() => check(p)} disabled={!!busy}><Icon name="shield" />{t('folders.check')}</button>
<button className="btn btn-sm" onClick={() => gallery(p)} disabled={!!busy} title={t('folders.galleryHint')}><Icon name="image" />{t('folders.gallery')}</button>
</>}
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => remove(p)} disabled={!!busy} aria-label={t('folders.remove', { name: p.name })} title={t('folders.remove', { name: p.name })}><Icon name="trash" /></button> <button className="btn btn-sm btn-ghost btn-icon" onClick={() => remove(p)} disabled={!!busy} aria-label={t('folders.remove', { name: p.name })} title={t('folders.remove', { name: p.name })}><Icon name="trash" /></button>
</div> </div>
</div> </div>

View file

@ -9,6 +9,8 @@ import { storeNow } from '../lib/model';
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
import { useData } from '../store'; import { useData } from '../store';
import { Icon } from '../ui/icons'; import { Icon } from '../ui/icons';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { FolderMissing } from '../ui/FolderMissing';
import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit'; import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
import { AppInfoTab } from './tabs/AppInfo'; import { AppInfoTab } from './tabs/AppInfo';
import { ArtTab } from './tabs/Art'; import { ArtTab } from './tabs/Art';
@ -138,7 +140,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
</div> </div>
<AppControls app={app} /> <AppControls app={app} />
{!app.project && <StoreOnlyBanner app={app} />} {!app.project && <StoreOnlyBanner app={app} />}
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}> <div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
{tabs.map((x, i) => ( {tabs.map((x, i) => (
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}> <button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
@ -147,18 +149,21 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
))} ))}
</div> </div>
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}> <div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}>
{tab === 'releases' && <ReleasesTab app={app} />} <ErrorBoundary key={tab} where={`tab ${tab}`}>
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />} {tab === 'releases' && <ReleasesTab app={app} />}
{tab === 'prices' && app.project && <PricesTab project={app.project} />} {tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
{tab === 'listings' && app.project && <ListingsTab project={app.project} />} {tab === 'prices' && app.project && <PricesTab project={app.project} />}
{tab === 'art' && app.project && <ArtTab project={app.project} />} {tab === 'listings' && app.project && <ListingsTab project={app.project} />}
{tab === 'health' && <HealthTab app={app} />} {tab === 'art' && app.project && <ArtTab project={app.project} />}
{tab === 'reviews' && <ReviewsTab app={app} />} {tab === 'health' && <HealthTab app={app} />}
{tab === 'testflight' && <TestFlightTab app={app} />} {tab === 'reviews' && <ReviewsTab app={app} />}
{tab === 'growth' && <GrowthTab app={app} />} {tab === 'testflight' && <TestFlightTab app={app} />}
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />} {tab === 'growth' && <GrowthTab app={app} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />} {tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
</ErrorBoundary>
</div> </div>
</>}
</div> </div>
); );
} }

View file

@ -176,7 +176,7 @@ export function Portfolio() {
const row = (r: typeof all[number]) => { const row = (r: typeof all[number]) => {
const { app, e, l } = r; const { app, e, l } = r;
const st = app.project ? checkState(e) : null; const st = app.project?.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : app.project ? checkState(e) : null;
const cat = l?.catalog ?? e?.catalog; const cat = l?.catalog ?? e?.catalog;
const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android);
const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? '';
@ -191,8 +191,8 @@ export function Portfolio() {
</div> </div>
</div> </div>
</td> </td>
<td>{e || !app.project ? <StoreCell e={l} store="ios" on={ios} /> : <Skeleton lines={2} />}</td> <td>{e || !app.project || app.project.missing ? <StoreCell e={l} store="ios" on={ios} /> : <Skeleton lines={2} />}</td>
<td>{e || !app.project ? <StoreCell e={l} store="android" on={android} /> : <Skeleton lines={2} />}</td> <td>{e || !app.project || app.project.missing ? <StoreCell e={l} store="android" on={android} /> : <Skeleton lines={2} />}</td>
<td><NextCell e={l} /></td> <td><NextCell e={l} /></td>
<td><HealthCell c={health[app.target]} android={android} /></td> <td><HealthCell c={health[app.target]} android={android} /></td>
<td> <td>

View file

@ -10,6 +10,8 @@ import { t } from './i18n';
import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity'; import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity';
import { isError, type HealthSnapshot, type Review } from './lib/model'; import { isError, type HealthSnapshot, type Review } from './lib/model';
import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify'; import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
import { setCrashContext } from './lib/crash';
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
import { fixHint } from './lib/views'; import { fixHint } from './lib/views';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info'; export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
@ -131,12 +133,14 @@ export function DataProvider({ children }: { children: ReactNode }) {
const reload = useCallback(async () => { const reload = useCallback(async () => {
const [p, a, w, s] = await Promise.all([api.projects(), api.accounts(), api.workspace(), api.storeAppsCached()]); const [p, a, w, s] = await Promise.all([api.projects(), api.accounts(), api.workspace(), api.storeAppsCached()]);
setProjects(p); // whatever an older or newer host sends, the screens get complete values (lib/normalize.ts)
setAccounts(a); const ws = normalizeWorkspace(w);
setStudios(w.studios); setProjects(normalizeProjects(p));
setOverrides(w.overrides); setAccounts(normalizeAccounts(a));
setSettings(w.settings); setStudios(ws.studios);
setStoreApps(Object.fromEntries(s.map(x => [x.account, x.entry]))); setOverrides(ws.overrides);
setSettings(ws.settings);
setStoreApps(Object.fromEntries((Array.isArray(s) ? s : []).filter(x => x && typeof x.account === 'string').map(x => [x.account, x.entry ?? null])));
}, []); }, []);
const apps = useMemo(() => buildApps({ const apps = useMemo(() => buildApps({
@ -220,7 +224,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
// start: app info, folders, accounts, workspace, sidecar, cached live portfolio // start: app info, folders, accounts, workspace, sidecar, cached live portfolio
useEffect(() => { useEffect(() => {
api.appInfo().then(setInfo, () => {}); api.appInfo().then(i => { setInfo(i); setCrashContext(i.home, i.version); }, () => {});
reload().catch(() => {}).finally(() => setReady(true)); reload().catch(() => {}).finally(() => setReady(true));
api.sidecarStatus().then(s => { setSidecar(s); setSidecarError(null); }, e => setSidecarError(errorText(e))); api.sidecarStatus().then(s => { setSidecar(s); setSidecarError(null); }, e => setSidecarError(errorText(e)));
api.cached<PortfolioResult>('portfolio.status', '*', { live: true }).then(e => { if (e) setLive(e); }, () => {}); api.cached<PortfolioResult>('portfolio.status', '*', { live: true }).then(e => { if (e) setLive(e); }, () => {});
@ -247,7 +251,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
// icons: cached first, then read (offline, cheap) // icons: cached first, then read (offline, cheap)
useEffect(() => { useEffect(() => {
for (const p of projects) { for (const p of projects) {
if (p.id in icons) continue; if (p.id in icons || p.missing) continue;
setIcons(m => ({ ...m, [p.id]: null })); setIcons(m => ({ ...m, [p.id]: null }));
api.cached<ToolResult & { data?: { dataUrl: string | null } }>('project.icon', p.id).then(c => { api.cached<ToolResult & { data?: { dataUrl: string | null } }>('project.icon', p.id).then(c => {
if (c?.value.data?.dataUrl) setIcons(m => ({ ...m, [p.id]: c.value.data!.dataUrl })); if (c?.value.data?.dataUrl) setIcons(m => ({ ...m, [p.id]: c.value.data!.dataUrl }));

View file

@ -914,3 +914,14 @@ button.ck.ck-pick:focus-visible { outline: 2px solid var(--focus); outline-offse
button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-solid) 16%, transparent); } button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-solid) 16%, transparent); }
.studio-new { cursor: default; } .studio-new { cursor: default; }
.studio-new:hover { border-color: var(--line); box-shadow: none; } .studio-new:hover { border-color: var(--line); box-shadow: none; }
/* Error screens: React error boundaries (ui/ErrorBoundary.tsx) and the plain panel of public/boot.js when the bundle
itself failed. Kept simple: they must render when everything else did not. */
.crash { padding: 24px 28px; }
.crash-full { min-height: 100%; display: grid; place-items: center; background: var(--bg); color: var(--ink); }
.crash-box { max-width: 760px; display: grid; gap: 12px; padding: 20px 22px; border: 1px solid var(--line-2); border-left: 4px solid var(--bad-solid); border-radius: 12px; background: var(--surface); }
.crash-box h2 { margin: 0; font-size: 17px; }
.crash-box p { margin: 0; color: var(--ink-2); line-height: 1.5; }
.crash-msg, .crash-detail { margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--bg-sunken); color: var(--ink); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
.crash-box summary { cursor: pointer; color: var(--ink-2); font-size: 13px; }
.crash-box .actions { display: flex; gap: 8px; flex-wrap: wrap; }

View file

@ -0,0 +1,61 @@
// Error boundaries: a render error shows what failed and the ways out (Copy details, Reload, Reset saved state with a
// backup) instead of an empty window. One wraps the whole app (`full`), one each screen and app tab, so a broken
// screen leaves the sidebar and the other screens working. The fallback uses plain elements only: it must not fail
// the way the screen did.
import { Component, type ErrorInfo, type ReactNode, useState } from 'react';
import { api, errorText } from '../api';
import { t } from '../i18n';
import { type CrashReport, copyText, describe, report } from '../lib/crash';
interface Props { where: string; full?: boolean; children: ReactNode }
interface State { error: unknown; info: CrashReport | null }
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null, info: null };
static getDerivedStateFromError(error: unknown): Partial<State> {
return { error: error ?? new Error('unknown error') };
}
override componentDidCatch(error: unknown, info: ErrorInfo): void {
this.setState({ info: report(this.props.where, error, info.componentStack) });
}
override render(): ReactNode {
if (this.state.error === null) return this.props.children;
const info = this.state.info ?? describe(this.props.where, this.state.error);
return <CrashPanel where={this.props.where} full={!!this.props.full} info={info} onRetry={this.props.full ? undefined : () => this.setState({ error: null, info: null })} />;
}
}
export function CrashPanel({ where, full, info, onRetry }: { where: string; full: boolean; info: CrashReport; onRetry?: () => void }) {
const [note, setNote] = useState<string | null>(null);
const copy = async () => setNote((await copyText(info.detail)) ? t('crash.copied') : t('crash.copyFailed'));
const reset = async () => {
try {
const backup = await api.resetState();
if (backup !== null) { setNote(t('crash.resetDone', { path: backup || '-' })); setTimeout(() => location.reload(), 1200); }
} catch (e) { setNote(errorText(e)); }
};
return (
<div className={full ? 'crash crash-full' : 'crash'} role="alert" data-crash={where}>
<div className="crash-box">
<h2>{full ? t('crash.titleApp') : t('crash.titleScreen')}</h2>
<p>{full ? t('crash.leadApp') : t('crash.leadScreen')}</p>
<pre className="crash-msg selectable">{info.message}</pre>
<details>
<summary>{t('crash.details')}</summary>
<pre className="crash-detail selectable">{info.detail}</pre>
</details>
<div className="actions">
<button className="btn" onClick={copy}>{t('crash.copy')}</button>
{onRetry && <button className="btn" onClick={onRetry}>{t('crash.retry')}</button>}
<button className="btn btn-primary" onClick={() => location.reload()}>{t('crash.reload')}</button>
<button className="btn btn-ghost" onClick={reset} title={t('crash.resetHint')}>{t('crash.reset')}</button>
</div>
{note && <p className="small muted" role="status">{note}</p>}
<p className="small muted">{t('crash.logNote')}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,42 @@
// An app folder that is gone (moved, renamed, deleted, or an old worktree): say so, and offer Locate… (keeps the app's
// id, accounts and saved reads) or Remove from the list (no files touched). Shown in the app view and the folder list.
import { useState } from 'react';
import { api, errorText, type Project } from '../api';
import { t } from '../i18n';
import { useNav } from '../nav';
import { shortPath } from '../paths';
import { useData } from '../store';
import { Icon } from './icons';
import { Notice } from './kit';
export function useFolderActions(p: Project) {
const d = useData();
const [busy, setBusy] = useState(false);
const locate = async () => {
setBusy(true);
try {
const found = await api.locateProject(p.id);
if (found) { await d.reload(); d.toast({ kind: 'ok', title: t('folder.located', { name: found.name }) }); }
} catch (e) { d.toast({ kind: 'bad', title: t('folder.locateFailed'), detail: errorText(e) }); } finally { setBusy(false); }
};
const remove = async (after?: () => void) => {
setBusy(true);
try { await api.removeProject(p.id); await d.reload(); d.toast({ kind: 'ok', title: t('folder.removed', { name: p.name }) }); after?.(); }
catch (e) { d.toast({ kind: 'bad', title: t('toast.saveFailed'), detail: errorText(e) }); } finally { setBusy(false); }
};
return { busy, locate, remove };
}
export function FolderMissing({ project }: { project: Project }) {
const d = useData();
const nav = useNav();
const { busy, locate, remove } = useFolderActions(project);
return (
<Notice tone="warn" icon="folder" action={<div className="actions">
<button className="btn btn-sm btn-primary" onClick={locate} disabled={busy} title={t('folder.locateHint')}><Icon name="search" />{t('folder.locate')}</button>
<button className="btn btn-sm" onClick={() => remove(() => nav.go({ screen: 'portfolio' }))} disabled={busy}><Icon name="trash" />{t('folder.remove')}</button>
</div>}>
<b>{t('folder.missing')}</b> {t('folder.missingText', { path: shortPath(project.path, d.info?.home) })}
</Notice>
);
}

View file

@ -0,0 +1,8 @@
Saved state fixtures (state.json as the desktop host reads it), used by
- src-tauri/tests/state.rs: migration of every old format to the current one
- tools/desktop-boot-check.ts: the built window booted against each, with a mocked host and the real sidecar
@EXAMPLES@ is replaced with the repo's examples/ folder (folders that exist); /nonexistent/... folders are missing.
empty.json no file content yet (fresh install)
d1-missing-folders.json desktop D1 format (projects + accounts only, no version) with two folders that are gone
d3-full.json D3 format without version: studios, accounts in studios, overrides, settings, one folder
that exists and one that is gone

View file

@ -0,0 +1,48 @@
{
"accounts_list": [],
"projects_list": [
{
"added": "2026-10-11T00:00:00Z",
"appleAccount": null,
"id": "d3m0d3m0d3m0d3m0",
"name": "Prism Puddle",
"path": "/nonexistent/storebound/worktrees/agent-0000000000000000/examples/demo-game",
"playAccount": null
},
{
"added": "2026-10-11T00:00:00Z",
"appleAccount": null,
"id": "d3m0app0d3m0app0",
"name": "Tidy Lists",
"path": "/nonexistent/storebound/worktrees/agent-0000000000000000/examples/demo-app",
"playAccount": null
}
],
"workspace_get": {
"overrides": {
"appStudio": [],
"ignored": [],
"links": [],
"pairs": [],
"unpairs": []
},
"settings": {
"cache": {
"keepDays": 30
},
"channel": "stable",
"notify": {
"crash": true,
"lowReviews": true,
"reviewState": true
},
"refresh": {
"autoMinutes": 0,
"icons": true,
"onLaunch": false,
"parallel": 4
}
},
"studios": []
}
}

View file

@ -0,0 +1,21 @@
{
"projects": [
{
"id": "d3m0d3m0d3m0d3m0",
"path": "/nonexistent/storebound/worktrees/agent-0000000000000000/examples/demo-game",
"name": "Prism Puddle",
"playAccount": null,
"appleAccount": null,
"added": "2026-10-11T00:00:00Z"
},
{
"id": "d3m0app0d3m0app0",
"path": "/nonexistent/storebound/worktrees/agent-0000000000000000/examples/demo-app",
"name": "Tidy Lists",
"playAccount": null,
"appleAccount": null,
"added": "2026-10-11T00:00:00Z"
}
],
"accounts": []
}

View file

@ -0,0 +1,92 @@
{
"accounts_list": [
{
"created": "2026-10-01T00:00:00Z",
"id": "0123456789abcdef",
"kind": "play",
"label": "Example Games Play",
"studio": "s1"
},
{
"created": "2026-10-01T00:00:00Z",
"id": "fedcba9876543210",
"kind": "apple",
"label": "Example Games App Store",
"studio": "s1"
},
{
"created": "2026-10-03T00:00:00Z",
"id": "00112233445566ff",
"kind": "apple",
"label": "Side project",
"studio": null
}
],
"projects_list": [
{
"added": "2026-10-01T00:00:00Z",
"appleAccount": "fedcba9876543210",
"id": "p0game0000000001",
"name": "demo-game",
"path": "@EXAMPLES@/demo-game",
"playAccount": "0123456789abcdef"
},
{
"added": "2026-10-02T00:00:00Z",
"appleAccount": null,
"id": "p0gone0000000002",
"name": "Runner",
"path": "/nonexistent/games/runner",
"playAccount": "0123456789abcdef"
}
],
"workspace_get": {
"overrides": {
"appStudio": [
{
"key": "project:p0gone0000000002",
"studio": "s2"
}
],
"ignored": [
"account:00112233445566ff"
],
"links": [],
"pairs": [],
"unpairs": []
},
"settings": {
"cache": {
"keepDays": 30
},
"channel": "stable",
"notify": {
"crash": true,
"lowReviews": true,
"reviewState": true
},
"refresh": {
"autoMinutes": 0,
"icons": true,
"onLaunch": false,
"parallel": 4
}
},
"studios": [
{
"color": "emerald",
"created": "2026-10-01T00:00:00Z",
"id": "s1",
"logo": null,
"name": "Example Games"
},
{
"color": "violet",
"created": "2026-10-02T00:00:00Z",
"id": "s2",
"logo": null,
"name": "Example Labs"
}
]
}
}

View file

@ -0,0 +1,28 @@
{
"projects": [
{ "id": "p0game0000000001", "path": "@EXAMPLES@/demo-game", "name": "demo-game", "playAccount": "0123456789abcdef", "appleAccount": "fedcba9876543210", "added": "2026-10-01T00:00:00Z" },
{ "id": "p0gone0000000002", "path": "/nonexistent/games/runner", "name": "Runner", "playAccount": "0123456789abcdef", "appleAccount": null, "added": "2026-10-02T00:00:00Z" }
],
"accounts": [
{ "id": "0123456789abcdef", "label": "Example Games Play", "kind": "play", "created": "2026-10-01T00:00:00Z", "studio": "s1" },
{ "id": "fedcba9876543210", "label": "Example Games App Store", "kind": "apple", "created": "2026-10-01T00:00:00Z", "studio": "s1" },
{ "id": "00112233445566ff", "label": "Side project", "kind": "apple", "created": "2026-10-03T00:00:00Z" }
],
"studios": [
{ "id": "s1", "name": "Example Games", "color": "emerald", "logo": null, "created": "2026-10-01T00:00:00Z" },
{ "id": "s2", "name": "Example Labs", "color": "violet", "created": "2026-10-02T00:00:00Z" }
],
"overrides": {
"pairs": [],
"unpairs": [],
"links": [],
"appStudio": [{ "key": "project:p0gone0000000002", "studio": "s2" }],
"ignored": ["account:00112233445566ff"]
},
"settings": {
"refresh": { "autoMinutes": 0, "onLaunch": false, "parallel": 4, "icons": true },
"cache": { "keepDays": 30 },
"notify": { "reviewState": true, "lowReviews": true, "crash": true },
"channel": "stable"
}
}

View file

@ -0,0 +1,31 @@
{
"accounts_list": [],
"projects_list": [],
"workspace_get": {
"overrides": {
"appStudio": [],
"ignored": [],
"links": [],
"pairs": [],
"unpairs": []
},
"settings": {
"cache": {
"keepDays": 30
},
"channel": "stable",
"notify": {
"crash": true,
"lowReviews": true,
"reviewState": true
},
"refresh": {
"autoMinutes": 0,
"icons": true,
"onLaunch": false,
"parallel": 4
}
},
"studios": []
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,92 @@
// The window must never be blank: the bundle loads under Tauri's frozen Object.prototype (build/freeze-safe.ts), host
// data of any age is made complete before render (src/lib/normalize.ts), errors are reported with the home folder as
// ~ (src/lib/crash.ts), and the pieces that show an error panel are wired in.
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { parseAst } from 'vite';
import { rewrite } from '../build/freeze-safe';
import { DEFAULT_SETTINGS } from '../src/api';
import { NO_OVERRIDES } from '../src/lib/identity';
import { normalizeAccounts, normalizeProjects, normalizeSettings, normalizeWorkspace } from '../src/lib/normalize';
const ROOT = join(import.meta.dir, '..');
/** Runs `code` (which defines main()) in a fresh realm, with that realm's Object.prototype frozen as Tauri does. */
const run = (code: string, freeze: boolean, strict: boolean): unknown =>
JSON.parse(runInNewContext(`${freeze ? 'Object.freeze(Object.prototype);' : ''}(function () { ${strict ? "'use strict';" : ''}\n${code}\nreturn JSON.stringify(main()); })()`) as string);
describe('freeze-safe', () => {
// d3-color's define() and d3-format's FormatSpecifier, as they ship
const d3 = `
function main() {
function define(constructor, factory, prototype) { constructor.prototype = factory.prototype = prototype; prototype.constructor = constructor; }
function Color() {} function color() {}
define(Color, color, { copy() { return 1; } });
function Spec() { this.x = 1; }
Spec.prototype.toString = function () { return 'spec'; };
return [new Color().constructor === Color, String(new Spec()), Object.keys(Color.prototype).includes('constructor')];
}`;
test('the original code throws under a frozen Object.prototype; the rewritten code does not and behaves the same', () => {
const fixed = rewrite(d3, c => parseAst(c))!;
expect(fixed).not.toBeNull();
const plain = run(d3, false, true);
expect(plain).toEqual([true, 'spec', true]);
expect(() => run(d3, true, true)).toThrow(/read ?only property/) // JavaScriptCore (WKWebView) and V8 word it differently;
expect(run(fixed, true, true)).toEqual(plain);
expect(run(fixed, true, false)).toEqual(plain);
expect(run(fixed, false, true)).toEqual(plain);
});
test('only member assignments of Object.prototype names are touched; nested ones too', () => {
expect(rewrite('a.b = 1; a.toStringX = 2; a.toString == 3;', c => parseAst(c))).toBeNull();
const out = rewrite('"use strict"; x.valueOf = y.toString = f;', c => parseAst(c))!;
expect(out.startsWith('"use strict";')).toBe(true);
expect(out).toContain('__storeboundFreezeSafeSet(x, "valueOf", __storeboundFreezeSafeSet(y, "toString", f))');
});
test('the Vite build uses it', () => {
expect(readFileSync(join(ROOT, 'vite.config.ts'), 'utf8')).toMatch(/plugins: \[freezeSafe\(\)/);
});
});
describe('host data of any age', () => {
test('an empty or broken workspace gives complete defaults', () => {
for (const w of [undefined, null, {}, { studios: 'x', overrides: [], settings: 3 }]) {
expect(normalizeWorkspace(w)).toEqual({ studios: [], overrides: NO_OVERRIDES, settings: DEFAULT_SETTINGS });
}
});
test('partial overrides and settings keep what is there', () => {
const w = normalizeWorkspace({ overrides: { ignored: ['project:a', 5] }, settings: { refresh: { autoMinutes: 15 }, channel: 'beta' } });
expect(w.overrides).toEqual({ ...NO_OVERRIDES, ignored: ['project:a'] });
expect(w.settings.refresh).toEqual({ ...DEFAULT_SETTINGS.refresh, autoMinutes: 15 });
expect(w.settings.channel).toBe('beta');
expect(normalizeSettings({ cache: { keepDays: 'x' } }).cache.keepDays).toBe(30);
});
test('projects: missing folders flagged, bad entries dropped, names filled', () => {
const p = normalizeProjects([
{ id: 'a', path: '/gone/demo-game', name: 'Prism Puddle', playAccount: null, appleAccount: null, added: '2026-10-11T00:00:00Z', missing: true },
{ id: 'b', path: '/x/Tidy' },
{ id: 'a', path: '/dup' },
{ path: '/no-id' },
'junk',
]);
expect(p.map(x => [x.id, x.name, x.missing])).toEqual([['a', 'Prism Puddle', true], ['b', 'Tidy', false]]);
expect(normalizeProjects(undefined)).toEqual([]);
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null }]);
});
});
describe('error screens are wired in', () => {
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
test('boot.js loads before the bundle and React marks the start', () => {
const html = src('index.html');
expect(html.indexOf('/boot.js')).toBeGreaterThan(0);
expect(html.indexOf('/boot.js')).toBeLessThan(html.indexOf('/src/main.tsx'));
expect(src('public/boot.js')).toContain("'log_webview'");
expect(src('src/main.tsx')).toContain("dataset.booted = '1'");
expect(src('src/main.tsx')).toContain('<ErrorBoundary where="app" full>');
});
test('every screen and app tab has its own boundary', () => {
expect(src('src/App.tsx')).toMatch(/<ErrorBoundary key=\{route\.screen === 'app'/);
expect(src('src/screens/AppView.tsx')).toMatch(/<ErrorBoundary key=\{tab\}/);
});
});

View file

@ -1,9 +1,11 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { freezeSafe } from './build/freeze-safe';
// Tauri loads the dev server from build.devUrl (http://127.0.0.1:1420) and the built files from ../dist. // Tauri loads the dev server from build.devUrl (http://127.0.0.1:1420) and the built files from ../dist.
export default defineConfig({ export default defineConfig({
plugins: [react()], // freezeSafe: the bundle must load under Tauri's frozen Object.prototype (build/freeze-safe.ts)
plugins: [freezeSafe(), react()],
clearScreen: false, clearScreen: false,
server: { host: '127.0.0.1', port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } }, server: { host: '127.0.0.1', port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } },
build: { build: {

View file

@ -272,6 +272,36 @@ Download sources (one click on the Dependencies screen or in the first-run guide
- **`storebound-desktop --self-test`**: starts the bundled sidecar, checks the `session.hello` handshake, prints one - **`storebound-desktop --self-test`**: starts the bundled sidecar, checks the `session.hello` handshake, prints one
JSON line and exits, before Tauri starts (no window). The release runs it on the exact artifacts. JSON line and exits, before Tauri starts (no window). The release runs it on the exact artifacts.
## Never a blank window (D6)
- **Frozen prototype.** `freezePrototype: true` makes Tauri run `Object.freeze(Object.prototype)` before any page
script. Dependencies that assign `x.constructor = ...` or `x.toString = ...` on a plain object (d3-color, d3-format
under the visx charts) then throw while the bundle loads ("Cannot assign to read only property"; WebKit:
"Attempted to assign to readonly property"), and the window shows only its background colour, before React and
before the sidecar starts. `apps/desktop/build/freeze-safe.ts` (a Vite plugin) rewrites those assignments in
dependencies into a helper that defines the property when the frozen prototype would refuse it.
- **Saved state versions.** `state.json` carries `version` (2). Files without it are format 1 (D1 to D3: projects and
accounts, later studios, overrides and settings) and are migrated once (`store::migrate`), the original kept as
`state.json.v1.bak`. An unreadable file is kept as `state.json.unreadable-<time>` and the app starts empty; entries
that do not parse are dropped one by one (named in host.log), settings missing a field keep the rest.
- **Folders that are gone** (moved, renamed, deleted, old worktrees): `projects_list` marks them `missing`; the app
shows "Folder not found" with Locate… (`project_locate`: keeps the id, accounts, links and saved reads) and Remove
from list. Portfolio reads skip them.
- **Error screens.** A React error boundary around the whole window and one per screen and app tab show the error,
Copy details, Reload and Reset saved state (keeps a backup: `state_reset` moves `state.json` to
`state.json.reset-<time>.bak` after a native confirmation; keys stay in the keychain). `public/boot.js` runs before
the bundle and shows the same panel in plain DOM when the bundle fails or the window stays empty.
- **Logs** in `<app data>/logs`: `host.log` (start, saved state and migration, sidecar start / stop with its last
stderr lines, panics) and `webview.log` (window.onerror, unhandled rejections, boundaries, through `log_webview`,
at most 60 entries a minute). Rotated at 512 KB (one `.1.log` kept); home folder shown as `~`, keys, tokens,
passwords and e-mail addresses replaced.
- **Boot check** (`bun tools/desktop-boot-check.ts`, in `bun run ci` and the macOS release): the built `dist/` in
headless Chromium with the release CSP and the frozen prototype, a mocked host answering from the saved state
fixtures (`apps/desktop/tests/fixtures/state`: empty, D1 with folders gone, D3 full; the host answers are written by
the Rust migration itself, `src-tauri/tests/state.rs`) and the real sidecar for reads. It opens every screen and
app and fails on any uncaught or console error, an error panel or an empty `#root`; a last case removes the bundle
and expects the boot.js panel.
## Develop ## Develop
```sh ```sh

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// Everything CI runs, in order; stops at the first failure. bun run ci // Everything CI runs, in order; stops at the first failure. bun run ci
// `bun test` covers the CLI and the desktop webview (apps/desktop/tests: model, i18n, theme contrast, webview rules), // `bun test` covers the CLI and the desktop webview (apps/desktop/tests: model, i18n, theme contrast, webview rules),
// and the no-hand-written-SVG guard (tests/no-handmade-svg.test.ts: visx charts, Phosphor icons, simple-icons store marks, Mermaid diagrams). // the desktop boot check (tools/desktop-boot-check.ts: the built window in headless Chromium, needs playwright's
// browser or Google Chrome), and the no-hand-written-SVG guard (tests/no-handmade-svg.test.ts: visx charts, Phosphor icons, simple-icons store marks, Mermaid diagrams).
// The desktop Rust host (cargo test, clippy // The desktop Rust host (cargo test, clippy
// with warnings as errors) runs when cargo is installed; set STOREBOUND_CI_RUST=0 to skip it on a machine without Rust. // with warnings as errors) runs when cargo is installed; set STOREBOUND_CI_RUST=0 to skip it on a machine without Rust.
import { join } from 'node:path'; import { join } from 'node:path';
@ -20,6 +21,9 @@ const steps: [string, string[], string?][] = [
['desktop build', ['bun', 'run', '--cwd', 'apps/desktop', 'build']], ['desktop build', ['bun', 'run', '--cwd', 'apps/desktop', 'build']],
// the release bundle must not carry the dev fixture mode (apps/desktop/tests/webview.test.ts checks dist/ when present) // the release bundle must not carry the dev fixture mode (apps/desktop/tests/webview.test.ts checks dist/ when present)
['desktop bundle check', ['bun', 'test', 'apps/desktop/tests/webview.test.ts']], ['desktop bundle check', ['bun', 'test', 'apps/desktop/tests/webview.test.ts']],
// the built window boots (release CSP, Tauri's frozen Object.prototype, mocked host + real sidecar) on an empty, a
// D1 (folders gone) and a full D3 saved state, through every screen: no uncaught error, never an empty window
['desktop boot check', ['bun', 'tools/desktop-boot-check.ts']],
...(cargo ? [ ...(cargo ? [
// tauri's build script needs the host sidecar binary (gitignored), so a clean checkout builds it first // tauri's build script needs the host sidecar binary (gitignored), so a clean checkout builds it first
['desktop sidecar (host)', ['bun', 'tools/build-sidecars.ts', '--host']], ['desktop sidecar (host)', ['bun', 'tools/build-sidecars.ts', '--host']],

295
tools/desktop-boot-check.ts Normal file
View file

@ -0,0 +1,295 @@
#!/usr/bin/env bun
// Release boot check of the desktop window: the built frontend (apps/desktop/dist) in a headless browser, served with
// the release CSP of tauri.conf.json and with Object.prototype frozen when `freezePrototype` is on (as Tauri does
// before any page script), against a mocked host. The host's answers come from the saved state fixtures
// (apps/desktop/tests/fixtures/state/*.host.json, written by the Rust host's own migration: src-tauri/tests/state.rs);
// sidecar calls go to the real sidecar (`storebound serve --stdio`, offline reads only), so a folder that is gone gets
// the sidecar's real error. For each fixture it boots the window, opens every screen and every app in the sidebar,
// and fails on an uncaught error, a console error, an error panel (boundary or public/boot.js) or an empty #root.
// A last case loads a page whose bundle is missing and expects the boot.js error panel (the guard itself works).
// bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json]
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
const ROOT = resolve(import.meta.dir, '..');
const APP = join(ROOT, 'apps/desktop');
const FIXTURES = join(APP, 'tests/fixtures/state');
const EXAMPLES = join(ROOT, 'examples');
/** A made-up home folder: paths under it show as ~ in the window. */
const HOME = '/Users/example';
const argv = process.argv.slice(2);
const opt = (k: string): string | undefined => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : undefined; };
const DIST = resolve(opt('dist') ?? join(APP, 'dist'));
const ONLY = (opt('only') ?? '').split(',').filter(Boolean);
interface TauriConf { version: string; identifier: string; app: { security: { csp: Record<string, string> | string; freezePrototype?: boolean } } }
const conf = JSON.parse(readFileSync(join(APP, 'src-tauri/tauri.conf.json'), 'utf8')) as TauriConf;
export const cspHeader = (csp: Record<string, string> | string): string => (typeof csp === 'string' ? csp : Object.entries(csp).map(([k, v]) => `${k} ${v}`).join('; '));
// ---------------------------------------------------------------------------------------------------------------------
// The real sidecar (JSON-RPC over stdio), started once
class Sidecar {
private proc = Bun.spawn(['bun', join(ROOT, 'cli/index.ts'), 'serve', '--stdio'], { cwd: ROOT, stdin: 'pipe', stdout: 'pipe', stderr: 'ignore', env: { ...process.env, HOME: process.env.HOME ?? HOME } });
private next = 1;
private pending = new Map<number, { ok: (v: unknown) => void; bad: (e: unknown) => void }>();
hello: { protocol: string; server: { version: string }; methods: { method: string; kind: string }[] } | null = null;
constructor() {
(async () => {
let buf = '';
const dec = new TextDecoder();
const reader = (this.proc.stdout as ReadableStream<Uint8Array>).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
let m: { id?: number; result?: unknown; error?: { code: number; message: string } };
try { m = JSON.parse(line); } catch { continue; }
if (typeof m.id !== 'number') continue;
const p = this.pending.get(m.id);
this.pending.delete(m.id);
if (m.error) p?.bad({ code: m.error.code, message: m.error.message }); else p?.ok(m.result);
}
}
})();
}
call(method: string, params: Record<string, unknown>): Promise<unknown> {
const id = this.next++;
return new Promise((ok, bad) => {
this.pending.set(id, { ok, bad });
this.proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
this.proc.stdin.flush();
});
}
async start(): Promise<void> {
const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
this.hello = await this.call('session.hello', { secret, client: { name: 'storebound-boot-check', version: conf.version } }) as Sidecar['hello'];
}
kind(method: string): string | undefined { return this.hello?.methods.find(m => m.method === method)?.kind; }
stop(): void { this.proc.kill(); }
}
// ---------------------------------------------------------------------------------------------------------------------
// The mocked host: what src-tauri/src/commands.rs answers for one saved state
interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
interface HostFixture { projects_list: Project[]; accounts_list: { id: string }[]; workspace_get: { overrides: { ignored: string[] } } & Record<string, unknown> }
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
export function loadFixture(name: string): HostFixture {
const text = readFileSync(join(FIXTURES, `${name}.host.json`), 'utf8').split('@EXAMPLES@').join(EXAMPLES);
return JSON.parse(text) as HostFixture;
}
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[]) {
const projects = () => fx.projects_list.map(p => ({ ...p, missing: !isDir(p.path) }));
const fail = (message: string) => { throw { code: -1, message }; };
return async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
switch (cmd) {
case 'plugin:event|listen': return Math.floor(Math.random() * 1e9);
case 'plugin:event|unlisten': return null;
case 'app_info': return { version: conf.version, identifier: conf.identifier, platform: 'macos', debug: false, home: HOME };
case 'projects_list': return projects();
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': return null;
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'sidecar_status': {
const h = sidecar.hello!;
const tools = await sidecar.call('system.tools', {}).catch(() => null);
return { running: true, version: h.server.version, protocol: h.protocol, methods: h.methods, tools };
}
case 'portfolio_status': {
if (a.live) fail('boot check: live store reads are not part of the boot check');
const ignored = fx.workspace_get.overrides.ignored.filter(i => i.startsWith('project:')).map(i => i.slice(8));
const paths = projects().filter(p => !ignored.includes(p.id) && !p.missing).map(p => p.path);
if (!paths.length) return { apps: [], at: new Date().toISOString() };
return sidecar.call('portfolio.status', { projects: paths });
}
case 'sidecar_call': {
const method = String(a.method);
const kind = sidecar.kind(method);
const params = { ...((a.params as Record<string, unknown> | null) ?? {}) };
if (kind === 'read' || kind === 'write' || kind === 'owner') {
if (kind !== 'read') fail(`boot check: ${method} writes`);
const p = projects().find(x => x.id === a.projectId);
if (!p) fail(String(a.projectId).startsWith('store:') ? 'boot check: store reads are not part of the boot check' : 'unknown project');
params.project = p!.path;
}
return sidecar.call(method, params);
}
default:
unmocked.add(cmd);
return fail(`boot check: ${cmd} is not answered here`);
}
};
}
/** Before any page script, as Tauri: the frozen prototype (tauri.conf.json freezePrototype), then the IPC object. */
const initScript = (freeze: boolean) => `(() => {
${freeze ? 'Object.freeze(Object.prototype);' : ''}
let n = 1;
const cbs = {};
window.__TAURI_INTERNALS__ = {
metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } },
transformCallback(cb) { const id = n++; cbs[id] = cb; return id; },
unregisterCallback(id) { delete cbs[id]; },
convertFileSrc(p) { return p; },
async invoke(cmd, args) {
const r = await window.__bootCheckHost(cmd, args || {});
if ('err' in r) throw r.err;
return r.ok;
},
};
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
})();`;
// ---------------------------------------------------------------------------------------------------------------------
// The page checks
const TYPES: Record<string, string> = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json' };
function serve(dist: string, csp: string, breakBundle: boolean) {
return Bun.serve({
port: 0, hostname: '127.0.0.1',
fetch(req) {
const p = decodeURIComponent(new URL(req.url).pathname);
const file = join(dist, p === '/' ? 'index.html' : p);
if (!file.startsWith(dist) || !existsSync(file) || statSync(file).isDirectory()) return new Response('not found', { status: 404 });
if (breakBundle && p.startsWith('/assets/') && p.endsWith('.js')) return new Response('not found', { status: 404 });
return new Response(Bun.file(file), { headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'content-security-policy': csp } });
},
});
}
interface CaseResult { name: string; ok: boolean; problems: string[]; visited: number; notes: string[] }
async function state(page: Page) {
return page.evaluate(() => ({
booted: document.documentElement.dataset.booted === '1',
rootChildren: document.getElementById('root')?.childElementCount ?? 0,
textLength: (document.getElementById('root')?.innerText ?? '').trim().length,
crash: [...document.querySelectorAll('[data-crash]')].map(e => `${e.getAttribute('data-crash')}: ${(e.querySelector('.crash-msg')?.textContent ?? '').slice(0, 300)}`),
frozen: Object.isFrozen(Object.prototype),
}));
}
async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { breakBundle?: boolean } = {}): Promise<CaseResult> {
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), !!o.breakBundle);
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const unmocked = new Set<string>();
const logged: string[] = [];
const fx = o.breakBundle ? loadFixture('empty') : loadFixture(name);
const answer = host(fx, sidecar, unmocked, logged);
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
let visited = 0;
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
if (o.breakBundle) {
await page.waitForSelector('#boot-fallback', { timeout: 8000 }).catch(() => {});
const s = await state(page);
const ok = s.crash.some(c => c.startsWith('boot'));
return { name, ok, problems: ok ? [] : ['the boot.js error panel did not appear for a missing bundle'], visited: 0, notes: [`boot.js error panel shown: ${ok}`, ...logged.map(l => `webview.log: ${l}`)] };
}
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1500);
const check = async (where: string) => {
const s = await state(page);
if (freeze && !s.frozen) problems.push('Object.prototype is not frozen in the page (the check would not see the override mistake)');
if (!s.rootChildren || s.textLength < 40) problems.push(`${where}: empty window (#root has ${s.rootChildren} elements, ${s.textLength} characters)`);
for (const c of s.crash) problems.push(`${where}: error panel ${c}`);
};
await check('start');
// a first run opens the welcome guide (a modal): it must draw too, then it is closed like the owner would
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) {
notes.push('welcome guide shown');
await wizard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
if (await wizard.count()) problems.push('the welcome guide does not close with Escape');
}
// every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen
const count = await page.locator('nav.side button.nav').count();
for (let i = 0; i < count; i++) {
const b = page.locator('nav.side button.nav').nth(i);
const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
await b.click({ timeout: 5000 }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(700);
await check(label);
visited++;
}
// apps whose folder is gone show "Folder not found" with Locate… and Remove
for (const p of fx.projects_list.filter(x => !isDir(x.path))) {
const item = page.locator('nav.side .nav-app', { hasText: p.name }).first();
if (!(await item.count())) { problems.push(`${p.name}: not in the sidebar`); continue; }
await item.click();
await page.waitForTimeout(500);
const text = await page.locator('#content').innerText();
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
}
} finally {
await ctx.close();
server.stop(true);
}
// anything the window logged as an error is a failure too (warnings are listed)
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
async function launch(): Promise<Browser> {
const { chromium } = await import('playwright');
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
return chromium.launch({ headless: true });
}
export async function bootCheck(): Promise<CaseResult[]> {
if (!existsSync(join(DIST, 'index.html'))) throw new Error(`no built frontend at ${DIST} (bun run --cwd apps/desktop build)`);
const names = readdirSync(FIXTURES).filter(f => f.endsWith('.host.json')).map(f => f.replace(/\.host\.json$/, '')).sort();
if (names.length < 3) throw new Error(`expected the state fixtures in ${FIXTURES}`);
const sidecar = new Sidecar();
await sidecar.start();
const browser = await launch();
const results: CaseResult[] = [];
try {
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
} finally {
await browser.close();
sidecar.stop();
}
return results;
}
if (import.meta.main) {
const results = await bootCheck().catch(e => { console.error(`desktop boot check: ${(e as Error).message}`); process.exit(1); });
if (argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
else for (const r of results) {
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.visited ? ` (${r.visited} screens)` : ''}`);
for (const n of r.notes) console.log(` ${n}`);
for (const p of r.problems) console.log(` ! ${p}`);
}
const bad = results.filter(r => !r.ok);
console.log(bad.length ? `desktop boot check: ${bad.length} of ${results.length} failed` : `desktop boot check: ${results.length} cases, window drawn every time (CSP${conf.app.security.freezePrototype ? ', frozen Object.prototype' : ''})`);
process.exit(bad.length ? 1 : 0);
}

View file

@ -8,7 +8,7 @@
// identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling. // identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling.
// Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's // Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's
// local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused. // local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused.
// Then, on the exact artifacts: codesign / Gatekeeper expectations, the self-test (sidecar handshake, both CPU // The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, the self-test (sidecar handshake, both CPU
// types) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with // types) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json. // the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json.
// bun tools/desktop-release.ts add --out <dir> <files...> [--code-signed] // bun tools/desktop-release.ts add --out <dir> <files...> [--code-signed]
@ -361,6 +361,13 @@ async function mac(argv: string[]): Promise<void> {
const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] }); { cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`); if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`);
// the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
// Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again
say('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)');
const boot = spawnSync('bun', [join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, encoding: 'utf8', timeout: 600_000 });
process.stdout.write(boot.stdout ?? '');
if (boot.status !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${(boot.stderr ?? '').trim().split('\n').slice(-3).join(' / ')}`);
const bootCheck = (boot.stdout ?? '').trim().split('\n').at(-1) ?? 'ok';
const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos'); const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos');
const app = join(bundleDir, APP_NAME); const app = join(bundleDir, APP_NAME);
if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`); if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`);
@ -449,7 +456,7 @@ async function mac(argv: string[]): Promise<void> {
rustc: run(['rustc', '--version'], { env: { ...process.env, PATH } }).out.trim(), bun: Bun.version, rustc: run(['rustc', '--version'], { env: { ...process.env, PATH } }).out.trim(), bun: Bun.version,
tauri: run(['bunx', 'tauri', '--version'], { cwd: APP_DIR }).out.trim(), macos: run(['sw_vers', '-productVersion']).out.trim(), darwin: osRelease(), tauri: run(['bunx', 'tauri', '--version'], { cwd: APP_DIR }).out.trim(), macos: run(['sw_vers', '-productVersion']).out.trim(), darwin: osRelease(),
}, },
checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, selfTest: tests, privacy }, checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, selfTest: tests, bootCheck, privacy },
artifacts, artifacts,
}; };
writeFileSync(join(out, `evidence-macos-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`); writeFileSync(join(out, `evidence-macos-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);