const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor, Menu, clipboard, globalShortcut } = require('electron'); const whisperService = require('./voice/whisperService'); const { createStreamingSession } = require('./voice/streamingSession'); const whisperModels = require('./voice/whisperModels'); const { injectText } = require('./voice/textInjector'); // Browser cards live in their own persistent partition so cookies/localStorage/IndexedDB survive reload + quit (Discord etc. stay logged in) and site data stays isolated from the app's defaultSession. The "clear browsing data" wipe nukes only this partition. MUST match BROWSER_PARTITION in frontend BrowserCard.tsx. const BROWSER_PARTITION = 'persist:openswarm-browser'; // Sites whose sign-in we borrowed out of the user's real Chrome. Populated when a session is // imported, and it changes exactly one thing: what user agent we present to that site. // // Our normal browser-card UA deliberately carries an "openswarm/" product token, because // Google's sign-in rejects a BARE Chrome UA as not-genuine-Chrome (see BrowserCard.tsx). But a // borrowed session was minted by real Chrome, and the anti-bot layer in front of these sites checks // the session against the UA that earned it, so that same token reads as "this is not the browser // that logged in" and the session is refused. On a borrowed site only, we drop the token and // present the same Chrome version bare, which is exactly what the onboarding harvest window does // (hiddenBrowser.js) and how it gets through Cloudflare with borrowed cookies. Google keeps the // token because we never borrow for it: its own sign-in is the thing the token exists to satisfy. const p_borrowedSessionDomains = new Set(); const p_uaSwapLogged = new Set(); const { warmBorrowedSession } = require('./warmBorrowedSession'); // The borrowed cookies live in a PERSISTENT partition, so they outlive a quit; this list has to as // well or the two halves drift apart. They did: the backend memoizes "already borrowed for this // domain" and skips re-importing, so after an Electron restart the site kept its session but // silently stopped being told we were plain Chrome. That desync produced a false negative in the // 2026-07-27 measurements and would have been invisible in normal use. Domain names only, never // cookie values. function p_borrowedDomainsPath() { return path.join(app.getPath('userData'), 'borrowed-session-domains.json'); } function loadBorrowedDomains() { try { const raw = JSON.parse(fs.readFileSync(p_borrowedDomainsPath(), 'utf8')); if (Array.isArray(raw)) raw.filter((d) => typeof d === 'string').forEach((d) => p_borrowedSessionDomains.add(d)); } catch { // No file yet, or unreadable: an empty list just means the first navigate re-borrows. } } function saveBorrowedDomains() { try { fs.writeFileSync(p_borrowedDomainsPath(), JSON.stringify([...p_borrowedSessionDomains])); } catch { // Losing this only costs one redundant re-import next launch; never worth failing an import over. } } function bareChromeUserAgent(ua) { return String(ua || '').replace(/\s*(?:openswarm|Electron)\/\S+/gi, '').replace(/\s{2,}/g, ' ').trim(); } function hostHasBorrowedSession(url) { if (!p_borrowedSessionDomains.size) return false; try { const host = new URL(url).hostname.toLowerCase(); for (const d of p_borrowedSessionDomains) { if (host === d || host.endsWith(`.${d}`)) return true; } } catch { // A malformed URL simply isn't a borrowed site. } return false; } // Sites whose browser-support wall PARSES the UA and allowlists browsers: an unknown "openswarm/x" product token reads as an unsupported browser and their sign-in becomes unreachable ("We're very sorry, but your browser is not supported"). const p_bareUaDomains = ['slack.com', 'slack-edge.com']; function hostWantsBareUa(url) { if (hostHasBorrowedSession(url)) return true; try { const host = new URL(url).hostname.toLowerCase(); return p_bareUaDomains.some((d) => host === d || host.endsWith(`.${d}`)); } catch { return false; } } // E2E flag: when OPENSWARM_E2E=1, append a Chromium command-line switch the // renderer reads at startup to set window.__OPENSWARM_E2E__ = true BEFORE any // page script parses, so the production-build store-on-window gate fires // deterministically. Normal user launches never set the env var so this is a // no-op for them; only Playwright's electron.launch({env}) flips it on. if (process.env.OPENSWARM_E2E === '1') { try { app.commandLine.appendSwitch('openswarm-e2e', '1'); } catch {} } // Local-only crash reporter. Captures native renderer crashes that escape JS-level error handlers and don't otherwise surface in Crashpad. uploadToServer=false keeps minidumps on disk under %APPDATA%/OpenSwarm/Crashpad so we can inspect them post-mortem without sending anywhere. try { crashReporter.start({ productName: 'OpenSwarm', companyName: 'OpenSwarm', submitURL: 'https://localhost.invalid', uploadToServer: false, ignoreSystemCrashHandler: false, }); } catch (err) { console.warn('[crashReporter] start failed:', err && err.message); } // Capture every main-process throw we can. Without these, a throw inside an IPC handler or BrowserWindow event listener can die silently and look indistinguishable from a renderer crash in the trace. const crashReports = require('./crashReports'); crashReports.init(app, null); process.on('uncaughtException', (err) => { console.error('[diag][main:uncaughtException]', err && err.stack || err); crashReports.writeCrashReport('main-uncaught-exception', { message: String(err && err.message || err), stack: String(err && err.stack || '') }); }); process.on('unhandledRejection', (reason) => { console.error('[diag][main:unhandledRejection]', reason && reason.stack || reason); }); // child-process-gone fires for GPU/utility/renderer process deaths. The GPU one is especially useful: a GPU crash forces the renderer to recover its compositor, and that recovery can itself crash on Windows. let gpuCrashCount = 0; app.on('child-process-gone', (_event, details) => { console.error('[diag][main:child-process-gone]', JSON.stringify(details)); // Clean exits and user kills are not crashes; reporting them would bury the real ones. if (details && details.reason && details.reason !== 'clean-exit' && details.reason !== 'killed') { crashReports.writeCrashReport('child-process-gone', details); // Three GPU deaths in one session: the compositor is losing on this machine, so the NEXT boot runs software rendering (one boot only; the marker is consumed at startup). ENG-228. if (details.type === 'GPU' && ++gpuCrashCount >= 3) { try { fs.writeFileSync(GPU_FALLBACK_MARKER, String(Date.now())); } catch (_) {} } } }); // Platform-split auto-updater: electron-updater on Mac (full-featured), Electron's // built-in autoUpdater on Windows (Squirrel.Windows target; electron-updater dropped Squirrel). let autoUpdater; let isSquirrelUpdater = false; try { if (process.platform === 'win32') { autoUpdater = require('electron').autoUpdater; isSquirrelUpdater = true; } else { autoUpdater = require('electron-updater').autoUpdater; } } catch (_) {} const path = require('path'); const { spawn, execFileSync } = require('child_process'); const os = require('os'); const fs = require('fs'); const hiddenBrowser = require('./hiddenBrowser'); const usageHarvest = require('./usageHarvest'); const { installVoiceHotkey } = require('./voiceHotkey'); const getPort = require('get-port'); const http = require('http'); const affiliateTracking = require('./affiliateTracking'); const cdpRoutes = require('./cdp-routes'); const workflowsLifecycle = require('./workflowsLifecycle'); // Squirrel makes the APP create its own shortcuts: on --squirrel-install it must // call Update.exe --createShortcut and exit, else the user finds only Setup.exe // and no app to click. NSIS never passes these args, so it's a no-op there. The // prewarm-touch the old Squirrel build did here is omitted: it hung silent installs. function _squirrelUpdate(args) { try { const updateExe = path.resolve(path.dirname(process.execPath), '..', 'Update.exe'); execFileSync(updateExe, [...args, path.basename(process.execPath)], { timeout: 20000, stdio: 'ignore', windowsHide: true }); } catch (_) {} } (function handleSquirrelEvents() { if (process.platform !== 'win32' || process.argv.length < 2) return; const sq = process.argv[1]; if (sq === '--squirrel-install' || sq === '--squirrel-updated') { _squirrelUpdate(['--createShortcut']); process.exit(0); } if (sq === '--squirrel-uninstall') { _squirrelUpdate(['--removeShortcut']); process.exit(0); } if (sq === '--squirrel-obsolete') { process.exit(0); } })(); // Windows toast notifications are dropped on the floor unless our AppUserModelID // matches the one Squirrel stamped on the Start Menu shortcut, and Squirrel's rule // is com.squirrel... Derived, not hardcoded, so renaming the // app can't silently kill notifications. Must run before the first Notification. if (process.platform === 'win32' && app.isPackaged) { try { const nuspecId = require('./package.json').name; app.setAppUserModelId(`com.squirrel.${nuspecId}.${path.basename(process.execPath, '.exe')}`); } catch (_) {} } // NSIS->Squirrel migration cleanup. The first time this Squirrel build runs after // an existing NSIS OpenSwarm was updated into it, silently uninstall that legacy // NSIS copy so the user isn't left with two installs + two shortcuts. Found via // the HKCU Uninstall entry whose UninstallString is the NSIS uninstaller (NOT // Squirrel's Update.exe). Deferred to quit so the NSIS uninstaller's taskkill of // OpenSwarm.exe can't kill this live session (same exe name). Best-effort + // detached: a failure just leaves the old install (never bricks); NSIS // deleteAppDataOnUninstall=false keeps the user's data across the swap. function _removeLegacyNsisInstall() { if (process.platform !== 'win32') return; const ps = "$ErrorActionPreference='SilentlyContinue';" + "$e = Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' |" + " ForEach-Object { Get-ItemProperty $_.PSPath } |" + " Where-Object { $_.DisplayName -like 'OpenSwarm*' -and $_.UninstallString -and ($_.UninstallString -notmatch 'Update\\.exe') } |" + " Select-Object -First 1;" + "if ($e) { if ($e.QuietUninstallString) { $u = $e.QuietUninstallString } else { $u = $e.UninstallString + ' /S' };" + " Start-Process -FilePath cmd.exe -ArgumentList '/c', $u -WindowStyle Hidden }"; try { spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); } catch (_) {} } if (process.platform === 'win32' && process.argv.includes('--squirrel-firstrun')) { try { app.once('before-quit', _removeLegacyNsisInstall); } catch (_) {} } // Phase 0 boot instrumentation. Records four ordered milestones as parseable // lines so the packaged-build timing test (and any future perf-regression // gate) can read them straight out of backend.log without a separate file. // Format is load-bearing: `[perf] t=` one per line. // APP_LAUNCH_T is captured at module load so t=0 is genuinely process start. const APP_LAUNCH_T = Date.now(); const _perfSeen = new Set(); const _perfValues = {}; // name -> ms; read by the boot beacon below. function perfMark(name) { // One-shot per milestone: first-paint etc. can re-fire on crash-recovery // window recreation, but the baseline we care about is the cold boot. if (_perfSeen.has(name)) return; _perfSeen.add(name); const t = Date.now() - APP_LAUNCH_T; _perfValues[name] = t; try { console.log(`[perf] ${name} t=${t}`); } catch (_) {} } // Preflight: log the usual "works on mine, not theirs" causes (python is already covered by the exists-log + spawn handler; this adds the rest). Log-only, guarded, no PII (lengths/flags, never paths). let _preflightInfo = {}; let _preflightVerdict = null; // Comprehensive preflight (electron/preflight.js): fans out checks under hard per-check timeouts, emits a [preflight2] verdict line, defers cache write until BOTH preflight finished AND backend-http-ready so a mid-boot kill cannot poison the next launch's cached verdict. Kill switch via OPENSWARM_DISABLE_PREFLIGHT=1. let _preflightPendingCache = null; // Cheap deterministic hash of installation_id into [0,99]; used by the cohort gate so the same install always falls in the same bucket regardless of when it boots. function installIdBucket(id) { if (!id) return 0; let h = 5381; for (let i = 0; i < id.length; i++) { h = ((h << 5) + h + id.charCodeAt(i)) >>> 0; } return h % 100; } function runComprehensivePreflight() { if (process.env.OPENSWARM_DISABLE_PREFLIGHT === '1') { console.log('[preflight2] skipped (OPENSWARM_DISABLE_PREFLIGHT=1)'); return; } // Honor settings.preflight_enabled and cohort gate. Read sync from settings.json // since the backend isn't up yet; missing/unreadable file just means "use defaults". try { const settingsPath = path.join(app.getPath('userData'), 'data', 'settings', 'settings.json'); const raw = fs.readFileSync(settingsPath, 'utf8'); const s = JSON.parse(raw); if (s && s.preflight_enabled === false) { console.log('[preflight2] skipped (settings.preflight_enabled=false)'); return; } const pct = (s && typeof s.preflight_rollout_pct === 'number') ? s.preflight_rollout_pct : 100; if (pct < 100) { const bucket = installIdBucket(s && s.installation_id); if (bucket >= pct) { console.log(`[preflight2] skipped (cohort gate: bucket ${bucket} >= ${pct}%)`); return; } } } catch { /* no settings yet = first launch = run with defaults */ } let pf; try { pf = require('./preflight'); } catch (e) { console.log(`[preflight2] module load failed: ${e && e.message}`); return; } let dataDir; try { dataDir = path.join(app.getPath('userData'), 'data'); } catch { dataDir = null; } const version = (() => { try { return app.getVersion(); } catch { return '0.0.0'; } })(); if (dataDir) { try { pf.pruneOldCaches(pf.defaultEnv(), dataDir, version); } catch {} } const cached = dataDir ? pf.readCache(pf.defaultEnv(), dataDir, version) : null; if (cached) { console.log(`[preflight2] cached verdict=${cached.verdict} (skipping fresh probes)`); _preflightVerdict = cached; return; } pf.run(pf.defaultEnv(), { dataDir, gpu: { app } }).then((result) => { _preflightVerdict = result; const reasons = result.results.filter((r) => r.status !== 'ok').map((r) => `${r.name}:${r.status}(${r.reason})`).join('; '); console.log(`[preflight2] verdict=${result.verdict} totalMs=${result.totalMs} ${reasons || 'all-checks-ok'}`); if (dataDir && result.verdict === 'ok') { _preflightPendingCache = { pf, dataDir, version, result }; maybeCommitPreflightCache(); } }).catch((e) => { console.log(`[preflight2] threw: ${e && e.message}`); }); } // Only write the cache once backend-http-ready has fired, so a kill in the // window between preflight-finish and backend-actually-serving cannot leave a // "verdict=ok" token that masks a real boot break on the next launch. function maybeCommitPreflightCache() { if (!_preflightPendingCache) return; if (_perfValues['backend-http-ready'] == null) return; const { pf, dataDir, version, result } = _preflightPendingCache; _preflightPendingCache = null; try { pf.writeCache(pf.defaultEnv(), dataDir, version, result); console.log(`[preflight2] cache committed for v${version}`); } catch (e) { console.log(`[preflight2] cache write failed: ${e && e.message}`); } } function logPreflight(backendPort) { const info = {}; const probe = (label, fn) => { try { info[label] = fn(); } catch (_) { info[label] = 'ERR'; } }; try { const userData = app.getPath('userData'); probe('userDataWritable', () => { const t = path.join(userData, '.preflight'); fs.writeFileSync(t, 'x'); fs.unlinkSync(t); return true; }); probe('userDataAscii', () => /^[\x00-\x7F]*$/.test(userData)); probe('userDataLen', () => userData.length); probe('oneDriveProfile', () => /onedrive/i.test(userData)); probe('portInPreferredRange', () => backendPort >= 8324 && backendPort <= 8424); probe('freeDiskMB', () => Math.round((fs.statfsSync(userData).bavail * fs.statfsSync(userData).bsize) / 1048576)); if (isPackaged) for (const bit of ['router', 'node', 'app.asar', 'frontend', 'backend', 'python-env']) probe(bit, () => fs.existsSync(getResourcePath(bit))); _preflightInfo = info; console.log(`[preflight] ${Object.entries(info).map(([k, v]) => `${k}=${v}`).join(' | ')}`); } catch (_) { /* never break boot */ } } // Count local Crashpad minidumps so the beacon can flag a crashy build (the cloud diffs by install_id over time). function countCrashDumps() { try { const base = path.join(app.getPath('userData'), 'Crashpad'); if (!fs.existsSync(base)) return 0; let n = 0; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/\.dmp$/i.test(e.name)) n++; } }; walk(base); return n; } catch (_) { return -1; } } // A native main-process crash runs none of our JS, so the app just vanishes and leaves a .dmp // nobody reads. On the next boot we read the CAUSE out of any dump written since last time. function newCrashDumps() { try { const scan = require('./crashDumpScan'); const base = path.join(app.getPath('userData'), 'Crashpad'); const markFile = path.join(app.getPath('userData'), 'crash-scan.json'); let since = 0; try { since = JSON.parse(fs.readFileSync(markFile, 'utf8')).last_scan_ms || 0; } catch (_) { since = 0; } // First run has no watermark; reporting the whole historical pile would look like a crash storm. const rows = since ? scan.newDumpsSince(base, since, 5) : []; try { fs.writeFileSync(markFile, JSON.stringify({ last_scan_ms: Date.now() })); } catch (_) {} return rows; } catch (_) { return []; } } // Fleet self-report: POST a compact boot outcome to the LOCAL backend, which forwards it via the existing service client (opt-out honored). No PII. Fire-and-forget, guarded. function sendBootBeacon() { try { if (!isPackaged || !backendPort) return; const bi = getBuildInfo(); const body = JSON.stringify({ surface: 'boot', action: 'ready', props: { sha: bi.shortSha, channel: bi.channel, version: app.getVersion(), os: process.platform, arch: process.arch, perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(), new_crashes: newCrashDumps(), }, }); const req = http.request({ hostname: '127.0.0.1', port: backendPort, path: '/api/service/event', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}), }, timeout: 4000, }, (res) => { res.on('data', () => {}); res.on('end', () => {}); }); req.on('error', () => {}); req.on('timeout', () => { try { req.destroy(); } catch (_) {} }); req.write(body); req.end(); } catch (_) { /* beacon must never affect the app */ } } // Fire the beacon once first-paint AND backend-http-ready have both landed (the POST needs the backend listening); a touch later so it stays off the critical path. let _beaconScheduled = false; function maybeSendBootBeacon() { if (_beaconScheduled) return; if (_perfValues['first-paint'] == null || _perfValues['backend-http-ready'] == null) return; _beaconScheduled = true; setTimeout(() => sendBootBeacon(), 1500); } // Defender warmup: NSIS runs us with --prewarm right after install so Windows scans the bundled binaries while the user is already watching the installer instead of staring at a slow first launch. if (process.argv.includes('--prewarm') && process.platform === 'win32') { const touchExe = (rel) => { const full = path.join(process.resourcesPath, rel); try { if (fs.existsSync(full)) { execFileSync(full, ['--version'], { timeout: 15000, stdio: 'ignore', windowsHide: true }); } } catch (_) {} }; touchExe(path.join('python-env', 'python.exe')); touchExe(path.join('node', 'x64', 'node.exe')); touchExe(path.join('node', 'arm64', 'node.exe')); process.exit(0); } // Prevent duplicate instances. Without this, double-clicking the app icon // (or macOS auto-launch + manual launch overlapping) spawns two independent // processes — each with its own backend on a different port — resulting in // one populated window and one empty window. // Register openswarm:// protocol handler BEFORE any gotLock branching. // Must happen synchronously at the top of main.js so the OS knows this // binary is the default handler even before whenReady fires. if (process.defaultApp) { // Dev run: `electron .` needs the entry-script path to re-launch cleanly. if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('openswarm', process.execPath, [path.resolve(process.argv[1])]); } } else { app.setAsDefaultProtocolClient('openswarm'); } // Deep links queue here until the RENDERER drains them. A single slot + a live // webContents.send lost links two ways that stranded a real user's sign-in // (ENG-240): !isLoading() means the page loaded, not that React subscribed, so a // link arriving in that gap was sent into the void; and a single slot dropped an // earlier link when a second arrived. A queue the renderer drains on mount AND on // a nudge has no window where a delivered link can be lost. const pendingDeepLinks = []; function notifyDeeplinkAvailable() { if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) { try { mainWindow.webContents.send('openswarm:deeplink-available'); } catch (_) {} } } function forwardDeepLinkToRenderer(url) { if (!url) return; // openswarm:// URLs split by host: "auth" → sign-in / subscription token, // "oauth/{provider}/complete" → OAuth claim. The renderer routes by host when it drains. let channel = 'openswarm:auth-url'; try { const u = new URL(url); if (u.host === 'oauth' && u.pathname.endsWith('/complete')) { channel = 'openswarm:oauth-claim'; } } catch (_) { // Malformed URL — default channel; the renderer ignores anything it can't parse. } pendingDeepLinks.push({ channel, url }); notifyDeeplinkAvailable(); } function extractOpenswarmUrl(argv) { return argv && argv.find((a) => typeof a === 'string' && a.startsWith('openswarm://')); } const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.exit(0); } else { app.on('second-instance', (_event, argv) => { // Windows/Linux: a `openswarm://...` click lands here because the OS // re-launches the app with the URL as an argv. We swallow the second // instance, focus the existing window, and forward the URL to renderer. const url = extractOpenswarmUrl(argv); if (url) forwardDeepLinkToRenderer(url); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); } // macOS-only: clicks on openswarm:// links fire this event (instead of // relaunching the process). app.on('open-url', (event, url) => { event.preventDefault(); forwardDeepLinkToRenderer(url); if (mainWindow && !mainWindow.isDestroyed()) { // focus() alone does not unhide a close-to-dock'd window. try { if (!mainWindow.isVisible()) mainWindow.show(); } catch (_) {} mainWindow.focus(); } else if (BrowserWindow.getAllWindows().length === 0 && !drainingForQuit && !isCreatingMainWindow && backendPort) { // Deep link (e.g. the browser sign-in redirect) arrived while alive but // windowless (macOS keep-alive): reopen so the pendingDeepLink stashed // by forwardDeepLinkToRenderer has a renderer to flush into (createWindow's // did-finish-load handler delivers it). Cold launches are unaffected: // backendPort is unset until boot completes, and the splash flow owns // first-window creation there. console.log('[diag][main] open-url with no window, reopening'); recreateMainWindow(); } }); // Disabled Chromium features. Mac gets one extra: MacWebContentsOcclusion is // Chromium's window-occlusion tracker that subscribes to NSEvent / NSApplicationSceneWorkspace // events on the main thread — exactly the code path the user-reported macOS 26.5 + Electron 42 // NSEvent null-deref crash lives in. Disabling it routes around the subscription. Conservative: // the only cost is slightly higher CPU when the window is fully hidden behind other apps // (Chromium keeps painting invisible frames instead of pausing), zero impact when window is // foreground. If this doesn't help, removing the flag is a one-line revert with no UX trace. const _disabledFeatures = ['HardwareMediaKeyHandling']; if (process.platform === 'darwin') _disabledFeatures.push('MacWebContentsOcclusion'); app.commandLine.appendSwitch('disable-features', _disabledFeatures.join(',')); // disableHardwareAcceleration() was tried as a fallback but did not stop the 0xC0000005 crashes, confirming the segfault is not GPU-side. Dev mode (http origin) never crashed, packaged (file:// origin) always crashed, so the embedded localhost HTTP server (see startFrontendServer below) is the real fix and we keep GPU acceleration on. app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // Agent-driven webviews must keep executing while the window is hidden or // occluded; macOS App Nap was suspending guest renderers, so every // executeJavaScript read (get_text, evaluate, wait probes) hung to its // timeout the moment the user looked away. Same lever VS Code ships with. app.commandLine.appendSwitch('disable-renderer-backgrounding'); app.commandLine.appendSwitch('disable-background-timer-throttling'); // The other half of that same problem: those two keep a guest's SCRIPTS running, this keeps its // PIXELS coming. Chromium marks a fully covered window occluded and stops compositing it, and a // guest with no composited frame cannot be screenshotted at all (capturePage never even settles), // so the agent went blind the moment you put another window in front of OpenSwarm. app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); // Heavy WebGL/webview churn (spam-switching apps, busy dashboards) can crash the // shared GPU process; Chromium kills the whole app after a few GPU crashes. Lift // that cap so a GPU hiccup recovers by restarting the GPU process instead of // taking the app down with it. Fails quiet: at worst a brief compositor blip. app.commandLine.appendSwitch('disable-gpu-process-crash-limit'); let mainWindow = null; let backendProcess = null; let backendRespawns = 0; const MAX_BACKEND_RESPAWNS = 5; let backendPort = null; let cachedUpdateStatus = { status: 'idle', info: null, error: null }; let isInstallingUpdate = false; // Splash boot UX. Opens immediately on app.whenReady so the user sees // motion within ~1s of double-click instead of a 30-60s frozen icon // while Python imports + Defender real-time scans warm up. Closed once // mainWindow is `ready-to-show`. See electron/splash/splash.html. let splashWindow = null; let mainWindowReady = false; let isQuittingFromSplash = false; // guards against double-quit during error shutdown let rendererCrashTimes = []; // timestamps of recent render-process-gone events; caps the auto-reload retry storm const recentBackendStderr = []; // ring buffer (last ~60 lines) for splash error UI let splashDataUrlCache = null; // Set to true around `new BrowserWindow()` for the top-level main window so the popup-UA spoofer in app.on('web-contents-created') doesn't accidentally rewrite the main window's UA. The web-contents-created event fires synchronously inside the BrowserWindow constructor, before mainWindow assignment returns; without this flag, the previous identity check (contents !== mainWindow.webContents) is racy across recreateMainWindow() because mainWindow still points to the OLD window during construction of the NEW one. let isCreatingMainWindow = false; // Embedded HTTP server that serves the packaged frontend bundle. The previous loadFile(...) path used file:// which on Windows Electron 40 CastLabs triggered a STATUS_ACCESS_VIOLATION (0xC0000005) renderer crash on every chat / dashboard mount; dev mode using http://localhost:3000 never crashed. Serving over http://127.0.0.1: from the same in-process Node http server keeps the same packaged asset layout, costs no measurable perf (in-process loopback), and avoids the file:// quirk that Chromium 144 segfaults on. let frontendServerPort = null; async function startFrontendServer() { const frontendDir = path.join(process.resourcesPath, 'frontend'); const mimeTypes = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.wasm': 'application/wasm', }; const server = http.createServer((req, res) => { try { let pathname = decodeURIComponent((req.url || '/').split('?')[0]); if (pathname === '/' || pathname === '') pathname = '/index.html'; const resolved = path.normalize(path.join(frontendDir, pathname)); // Defense-in-depth path-traversal guard; loopback-only listener already prevents external access but a misparsed URL must not escape the frontend dir. if (!resolved.startsWith(frontendDir + path.sep) && resolved !== path.join(frontendDir, 'index.html')) { res.writeHead(403); res.end(); return; } fs.readFile(resolved, (err, data) => { if (err) { // SPA fallback: unknown paths return index.html so client-side routing works even if some code uses BrowserRouter instead of HashRouter. fs.readFile(path.join(frontendDir, 'index.html'), (err2, indexData) => { if (err2) { res.writeHead(404); res.end(); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(indexData); }); return; } const ext = path.extname(resolved).toLowerCase(); res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }); res.end(data); }); } catch (err) { console.error('[frontend-server] request handler threw:', err && err.message); try { res.writeHead(500); res.end(); } catch (_) {} } }); // Try a deterministic port first so the renderer's origin stays stable across launches. // localStorage is keyed by origin (incl. port), and the old listen(0) handed out a random // port every launch, which wiped onboarding state on every restart and re-triggered the // tour. Try a preferred port; if held, fall back to OS-assigned. const PREFERRED_PORT = 4173; return new Promise((resolve) => { server.once('error', () => { // Preferred port held; fall back. localStorage may rotate this run but stabilizes once 4173 frees up. const fallback = http.createServer(server.listeners('request')[0]); fallback.on('error', (err) => { console.error('[frontend-server] fallback also failed:', err && err.message); }); fallback.listen(0, '127.0.0.1', () => { const addr = fallback.address(); frontendServerPort = typeof addr === 'object' && addr ? addr.port : null; console.log(`[frontend-server] listening (fallback) on 127.0.0.1:${frontendServerPort}`); resolve(frontendServerPort); }); }); server.listen(PREFERRED_PORT, '127.0.0.1', () => { const addr = server.address(); frontendServerPort = typeof addr === 'object' && addr ? addr.port : null; console.log(`[frontend-server] listening on 127.0.0.1:${frontendServerPort}`); resolve(frontendServerPort); }); }); } const GPU_FALLBACK_MARKER = path.join(os.homedir(), 'Library', 'Application Support', 'openswarm', 'gpu-fallback.marker'); let reducedGraphicsThisBoot = false; // Must run BEFORE app ready: a marker from last session's repeated GPU crashes buys ONE boot of software rendering, then normal service resumes (the marker is consumed here). ENG-228. try { if (process.platform === 'darwin' && fs.existsSync(GPU_FALLBACK_MARKER)) { fs.unlinkSync(GPU_FALLBACK_MARKER); app.disableHardwareAcceleration(); reducedGraphicsThisBoot = true; console.warn('[gpu-fallback] repeated GPU crashes last session; this boot uses software rendering'); } } catch (_) {} const isPackaged = app.isPackaged; const isDev = process.env.ELECTRON_DEV === '1'; // Mac-only crash watchdog. Targets the macOS 26.5 + Electron 42 NSEvent // null-deref users have reported (wake-from-sleep mostly). When the parent // dies unexpectedly, the watchdog calls `open -n /Applications/OpenSwarm.app` // to bring the user back in ~2s. Five guards in crash-watchdog.js prevent // false-positive relaunches (intentional Cmd+Q, auto-updater swap, startup // crash loop, repeat cap). Packaged builds only; never runs in dev. const CRASH_WATCHDOG_SUPPORT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'openswarm'); const CRASH_WATCHDOG_CLEAN_QUIT_LOCK = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'clean-quit.lock'); // The watchdog skips a relaunch while this exists (guard 4) so the parent dying // mid-swap isn't read as a crash. We write it when an update install starts and // clear it on the next boot (the freshly-installed app deletes its own stale lock). const CRASH_WATCHDOG_UPDATING_LOCK = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'updating.lock'); function spawnCrashWatchdog() { if (process.platform !== 'darwin') return; if (!isPackaged) return; try { const watchdogScript = path.join(__dirname, 'crash-watchdog.js'); if (!fs.existsSync(watchdogScript)) return; // .../OpenSwarm.app/Contents/Resources/ -> .../OpenSwarm.app const appBundle = path.join(process.resourcesPath, '..', '..'); const { spawn: _spawn } = require('child_process'); const child = _spawn(process.execPath, [watchdogScript], { detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', OPENSWARM_PARENT_PID: String(process.pid), OPENSWARM_APP_BUNDLE_PATH: appBundle, OPENSWARM_PARENT_START_TIME: String(Date.now()), }, }); child.unref(); } catch (e) { console.warn('[crash-watchdog] spawn failed:', e && e.message); } } function writeCleanQuitLock() { // Session lock clears on EVERY platform; only the watchdog half below is mac-specific. try { fs.unlinkSync(SESSION_RUNNING_LOCK); } catch (_) {} if (process.platform !== 'darwin') return; try { if (!fs.existsSync(CRASH_WATCHDOG_SUPPORT_DIR)) fs.mkdirSync(CRASH_WATCHDOG_SUPPORT_DIR, { recursive: true }); fs.writeFileSync(CRASH_WATCHDOG_CLEAN_QUIT_LOCK, ''); } catch (_) {} } // Safe-mode loop breaker (ENG-228). A session lock written at boot and cleared on clean quit makes // dirty exits detectable without any crash handler firing; two dirty exits inside ten minutes means // relaunching keeps rebuilding the exact state that dies, so the NEXT boot restores layout with // webviews parked as screenshots until clicked. Also grabs a crash fingerprint (exception name + // address from the newest Crashpad minidump) so the renderer chip and diagnostics can say WHAT died. const SESSION_RUNNING_LOCK = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'session-running.lock'); const DIRTY_EXITS_LOG = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'dirty-exits.json'); const SAFE_MODE_WINDOW_MS = 10 * 60 * 1000; const SAFE_MODE_THRESHOLD = 2; let safeModeInfo = { safeMode: false, dirtyCount: 0, fingerprint: null }; function scanCrashFingerprint(sinceMs) { try { const { newDumpsSince } = require('./crashDumpScan'); const crashpadDir = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'Crashpad', 'completed'); const dumps = newDumpsSince(crashpadDir, sinceMs, 1); if (!dumps || !dumps.length) return null; const d = dumps[0]; return { exception: d.exception_name || null, code: d.exception_code || null, address: d.exception_address || null, mtime: d.mtime_ms || null }; } catch (_) { return null; } } function detectDirtyExitAndArmSafeMode() { try { if (!fs.existsSync(CRASH_WATCHDOG_SUPPORT_DIR)) fs.mkdirSync(CRASH_WATCHDOG_SUPPORT_DIR, { recursive: true }); let lastBootTs = 0; const wasDirty = fs.existsSync(SESSION_RUNNING_LOCK); if (wasDirty) { try { lastBootTs = parseInt(fs.readFileSync(SESSION_RUNNING_LOCK, 'utf-8'), 10) || 0; } catch (_) {} } let stamps = []; try { stamps = JSON.parse(fs.readFileSync(DIRTY_EXITS_LOG, 'utf-8')); } catch (_) {} const cutoff = Date.now() - SAFE_MODE_WINDOW_MS; stamps = (Array.isArray(stamps) ? stamps : []).filter((t) => typeof t === 'number' && t > cutoff); if (wasDirty) stamps.push(Date.now()); try { fs.writeFileSync(DIRTY_EXITS_LOG, JSON.stringify(stamps)); } catch (_) {} safeModeInfo.dirtyCount = stamps.length; safeModeInfo.safeMode = stamps.length >= SAFE_MODE_THRESHOLD; if (wasDirty) safeModeInfo.fingerprint = scanCrashFingerprint(lastBootTs || cutoff); fs.writeFileSync(SESSION_RUNNING_LOCK, String(Date.now())); if (wasDirty) console.log('[safe-mode] dirty exit detected; count=', safeModeInfo.dirtyCount, 'safeMode=', safeModeInfo.safeMode, 'fingerprint=', JSON.stringify(safeModeInfo.fingerprint)); } catch (e) { console.warn('[safe-mode] detect failed:', e && e.message); } } ipcMain.handle('get-safe-mode', () => ({ ...safeModeInfo, reducedGraphics: reducedGraphicsThisBoot })); // Quit-cause forensics. On a real quit (Cmd+Q, dock Quit, app.quit()) Electron // fires before-quit BEFORE any window 'close' events; a window closing on its // own (Cmd+W, red X, programmatic close) fires 'close' with quitInitiated // still false. The 1.2.77 self-quit investigation died for lack of exactly // this line: every prod "crash" was an orderly quit and nothing recorded who // started it. Console output is teed into backend.log in packaged builds. let quitInitiated = false; app.on('before-quit', () => { quitInitiated = true; console.log('[diag][main] before-quit (quit initiated)'); }); app.on('before-quit', writeCleanQuitLock); const iconPath = process.platform === 'win32' ? path.join(__dirname, 'build', 'icon.ico') : path.join(__dirname, 'build', 'icon.png'); // PNG version of the icon for the splash. We ship a copy at splash/icon.png // because electron-builder's `build/` directory is its inputs folder (used // to GENERATE the .icns bundled icon) and is NOT included in the shipped // asar archive — so `build/icon.png` exists in dev but ENOENTs in packaged // builds. `splash/` IS shipped (alongside splash.html), so reading from // there works in both modes. See the kept-in-sync copy command in the // build scripts (or just commit both). const iconPngPath = path.join(__dirname, 'splash', 'icon.png'); function loadSplashDataUrl() { if (splashDataUrlCache) return splashDataUrlCache; try { const html = fs.readFileSync(path.join(__dirname, 'splash', 'splash.html'), 'utf8'); const iconBytes = fs.readFileSync(iconPngPath); const iconDataUrl = 'data:image/png;base64,' + iconBytes.toString('base64'); const finalHtml = html.replace('__OPENSWARM_LOGO__', iconDataUrl); splashDataUrlCache = 'data:text/html;charset=utf-8;base64,' + Buffer.from(finalHtml).toString('base64'); return splashDataUrlCache; } catch (err) { console.warn('[splash] failed to load splash payload:', err && err.message); return null; } } function createSplashWindow() { const dataUrl = loadSplashDataUrl(); if (!dataUrl) return null; const w = new BrowserWindow({ width: 460, height: 340, frame: false, resizable: false, movable: true, minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, // avoid duplicate taskbar entry next to mainWindow show: true, center: true, backgroundColor: '#0a0a10', // opaque to dodge Windows DWM transparency quirks title: 'OpenSwarm', icon: iconPath, webPreferences: { // Splash content is fully self-contained (data URL, no remote // resources) so nodeIntegration here is safe and lets the splash // listen on ipcRenderer directly without a separate preload. nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false, }, }); w.setMenuBarVisibility(false); w.loadURL(dataUrl); // If the splash is dismissed BEFORE the main window has shown itself, // treat that as the user intentionally bailing out of boot. Without // this, splash.close() would silently leave a backend running with // no UI, which is confusing and leaks the python process. // The isQuittingFromSplash guard avoids a double-quit when the user // clicked the splash's Quit button (which also calls app.quit) — that // path closes the splash and would re-trigger this branch. w.on('closed', () => { splashWindow = null; if (!mainWindowReady && !isQuittingFromSplash) { isQuittingFromSplash = true; console.log('[splash] closed before main window appeared — quitting app'); try { if (!isDev) killBackend(); } catch (_) {} app.quit(); } }); return w; } function emitSplashStatus(payload) { if (splashWindow && !splashWindow.isDestroyed() && splashWindow.webContents) { try { splashWindow.webContents.send('splash:status', payload); } catch (_) {} } } // OS-tailored status copy. The "first launch is slow" experience has very // different causes per platform (Defender on Windows, Gatekeeper + // XProtect notarization scan on macOS), and naming the actual culprit // helps users feel like the wait is intentional rather than the app being // broken. Used by the long-wait branches in waitForBackend below. function osStillStartingText() { if (process.platform === 'win32') { return 'Still starting — Windows Defender is scanning files (first launch only)…'; } if (process.platform === 'darwin') { return 'Still starting — macOS is verifying the bundle (first launch only)…'; } return 'Still starting (first launch is slower than subsequent launches)…'; } function osTakingTooLongText() { if (process.platform === 'win32') { return 'Backend is taking longer than usual. Defender scans of 14k files can take a few minutes on slow drives.'; } if (process.platform === 'darwin') { return 'Backend is taking longer than usual. macOS first-launch checks can be slow on cold cache.'; } return 'Backend is taking longer than usual. You can wait, view logs, or restart.'; } /** * macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd * (/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin) — none of the user's shell * additions (nvm, volta, homebrew, bun, etc.) are present. Resolve the real * PATH by asking the user's default shell, then fall back to well-known dirs. */ function getShellPath() { if (process.platform !== 'darwin' || isDev) return process.env.PATH || ''; // Strategy 1: ask the user's login shell for its PATH try { const userShell = process.env.SHELL || '/bin/zsh'; const result = execFileSync(userShell, ['-ilc', 'echo $PATH'], { encoding: 'utf8', timeout: 5000, env: { ...process.env, HOME: os.homedir() }, }); const resolved = result.trim(); if (resolved) return resolved; } catch (_) { /* fall through */ } // Strategy 2: read macOS system PATH config (/etc/paths + /etc/paths.d/*) const systemPaths = []; try { const base = fs.readFileSync('/etc/paths', 'utf8'); for (const line of base.split('\n')) { const p = line.trim(); if (p) systemPaths.push(p); } } catch (_) { /* ignore */ } try { const pathsD = '/etc/paths.d'; if (fs.existsSync(pathsD)) { for (const file of fs.readdirSync(pathsD).sort()) { const content = fs.readFileSync(path.join(pathsD, file), 'utf8'); for (const line of content.split('\n')) { const p = line.trim(); if (p) systemPaths.push(p); } } } } catch (_) { /* ignore */ } // Strategy 3: well-known user-local bin directories const home = os.homedir(); const fallbackDirs = [ path.join(home, '.local/bin'), path.join(home, '.volta/bin'), path.join(home, '.fnm/aliases/default/bin'), path.join(home, '.bun/bin'), path.join(home, '.cargo/bin'), '/opt/homebrew/bin', '/usr/local/bin', ]; const nvmDir = path.join(home, '.nvm/versions/node'); try { if (fs.existsSync(nvmDir)) { const versions = fs.readdirSync(nvmDir).sort().reverse(); if (versions.length) { fallbackDirs.unshift(path.join(nvmDir, versions[0], 'bin')); } } } catch (_) { /* ignore */ } const seen = new Set(); const dirs = []; for (const d of [...fallbackDirs, ...systemPaths, ...(process.env.PATH || '').split(':')]) { if (!d || seen.has(d)) continue; seen.add(d); try { if (fs.statSync(d).isDirectory()) dirs.push(d); } catch { /* skip */ } } return dirs.join(':'); } function getResourcePath(...segments) { if (isPackaged) { return path.join(process.resourcesPath, ...segments); } return path.join(__dirname, '..', ...segments); } function getPythonPath() { // python-build-standalone layout differs by OS: // macOS / Linux: /bin/python3 // Windows: \python.exe (no bin/, no python3) // // macOS extra: invoke via Python.app/Contents/MacOS/python3 instead of // bin/python3 so LaunchServices reads LSUIElement=1 from the wrapper // bundle's Info.plist and skips the Dock entry. Without this, the // bundleless python3.13 binary appears as a generic "exec" placeholder // in the Dock on fresh user Macs, bouncing for the entire boot window. // sys.prefix / sys.executable still resolve via realpath so all stdlib // and site-packages discovery is unchanged. See scripts/build-python-env.sh // for the wrapper layout invariants. if (isPackaged) { const envPath = path.join(process.resourcesPath, 'python-env'); if (process.platform === 'win32') { return path.join(envPath, 'python.exe'); } if (process.platform === 'darwin') { const wrapped = path.join(envPath, 'Python.app', 'Contents', 'MacOS', 'python3'); // Defensive fallback: if the wrapper is missing for any reason // (e.g. older build cache), fall back to the bare binary so boot // still succeeds — only the Dock-icon suppression is lost. if (fs.existsSync(wrapped)) return wrapped; } return path.join(envPath, 'bin', 'python3'); } if (process.platform === 'win32') { return path.join(__dirname, '..', 'backend', '.venv', 'Scripts', 'python.exe'); } return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3'); } // Read the user's provider session cookies via a one-shot bundled-python invocation, so the // offscreen harvest can inject them and pass provider Cloudflare with a real Chrome TLS // handshake. Spawned, NEVER an HTTP endpoint, so a token-holding agent can't reach it: only // the app shell invokes it. Always resolves (to [] on any failure) so the harvest just falls // back to the opportunistic path. mirrors startBackend's python env (projectRoot + site-packages). function p_readProviderCookies(domain) { return new Promise((resolve) => { let done = false; const finish = (v) => { if (!done) { done = true; resolve(v); } }; try { const root = isPackaged ? process.resourcesPath : path.join(__dirname, '..'); const env = { ...process.env, PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' }; if (isPackaged) { const sitePackages = process.platform === 'win32' ? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages') : path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages'); env.PYTHONPATH = [root, sitePackages].join(path.delimiter); } const proc = spawn(getPythonPath(), ['-m', 'backend.apps.onboarding.usage.dump_cookies', String(domain)], { cwd: root, env }); let out = ''; proc.stdout.on('data', (d) => { out += d.toString(); }); proc.on('error', () => finish([])); proc.on('close', () => { try { const j = JSON.parse(out); finish(Array.isArray(j) ? j : []); } catch (_) { finish([]); } }); setTimeout(() => { try { proc.kill(); } catch (_) {} finish([]); }, 25000); } catch (_) { finish([]); } }); } usageHarvest.configure({ readCookies: p_readProviderCookies }); // Path to a real Node.js binary bundled in extraResources, or null if not // shipped (dev mode, or build that skipped the node-fetch step). Backend // reads OPENSWARM_NODE_PATH env var to prefer this over both system `node` // (which fresh user Macs lack) and the ELECTRON_RUN_AS_NODE fallback // (which has flaky Dock behavior + slow cold-start). Used by 9Router and // MCP bundle spawning. // // Layout shipped by scripts/build-app.sh: // /node/arm64/bin/node // /node/x64/bin/node // Both arches are staged so a single extraResources entry covers // publish-mode dual-arch builds without per-arch staging hooks; the // runtime picks the matching one by process.arch. Wasted ~25 MB per // DMG of cross-arch payload is the cost of avoiding electron-builder's // per-arch beforePack complexity. Windows uses node.exe at the root of // the per-arch subdir. function getBundledNodePath() { if (!isPackaged) return null; const arch = process.arch === 'x64' ? 'x64' : (process.arch === 'arm64' ? 'arm64' : null); if (!arch) return null; const candidate = process.platform === 'win32' ? path.join(process.resourcesPath, 'node', arch, 'node.exe') : path.join(process.resourcesPath, 'node', arch, 'bin', 'node'); return fs.existsSync(candidate) ? candidate : null; } // Polls /api/health/check until the backend answers 200, or the spawned // python process exits non-zero (real failure). Never times out by wall // clock — on a cold-Defender Windows install this can take several // minutes the first time, and silently calling app.quit() would leave // users staring at a vanished icon. Instead we surface progressive // warnings on the splash so the wait feels intentional. function waitForBackend(port, opts = {}) { const proc = opts.process || null; const start = Date.now(); return new Promise((resolve, reject) => { let settled = false; let stillStartingNotified = false; let actionsShown = false; const finish = (fn, val) => { if (settled) return; settled = true; fn(val); }; if (proc) { proc.once('exit', (code) => { // exit with code === null means we killed it ourselves (normal shutdown). if (code !== 0 && code !== null) { finish(reject, new Error(`Backend process exited with code ${code} during startup`)); } }); // spawn 'error' (missing/quarantined/wrong-arch python.exe) never fires // 'exit', so without this the health poll loops forever and the splash // hangs. Reject so the caller surfaces the failure UI instead. proc.once('error', (err) => { finish(reject, new Error(`Backend failed to spawn: ${err && err.message || err}`)); }); } function check() { if (settled) return; const elapsed = Date.now() - start; if (elapsed > 60_000 && !stillStartingNotified) { stillStartingNotified = true; emitSplashStatus({ text: osStillStartingText(), level: 'warning' }); } if (elapsed > 180_000 && !actionsShown) { actionsShown = true; emitSplashStatus({ text: osTakingTooLongText(), level: 'warning', showActions: true, logs: recentBackendStderr.slice(-20).join(''), }); } const req = http.get(`http://127.0.0.1:${port}/api/health/check`, (res) => { if (res.statusCode === 200) { finish(resolve); } else { setTimeout(check, 500); } }); req.on('error', () => setTimeout(check, 500)); req.setTimeout(2000, () => { req.destroy(); setTimeout(check, 500); }); } check(); }); } // Race a port-range search against a 3-second wall clock. On most machines // `getPort.makeRange(8324, 8424)` returns within milliseconds, but Windows // EDR / corp-firewall stacks can intercept the bind() probes and stall each // attempt for seconds — 100 attempts × multi-second stalls = "OpenSwarm is // hung at startup." The fallback `getPort({ port: 0 })` lets the OS pick // any free ephemeral port; we don't actually care about staying inside the // 8324-range — the renderer reads the port via IPC, no hardcoded assumption. async function pickBackendPort() { const PREFERRED_TIMEOUT_MS = 3000; // host:'127.0.0.1' is load-bearing. The backend binds uvicorn --host // 127.0.0.1, but get-port defaults to probing 0.0.0.0, and on Windows a // 0.0.0.0:PORT probe SUCCEEDS even when another process already holds // 127.0.0.1:PORT (loopback). So without this, get-port hands back e.g. // 8324 as "free" while something else owns 127.0.0.1:8324, the backend // then fails its 127.0.0.1 bind with WinError 10048 and exits, and the // app shows "backend crashed". Probing the same interface uvicorn binds // makes get-port skip the occupied port. (POSIX already rejects the // mismatched 0.0.0.0 probe, so this is a no-op correctness win on Mac.) const preferred = getPort({ port: getPort.makeRange(8324, 8424), host: '127.0.0.1' }); let timeoutHandle; const timeout = new Promise((resolve) => { timeoutHandle = setTimeout(() => resolve(null), PREFERRED_TIMEOUT_MS); }); const winner = await Promise.race([preferred, timeout]); clearTimeout(timeoutHandle); if (winner !== null) return winner; console.warn(`[boot] getPort.makeRange(8324,8424) stalled past ${PREFERRED_TIMEOUT_MS}ms — falling back to OS-assigned port`); return await getPort({ port: 0, host: '127.0.0.1' }); } async function startBackend() { if (!backendPort) backendPort = await pickBackendPort(); const pythonPath = getPythonPath(); const backendDir = getResourcePath('backend'); const projectRoot = isPackaged ? process.resourcesPath : path.join(__dirname, '..'); const shellPath = getShellPath(); // Identifies how this build was packaged. Read by the backend service // client so the cloud can split installer-using customers from // run-from-source developers in dashboards. Honors a build-time override // (set in CI when producing platform installers) before falling back to // OS-derived defaults. let installMethod = process.env.OPENSWARM_INSTALL_METHOD; if (!installMethod) { if (!isPackaged) { installMethod = 'dev'; } else if (process.platform === 'darwin') { installMethod = 'dmg'; } else if (process.platform === 'win32') { installMethod = 'windows-setup'; } else if (process.platform === 'linux') { // electron-builder produces AppImage by default for linux targets. // Override at packaging time when building .deb / .rpm. installMethod = 'appimage'; } else { installMethod = 'unknown'; } } const env = { ...process.env, PATH: shellPath, OPENSWARM_PACKAGED: isPackaged ? '1' : '0', OPENSWARM_PORT: String(backendPort), OPENSWARM_ELECTRON_PATH: process.execPath, OPENSWARM_INSTALL_METHOD: installMethod, // Inject the app version so the Python backend can report it in the // analytics envelope. Without this, _read_app_version() in // service/service.py tries to read electron/package.json via a relative // path that resolves correctly in `bash run.sh` dev mode but fails in // packaged dmg/exe builds — which made every shipped install report // app_version="unknown". The path-based fallback stays in place so this // change is purely additive. OPENSWARM_APP_VERSION: app.getVersion(), // Packaged builds send analytics straight to its own public edge (analytics.openswarm.com), bypassing the billing/account core; dev leaves it unset so the backend hits the local ingest. Older shipped builds still point at api.openswarm.com, whose /public/* relay stays in place for them. ...(isPackaged ? { OPENSWARM_ANALYTICS_URL: 'https://analytics.openswarm.com' } : {}), // Inject the user's BCP 47 locale + IANA timezone. The Python backend // doesn't have reliable APIs for either: locale.getdefaultlocale() is // deprecated and inconsistent across OSes, and Python's local-tz string // sometimes returns "PDT" or "Romance (zomertijd)" rather than // "America/Los_Angeles". Electron has both in canonical form via // app.getLocale() and Intl.DateTimeFormat().resolvedOptions().timeZone. OPENSWARM_LOCALE: app.getLocale(), OPENSWARM_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || '', PYTHONDONTWRITEBYTECODE: '1', // PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where // the locale is otherwise cp1252. Many backend modules read UTF-8 // .md / .json files without an explicit encoding= argument. PYTHONUTF8: '1', }; try { env.OPENSWARM_INSTALLATION_ID = affiliateTracking.resolveInstallId({ userDataDir: app.getPath('userData'), isPackaged, projectRoot, }); } catch (err) { console.warn('[affiliate] resolveInstallId failed:', err && err.message); } // Tell the backend where to find a real Node binary for 9Router and // bundled MCP servers. Preferring this over ELECTRON_RUN_AS_NODE avoids // (a) the second OpenSwarm-as-Node process briefly registering in the // Dock on fresh Macs, and (b) the slow Electron cold-start tail (~5-15s) // that Electron-as-Node adds vs. native node (~1-2s). Falls back to the // existing system-node / Electron-as-Node chain in nine_router._find_node() // if the env var is unset (dev mode, or build without node fetch). const bundledNode = getBundledNodePath(); if (bundledNode) { env.OPENSWARM_NODE_PATH = bundledNode; } if (isPackaged) { // site-packages location differs by OS — Windows has no lib/python3.13/. const pythonEnvSitePackages = process.platform === 'win32' ? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages') : path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages'); const debuggerDir = getResourcePath('debugger'); env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(path.delimiter); } openBackendLog(); // app-launch is the first milestone that can reach backend.log, since the // console tee is installed by openBackendLog() just above. APP_LAUNCH_T // (module load) remains the t=0 reference, so this t is real elapsed. perfMark('app-launch'); // Provenance: name the exact commit + version at the top of every boot trace, // so a user-submitted backend.log instantly says what shipped. Emitted here // (not in whenReady) because openBackendLog() above just installed the console // tee; logging earlier would miss the persistent file. const p_buildInfo = getBuildInfo(); console.log(`[provenance] OpenSwarm ${app.getVersion()} sha=${p_buildInfo.shortSha} channel=${p_buildInfo.channel} builtAt=${p_buildInfo.builtAt || 'n/a'}`); logPreflight(backendPort); runComprehensivePreflight(); // Record what we're about to launch and whether the interpreter is even // present. If AV quarantined python.exe or the wrong-arch bundle shipped, // exists=false (or spawn 'error' below) names the cause that otherwise // produces a silent "backend crashed" with no stdout/stderr at all. let pythonExists = false; try { pythonExists = fs.existsSync(pythonPath); } catch (_) {} console.log(`Starting backend: ${pythonPath} (exists=${pythonExists}) on port ${backendPort}`); console.log(`Project root: ${projectRoot}`); backendProcess = spawn( pythonPath, ['-m', 'uvicorn', 'backend.main:app', '--host', '127.0.0.1', '--port', String(backendPort), '--timeout-graceful-shutdown', '8'], { cwd: projectRoot, env, stdio: ['pipe', 'pipe', 'pipe'], } ); backendProcess.stdout.on('data', (data) => { const text = data.toString(); process.stdout.write(`[backend] ${text}`); // uvicorn prints this exact phrase once the ASGI app is live and // routes are mounted — perfect milestone for the splash to flip // from "starting backend" to "loading components". if (text.indexOf('Application startup complete') !== -1) { emitSplashStatus('Loading components…'); } }); backendProcess.stderr.on('data', (data) => { const text = data.toString(); process.stderr.write(`[backend] ${text}`); // Buffer the most recent stderr lines for the splash error UI so // when boot fails we can show actionable context inline instead of // making the user dig through a log file. recentBackendStderr.push(text); while (recentBackendStderr.length > 60) recentBackendStderr.shift(); }); // spawn() fires 'error' (not 'exit', not stdout/stderr) when the binary is // missing, AV-quarantined, blocked, or the wrong arch (ENOEXEC). This is the // most common silent cross-machine failure; without this handler it produced // an unhandled emitter error and an empty log. Surface it in both the log and // the splash error buffer so "View logs" actually explains the crash. backendProcess.on('error', (err) => { const msg = `\n[electron] backend spawn FAILED: ${err && err.code ? err.code + ' ' : ''}${err && err.message || err}\n` + ` python path: ${pythonPath} (exists=${pythonExists})\n` + ` arch: ${process.arch}, platform: ${process.platform}\n`; console.error(msg); recentBackendStderr.push(msg); while (recentBackendStderr.length > 60) recentBackendStderr.shift(); }); backendProcess.on('exit', (code) => { console.log(`Backend exited with code ${code}`); if (code !== 0 && code !== null && mainWindow) { mainWindow.webContents.executeJavaScript( `document.title = "OpenSwarm (backend crashed)";` ); } // Respawn a backend that died UNEXPECTEDLY. Without this a crash or SIGKILL left the app a dead // shell whose only recovery was a full relaunch, and every app-runtime it had spawned became a // permanent orphan (the boot reaper only runs at launch, which never came). Skip during an // orderly quit, and back off so a backend that instantly dies can't spin a respawn loop. backendProcess = null; if (quitInitiated || isInstallingUpdate) return; backendRespawns = (backendRespawns || 0) + 1; if (backendRespawns > MAX_BACKEND_RESPAWNS) { console.error(`[electron] backend died ${backendRespawns} times; giving up respawn`); return; } const delay = Math.min(1000 * backendRespawns, 8000); console.warn(`[electron] backend died unexpectedly; respawning in ${delay}ms (attempt ${backendRespawns})`); setTimeout(() => { startBackend().catch((e) => console.error('[electron] backend respawn failed', e)); }, delay); }); emitSplashStatus('Starting backend…'); await waitForBackend(backendPort, { process: backendProcess }); backendRespawns = 0; // healthy boot resets the budget perfMark('backend-http-ready'); console.log(`Backend ready on port ${backendPort}`); maybeCommitPreflightCache(); maybeSendBootBeacon(); // Backend writes a per-install auth token file at startup. Read it // here so the renderer can include it in WS URLs (`?token=...`) and // HTTP Authorization headers. Without this, any webpage loaded in // any browser on the machine could hit our localhost API and // impersonate the user. See backend/auth.py. await loadAuthToken(); markBackendReady(); } // Per-install auth token read from /auth.token (backend // generates this at startup). Cached here so `get-auth-token` IPC // calls are fast. If reads fail initially (race with backend) we // retry a few times. let authToken = ''; // Lazy-backend gate: renderer fetches block on this until backend is reachable AND auth token is loaded. Lets the main window open immediately while Python is still cold-starting on Windows. let backendReady = false; let _backendReadyResolve; const backendReadyPromise = new Promise((resolve) => { _backendReadyResolve = resolve; }); function markBackendReady() { if (backendReady) return; backendReady = true; _backendReadyResolve(); try { workflowsLifecycle.setBackend({ port: backendPort, token: authToken }); // Read lazily: mainWindow is replaced by recreateMainWindow, so a captured value goes stale. workflowsLifecycle.setNotificationTarget(() => mainWindow); workflowsLifecycle.startPolling(); crashReports.init(app, (payload) => { try { workflowsLifecycle.showNativeNotification(payload); } catch (_) {} }); } catch (_) {} try { connectMainBridge(); } catch (_) {} } function getAuthTokenFilePath() { // Mirrors backend/config/paths.py. On macOS the file lives at // ~/Library/Application Support/OpenSwarm/data/auth.token; on // Windows under %APPDATA%/OpenSwarm/data/; on Linux under // ~/.local/share/OpenSwarm/data/. In dev the backend writes it to // backend/data/auth.token instead. if (isPackaged) { if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'auth.token'); } else if (process.platform === 'win32') { return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'auth.token'); } else { const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); return path.join(xdg, 'OpenSwarm', 'data', 'auth.token'); } } // Dev: backend/data/auth.token relative to repo root. return path.join(__dirname, '..', 'backend', 'data', 'auth.token'); } // Persistent backend log on disk. Until now the bundled-Python stdout/stderr // only went to the Electron process streams, which a packaged Windows app // has no console for, so a user whose backend failed on their machine had // nothing to send us. This file is the one artifact that names the actual // cause (UnicodeDecodeError, EADDRINUSE, missing DLL, AV quarantine) of the // "works on my laptop, not theirs" failures. Lives next to auth.token. function getBackendLogPath() { return path.join(path.dirname(getAuthTokenFilePath()), 'backend.log'); } let backendLogStream = null; let _consoleTeed = false; function openBackendLog() { try { const logPath = getBackendLogPath(); fs.mkdirSync(path.dirname(logPath), { recursive: true }); // Size-based rotation: keep one previous file so the log can't grow // unbounded across long-running sessions. 5MB is plenty for a boot trace. try { if (fs.existsSync(logPath) && fs.statSync(logPath).size > 5 * 1024 * 1024) { fs.renameSync(logPath, logPath + '.1'); } } catch (_) {} backendLogStream = fs.createWriteStream(logPath, { flags: 'a' }); backendLogStream.write(`\n===== launch ${new Date().toISOString()} (app ${app.getVersion()}, ${process.platform}/${process.arch}) =====\n`); installConsoleTee(); } catch (err) { console.warn('[backend-log] could not open log file:', err && err.message); backendLogStream = null; } } // Tee the whole main-process stdout/stderr into the log file, not just the // Python child's streams. A packaged Windows GUI app has no console, so // otherwise every main-process console.log/error (boot failures, frontend // server errors, renderer-forwarded crashes, the spawn-error handler) is lost. // Patched once; reads the current backendLogStream so it survives restarts. function installConsoleTee() { if (_consoleTeed) return; _consoleTeed = true; for (const name of ['stdout', 'stderr']) { const orig = process[name].write.bind(process[name]); process[name].write = (chunk, ...rest) => { try { if (backendLogStream) backendLogStream.write(chunk); } catch (_) {} return orig(chunk, ...rest); }; } } async function loadAuthToken() { const tokenPath = getAuthTokenFilePath(); // Retry up to 20 × 100ms = 2s in case backend is still writing the // file. Backend writes BEFORE binding HTTP port though, so this // usually returns on the first attempt. for (let attempt = 0; attempt < 20; attempt++) { try { const contents = fs.readFileSync(tokenPath, 'utf8').trim(); if (contents) { authToken = contents; console.log(`[auth] loaded token from ${tokenPath}`); return; } } catch (_) {} await new Promise(r => setTimeout(r, 100)); } console.warn(`[auth] FAILED to load auth token from ${tokenPath} after 2s — WS/HTTP will be rejected`); } function createWindow() { isCreatingMainWindow = true; console.log('[diag][main] createWindow start'); mainWindow = new BrowserWindow({ width: 1400, height: 900, minWidth: 800, minHeight: 600, title: 'OpenSwarm', icon: iconPath, titleBarStyle: 'hiddenInset', // Stay hidden until the renderer fires `ready-to-show`. The splash // is what the user looks at; we swap it out for this window only // once React has actually painted, avoiding the white-flash that // Electron windows do during initial layout. show: false, backgroundColor: '#1a1a1f', webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, webviewTag: true, // E2E: additionalArguments lands in the renderer process.argv, which the // preload reads to expose the Redux store deterministically. No-op for // normal launches (env var unset). ...(process.env.OPENSWARM_E2E === '1' ? { additionalArguments: ['--openswarm-e2e=1'] } : {}), }, }); // Arc-style traffic lights: hidden until the renderer's top-edge hover asks for them. if (process.platform === 'darwin') { try { mainWindow.setWindowButtonVisibility(false); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); } } if (isDev) { // Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch. mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`); } else if (frontendServerPort) { mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`); } else { // Fallback only if the embedded server failed to start; the file:// path is known to segfault on Windows CastLabs Electron 40 but it is better than a white screen. const frontendPath = getResourcePath('frontend', 'index.html'); mainWindow.loadFile(frontendPath); } mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, params) => { webPreferences.plugins = true; webPreferences.enableBlinkFeatures = 'EncryptedMedia'; // Spellcheck the page's editable fields so the right-click menu can offer corrections. webPreferences.spellcheck = true; // Block autoplay in agent webviews. A profile page full of autoplaying video // (the repeated video.js logs) saturates the renderer's main thread and is a // prime reason the tab goes unresponsive and every command then times out. The // agent never needs autoplay; a human who wants to watch just clicks play, which // is the user gesture that re-enables it. Scoped to webviews, not the main window. webPreferences.autoplayPolicy = 'document-user-activation-required'; // Force our webview preload to attach for every , unconditionally. // The alternative (reading window.openswarm.getWebviewPreloadPath() in // BrowserCard's React code at module-eval time) raced against the // preload's async contextBridge exposure — the resulting attribute on // the element ended up empty, so no preload ran and our // passkey shim never loaded. Setting webPreferences.preload here runs // on every attach and can't be out-raced. Absolute path (not file://) // is what webPreferences expects. webPreferences.preload = path.join(__dirname, 'webview-preload.js'); try { console.log('[openswarm:attach-webview] forced preload=', webPreferences.preload, 'src=', params.src); } catch (_) {} }); mainWindow.webContents.on('will-navigate', (event, url) => { // Same-origin navigations are the app's own routing (reload, hash routes), // never an external link to pop into a browser card. The old port-specific // exemptions missed prod (renderer on 127.0.0.1:4173, not localhost:3000 or // file://), so a reload, e.g. Restart tour, got intercepted and re-opened // as a browser card loading the app itself (the recursive nested window). try { const current = mainWindow.webContents.getURL(); if (current && new URL(url).origin === new URL(current).origin) return; } catch (_) {} if (url.startsWith('file://')) return; event.preventDefault(); mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id); }); // Neuter renderer-side window.close() in the main window's page world. // Our React bundle never calls it legitimately, and while a window-level // close (Cmd+W / red X) is hidden-not-destroyed by the close interception // below, a renderer-side window.close() SKIPS the preventable 'close' // event and destroys the webContents outright (reproduced via CDP). // contextIsolation is on, so a preload override can't reach page callers; // executeJavaScript runs in the page world. The stub logs the caller's // stack and the console tee lands it in backend.log, so a phantom close // becomes a self-identifying report instead of a vanished window. // on(), not once(): re-applies across reloads and crash-recreates. { const wc = mainWindow.webContents; wc.on('did-finish-load', () => { wc.executeJavaScript( "window.close = function () { console.warn('[diag][renderer] window.close() blocked; caller:', new Error().stack); };" ).catch(() => {}); }); } // Once the renderer has loaded, nudge it to drain any deep link captured // before the window existed (cold-launch via openswarm://). The renderer also // drains on its own mount, so this is a belt over the same queue, never the // only delivery. mainWindow.webContents.once('did-finish-load', () => { perfMark('first-paint'); maybeSendBootBeacon(); if (pendingDeepLinks.length) notifyDeeplinkAvailable(); }); // Identity-checked: on crash recovery we recreate the window, which means BOTH the old and new BrowserWindow are alive briefly. The OLD window's closed handler must not clobber the NEW mainWindow reference when the old finally destroys. const thisWindow = mainWindow; // Forensic: quitInitiated=false here means the close was window-initiated // (Cmd+W via the default menu, red X, or a programmatic close) — the // signature of the 1.2.77 prod self-quits. True means a normal quit is // closing windows as part of its pipeline. mainWindow.on('close', (e) => { console.log(`[diag][main] mainWindow close (quitInitiated=${quitInitiated})`); // macOS: the only way to land here with quitInitiated still false is the red // traffic-light button. Cmd+W is swallowed in before-input-event, renderer // window.close() is neutered above, and crash-recovery uses destroy() (which // skips 'close'). So a red-button click means "quit": route it through // app.quit() so before-quit drains the App Builder subprocesses and will-quit // kills the backend, instead of leaving a headless app running. Real quits // (Cmd+Q, dock Quit, logout) flip quitInitiated via before-quit first and pass // straight through. isInstallingUpdate must also pass through: native // quitAndInstall closes the window with quitInitiated still false, and // intercepting it strands the update (THE "Restart & Update does nothing" bug). if (process.platform === 'darwin' && !quitInitiated && !isInstallingUpdate) { e.preventDefault(); // A staged update waiting + a user close = "apply it on the way out": the // install arms ShipIt and drives its own quit, so update instead of quitting. if (cachedUpdateStatus && cachedUpdateStatus.status === 'downloaded') { console.log('[updater] close with a staged update; applying it'); installDownloadedUpdate(); return; } console.log('[diag][main] red-button close, quitting app'); app.quit(); } }); mainWindow.on('closed', () => { // 'close' only fires for window-level closes (Cmd+W / red X / win.close()); // a webContents-level teardown skips it and lands here directly, so log // both or the forensics miss that family. console.log(`[diag][main] mainWindow closed (quitInitiated=${quitInitiated})`); if (mainWindow === thisWindow) mainWindow = null; }); // Renderer process death (GPU/native/OOM crash) is invisible to React error boundaries since the whole content process is gone. We RECREATE the window rather than reload(): the Electron 40 CastLabs build hits NOTREACHED in base/observer_list.h on reload after a renderer crash (some session/webview observer is re-registered against a list that disallows duplicates), aborting the entire main process with exit 3. A fresh BrowserWindow side-steps that. Crashes capped at 3 in 60s; after the cap we surface a native dialog so the user picks Reload vs Quit themselves rather than thrashing. mainWindow.webContents.on('preload-error', (_event, preloadPath, err) => { console.error('[diag][main:preload-error]', preloadPath, err && err.stack || err); }); // Frozen-but-not-crashed is the silent class no crash log sees; Chromium's own unresponsive // signal costs nothing and the report fires from the renderer AFTER it recovers. let wedgeStartedAt = 0; try { const { startMemorySensor } = require('./memorySensor'); startMemorySensor(app, () => mainWindow); } catch (e) { console.warn('[diag] memory sensor unavailable:', e && e.message); } mainWindow.webContents.on('unresponsive', () => { wedgeStartedAt = Date.now(); console.error('[diag][main] renderer unresponsive'); }); mainWindow.webContents.on('responsive', () => { if (!wedgeStartedAt) return; const ms = Date.now() - wedgeStartedAt; wedgeStartedAt = 0; console.error('[diag][main] renderer responsive again after', ms, 'ms'); try { mainWindow.webContents.send('diag:wedge', { ms }); } catch (_) { /* window mid-teardown */ } }); mainWindow.webContents.on('render-process-gone', (_event, details) => { const reason = details && details.reason; if (reason === 'clean-exit') return; // Renderer dying mid-quit-drain is expected; recreating then would resurrect a window we're trying to close. if (drainingForQuit) return; console.error('[main] renderer process gone:', JSON.stringify(details)); const now = Date.now(); rendererCrashTimes = rendererCrashTimes.filter((t) => now - t < 60_000); if (rendererCrashTimes.length >= 3) { console.error('[main] renderer crashed 3x in 60s, showing recovery dialog'); showCrashRecoveryOverlay(); return; } rendererCrashTimes.push(now); recreateMainWindow(); }); // Window-blur / window-focus tracking — analytics signal for "user // switched to another app" (temp-churn). The renderer captures these // through the existing report() pipeline; we just emit IPC notices // here so the React layer can timestamp them and forward to the // local backend's /api/service/submit endpoint. // // Cadence: at most once every 2 seconds per direction. Without that // throttle, dragging a window across desktops or having a popup steal // focus generates a burst of blur/focus pairs that pollute analytics // with noise. let _lastFocusEvent = 0; const FOCUS_THROTTLE_MS = 2000; const sendFocusEvent = (kind) => { const now = Date.now(); if (now - _lastFocusEvent < FOCUS_THROTTLE_MS) return; _lastFocusEvent = now; sendToRenderer('openswarm:window-focus', { kind, ts: now }); }; mainWindow.on('blur', () => sendFocusEvent('blur')); mainWindow.on('focus', () => sendFocusEvent('focus')); // Forward renderer console output to main stderr so packaged-build diagnostics survive without DevTools open. mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG'; const src = sourceId ? sourceId.split('/').pop() : ''; console.log(`[renderer:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`); }); // DevTools shortcut. Windows/Linux hide the menu bar, so the default View > // Toggle Developer Tools route is unreachable there (Mac keeps its menu); // wire F12 and Ctrl/Cmd+Shift+I directly so support can grab logs anywhere. mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; const key = (input.key || '').toLowerCase(); const isInspect = (input.control || input.meta) && input.shift && key === 'i'; if (key === 'f12' || isInspect) { mainWindow.webContents.toggleDevTools(); event.preventDefault(); } }); isCreatingMainWindow = false; console.log('[diag][main] createWindow end, ua=', mainWindow.webContents.getUserAgent()); } // Crash recovery path A: tear down the dead BrowserWindow and stand up a fresh one. Used by the render-process-gone handler under the 3-in-60s cap. // // Why createWindow first, destroy old after: // - If we destroy old before creating new, mainWindow goes null. Electron fires window-all-closed → app.quit() runs in the gap and we lose the process before the new window exists. // - createWindow() assigns mainWindow = newWindow synchronously, so the window list never drops to zero. // // Why setImmediate for the destroy: // - We're INSIDE the old window's render-process-gone handler. Destroying its BrowserWindow from inside its own event callback works in current Electron but is fragile across version bumps; deferring one tick is free insurance. function recreateMainWindow() { console.log('[diag][main] recreateMainWindow START, crashesInWindow=', rendererCrashTimes.length); const oldWindow = mainWindow; mainWindowReady = false; try { createWindow(); } catch (err) { console.error('[diag][main] recreateMainWindow: createWindow failed:', err && err.message); return; } console.log('[diag][main] recreateMainWindow created fresh window, ua=', mainWindow && mainWindow.webContents.getUserAgent()); const freshWindow = (mainWindow && mainWindow !== oldWindow) ? mainWindow : null; if (freshWindow) { freshWindow.once('ready-to-show', () => { try { freshWindow.show(); freshWindow.focus(); mainWindowReady = true; } catch (_) {} }); // After recreate the splash is long gone, so we can't rely on the boot path's swapToMain. Re-attach the update-notif listener that app.whenReady installed on the original webContents; the new webContents has no listeners yet. if (!isDev) { freshWindow.webContents.on('did-finish-load', () => { if (cachedUpdateStatus.status === 'available') { sendToRenderer('update-available', cachedUpdateStatus.info); } else if (cachedUpdateStatus.status === 'downloaded') { sendToRenderer('update-downloaded', cachedUpdateStatus.info); } }); } } setImmediate(() => { if (oldWindow && !oldWindow.isDestroyed()) { try { oldWindow.destroy(); } catch (_) {} } }); } // Crash recovery path B: the cap-exceeded fallback. Native dialog (not a BrowserWindow) so we cannot trigger the same observer-double-add DCHECK that motivated this whole change. User-driven Reload runs in a clean call stack outside the render-process-gone handler. async function showCrashRecoveryOverlay() { try { const result = await dialog.showMessageBox({ type: 'error', title: 'OpenSwarm needs to reload', message: 'OpenSwarm had repeated UI errors and stopped auto-recovering.', detail: 'Reload to try again, or quit if this keeps happening.', buttons: ['Reload', 'Quit'], defaultId: 0, cancelId: 1, noLink: true, }); if (result.response === 0) { rendererCrashTimes = []; recreateMainWindow(); } else { app.quit(); } } catch (err) { console.error('[main] showCrashRecoveryOverlay failed:', err && err.message); app.quit(); } } function sendToRenderer(channel, ...args) { if (mainWindow && !mainWindow.isDestroyed()) { try { mainWindow.webContents.send(channel, ...args); } catch (err) { // webContents.send throws after the renderer dies but before mainWindow.isDestroyed() returns true (race during recreate). Swallow so the blur/focus listener cannot become a secondary crash source. console.warn('[sendToRenderer] send failed for', channel, ':', err && err.message); } } } // Maps a raw electron-updater error to a short, human message. The raw error // is always logged separately for debugging; users only ever see this. No // em/en dashes per repo style. // Extracted to electron/updateErrorMessage.js so the mapping is unit-testable; see node --test there. const { friendlyUpdateError } = require('./updateErrorMessage'); const { diagnoseSilentUpdateCheck } = require('./updateCheckDiagnosis'); // Squirrel's built-in updater reports only via events; when AV or a proxy kills its request // internally, no event EVER arrives and the renderer's spinner spins forever. This watchdog turns // that silence into a diagnosed update-error. Settled by every real updater event. let p_squirrelCheckWatchdog = null; function settleUpdateCheckWatchdog() { if (p_squirrelCheckWatchdog) { clearTimeout(p_squirrelCheckWatchdog); p_squirrelCheckWatchdog = null; } } // Reachability probe through Electron's net stack, so a system proxy that blocks Squirrel blocks this the same way. Any HTTP response (even a redirect) proves the feed is reachable. function probeUpdateFeed(timeoutMs = 8000) { return new Promise((resolve) => { try { const { net } = require('electron'); const req = net.request({ method: 'HEAD', url: 'https://github.com/openswarm-ai/openswarm/releases/latest/download/RELEASES' }); const timer = setTimeout(() => { try { req.abort(); } catch (_) {} resolve(false); }, timeoutMs); req.on('response', () => { clearTimeout(timer); resolve(true); }); req.on('error', () => { clearTimeout(timer); resolve(false); }); req.end(); } catch (_) { resolve(false); } }); } function armSquirrelCheckWatchdog() { settleUpdateCheckWatchdog(); p_squirrelCheckWatchdog = setTimeout(async () => { p_squirrelCheckWatchdog = null; let updateExeExists = false; try { updateExeExists = fs.existsSync(path.resolve(path.dirname(process.execPath), '..', 'Update.exe')); } catch (_) {} const feedReachable = await probeUpdateFeed(); const msg = diagnoseSilentUpdateCheck({ updateExeExists, feedReachable }); console.warn('[updater] Squirrel check went silent; diagnosis:', msg); cachedUpdateStatus = { status: 'error', info: null, error: msg }; sendToRenderer('update-error', msg); }, 15000); } // Phase 2 provenance: which exact commit produced this build. The build // scripts write electron/build-info.json (gitignored, regenerated each build) // next to main.js, so it ships inside the asar. In dev there is no such file, // so we fall back to a live `git rev-parse` and tag it dev. Cached after first // read; never throws (a missing/garbled file just yields 'unknown'). let _buildInfoCache = null; function getBuildInfo() { if (_buildInfoCache) return _buildInfoCache; let info = { sha: 'unknown', shortSha: 'unknown', builtAt: null, channel: 'unknown' }; try { const raw = fs.readFileSync(path.join(__dirname, 'build-info.json'), 'utf8'); const parsed = JSON.parse(raw); if (parsed && parsed.sha) info = parsed; } catch (_) { // Dev fallback: resolve the working-tree HEAD so `npm start` still reports something useful. try { const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: __dirname, timeout: 2000 }).toString().trim(); if (sha) info = { sha, shortSha: sha.slice(0, 12), builtAt: null, channel: 'dev' }; } catch (_) { /* not a git checkout either; keep 'unknown' */ } } _buildInfoCache = info; return info; } // The packaged frontend ships as an unversioned `./bundle.js` served with no // validation headers, so Chromium caches it heuristically and can keep serving // OLD cross-version JS, a shipped fix silently no-ops and reinstalling the .app // doesn't help (the cache outlives it). A marker-on-build-change clear missed the // downgrade-bounce (new->old->new leaves the same marker but a re-poisoned cache), // so just drop the HTTP cache every launch before the window loads. The V8 code // cache is left intact, so unchanged JS still skips recompile; the only cost is a // couple of localhost refetches at startup. async function clearStaleFrontendCache() { if (isDev) return; try { await session.defaultSession.clearCache(); console.log('[cache] cleared HTTP cache so the on-disk frontend always loads'); } catch (err) { console.warn('[cache] clearStaleFrontendCache failed:', err && err.message); } } function setupAutoUpdater() { if (!autoUpdater) return; // Escape hatch for locally-built packaged smokes: an unpublished build otherwise downloads the // published release and silently DOWNGRADES on quit (the draft self-revert footgun, seen live on // 1.7.0), which both ruins the test and pollutes its memory numbers with ShipIt churn. if (process.env.OPENSWARM_NO_UPDATE === '1') { console.log('[updater] disabled via OPENSWARM_NO_UPDATE=1 (local packaged smoke)'); return; } // Proactive, not post-mortem: an app running off the DMG or a Gatekeeper-translocated copy can NEVER self-update (Squirrel.Mac refuses read-only volumes, proven in the packaged smoke). Tell that cohort what to do at boot instead of after a failed check they may never click. if (process.platform === 'darwin' && isPackaged) { const exe = process.execPath || ''; if (exe.includes('/AppTranslocation/') || exe.startsWith('/Volumes/')) { const msg = 'OpenSwarm is running from the disk image, so macOS blocks self-update. Drag OpenSwarm to Applications, then relaunch it from there.'; console.warn('[updater] read-only launch detected at boot:', exe); cachedUpdateStatus = { status: 'error', info: null, error: msg }; sendToRenderer('update-error', msg); return; } } if (isSquirrelUpdater) { // Squirrel.Windows fetches its RELEASES feed from GH /latest/download/. The // built-in autoUpdater has no autoDownload/allowPrerelease/allowDowngrade knobs. try { autoUpdater.setFeedURL({ url: 'https://github.com/openswarm-ai/openswarm/releases/latest/download/' }); } catch (err) { console.warn('[updater] Squirrel setFeedURL failed:', err && err.message); return; } } else { // Silent background updates: download on detect, install on next quit. // The OS gates the install on main-process exit (can't replace a // running .app / locked .exe), so an active session is never disrupted. autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; // Renderer pushes the user's experimental-updates setting via IPC right after settings load. autoUpdater.allowPrerelease = false; // The boot check below must not race that IPC push: with allowDowngrade on, a prerelease build checking while allowPrerelease is still false sees latest-stable as a valid target and silently self-downgrades, then upgrades again next boot (the 1.7.5 <-> 1.7.6-exp1 ping-pong). Read the toggle straight from disk so the first check already knows it. try { const onDisk = JSON.parse(fs.readFileSync(path.join(app.getPath('userData'), 'data', 'settings', 'settings.json'), 'utf8')); autoUpdater.allowPrerelease = !!onDisk.allow_experimental_updates; } catch (_) { // Fresh install or unreadable settings: stays false, and a fresh install is never on a prerelease. } // Lets us un-ship a bad release: re-flip GH 'latest' to an older one and users hop back to it. autoUpdater.allowDowngrade = true; } // electron-updater (Mac) passes an info object ({version,...}); the built-in // Windows autoUpdater (Squirrel) fires update-available/-not-available with NO // args and update-downloaded with positional (event, releaseNotes, releaseName, // releaseDate, updateURL). Normalize so these handlers work for both. autoUpdater.on('update-available', (info) => { settleUpdateCheckWatchdog(); const norm = info && info.version ? info : { version: '' }; console.log(`Update available: ${norm.version || '(version not reported by Squirrel)'}`); cachedUpdateStatus = { status: 'available', info: norm, error: null }; sendToRenderer('update-available', norm); }); autoUpdater.on('update-not-available', (info) => { settleUpdateCheckWatchdog(); console.log('App is up to date'); cachedUpdateStatus = { status: 'not-available', info: info || {}, error: null }; sendToRenderer('update-not-available', info || {}); }); autoUpdater.on('download-progress', (progress) => { cachedUpdateStatus = { status: 'downloading', info: progress, error: null }; sendToRenderer('download-progress', progress); }); autoUpdater.on('update-downloaded', (info, releaseNotes, releaseName) => { settleUpdateCheckWatchdog(); const version = (info && info.version) || releaseName || ''; console.log(`Update downloaded: ${version || '(ready to install)'}`); const norm = info && info.version ? info : { version }; cachedUpdateStatus = { status: 'downloaded', info: norm, error: null }; sendToRenderer('update-downloaded', norm); }); autoUpdater.on('error', (err) => { settleUpdateCheckWatchdog(); // Squirrel throws "AutoUpdater process ... is already running" when a check or // download is already in flight (e.g. the user clicked Check twice). Benign. if (/already running/i.test((err && err.message) || '')) { console.log('[updater] check already in progress; ignoring duplicate trigger'); return; } // Raw electron-updater errors are verbose (full URL, HTTP status, stack, // sometimes an HTML body). Keep the raw text in the log for debugging, but // never show it to the user. The common case is "Experimental updates is on // but no pre-release exists": the GitHub provider 404s hunting a pre-release // feed, which is not a real failure, just "nothing newer to install". console.error('Auto-update error:', err); const friendly = friendlyUpdateError(err, !!(autoUpdater && autoUpdater.allowPrerelease)); cachedUpdateStatus = { status: 'error', info: null, error: friendly }; sendToRenderer('update-error', friendly); }); // electron-updater's checkForUpdates() returns a promise; the built-in Windows // autoUpdater (Squirrel) returns nothing and reports via events, so a bare // .catch() on it throws. Guard the call so both updaters work. const _runUpdateCheck = (label) => { try { const p = autoUpdater.checkForUpdates(); if (p && typeof p.catch === 'function') p.catch((err) => console.log(`${label}:`, err && err.message)); } catch (err) { console.log(`${label} threw:`, err && err.message); } }; _runUpdateCheck('Update check skipped'); // Always-on users (lid never closes) miss the once-at-startup check // above. Re-check every 4h; coalesces if a download is already cached. setInterval(() => _runUpdateCheck('Periodic update check failed'), 4 * 60 * 60 * 1000); // Evergreen catch-all: our keep-alive means the app rarely truly quits, so the staged // update can sit unapplied for days. If one is downloaded AND the machine has been idle // with NO agent running for a sustained stretch, swap to it silently via the same path // the button uses. Deliberately conservative so it can never land on top of a live task. const IDLE_INSTALL_MIN_IDLE_S = 30 * 60; const IDLE_INSTALL_MIN_UPTIME_MS = 2 * 60 * 60 * 1000; const IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S = 15 * 60; const _idleInstallStart = Date.now(); const _backendActivity = () => new Promise((resolve) => { if (!backendPort) return resolve(null); const req = http.request({ hostname: '127.0.0.1', port: backendPort, path: '/api/agents/activity', method: 'GET', headers: { ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 4000, }, (res) => { let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => { try { const j = JSON.parse(d); resolve({ active: Number(j.active), nextRunInS: j.next_run_in_s == null ? null : Number(j.next_run_in_s) }); } catch (_) { resolve(null); } }); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); req.end(); }); // Breadcrumb so fleet convergence is queryable in analytics; bounded + best-effort, the install never waits on it failing. const _reportIdleInstall = () => new Promise((resolve) => { if (!backendPort) return resolve(); const payload = JSON.stringify({ kind: 'idle_install', staged_version: (cachedUpdateStatus && cachedUpdateStatus.info && cachedUpdateStatus.info.version) || null, }); const req = http.request({ hostname: '127.0.0.1', port: backendPort, path: '/api/service/updater-event', method: 'POST', headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 2000, }, (res) => { res.resume(); res.on('end', resolve); }); req.on('error', resolve); req.on('timeout', () => { req.destroy(); resolve(); }); req.write(payload); req.end(); }); setInterval(async () => { try { if (isInstallingUpdate || !cachedUpdateStatus || cachedUpdateStatus.status !== 'downloaded') return; if (Date.now() - _idleInstallStart < IDLE_INSTALL_MIN_UPTIME_MS) return; if (powerMonitor.getSystemIdleTime() < IDLE_INSTALL_MIN_IDLE_S) return; const act = await _backendActivity(); if (!act || act.active !== 0) return; // unknown or busy -> stay put, never interrupt a task // A scheduled workflow fires soon; restarting now would race it. Let it run, catch the next idle window. if (act.nextRunInS != null && act.nextRunInS < IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S) return; console.log('[updater] staged update + machine idle + no agents + no imminent workflow; applying silently'); try { await _reportIdleInstall(); } catch (_) {} installDownloadedUpdate(); } catch (_) { /* a heartbeat must never throw */ } }, 5 * 60 * 1000); } function killBackend() { if (backendProcess) { console.log('Killing backend process...'); if (process.platform === 'win32') { // Windows: Node's child.kill() only terminates the direct child, leaving // grandchildren (e.g. the router node process the Python backend // spawned) as orphans. Use `taskkill /T /F` to walk the process tree. try { require('child_process').execFileSync( 'taskkill', ['/PID', String(backendProcess.pid), '/T', '/F'], { stdio: 'ignore' }, ); } catch (_) { // taskkill failed (process may have already exited) — fall back to kill(). try { backendProcess.kill(); } catch (_) {} } } else { backendProcess.kill('SIGTERM'); setTimeout(() => { if (backendProcess && !backendProcess.killed) { backendProcess.kill('SIGKILL'); } }, 3000); } backendProcess = null; } if (backendLogStream) { try { backendLogStream.end(`[electron] backend killed ${new Date().toISOString()}\n`); } catch (_) {} backendLogStream = null; } } // macOS only: dodge the Chromium RootView::UpdateCursor null-deref (a browser-process // SIGSEGV when the mouse is released OUTSIDE the window mid-drag, easy with a second // display) by snapping off-window releases to the window edge before Chromium hit-tests // them. The fault is upstream of our renderer so JS can't catch it; this native addon // sits on an AppKit local event monitor. Fail-open: any miss leaves behavior as today. function installMacMouseClamp() { if (process.platform !== 'darwin') return; try { const nodePath = isPackaged ? path.join(process.resourcesPath, 'mouseclamp', 'mouseclamp.node') : path.join(__dirname, 'build-staging', 'mouseclamp', process.arch, 'mouseclamp.node'); if (!fs.existsSync(nodePath)) { console.log('[mouseclamp] addon not present, skipping:', nodePath); return; } console.log('[mouseclamp] install =>', require(nodePath).install()); } catch (e) { console.log('[mouseclamp] install failed (continuing):', e && e.message); } } // Trackpad haptic taps (macOS, Force Touch only): dictation start/stop feedback. Fail-open like // mouseclamp; a missing addon or non-mac just makes 'haptic:perform' return false. let hapticsAddon = null; function installHaptics() { if (process.platform !== 'darwin') return; try { const nodePath = isPackaged ? path.join(process.resourcesPath, 'haptics', 'haptics.node') : path.join(__dirname, 'build-staging', 'haptics', process.arch, 'haptics.node'); if (!fs.existsSync(nodePath)) { console.log('[haptics] addon not present, skipping:', nodePath); return; } hapticsAddon = require(nodePath); console.log('[haptics] addon loaded'); } catch (e) { console.log('[haptics] load failed (continuing):', e && e.message); } } ipcMain.handle('haptic:perform', (event, pattern) => { try { if (!hapticsAddon) return false; const p = pattern === 'alignment' ? 1 : pattern === 'level' ? 2 : 0; return hapticsAddon.perform(p); } catch (_) { return false; } }); app.whenReady().then(async () => { // We made it here, so any prior update swap finished. Drop a stale updating.lock // (the watchdog never deletes it) so a real crash later isn't silently swallowed. try { fs.unlinkSync(CRASH_WATCHDOG_UPDATING_LOCK); } catch (_) {} // Spawn the Mac crash watchdog. Detached process; if it fails to spawn the // app continues normally (silent fail by design). Guards inside the // watchdog itself prevent false-positive relaunches. detectDirtyExitAndArmSafeMode(); spawnCrashWatchdog(); // Off-window mouse-release crash dodge (macOS). Safe to call before windows exist. installMacMouseClamp(); installHaptics(); // Voice dictation hotkey (F5 / Cmd-Ctrl+Shift+D). Native uiohook key tap = true keyboard // hold-to-talk on every platform; falls back to the old press-to-toggle when the tap can't run // (module missing, or macOS without the Accessibility grant). Tiers live in voiceHotkey.js. installVoiceHotkey(() => mainWindow); // PASSKEY SPIKE (macOS only): turn on the Secure-Enclave/Touch ID WebAuthn authenticator that Electron 42 added. Without this, isUserVerifyingPlatformAuthenticatorAvailable() is hardwired false (why the old reject-shim existed). keychainAccessGroup MUST match the keychain-access-groups entitlement (Y26NUZH4NG..webauthn) or this throws. Windows has no equivalent, so the reject-shim still runs there. if (process.platform === 'darwin' && typeof app.configureWebAuthn === 'function') { try { app.configureWebAuthn({ touchID: { keychainAccessGroup: 'Y26NUZH4NG.com.clusterlabs.openswarm.webauthn', promptReason: 'sign in to $1' } }); console.log('[passkey] configureWebAuthn(touchID) enabled'); } catch (e) { console.warn('[passkey] configureWebAuthn failed (entitlement missing? unsigned dev build?):', e && e.message); } } // Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's // in argv; macOS fires open-url AFTER whenReady which we handle above) // route through forwardDeepLinkToRenderer so the URL gets stashed under // its correct IPC channel (auth-url vs oauth-claim). const initialDeepLink = extractOpenswarmUrl(process.argv); if (initialDeepLink) forwardDeepLinkToRenderer(initialDeepLink); if (process.platform === 'darwin' && !isPackaged) { try { app.dock.setIcon(iconPath); } catch (_) {} } // Same permission grants + iframe header-strip on BOTH the app's defaultSession and the browser-card partition. A named partition is a separate session, so without re-applying these, browser cards lose camera/mic prompts and the ability to embed sites that send X-Frame-Options. allowFullscreen is true ONLY for the browser-card partition (video sites): on the defaultSession (App Builder preview webviews) a preview's HTML5 fullscreen gets promoted to setFullScreen on the top-level window and hijacks the whole app, so it's denied there. The user's green-button fullscreen is a different path, unaffected. const configureBrowsingSession = (ses, { allowFullscreen }) => { // Spellcheck for editable fields in browser webviews (the context menu reads its suggestions). macOS uses the always-on system checker and ignores the language list; Windows/Linux Hunspell needs the dictionary, so seed it from the OS locale, falling back to en-US. try { if (typeof ses.setSpellCheckerEnabled === 'function') ses.setSpellCheckerEnabled(true); if (typeof ses.setSpellCheckerLanguages === 'function') { const avail = ses.availableSpellCheckerLanguages || []; const sys = app.getLocale() || 'en-US'; const pick = avail.includes(sys) ? sys : (avail.includes('en-US') ? 'en-US' : null); if (pick) ses.setSpellCheckerLanguages([pick]); } } catch (_) {} ses.setPermissionRequestHandler((_wc, permission, callback) => { const allowed = [ 'media', 'mediaKeySystem', 'protected-media-identifier', 'geolocation', 'notifications', 'midi', 'midiSysex', 'clipboard-read', 'clipboard-sanitized-write', 'pointerLock', 'idle-detection', ]; if (allowFullscreen) allowed.push('fullscreen'); console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied'); callback(allowed.includes(permission)); }); ses.setPermissionCheckHandler((_wc, permission) => { const allowed = [ 'media', 'mediaKeySystem', 'protected-media-identifier', 'clipboard-read', 'clipboard-sanitized-write', 'pointerLock', 'idle-detection', ]; if (allowFullscreen) allowed.push('fullscreen'); return allowed.includes(permission); }); // Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because tag commit segfaults on Chromium 144 + this Electron 40 CastLabs build) can render sites that normally refuse to be embedded. Scoped to types:['sub_frame'] so OAuth popups, the main app frame, deep-link redirects, and DRM license fetches keep their security headers intact. urls filter limits to http/https so file:// loads of the bundled frontend are untouched. ses.webRequest.onHeadersReceived( // Electron's webRequest type name for iframes is 'subFrame' (camelCase), not the Chrome-extension 'sub_frame' — passing the wrong name throws "Invalid type sub_frame" synchronously which becomes an unhandledRejection and prevents the app from booting. { urls: ['http://*/*', 'https://*/*'], types: ['subFrame'] }, (details, callback) => { const headers = { ...(details.responseHeaders || {}) }; for (const k of Object.keys(headers)) { const lk = k.toLowerCase(); if (lk === 'x-frame-options') { delete headers[k]; } else if (lk === 'content-security-policy' || lk === 'content-security-policy-report-only') { const cleaned = (headers[k] || []) .map((v) => v.split(';').filter((d) => !/^\s*frame-ancestors\b/i.test(d)).join(';').trim()) .filter(Boolean); if (cleaned.length) headers[k] = cleaned; else delete headers[k]; } } callback({ responseHeaders: headers }); }, ); }; configureBrowsingSession(session.defaultSession, { allowFullscreen: false }); configureBrowsingSession(session.fromPartition(BROWSER_PARTITION), { allowFullscreen: true }); // Restore which sites we borrowed a sign-in for BEFORE any card can load one, so the very first // request of the launch already presents as the browser that earned the session. loadBorrowedDomains(); // When a site offers several discoverable passkeys, Electron asks which one; answering null // CANCELS the ceremony, which the site then reports as its own generic failure ("Something went // wrong" on Google). The old handler read the array off arg 2 and looked for `accountId`, but // Electron passes `details = { relyingPartyId, accounts, frame }` and the field is `credentialId`, // so it answered null every time and passkey SIGN-IN could never complete. Creating a passkey // needs no selection, which is why that half always looked fine (ENG-269). for (const ses of [session.defaultSession, session.fromPartition(BROWSER_PARTITION)]) { try { ses.on('select-webauthn-account', (event, details, callback) => { const accounts = (details && details.accounts) || []; console.log('[passkey] select-webauthn-account rp=', details && details.relyingPartyId, 'n=', accounts.length); event.preventDefault(); // One passkey is unambiguous, so answering it directly keeps the flow to a single Touch ID // prompt. With several, the OS sheet is the right chooser and we must not silently guess a // credential the user did not pick; a picker is the follow-up, never a blind first(). if (accounts.length === 1) return callback(accounts[0].credentialId); if (accounts.length === 0) return callback(null); console.warn('[passkey] multiple passkeys offered; needs a picker, defaulting to the first'); callback(accounts[0].credentialId); }); } catch (_) {} } // Add a "Google Chrome" brand to the browser partition's sec-ch-ua request hints so they match the navigator.userAgentData patch injected on dom-ready and the spoofed Chrome UA string; a Chrome UA paired with Chromium-only hints is the embedded-app tell aggressive anti-bot (Cloudflare) flags on a real human. Scoped to the browser partition, the app's own file:// + localhost traffic is untouched. const addGoogleChromeBrand = (value) => { if (typeof value !== 'string' || value.includes('"Google Chrome"')) return value; const m = value.match(/"Chromium";v="([^"]+)"/); return m ? `${value}, "Google Chrome";v="${m[1]}"` : value; }; session.fromPartition(BROWSER_PARTITION).webRequest.onBeforeSendHeaders( { urls: ['http://*/*', 'https://*/*'] }, (details, callback) => { const headers = { ...(details.requestHeaders || {}) }; const borrowed = hostWantsBareUa(details.url); for (const k of Object.keys(headers)) { const lk = k.toLowerCase(); if (lk === 'sec-ch-ua' || lk === 'sec-ch-ua-full-version-list') { headers[k] = addGoogleChromeBrand(headers[k]); } else if (borrowed && lk === 'user-agent') { const swapped = bareChromeUserAgent(headers[k]); // Once per site: proof the swap actually fired, so "borrowed but still signed out" can be // read as the site refusing us rather than as this code silently never running. if (swapped !== headers[k] && !p_uaSwapLogged.has(details.url.split('/')[2])) { p_uaSwapLogged.add(details.url.split('/')[2]); console.log(`[borrowed-ua] ${details.url.split('/')[2]} -> ${swapped}`); } headers[k] = swapped; } } callback({ requestHeaders: headers }); }, ); // Read-only logging for DRM license requests — no modifying interceptors // so the network stack can set Content-Type and other headers normally. session.defaultSession.webRequest.onSendHeaders( { urls: ['*://*/*widevine*license*'] }, (details) => { console.log(`[drm-req] ${details.method} ${details.url}`); for (const [k, v] of Object.entries(details.requestHeaders || {})) { if (/content-type|origin|referer|auth|accept/i.test(k)) { // Keep the auth scheme for debugging, never the token itself. let safe = v; if (/authorization/i.test(k)) { const sp = String(v).indexOf(' '); safe = sp > 0 ? `${String(v).slice(0, sp)} ` : ''; } console.log(`[drm-req] ${k}: ${safe}`); } } }, ); session.defaultSession.webRequest.onCompleted( { urls: ['*://*/*widevine*', '*://*/*license*'] }, (details) => { console.log(`[drm-net] ${details.method} ${details.url} → ${details.statusCode}`); }, ); session.defaultSession.webRequest.onErrorOccurred( { urls: ['*://*/*widevine*', '*://*/*license*'] }, (details) => { console.log(`[drm-net] FAILED ${details.method} ${details.url} → ${details.error}`); }, ); // Splash window opens immediately so the user sees motion within ~1s // of double-clicking. Without this, on a cold-Defender Windows install // the dock/taskbar icon flashes for 30-60s with nothing visible. splashWindow = createSplashWindow(); emitSplashStatus('Starting OpenSwarm…'); // Widevine CDM and backend startup are independent — run them // concurrently. Backend is the long pole on Windows (Defender + Python // cold start), so we don't want a slow CDM download to add seconds to // every boot. Webviews that need DRM still wait on `components.whenReady` // before loading via the existing webview-preload flow, so parallelizing // here is safe. let widevinePromise; if (components && typeof components.whenReady === 'function') { widevinePromise = components.whenReady().then( () => { console.log('Widevine CDM ready'); if (typeof components.status === 'function') { console.log('CDM component status:', JSON.stringify(components.status())); } }, (err) => { console.warn('Widevine CDM not available:', err && err.message); } ); } else { console.log('CastLabs components API not available — using standard Electron (no DRM)'); widevinePromise = Promise.resolve(); } try { if (isDev) { backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10); console.log(`Dev mode: using existing backend on port ${backendPort}`); emitSplashStatus('Connecting to dev backend…'); // Load the token before marking ready, same as prod, so the workflow // poller's setBackend() gets a real token instead of '' (else it 401s). await loadAuthToken(); markBackendReady(); } else { // Kick off backend without awaiting so the window can paint while Python is still cold-starting. Renderer fetches lazy-await markBackendReady() via the get-auth-token IPC; splash status updates still fire from inside startBackend. backendPort = await pickBackendPort(); const _backendBoot = startBackend().catch((err) => { console.error('[boot] backend startup failed:', err && err.message); emitSplashStatus({ text: 'Backend failed to start', level: 'error', logs: recentBackendStderr.slice(-20).join('') }); }); } // Start the embedded frontend HTTP server before createWindow so loadURL has a real port. Only relevant in packaged mode; in dev, frontend lives on webpack-dev-server :3000. if (!isDev) { try { await startFrontendServer(); } catch (err) { console.error('[boot] frontend server failed to start, falling back to file://:', err && err.message); } } emitSplashStatus('Almost ready…'); // Must run before createWindow loads the URL, or the renderer fetches the stale bundle first. await clearStaleFrontendCache(); createWindow(); if (!isDev) { setupAutoUpdater(); mainWindow.webContents.on('did-finish-load', () => { if (cachedUpdateStatus.status === 'available') { sendToRenderer('update-available', cachedUpdateStatus.info); } else if (cachedUpdateStatus.status === 'downloaded') { sendToRenderer('update-downloaded', cachedUpdateStatus.info); } }); } // Swap splash → main only once React has actually painted. ready-to-show // fires after the renderer's first frame, eliminating the white-flash // that would otherwise pop between splash close and React mount. // Also gated on backendReady: with lazy backend, ready-to-show can fire while React is still showing null (SignInGateLoader returns null until settings load), so we'd show a blank window if we swapped early. if (mainWindow) { const swapToMain = () => { if (mainWindowReady || mainWindow.isDestroyed()) return; if (!backendReady) { backendReadyPromise.then(() => swapToMain()).catch(() => {}); return; } mainWindowReady = true; try { mainWindow.show(); mainWindow.focus(); } catch (_) {} // Tiny delay so the OS gets a chance to bring main to front // before splash disappears — avoids a single-frame "no window" // gap on Windows. setTimeout(() => { if (splashWindow && !splashWindow.isDestroyed()) { splashWindow.destroy(); } splashWindow = null; }, 120); }; mainWindow.once('ready-to-show', swapToMain); // Fallback: if the renderer fails to load (e.g. dev server not // running on localhost:3000), `ready-to-show` never fires and // the splash would hang forever. Show main anyway so the dev // sees the load error in the window itself. // Not once(): a boot-time Chromium network-service crash fails the FIRST load and the service self-restarts seconds later, but with no retry the splash was permanent ("Starting..." forever, the ENG-182 brick, reproduced live 2026-08-10 under load). Retry with backoff until a load commits. let bootLoadRetries = 0; mainWindow.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { console.warn('[boot] mainWindow load failed:', errorCode, errorDescription, validatedURL); if (isDev) { // Force-skip the backend gate so dev sees the error. mainWindowReady = true; try { mainWindow.show(); mainWindow.focus(); } catch (_) {} if (splashWindow && !splashWindow.isDestroyed()) splashWindow.destroy(); splashWindow = null; return; } if (isMainFrame === false || errorCode === -3) return; // subframe noise and user-aborted loads are not boot failures if (bootLoadRetries >= 10) { console.error('[boot] load retries exhausted; leaving splash'); return; } const delay = Math.min(1000 * Math.pow(2, bootLoadRetries), 8000); bootLoadRetries += 1; setTimeout(() => { if (!mainWindow || mainWindow.isDestroyed()) return; const cur = mainWindow.webContents.getURL() || ''; if (cur.startsWith('http')) return; // a later load already succeeded console.warn(`[boot] retrying main URL (attempt ${bootLoadRetries})`); mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`).catch(() => {}); }, delay); }); } // Don't block on Widevine; it'll resolve in the background. Logged above. widevinePromise.catch(() => {}); // Warm the dictation model well after the window is up, so the first phrase transcribes at the // steady-state speed instead of waiting out a cold model load under the user's keypress. const warmDelay = setTimeout(() => { const started = whisperService.warmInBackground(voiceResourceDir(), voiceUserDataDir()); console.log(started ? '[voice] warming whisper in background' : '[voice] no model yet, skipping boot warm'); }, 8000); if (warmDelay.unref) warmDelay.unref(); powerMonitor.on('resume', () => { whisperService.reprimeAfterWake().catch(() => {}); }); // Affiliate / referral handshake. On the very first launch, opens the // landing page's /welcome handler in the user's default browser so the // browser (which holds the install_token from the click on the // download CTA) can pair our app_install_id with the referral code. // No-op on every subsequent launch, no-op in dev unless forced. Fire // and forget, never blocks UI startup. See electron/affiliateTracking.js. affiliateTracking.maybeRunFirstLaunchHandshake({ shell, userDataDir: app.getPath('userData'), isDev, isPackaged, }).catch((err) => { console.warn('[affiliate] handshake failed:', err && err.message); }); } catch (err) { console.error('Failed to start:', err); // Surface the failure on the splash instead of silently quitting. // The user picks: view logs, restart, or quit. This eliminates the // class of "I clicked OpenSwarm and nothing happened" reports. emitSplashStatus({ text: "OpenSwarm couldn't start: " + (err && err.message ? err.message : String(err)), level: 'error', showActions: true, logs: recentBackendStderr.slice(-30).join(''), }); // Do NOT call app.quit() here — the user controls the next step // through the splash action buttons. } }); // Cmd+W is the default menu's "File > Close Window". Now that the red button // routes a close into app.quit(), an unguarded Cmd+W would tear down the whole // app + every running agent on a stray tab-close reflex (the exact 1.2.77 // self-quit class). preventDefault here also blocks the menu accelerator // (electron/electron#19279), and because macOS dispatches that accelerator // against whichever webContents is focused, we have to guard the main window AND // its webview guests, not just one. mac-only; on Windows Ctrl+W is input.control // so this no-ops there and leaves that platform's close-on-last-window intact. function swallowCloseWindowShortcut(event, input) { if ( input.type === 'keyDown' && process.platform === 'darwin' && input.meta && !input.control && !input.alt && (input.key || '').toLowerCase() === 'w' ) { event.preventDefault(); // Arc semantics: the swallowed close becomes "close the focused card" in the renderer (undoable via Cmd+Z). if (!input.shift) { try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:close-shortcut'); } catch (_) {} } } } // Cmd/Ctrl+T: new tab in the last-interacted browser, or a new browser card (Arc muscle memory). function routeNewTabShortcut(event, input) { if (input.type !== 'keyDown') return; if (!(input.meta || input.control) || input.shift || input.alt) return; if ((input.key || '').toLowerCase() !== 't') return; event.preventDefault(); try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:newtab-shortcut'); } catch (_) {} } // Cmd/Ctrl+1..9: focus the Nth dock tile, Arc-style. Routed through main so it works from a focused webview too. function routeDockShortcut(event, input) { if (input.type !== 'keyDown') return; if (!(input.meta || input.control) || input.shift || input.alt) return; const key = input.key || ''; if (key < '1' || key > '9' || key.length !== 1) return; event.preventDefault(); try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:dock-shortcut', Number(key) - 1); } catch (_) {} } // Cmd/Ctrl+R: the default menu's Reload accelerator reloads the WHOLE app even when a browser webview is focused (the "Ctrl+R reloads OpenSwarm, not the browser" complaint). preventDefault kills that accelerator (same electron#19279 path as Cmd+W, dispatched against whichever webContents is focused, hence both main window AND guests); the renderer then reloads the last-interacted browser, or the app if none. Shift+R (force reload) is left alone. function routeReloadShortcut(event, input) { if (input.type !== 'keyDown') return; if (!(input.meta || input.control) || input.shift || input.alt) return; if ((input.key || '').toLowerCase() !== 'r') return; event.preventDefault(); try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:reload-shortcut'); } catch (_) {} } // In-page browser shortcuts (zoom, find, tab-cycle) for a focused guest. Keydowns inside a // guest never reach the host renderer, so we catch them here and forward the intent + the guest's // webContents id so the renderer can target that exact browser. Attached to guests ONLY: on the host // the renderer's own keydown handles canvas-vs-browser, and intercepting there would eat canvas zoom. // The renderer registers the user's new-agent combo so it still fires while a guest webview holds focus (host keydown never sees those). let newAgentCombo = { primary: true, shift: false, key: 'l' }; ipcMain.on('set-new-agent-shortcut', (_e, combo) => { if (combo && typeof combo.key === 'string' && combo.key) { newAgentCombo = { primary: !!combo.primary, shift: !!combo.shift, key: combo.key.toLowerCase() }; } }); function routeBrowserShortcut(event, input, webContentsId) { if (input.type !== 'keyDown' || input.alt) return; const mod = input.meta || input.control; const key = (input.key || '').toLowerCase(); let action = null; if (mod && !input.shift && (key === '=' || key === '+')) action = 'zoom-in'; else if (mod && !input.shift && key === '-') action = 'zoom-out'; else if (mod && !input.shift && key === '0') action = 'zoom-reset'; else if (mod && !input.shift && key === 'f') action = 'find'; else if (mod && input.shift && key === 't') action = 'reopen-closed'; else if (input.control && !input.meta && key === 'tab') action = input.shift ? 'tab-prev' : 'tab-next'; else if (mod === newAgentCombo.primary && input.shift === newAgentCombo.shift && key === newAgentCombo.key) action = 'new-agent'; if (!action) return; event.preventDefault(); try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:browser-shortcut', { action, webContentsId }); } catch (_) {} } function openInNewBrowserTab(url, webContentsId) { if (mainWindow && !mainWindow.isDestroyed()) { try { mainWindow.webContents.send('webview-new-window', url, webContentsId, 'background-tab'); } catch (_) {} } } // Native right-click menu for browser webviews. Electron shows none by default, so most sites had no menu // at all (Notion etc. only worked because they draw their own in-page one). Built fresh per click from the // hit-test params: spelling fixes on a misspelled word, link/image actions, edit roles, then nav. function buildBrowserContextMenu(contents, params, webContentsId) { const template = []; const sep = () => template.push({ type: 'separator' }); if (params.misspelledWord) { const suggestions = params.dictionarySuggestions || []; if (suggestions.length) { for (const s of suggestions) template.push({ label: s, click: () => { try { contents.replaceMisspelling(s); } catch (_) {} } }); } else { template.push({ label: 'No spelling suggestions', enabled: false }); } template.push({ label: 'Add to Dictionary', click: () => { try { contents.session.addWordToSpellCheckerDictionary(params.misspelledWord); } catch (_) {} } }); sep(); } if (params.linkURL) { template.push({ label: 'Open Link in New Tab', click: () => openInNewBrowserTab(params.linkURL, webContentsId) }); template.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); sep(); } if (params.mediaType === 'image' && params.srcURL) { template.push({ label: 'Open Image in New Tab', click: () => openInNewBrowserTab(params.srcURL, webContentsId) }); template.push({ label: 'Copy Image', click: () => { try { contents.copyImageAt(params.x, params.y); } catch (_) {} } }); template.push({ label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }); sep(); } const flags = params.editFlags || {}; if (params.isEditable) { template.push({ role: 'cut', enabled: flags.canCut !== false }); template.push({ role: 'copy', enabled: flags.canCopy !== false }); template.push({ role: 'paste', enabled: flags.canPaste !== false }); template.push({ role: 'selectAll' }); sep(); } else if (params.selectionText) { template.push({ role: 'copy' }); sep(); } const nav = contents.navigationHistory; const canBack = nav ? nav.canGoBack() : contents.canGoBack(); const canFwd = nav ? nav.canGoForward() : contents.canGoForward(); template.push({ label: 'Back', enabled: canBack, click: () => { try { nav ? nav.goBack() : contents.goBack(); } catch (_) {} } }); template.push({ label: 'Forward', enabled: canFwd, click: () => { try { nav ? nav.goForward() : contents.goForward(); } catch (_) {} } }); template.push({ label: 'Reload', click: () => { try { contents.reload(); } catch (_) {} } }); if (isDev) { sep(); template.push({ label: 'Inspect Element', click: () => { try { contents.inspectElement(params.x, params.y); } catch (_) {} } }); } try { Menu.buildFromTemplate(template).popup({ window: mainWindow || undefined }); } catch (_) {} } // App-preview webviews (a generated app's live preview) are not browser tabs: no Back/Forward that // could strand the preview on an external page, and the link action says where the link really goes. function buildAppPreviewContextMenu(contents, params) { const template = []; const sep = () => template.push({ type: 'separator' }); if (params.linkURL) { template.push({ label: 'Open Link in Browser', click: () => openInNewBrowserTab(params.linkURL, null) }); template.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); sep(); } if (params.mediaType === 'image' && params.srcURL) { template.push({ label: 'Copy Image', click: () => { try { contents.copyImageAt(params.x, params.y); } catch (_) {} } }); template.push({ label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }); sep(); } const flags = params.editFlags || {}; if (params.isEditable) { template.push({ role: 'cut', enabled: flags.canCut !== false }); template.push({ role: 'copy', enabled: flags.canCopy !== false }); template.push({ role: 'paste', enabled: flags.canPaste !== false }); template.push({ role: 'selectAll' }); sep(); } else if (params.selectionText) { template.push({ role: 'copy' }); sep(); } template.push({ label: 'Reload App', click: () => { try { contents.reload(); } catch (_) {} } }); if (isDev) { sep(); template.push({ label: 'Inspect Element', click: () => { try { contents.inspectElement(params.x, params.y); } catch (_) {} } }); } try { Menu.buildFromTemplate(template).popup({ window: mainWindow || undefined }); } catch (_) {} } // The app's OWN renderer (chat, outputs, sidebar) gets no native menu from Electron by default, so // right-clicking text used to do nothing. This is the browser menu minus the nav items that mean // nothing inside a single-page app: spelling, copy-link, and the edit/copy roles. function buildAppContextMenu(contents, params) { const template = []; const sep = () => template.push({ type: 'separator' }); if (params.misspelledWord) { const suggestions = params.dictionarySuggestions || []; if (suggestions.length) { for (const s of suggestions) template.push({ label: s, click: () => { try { contents.replaceMisspelling(s); } catch (_) {} } }); } else { template.push({ label: 'No spelling suggestions', enabled: false }); } template.push({ label: 'Add to Dictionary', click: () => { try { contents.session.addWordToSpellCheckerDictionary(params.misspelledWord); } catch (_) {} } }); sep(); } if (params.linkURL) { template.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); sep(); } const flags = params.editFlags || {}; if (params.isEditable) { template.push({ role: 'cut', enabled: flags.canCut !== false }); template.push({ role: 'copy', enabled: flags.canCopy !== false }); template.push({ role: 'paste', enabled: flags.canPaste !== false }); template.push({ role: 'selectAll' }); } else if (params.selectionText) { template.push({ role: 'copy' }); } if (isDev) { if (template.length) sep(); template.push({ label: 'Inspect Element', click: () => { try { contents.inspectElement(params.x, params.y); } catch (_) {} } }); } // Nothing worth showing (empty right-click on non-dev chrome): let the OS do nothing. if (!template.length) return; try { Menu.buildFromTemplate(template).popup({ window: mainWindow || undefined }); } catch (_) {} } app.on('web-contents-created', (_event, contents) => { // Block Cmd+W from closing the main window, whether the window chrome or one of // its embedded webviews has focus. OAuth popups (their own 'window' contents, // created while isCreatingMainWindow is false) are left alone so the user can // still Cmd+W them shut. if (isCreatingMainWindow || contents.getType() === 'webview') { contents.on('before-input-event', swallowCloseWindowShortcut); contents.on('before-input-event', routeReloadShortcut); contents.on('before-input-event', routeNewTabShortcut); contents.on('before-input-event', routeDockShortcut); } // The main app window (created while this flag is set) gets a text-focused native menu; OAuth // popups are 'window' contents created with the flag OFF, so they keep the OS default. if (isCreatingMainWindow) { contents.on('context-menu', (_e, params) => buildAppContextMenu(contents, params)); // The dashboard IS a Figma-style canvas listening for ctrl+wheel, so it needs the very fix the // webview branch below spells out: at the default (1,1) limits Electron drops macOS pinch instead // of delivering it, which is the "pinch-to-zoom just stopped working" report. Widening them here // is safe because the canvas wheel handler is passive:false and preventDefaults the zoom path, so // Chromium never also magnifies the UI. try { contents.setVisualZoomLevelLimits(1, 3); } catch (_) { /* older Electron */ } } if (contents.getType() === 'webview') { const wcId = contents.id; // Chrome parity for trackpad pinch: Electron DROPS macOS pinch gestures at the default (1,1) visual-zoom limits, so Figma/Miro/Maps never received the ctrl+wheel their canvas zoom listens for. With limits widened, the guest synthesizes ctrl+wheel first (a preventDefault-ing page like Figma owns the zoom), and plain pages get Chrome's pinch magnify. try { contents.setVisualZoomLevelLimits(1, 3); } catch (_) { /* older Electron */ } contents.on('before-input-event', (event, input) => routeBrowserShortcut(event, input, wcId)); contents.on('context-menu', (_e, params) => { // Browser cards ride the persist:openswarm-browser partition; app previews share the main window's default session, and get the app-flavored menu instead of browser-tab verbs. const isAppPreview = mainWindow && !mainWindow.isDestroyed() && contents.session === mainWindow.webContents.session; if (isAppPreview) buildAppPreviewContextMenu(contents, params); else buildBrowserContextMenu(contents, params, wcId); }); } // Override the user-agent on popup BrowserWindows (i.e. anything created // via window.open from the renderer, which includes the OAuth popup for // subscription connect flows). Electron's default UA includes an // `Electron/X.Y.Z` token that accounts.google.com blacklists with a // "browser not supported" page — and auth.openai.com is similarly picky. // Spoofing a current Chrome UA makes those identity providers treat the // popup like a real browser without changing the flow OpenSwarm uses to // capture the callback (window.open + postMessage). // // This check runs synchronously during `new BrowserWindow()` construction. // On the very first invocation (for mainWindow itself), `mainWindow` is // still null because assignment happens after the constructor returns, // so the `mainWindow &&` short-circuits and we leave the main window's // UA alone. Webview tags report `getType() === 'webview'` and are also // skipped — they render user-visited sites and must advertise the real UA. if ( contents.getType() === 'window' && !isCreatingMainWindow && !global.__osHiddenBrowserCreating && mainWindow && contents !== mainWindow.webContents ) { console.log('[diag][main] spoofing UA for popup webContents id=', contents.id); // Pinned to the RUNTIME Chrome version, never a hardcoded one: Slack started rejecting the old hardcoded Chrome/131 as an outdated browser. const OAUTH_POPUP_UA = process.platform === 'win32' ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + `(KHTML, like Gecko) Chrome/${process.versions.chrome} Safari/537.36` : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + `(KHTML, like Gecko) Chrome/${process.versions.chrome} Safari/537.36`; contents.setUserAgent(OAUTH_POPUP_UA); } contents.setWindowOpenHandler(({ url, disposition }) => { if (disposition === 'foreground-tab' || disposition === 'background-tab') { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('webview-new-window', url, contents.id, disposition); } return { action: 'deny' }; } // Note on which providers still use this popup path: // - Anthropic/Claude: still works here with the Chrome UA override above. // - Google (Gemini, Antigravity): blocks embedded browsers wholesale // ("browser not supported"), even with UA spoofing + sandboxed // partition + navigator.webdriver patches. Routes through // shell.openExternal instead. // - OpenAI/Codex: now also routes through shell.openExternal — the // embedded popup renders blank for some users (newer embed // detection + regional access checks), and the system browser // surfaces the actual error. // See _EXTERNAL_BROWSER_PROVIDERS in backend/apps/nine_router.py. // When Anthropic adds the same checks, add "claude" there too. return { action: 'allow', overrideBrowserWindowOptions: { parent: mainWindow || undefined, width: 520, height: 680, center: true, fullscreen: false, fullscreenable: false, resizable: true, minimizable: false, maximizable: false, }, }; }); contents.on('did-create-window', (childWindow) => { if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) { childWindow.setParentWindow(mainWindow); // Belt-and-suspenders: if the parent was fullscreen when window.open // fired, Electron can still spawn the child fullscreen. Force it back. if (childWindow.isFullScreen()) childWindow.setFullScreen(false); } // A sign-in popup opened FROM a browser card inherits that card's UA. The generic popup spoofer // above hands out a BARE Chrome UA (what Slack's wall wants), but Google rejects bare Chrome as // not-genuine-Chrome, which is why browser cards carry an openswarm product token in the first // place; without this a site's "Continue with Google" was unreachable inside a card (ENG-238). if (contents.getType() === 'webview' && !childWindow.isDestroyed()) { try { childWindow.webContents.setUserAgent(contents.getUserAgent()); } catch (_) { /* popup already gone */ } } }); // OAuth callback URL interception. The npm `9router` package's /callback // page relays the code back via window.opener.postMessage — which // silently no-ops on some flows (e.g. Anthropic's Claude Code auth pages // that reset the opener chain across cross-origin redirects). Capturing // the URL at the navigation layer is format-agnostic and works regardless // of whether the relay via postMessage/BroadcastChannel/localStorage made // it back to the renderer. Same code+state then gets forwarded to the // main window via IPC, where Settings.tsx picks it up and calls // /api/agents/subscriptions/exchange. const forwardOauthCallback = (url) => { try { const u = new URL(url); const onRouter = (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && u.port === '20128' && u.pathname === '/callback'; if (!onRouter) return; const code = u.searchParams.get('code'); const state = u.searchParams.get('state'); const error = u.searchParams.get('error'); if (!code && !error) return; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('openswarm:oauth-callback', { code, state, error }); } } catch { /* not a URL we care about */ } }; contents.on('did-navigate', (_e, url) => forwardOauthCallback(url)); contents.on('did-redirect-navigation', (_e, url) => forwardOauthCallback(url)); if (contents.getType() === 'webview') { contents.on('console-message', (_e, level, message, line, sourceId) => { const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG'; const src = sourceId ? sourceId.split('/').pop() : ''; if (message.includes('widevine') || message.includes('drm') || message.includes('license') || message.includes('MediaKeySession') || message.includes('EME') || message.includes('[drm-diag]') || message.includes('openswarm') || level >= 2) { console.log(`[webview:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`); } // Buffer warnings + errors so a stuck browser agent can READ why a page is // broken (JS exceptions, failed resource loads) via BrowserGetConsole. The // listener already fires for these, so this adds no forwarding; capped at 30 // and clamped to 300 chars each so a chatty page can't bloat memory. if (level >= 2) { let buf = webviewConsoleErrors.get(contents.id); if (!buf) { buf = []; webviewConsoleErrors.set(contents.id, buf); } buf.push({ level: tag, message: String(message).slice(0, 300), source: src, line }); if (buf.length > 30) buf.shift(); } }); // ----------------------------------------------------------------- // CDP debugger auto-attach for browser sub-agent accessibility tree // ----------------------------------------------------------------- // The browser sub-agent uses Chrome DevTools Protocol (specifically // Accessibility.getFullAXTree, DOM.resolveNode, Input.dispatchMouseEvent) // to perceive and act on hostile sites where CSS selectors fail. CDP // commands require webContents.debugger.attach() which is only callable // from the main process. We attach lazily on first use rather than at // creation time — that avoids the "Another debugger is already attached" // race when DevTools is opened on the webview. try { contents.debugger.on('detach', (_e, reason) => { console.log(`[cdp] detach on wcId ${contents.id}: ${reason}`); cdpAxIndexCache.delete(contents.id); // Clear stale child sessions but KEEP the map object + the wired guard: // the 'message' listener stays bound to wc.debugger across detach, so // dropping the guard here would stack a duplicate listener on reattach. cdpChildSessions.get(contents.id)?.clear(); }); } catch (e) { // Older Electron may not have the listener API; non-fatal. } contents.on('destroyed', () => { cdpAxIndexCache.delete(contents.id); cdpQueueByWcId.delete(contents.id); cdpChildSessions.delete(contents.id); cdpAutoAttachWired.delete(contents.id); cdpRoutesByWcId.delete(contents.id); webviewConsoleErrors.delete(contents.id); cdpTearingDown.delete(contents.id); }); contents.on('render-process-gone', () => { cdpAxIndexCache.delete(contents.id); cdpQueueByWcId.delete(contents.id); cdpChildSessions.delete(contents.id); cdpAutoAttachWired.delete(contents.id); cdpRoutesByWcId.delete(contents.id); webviewConsoleErrors.delete(contents.id); cdpTearingDown.delete(contents.id); }); // A heavy SPA can HANG the renderer without crashing it (a render-process-gone // never fires), leaving every CDP command to time out and the agent to abort the // card. Chromium flags that state as 'unresponsive'; reload once to try to un-stick // it instead of giving up. Rate-limited so a page that also hangs on reload can't // spin, and the agent's own card-gone detection still bails if reload doesn't help. let lastRecoveryReloadAt = 0; contents.on('unresponsive', () => { const now = Date.now(); if (now - lastRecoveryReloadAt < 30000) return; lastRecoveryReloadAt = now; console.log(`[webview] renderer unresponsive on wcId ${contents.id}; reloading to recover`); try { contents.reload(); } catch { /* nothing more we can do from here */ } }); // Match navigator.userAgentData to the spoofed Chrome UA + the browser-partition sec-ch-ua header rewrite so the page world agrees with the headers; contextIsolation hides the preload, so this page-world patch is injected here. A Chrome UA with Chromium-only hints is the embedded-app tell that aggressive anti-bot (Cloudflare) flags on a real human. contents.on('dom-ready', () => { contents.executeJavaScript(` (function(){ try { var orig = navigator.userAgentData; if (!orig || !Array.isArray(orig.brands) || orig.brands.some(function(b){ return b.brand === 'Google Chrome'; })) return; var addChrome = function(list){ if (!Array.isArray(list) || list.some(function(b){ return b.brand === 'Google Chrome'; })) return list; var ch = list.find(function(b){ return b.brand === 'Chromium'; }); return ch ? list.concat([{ brand: 'Google Chrome', version: ch.version }]) : list; }; var brands = addChrome(orig.brands); var patched = { brands: brands, mobile: orig.mobile, platform: orig.platform, getHighEntropyValues: function(h){ return orig.getHighEntropyValues(h).then(function(v){ if (v && Array.isArray(v.fullVersionList)) v.fullVersionList = addChrome(v.fullVersionList); return v; }); }, toJSON: function(){ return { brands: brands, mobile: orig.mobile, platform: orig.platform }; }, }; Object.defineProperty(navigator, 'userAgentData', { get: function(){ return patched; }, configurable: true }); } catch (e) {} })(); `).catch(() => {}); }); // On a site whose sign-in we borrowed we send a bare Chrome UA header (see // p_borrowedSessionDomains), so navigator.userAgent has to say the same thing. A page that // reads one UA in JS while the request carried another is a louder automation tell than the // product token we removed, and plenty of anti-bot scripts compare exactly those two. contents.on('dom-ready', () => { let borrowed = false; try { borrowed = hostWantsBareUa(contents.getURL()); } catch { borrowed = false; } if (!borrowed) return; const bare = bareChromeUserAgent(contents.getUserAgent()); contents.executeJavaScript(` (function(){ try { if (navigator.userAgent === ${JSON.stringify(bare)}) return; Object.defineProperty(navigator, 'userAgent', { get: function(){ return ${JSON.stringify(bare)}; }, configurable: true }); } catch (e) {} })(); `).catch(() => {}); }); // Real headed Chrome exposes window.chrome.app/csi/loadTimes; an Electron webview's window.chrome is empty ({}), the single most-checked headless/automation tell (PerimeterX/DataDome et al). Stub the same shape real Chrome reports (app = object, csi + loadTimes = functions, NO runtime, matching a non-extension page). Also restore the base 'en' language Electron drops. Page-world (contextIsolation hides the preload), measured to flip every bot.sannysoft row to its Chrome value. contents.on('dom-ready', () => { contents.executeJavaScript(` (function(){ try { if (typeof window.chrome !== 'object' || window.chrome === null) window.chrome = {}; var def = function(o, k, v){ try { if (!o[k]) Object.defineProperty(o, k, { value: v, configurable: true, writable: true, enumerable: true }); } catch(e){} }; def(window.chrome, 'app', { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, getDetails: function(){ return null; }, getIsInstalled: function(){ return false; }, runningState: function(){ return 'cannot_run'; }, }); def(window.chrome, 'csi', function(){ return { startE: Date.now(), onloadT: Date.now(), pageT: (performance && performance.now ? performance.now() : 0), tran: 15 }; }); def(window.chrome, 'loadTimes', function(){ var now = Date.now() / 1000; return { commitLoadTime: now, connectionInfo: 'h2', finishDocumentLoadTime: now, finishLoadTime: now, firstPaintAfterLoadTime: 0, firstPaintTime: now, navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: now, startLoadTime: now, wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true }; }); if (Array.isArray(navigator.languages) && navigator.languages.length === 1) { var langs = navigator.languages.concat([navigator.languages[0].split('-')[0]]); Object.defineProperty(navigator, 'languages', { get: function(){ return langs; }, configurable: true }); } } catch (e) {} })(); `).catch(() => {}); }); // Force the guest's PAGE WORLD to always report visible/foregrounded. When a kept-alive browser card sits on another dashboard it's parked off-screen; the page-visibility API then reads hidden, so a real-time app (Discord) backgrounds itself, drops its gateway socket, and on return can't resume the session -> "please log in again". The webview-preload patches this too but only in the isolated world (contextIsolation), so the page's OWN code never sees it; injecting here in the main world is what actually keeps Discord logged in while hidden. document.hasFocus is forced true for the same reason; visibilitychange/freeze/pagehide are swallowed so nothing downstream reacts to a backgrounding that, to us, never happens. contents.on('dom-ready', () => { contents.executeJavaScript(` (function(){ try { if (window.__openswarm_vis__) return; window.__openswarm_vis__ = true; var def = function(o, k, v){ try { Object.defineProperty(o, k, { get: function(){ return v; }, configurable: true }); } catch(e){} }; def(document, 'hidden', false); def(document, 'visibilityState', 'visible'); def(document, 'webkitHidden', false); def(document, 'webkitVisibilityState', 'visible'); try { document.hasFocus = function(){ return true; }; } catch(e){} var swallow = function(e){ e.stopImmediatePropagation(); }; ['visibilitychange','webkitvisibilitychange','freeze','pagehide'].forEach(function(t){ window.addEventListener(t, swallow, true); document.addEventListener(t, swallow, true); }); } catch (e) {} })(); `).catch(() => {}); }); // WebAuthn/passkey shim. Injected on every dom-ready in the main world // via executeJavaScript (which uses V8's direct evaluation path and // bypasses Trusted Types CSP — inline