From eb70337b00aa606cde85a7ace1655e17170fb261 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 31 May 2026 22:22:13 -0700 Subject: [PATCH] [eric] mac: after a crash relaunch user sees a tiny 'we had a hiccup, sessions are still here' chip for 8s instead of nothing --- electron/crash-watchdog.js | 13 ++++++++++ electron/main.js | 21 ++++++++++++++++ electron/preload.js | 1 + frontend/src/app/Main.tsx | 42 ++++++++++++++++++++++++++++++++ frontend/src/types/electron.d.ts | 1 + 5 files changed, 78 insertions(+) diff --git a/electron/crash-watchdog.js b/electron/crash-watchdog.js index 57b90b38..98fb259e 100644 --- a/electron/crash-watchdog.js +++ b/electron/crash-watchdog.js @@ -34,6 +34,10 @@ const SUPPORT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'o const UPDATING_LOCK = path.join(SUPPORT_DIR, 'updating.lock'); const CLEAN_QUIT_LOCK = path.join(SUPPORT_DIR, 'clean-quit.lock'); const RELAUNCH_LOG = path.join(SUPPORT_DIR, 'crash-watchdog-relaunches.log'); +// Touched ONLY when watchdog decides to relaunch. main.js reads + deletes it on +// next startup, forwards to renderer, which shows a small "We recovered from +// a crash" chip. Renderer-side: ipcMain.handle 'crash-recovery-info'. +const RECOVERY_MARKER = path.join(SUPPORT_DIR, 'crash-recovery.json'); const MIN_UPTIME_MS = 30_000; const POLL_INTERVAL_MS = 2_000; @@ -91,6 +95,15 @@ async function sleep(ms) { // All guards passed: relaunch. `open -n` opens a fresh instance even if the // app is registered, which it always will be (LaunchServices remembers). recordRelaunch(); + // Mark the relaunch so the next startup can show a "recovered" chip. Best-effort + // write; if the disk is full or perms fail, watchdog still relaunches silently. + try { + fs.writeFileSync(RECOVERY_MARKER, JSON.stringify({ + ts: Date.now(), + parent_pid: PARENT_PID, + uptime_ms: uptime, + })); + } catch (_) {} try { spawn('open', ['-n', APP_BUNDLE_PATH], { detached: true, stdio: 'ignore' }).unref(); } catch (_) {} diff --git a/electron/main.js b/electron/main.js index 7a4c93f7..45fcc596 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2102,6 +2102,27 @@ ipcMain.handle('get-webview-preload-path', () => { ipcMain.handle('get-update-status', () => cachedUpdateStatus); +// One-shot recovery info: if the crash-watchdog relaunched us, returns the +// {ts, parent_pid, uptime_ms} JSON it wrote and then DELETES the file so the +// chip only shows once. Returns null if no marker present (normal launch). +// macOS-only path; Windows/Linux always returns null. +let _cachedRecoveryInfo = undefined; +ipcMain.handle('get-crash-recovery-info', () => { + if (process.platform !== 'darwin') return null; + if (_cachedRecoveryInfo !== undefined) return _cachedRecoveryInfo; + const markerPath = path.join(os.homedir(), 'Library', 'Application Support', 'openswarm', 'crash-recovery.json'); + try { + if (!fs.existsSync(markerPath)) { _cachedRecoveryInfo = null; return null; } + const raw = fs.readFileSync(markerPath, 'utf-8'); + _cachedRecoveryInfo = JSON.parse(raw); + try { fs.unlinkSync(markerPath); } catch (_) {} + return _cachedRecoveryInfo; + } catch (_) { + _cachedRecoveryInfo = null; + return null; + } +}); + ipcMain.handle('check-for-updates', async () => { if (!autoUpdater || !isPackaged) { sendToRenderer('update-error', 'Update check is only available in the packaged app.'); diff --git a/electron/preload.js b/electron/preload.js index 7aad8265..25ed1c85 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -56,6 +56,7 @@ contextBridge.exposeInMainWorld('openswarm', { cdpCacheClear: (wcId) => ipcRenderer.invoke('cdp-cache-clear', wcId), capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), + getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('download-update'), installUpdate: () => ipcRenderer.invoke('install-update'), diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 1669c52e..c81a91bf 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useEffect, useState, useRef, Suspense } from 'react'; import { Provider } from 'react-redux'; import { HashRouter, Routes, Route } from 'react-router-dom'; import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material'; +import Box from '@mui/material/Box'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; import { store } from '../shared/state/store'; @@ -368,6 +369,46 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } ); }; +/** Surfaces a brief recovery chip if the crash-watchdog relaunched us last cycle. + * Mac-only path (watchdog only runs on darwin); main.js returns null elsewhere. + * Auto-hides after 8s. No interaction required from the user; sessions are server-side + * so reattachment is automatic via the WS dashboard subscription that's already wired. */ +const CrashRecoveryChip: React.FC = () => { + const [show, setShow] = React.useState(false); + React.useEffect(() => { + const api = (window as any).openswarm as OpenSwarmAPI | undefined; + if (!api?.getCrashRecoveryInfo) return; + api.getCrashRecoveryInfo().then((info) => { + if (info) setShow(true); + }).catch(() => {}); + }, []); + React.useEffect(() => { + if (!show) return; + const t = setTimeout(() => setShow(false), 8000); + return () => clearTimeout(t); + }, [show]); + if (!show) return null; + return ( + + + + We had a hiccup and brought you back. Your sessions are still here. + + + ); +}; + const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) => { const dispatch = useAppDispatch(); @@ -446,6 +487,7 @@ const ThemedApp: React.FC = () => { + diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 54274581..8145bccc 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -37,6 +37,7 @@ declare global { getAppVersion: () => Promise; getBuildInfo: () => Promise<{ sha: string; shortSha: string; builtAt: string | null; channel: string }>; getUpdateStatus: () => Promise<{ status: string; info: any; error: string | null }>; + getCrashRecoveryInfo?: () => Promise<{ ts: number; parent_pid: number; uptime_ms: number } | null>; checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>; downloadUpdate: () => Promise<{ success: boolean; error?: string }>; installUpdate: () => Promise;