Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/lib/bridge/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
53 changes: 47 additions & 6 deletions client/src/lib/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ts>`) 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<string | null>(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
Expand Down Expand Up @@ -96,8 +119,11 @@ async function populatedMonitorKeys(
legacyMapping?: Record<string, string>
): Promise<Set<string> | null> {
const keys = new Set<string>();
// 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<string | null>(COMMANDS.loadLayout);
raw = await invoke<string | null>(COMMANDS.loadLayout);
const obj = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
if (obj && legacyMapping && typeof obj.monitors === 'object' && obj.monitors !== null) {
const migrated = migrateMonitorKeys(obj.monitors as Record<string, unknown>, legacyMapping);
Expand All @@ -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;
Expand Down Expand Up @@ -174,8 +201,22 @@ export async function recreateMain(): Promise<void> {
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', {
Expand Down
11 changes: 9 additions & 2 deletions client/src/lib/widgets/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ import {
minimizeWindow,
toggleMaximizeWindow,
closeWindow,
logClient,
onStudioCloseRequested,
reconcileOverlays,
recreateMain,
requestLayoutBackup,
setMainWindowVisible,
syncInteractiveRects,
applyOverlayPresentation
Expand Down Expand Up @@ -923,8 +925,12 @@ export default function Canvas({ studio = false }: Props) {
dispatch({ type: 'patch', patch: { historyReady: false } });
const patch: Partial<EditorState> = {};
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<string | null>(COMMANDS.loadLayout);
raw = await invoke<string | null>(COMMANDS.loadLayout);
const obj = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
const saved = obj ? parseLayoutAny(obj) : null;
const mon = saved?.monitors[myMon];
Expand All @@ -947,7 +953,8 @@ export default function Canvas({ studio = false }: Props) {
patch.tokenOverrides =
tk && typeof tk === 'object' && !Array.isArray(tk) ? (tk as Record<string, string>) : {};
} 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.
Expand Down
10 changes: 8 additions & 2 deletions client/src/lib/widgets/canvas/useStudioInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
fillOwnMonitor,
fillPrimaryMonitor,
listThemes,
logClient,
monitorParam,
openStudio,
setMainWindowVisible,
Expand Down Expand Up @@ -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);
Expand Down
102 changes: 100 additions & 2 deletions widgetsack/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<epoch-ms>`).
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<String>, keep: usize) -> Vec<String> {
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-<epoch-ms>` — 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<Option<String>, 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<String> = 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<PathBuf, String> {
let dir = config_root(app)?;
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
Expand Down Expand Up @@ -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::<String>::new());
}

#[test]
fn client_log_level_maps_error_warn_else_info() {
assert_eq!(client_log_level("error"), LogLevel::Error);
Expand Down
24 changes: 21 additions & 3 deletions widgetsack/src/displaywatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
});
}

Expand Down
8 changes: 7 additions & 1 deletion widgetsack/src/keepalive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
});
}

Expand Down
1 change: 1 addition & 0 deletions widgetsack/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down