diff --git a/client/src/lib/bridge/contract.ts b/client/src/lib/bridge/contract.ts index 248cd44..f737181 100644 --- a/client/src/lib/bridge/contract.ts +++ b/client/src/lib/bridge/contract.ts @@ -45,6 +45,7 @@ export const COMMANDS = { // layout persistence + saved layout profiles (command.rs) loadLayout: 'load_layout', saveLayout: 'save_layout', + backupLayout: 'backup_layout', listLayouts: 'list_layouts', readLayout: 'read_layout', saveLayoutAs: 'save_layout_as', diff --git a/client/src/lib/overlay.ts b/client/src/lib/overlay.ts index 6186943..9530606 100644 --- a/client/src/lib/overlay.ts +++ b/client/src/lib/overlay.ts @@ -29,14 +29,37 @@ import { singleFlight } from './core/singleFlight'; * still sees it) and the backend's persistent rotating log file (so it survives the webview * dying with the app — the whole point of last night's silent-death postmortem). Best-effort: * the backend `log_client` invoke NEVER throws or recurses into this helper, so a logging - * failure can't itself take down the overlay. `component` is always 'overlay' here; `message` - * should name the window label / monitor key where relevant. */ -function logClient(level: 'info' | 'warn' | 'error', component: string, message: string): void { + * failure can't itself take down the overlay. `component` names the caller's subsystem + * ('overlay', 'layout', …); `message` should name the window label / monitor key where relevant. + * Exported for the OTHER boot-path choke points (Canvas reloadLayout, useStudioInit's init catch) + * so no startup failure is ever console-only again. */ +export function logClient( + level: 'info' | 'warn' | 'error', + component: string, + message: string +): void { if (level === 'error') console.error(`[${component}] ${message}`); else console.warn(`[${component}] ${message}`); invoke(COMMANDS.logClient, { level, component, message }).catch(() => undefined); } +// One backup per session: every parse-failure path funnels here, and the FIRST one wins — later +// calls would only re-copy the same bytes (or, worse, a default layout already saved over them). +let layoutBackupRequested = false; + +/** Ask the backend to copy the current widgets.json aside (`widgets.json.bad-`) because this + * session failed to PARSE it — called BEFORE the in-memory default layout can be saved over the + * original, so whatever was hand-recoverable in it survives. Best-effort and once per session. */ +export function requestLayoutBackup(): void { + if (layoutBackupRequested) return; + layoutBackupRequested = true; + invoke(COMMANDS.backupLayout) + .then((path) => { + if (path) logClient('warn', 'layout', `unparseable widgets.json backed up to ${path}`); + }) + .catch((err) => logClient('error', 'layout', `layout backup failed: ${String(err)}`)); +} + /** Apply the overlay z-order layer to THIS window: 'top' = always-on-top (default), 'bottom' = * always-on-bottom (below app windows), 'wallpaper' = parented to the desktop WorkerW (on the * wallpaper, behind icons) via the Rust `set_overlay_wallpaper` command. Each overlay window @@ -96,8 +119,11 @@ async function populatedMonitorKeys( legacyMapping?: Record ): Promise | null> { const keys = new Set(); + // Assigned once load_layout resolves — so the catch can tell "file read but UNPARSEABLE" (worth + // backing up before anything saves over it) from "couldn't read it at all" (nothing to copy). + let raw: string | null = null; try { - const raw = await invoke(COMMANDS.loadLayout); + raw = await invoke(COMMANDS.loadLayout); const obj = raw ? (JSON.parse(raw) as Record) : null; if (obj && legacyMapping && typeof obj.monitors === 'object' && obj.monitors !== null) { const migrated = migrateMonitorKeys(obj.monitors as Record, legacyMapping); @@ -120,6 +146,7 @@ async function populatedMonitorKeys( } } catch (err) { logClient('error', 'overlay', `populatedMonitorKeys: load_layout failed: ${String(err)}`); + if (raw !== null) requestLayoutBackup(); return null; } return keys; @@ -174,8 +201,22 @@ export async function recreateMain(): Promise { try { const all = await getAllWebviewWindows(); if (all.some((w) => w.label === 'main')) return; // already present - const populated = await populatedMonitorKeys(); - if (populated === null) return; // couldn't tell — leave main gone rather than guess + let populated = await populatedMonitorKeys(); + if (populated === null) { + // Transient read failure. This may be the ONLY recreate attempt (studio-close calls once; + // keepalive won't fire while secondaries keep windows alive), so retry once before giving + // up — a genuinely broken layout still bails below, with watch_layout covering later edits. + await new Promise((r) => setTimeout(r, 2000)); + populated = await populatedMonitorKeys(); + } + if (populated === null) { + logClient( + 'warn', + 'overlay', + 'recreateMain: layout unreadable after retry; leaving main down' + ); + return; + } if (!populated.has('default')) return; // primary still empty — leave it gone const layer = readOverlayPrefs().overlayLayer; const w = new WebviewWindow('main', { diff --git a/client/src/lib/widgets/Canvas.tsx b/client/src/lib/widgets/Canvas.tsx index 5f6147d..449b5c5 100644 --- a/client/src/lib/widgets/Canvas.tsx +++ b/client/src/lib/widgets/Canvas.tsx @@ -100,9 +100,11 @@ import { minimizeWindow, toggleMaximizeWindow, closeWindow, + logClient, onStudioCloseRequested, reconcileOverlays, recreateMain, + requestLayoutBackup, setMainWindowVisible, syncInteractiveRects, applyOverlayPresentation @@ -923,8 +925,12 @@ export default function Canvas({ studio = false }: Props) { dispatch({ type: 'patch', patch: { historyReady: false } }); const patch: Partial = {}; let nextTheme: string | null = null; + // Assigned once load_layout resolves — the catch uses it to tell "read but unparseable" + // (back the file up before this session's default layout can be saved over it) from + // "couldn't read at all" (nothing to copy). Mirrors overlay.ts populatedMonitorKeys. + let raw: string | null = null; try { - const raw = await invoke(COMMANDS.loadLayout); + raw = await invoke(COMMANDS.loadLayout); const obj = raw ? (JSON.parse(raw) as Record) : null; const saved = obj ? parseLayoutAny(obj) : null; const mon = saved?.monitors[myMon]; @@ -947,7 +953,8 @@ export default function Canvas({ studio = false }: Props) { patch.tokenOverrides = tk && typeof tk === 'object' && !Array.isArray(tk) ? (tk as Record) : {}; } catch (err) { - console.warn('load_layout failed; using default layout', err); + logClient('error', 'layout', `load_layout failed; using default layout: ${String(err)}`); + if (raw !== null) requestLayoutBackup(); } // historyReady=false during the load + interim awaits; clear pendingExtras; reset history; // set baseline — all folded into one dispatch so the loaded layout is the committed baseline. diff --git a/client/src/lib/widgets/canvas/useStudioInit.ts b/client/src/lib/widgets/canvas/useStudioInit.ts index 9848e7a..c698777 100644 --- a/client/src/lib/widgets/canvas/useStudioInit.ts +++ b/client/src/lib/widgets/canvas/useStudioInit.ts @@ -12,6 +12,7 @@ import { fillOwnMonitor, fillPrimaryMonitor, listThemes, + logClient, monitorParam, openStudio, setMainWindowVisible, @@ -183,8 +184,13 @@ export function useStudioInit(deps: StudioInitDeps): void { // The primary main window is born hidden (config `visible:false`) and only revealed once // init reaches `syncPrimaryOverlays`; a secondary is born hidden and reveals itself via // fillOwnMonitor. If init throws before the reveal, reveal anyway so a failure can never - // strand a window permanently invisible (the old always-visible default). - console.warn('overlay init failed', err); + // strand a window permanently invisible (the old always-visible default). This catch is + // the choke point for EVERY init failure — logClient it so it survives the webview. + logClient( + 'error', + 'overlay', + `init failed (${d.current.studio ? 'studio' : (monitorParam() ?? 'main')}): ${String(err)}` + ); if (!cancelled && !d.current.studio) { const key = monitorParam(); if (key) void fillOwnMonitor(key); diff --git a/widgetsack/src/command.rs b/widgetsack/src/command.rs index 261d8b3..92241c9 100644 --- a/widgetsack/src/command.rs +++ b/widgetsack/src/command.rs @@ -75,6 +75,59 @@ pub async fn save_layout(app: tauri::AppHandle, contents: String) -> Result<(), fs::write(&path, contents).map_err(|e| e.to_string()) } +/// Filename prefix for layout backups taken on parse failure (`widgets.json.bad-`). +const LAYOUT_BACKUP_PREFIX: &str = "widgets.json.bad-"; +/// How many parse-failure backups to keep (oldest pruned). +const LAYOUT_BACKUPS_KEPT: usize = 3; + +/// Pure seam: which backup FILENAMES to delete so only the newest `keep` remain. The epoch-ms +/// suffix is fixed-width for any realistic date, so a plain descending lexicographic sort is +/// newest-first. Tested below. +fn stale_backups(mut names: Vec, keep: usize) -> Vec { + names.sort_by(|a, b| b.cmp(a)); + names.split_off(keep.min(names.len())) +} + +/// Copy the CURRENT widgets.json aside as `widgets.json.bad-` — called by the frontend +/// when it fails to PARSE the layout, BEFORE the running app (now on an in-memory default) can +/// save over the original and destroy whatever was hand-recoverable in it. Keeps the newest +/// `LAYOUT_BACKUPS_KEPT` backups, pruning older ones. Returns the backup path, or `None` when +/// there is no layout file to back up. Best-effort by design: the caller logs, never blocks on it. +#[tauri::command] +pub async fn backup_layout(app: tauri::AppHandle) -> Result, String> { + let path = layout_path(&app)?; + if !path.exists() { + return Ok(None); + } + let dir = path + .parent() + .ok_or_else(|| "layout path has no parent".to_string())?; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis(); + let backup = dir.join(format!("{LAYOUT_BACKUP_PREFIX}{ts}")); + fs::copy(&path, &backup).map_err(|e| e.to_string())?; + // Prune older backups (best-effort — a leftover extra backup is harmless). + if let Ok(entries) = fs::read_dir(dir) { + let names: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.starts_with(LAYOUT_BACKUP_PREFIX)) + .collect(); + for stale in stale_backups(names, LAYOUT_BACKUPS_KEPT) { + let _ = fs::remove_file(dir.join(stale)); + } + } + log::warn( + "layout", + "layout failed to parse; backed up before any overwrite", + ) + .field("backup", backup.display()) + .emit(); + Ok(Some(backup.display().to_string())) +} + /// Path to the persisted control remaps (`controls.json` in the app config dir). fn controls_path(app: &tauri::AppHandle) -> Result { let dir = config_root(app)?; @@ -208,6 +261,21 @@ fn client_log_level(level: &str) -> log::LogLevel { } } +/// Cap a client-supplied string at `max` CHARS (not bytes — never splits a UTF-8 scalar), marking +/// the cut with a trailing `…`. The webview side of `log_client` is app code, but a buggy render +/// loop stringifying a huge object into `message` would otherwise churn straight through the log's +/// 1 MiB rotation. Pure seam (tested below). +fn truncate_chars(s: &str, max: usize) -> String { + match s.char_indices().nth(max) { + Some((i, _)) => format!("{}…", &s[..i]), + None => s.to_string(), + } +} + +/// Caps for `log_client` fields: generous for a diagnostic line, tiny next to the 1 MiB rotation. +const CLIENT_LOG_MESSAGE_MAX: usize = 4096; +const CLIENT_LOG_COMPONENT_MAX: usize = 64; + /// Persist a FRONTEND failure into the backend log pipeline (console + ring buffer + rotating file + /// `log` event). Frontend errors — an overlay reconcile that threw, a failed invoke — otherwise live /// only in the webview's console and VANISH when that webview dies, which is exactly the class of @@ -217,13 +285,17 @@ fn client_log_level(level: &str) -> log::LogLevel { /// builders take a `&'static str` target, so the dynamic part goes in a field). Mirrors `log_diag`. #[tauri::command] pub fn log_client(window: tauri::WebviewWindow, level: String, component: String, message: String) { + let message = truncate_chars(&message, CLIENT_LOG_MESSAGE_MAX); let entry = match client_log_level(&level) { log::LogLevel::Error => log::error("client", message), log::LogLevel::Warn => log::warn("client", message), _ => log::info("client", message), }; entry - .field("component", component) + .field( + "component", + truncate_chars(&component, CLIENT_LOG_COMPONENT_MAX), + ) .field("window", window.label()) .emit(); } @@ -1360,9 +1432,35 @@ pub fn watch_controls(app: tauri::AppHandle) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{client_log_level, valid_name, version_is_newer}; + use super::{client_log_level, stale_backups, truncate_chars, valid_name, version_is_newer}; use crate::log::LogLevel; + #[test] + fn truncate_chars_caps_at_chars_not_bytes() { + assert_eq!(truncate_chars("short", 10), "short"); + assert_eq!(truncate_chars("abcdef", 3), "abc…"); + // Multi-byte scalars: 3 CHARS, never a split UTF-8 sequence. + assert_eq!(truncate_chars("日本語です", 3), "日本語…"); + assert_eq!(truncate_chars("", 5), ""); + } + + #[test] + fn stale_backups_keeps_the_newest_n() { + let names = vec![ + "widgets.json.bad-1783600000000".to_string(), + "widgets.json.bad-1783700000000".to_string(), + "widgets.json.bad-1783500000000".to_string(), + "widgets.json.bad-1783650000000".to_string(), + ]; + // Keep the 3 newest → only the oldest is stale. + assert_eq!( + stale_backups(names.clone(), 3), + vec!["widgets.json.bad-1783500000000".to_string()] + ); + // Fewer than `keep` → nothing to prune. + assert_eq!(stale_backups(names[..2].to_vec(), 3), Vec::::new()); + } + #[test] fn client_log_level_maps_error_warn_else_info() { assert_eq!(client_log_level("error"), LogLevel::Error); diff --git a/widgetsack/src/displaywatch.rs b/widgetsack/src/displaywatch.rs index a530acd..e63879e 100644 --- a/widgetsack/src/displaywatch.rs +++ b/widgetsack/src/displaywatch.rs @@ -81,8 +81,15 @@ fn spawn_display_pump() { ..Default::default() }; // A zero return means the class couldn't be registered — without it we can't create the - // window, so there's nothing to pump. Bail (keepalive's 30s path still covers recovery). + // window, so there's nothing to pump. Bail (keepalive's 30s path still covers recovery), + // but SAY SO: a silently-dead watcher is indistinguishable from a healthy one. if RegisterClassW(&wc) == 0 { + crate::log::warn( + "displaywatch", + "RegisterClassW failed; display-change fast path disabled (30s keepalive still covers recovery)", + ) + .field("error", std::io::Error::last_os_error()) + .emit(); return; } // Hidden top-level window (no WS_VISIBLE, zero-size): it never shows but sits in the @@ -102,7 +109,13 @@ fn spawn_display_pump() { Some(hinstance.into()), None, ); - if hwnd.is_err() { + if let Err(err) = hwnd { + crate::log::warn( + "displaywatch", + "CreateWindowExW failed; display-change fast path disabled (30s keepalive still covers recovery)", + ) + .field("error", err) + .emit(); return; } // Pump: sent messages (WM_DISPLAYCHANGE is delivered as one) are dispatched to the wndproc @@ -150,12 +163,17 @@ fn on_display_change() { tokio::time::sleep(DEBOUNCE).await; let handle = app.clone(); // Window creation must run on the main thread (same constraint as keepalive/watch_layout). - let _ = app.run_on_main_thread(move || { + let dispatched = app.run_on_main_thread(move || { RESPAWN_PENDING.store(false, Ordering::SeqCst); if should_respawn_on_display_change(handle.webview_windows().len()) { crate::command::respawn_main_hidden(&handle, "display change"); } }); + // Normally reset inside the closure; if the dispatch failed (event loop unavailable — + // normally only mid-shutdown) a stuck `true` would eat every future display change. + if dispatched.is_err() { + RESPAWN_PENDING.store(false, Ordering::SeqCst); + } }); } diff --git a/widgetsack/src/keepalive.rs b/widgetsack/src/keepalive.rs index f009581..e50d51b 100644 --- a/widgetsack/src/keepalive.rs +++ b/widgetsack/src/keepalive.rs @@ -55,7 +55,7 @@ pub fn on_zero_windows(app: &tauri::AppHandle) { tokio::time::sleep(RESPAWN_DELAY).await; let handle = app.clone(); // Window creation must run on the main thread (same constraint as watch_layout's hook). - let _ = app.run_on_main_thread(move || { + let dispatched = app.run_on_main_thread(move || { RESPAWN_PENDING.store(false, Ordering::SeqCst); // A window may have appeared meanwhile (tray-opened studio, watch_layout respawn, // single-instance second launch) — then the reconcile driver is alive; stand down. @@ -69,6 +69,12 @@ pub fn on_zero_windows(app: &tauri::AppHandle) { on_zero_windows(&handle); } }); + // The pending flag is normally reset INSIDE the closure; if the dispatch itself failed + // (event loop unavailable — normally only mid-shutdown) the closure never ran, and a + // stuck `true` would disable keep-alive for the rest of the process. Reset it here. + if dispatched.is_err() { + RESPAWN_PENDING.store(false, Ordering::SeqCst); + } }); } diff --git a/widgetsack/src/main.rs b/widgetsack/src/main.rs index 5bcf076..6ce7dd5 100644 --- a/widgetsack/src/main.rs +++ b/widgetsack/src/main.rs @@ -252,6 +252,7 @@ async fn main() -> Result<(), ()> { is_dev_instance, command::load_layout, command::save_layout, + command::backup_layout, command::load_controls, command::save_controls, windowmgr::list_windows,