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;