Two boot-time fixes in the desktop main process: Show a lightweight "Starting Executor" window at app.whenReady, before any service or sidecar work, then load the real web UI into the same window once the connection resolves (no second-window flash). A pending second-instance now focuses that startup window instead of no-opping while connection is null. Classify the supervised service by both registered and running instead of ignoring the running signal: - registered and running: attach; only kick if the attach still misses it. - registered but not running (the stale plist/unit/task case): restart, and if the OS manager cannot start it, reinstall the service (install is already idempotent) before waiting. If install also fails, fall back to managed spawn immediately. The 15s attach wait is only paid after a kick or install actually succeeds, so a stale registration no longer costs a dead wait.
Unified · Split
| @@ -11,6 +11,104 @@ export interface CrashScreenOptions { · 10 unmodified lines | ||
| 11 | 11 | readonly reported: boolean; |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | +export const startupWindowHtml = (): string => `<!doctype html> | |
| 15 | +<html> | |
| 16 | + <head> | |
| 17 | + <meta charset="utf-8" /> | |
| 18 | + <title>Executor</title> | |
| 19 | + <style> | |
| 20 | + :root { | |
| 21 | + color-scheme: light dark; | |
| 22 | + --background: #0a0a0a; | |
| 23 | + --foreground: #fafafa; | |
| 24 | + --muted: #a1a1aa; | |
| 25 | + --line: #27272a; | |
| 26 | + --pulse: #fafafa; | |
| 27 | + } | |
| 28 | + @media (prefers-color-scheme: light) { | |
| 29 | + :root { | |
| 30 | + --background: #f7f7f4; | |
| 31 | + --foreground: #18181b; | |
| 32 | + --muted: #60646c; | |
| 33 | + --line: #d8d8d0; | |
| 34 | + --pulse: #18181b; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + body { | |
| 38 | + margin: 0; | |
| 39 | + min-height: 100vh; | |
| 40 | + display: grid; | |
| 41 | + place-items: center; | |
| 42 | + background: var(--background); | |
| 43 | + color: var(--foreground); | |
| 44 | + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 45 | + } | |
| 46 | + main { | |
| 47 | + width: min(22rem, calc(100vw - 4rem)); | |
| 48 | + display: grid; | |
| 49 | + gap: 1rem; | |
| 50 | + justify-items: center; | |
| 51 | + text-align: center; | |
| 52 | + } | |
| 53 | + .mark { | |
| 54 | + width: 2.25rem; | |
| 55 | + height: 2.25rem; | |
| 56 | + border: 1px solid var(--line); | |
| 57 | + border-radius: 8px; | |
| 58 | + display: grid; | |
| 59 | + place-items: center; | |
| 60 | + font-size: 0.82rem; | |
| 61 | + font-weight: 650; | |
| 62 | + letter-spacing: 0; | |
| 63 | + } | |
| 64 | + h1 { | |
| 65 | + margin: 0; | |
| 66 | + font-size: 1.05rem; | |
| 67 | + font-weight: 600; | |
| 68 | + letter-spacing: 0; | |
| 69 | + } | |
| 70 | + p { | |
| 71 | + margin: -0.35rem 0 0; | |
| 72 | + color: var(--muted); | |
| 73 | + font-size: 0.82rem; | |
| 74 | + line-height: 1.5; | |
| 75 | + } | |
| 76 | + .activity { | |
| 77 | + width: 8rem; | |
| 78 | + height: 2px; | |
| 79 | + overflow: hidden; | |
| 80 | + border-radius: 999px; | |
| 81 | + background: var(--line); | |
| 82 | + } | |
| 83 | + .activity::after { | |
| 84 | + content: ""; | |
| 85 | + display: block; | |
| 86 | + width: 45%; | |
| 87 | + height: 100%; | |
| 88 | + border-radius: inherit; | |
| 89 | + background: var(--pulse); | |
| 90 | + animation: slide 1.15s ease-in-out infinite; | |
| 91 | + } | |
| 92 | + @keyframes slide { | |
| 93 | + 0% { transform: translateX(-110%); opacity: 0.35; } | |
| 94 | + 50% { opacity: 0.9; } | |
| 95 | + 100% { transform: translateX(245%); opacity: 0.35; } | |
| 96 | + } | |
| 97 | + @media (prefers-reduced-motion: reduce) { | |
| 98 | + .activity::after { animation: none; transform: none; width: 100%; opacity: 0.55; } | |
| 99 | + } | |
| 100 | + </style> | |
| 101 | + </head> | |
| 102 | + <body> | |
| 103 | + <main> | |
| 104 | + <div class="mark">Ex</div> | |
| 105 | + <h1>Starting Executor…</h1> | |
| 106 | + <p>Preparing your local workspace.</p> | |
| 107 | + <div class="activity" aria-hidden="true"></div> | |
| 108 | + </main> | |
| 109 | + </body> | |
| 110 | +</html>`; | |
| 111 | + | |
| 14 | 112 | export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<!doctype html> |
| 15 | 113 | <html> |
| 16 | 114 | <head> |
| @@ -34,7 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | initErrorReporting, |
| 35 | 35 | reportAProblem, |
| 36 | 36 | } from "./diagnostics"; |
| 37 | −import { sidecarCrashHtml } from "./crash-screen"; | |
| 37 | +import { sidecarCrashHtml, startupWindowHtml } from "./crash-screen"; | |
| 38 | 38 | import { replaceSupervisedDaemonForDesktop } from "./supervised-connection"; |
| 39 | 39 | import { |
| 40 | 40 | bundledExecutorPath, |
| @@ -137,6 +137,9 @@ const webUrlForConnection = (conn: SidecarConnection): string => { · 96 unmodified lines | ||
| 137 | 137 | return url.toString(); |
| 138 | 138 | }; |
| 139 | 139 | |
| 140 | +const htmlDataUrl = (html: string): string => | |
| 141 | + `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; | |
| 142 | + | |
| 140 | 143 | // The supervised daemon (and the desktop sidecar) own this data dir — the same |
| 141 | 144 | // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. |
| 142 | 145 | const DESKTOP_DATA_DIR = join(homedir(), ".executor"); |
| @@ -216,6 +219,40 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 73 unmodified lines | ||
| 216 | 219 | return response === 0; |
| 217 | 220 | }; |
| 218 | 221 | |
| 222 | +const acceptSupervisedConnection = async ( | |
| 223 | + attached: SidecarConnection, | |
| 224 | +): Promise<SidecarConnection | null> => { | |
| 225 | + if (!shouldReplaceDaemonForDesktop(attached)) return attached; | |
| 226 | + const settings = getServerSettings(); | |
| 227 | + return replaceSupervisedDaemonForDesktop(attached, { | |
| 228 | + install: () => | |
| 229 | + installSupervisedService({ | |
| 230 | + port: settings.port, | |
| 231 | + dataDir: DESKTOP_DATA_DIR, | |
| 232 | + }), | |
| 233 | + waitForAttach: () => waitForSupervisedAttach(30_000, { port: settings.port }), | |
| 234 | + attach: attachToSupervisedDaemon, | |
| 235 | + onInstallFailure: (error) => { | |
| 236 | + log.warn("Failed to replace older supervised daemon; re-checking before falling back", error); | |
| 237 | + }, | |
| 238 | + }); | |
| 239 | +}; | |
| 240 | + | |
| 241 | +const installSupervisedAndWait = async (reason: string): Promise<SidecarConnection | null> => { | |
| 242 | + const settings = getServerSettings(); | |
| 243 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches | |
| 244 | + try { | |
| 245 | + await installSupervisedService({ | |
| 246 | + port: settings.port, | |
| 247 | + dataDir: DESKTOP_DATA_DIR, | |
| 248 | + }); | |
| 249 | + } catch (error) { | |
| 250 | + log.warn(`Failed to install supervised service after ${reason}; using managed sidecar`, error); | |
| 251 | + return null; | |
| 252 | + } | |
| 253 | + return waitForSupervisedAttach(15_000, { port: settings.port }); | |
| 254 | +}; | |
| 255 | + | |
| 219 | 256 | /** |
| 220 | 257 | * Resolve a connection to the OS-supervised daemon, installing it on first run |
| 221 | 258 | * (with consent). Returns null when supervision is unavailable or the user |
| @@ -225,23 +262,7 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 3 unmodified lines | ||
| 225 | 262 | // 1. Already running → attach. |
| 226 | 263 | const attached = await attachToSupervisedDaemon(); |
| 227 | 264 | if (attached) { |
| 228 | − if (!shouldReplaceDaemonForDesktop(attached)) return attached; | |
| 229 | − const settings = getServerSettings(); | |
| 230 | − return replaceSupervisedDaemonForDesktop(attached, { | |
| 231 | − install: () => | |
| 232 | − installSupervisedService({ | |
| 233 | − port: settings.port, | |
| 234 | − dataDir: DESKTOP_DATA_DIR, | |
| 235 | − }), | |
| 236 | − waitForAttach: () => waitForSupervisedAttach(30_000, { port: settings.port }), | |
| 237 | − attach: attachToSupervisedDaemon, | |
| 238 | − onInstallFailure: (error) => { | |
| 239 | − log.warn( | |
| 240 | − "Failed to replace older supervised daemon; re-checking before falling back", | |
| 241 | − error, | |
| 242 | − ); | |
| 243 | − }, | |
| 244 | − }); | |
| 265 | + return acceptSupervisedConnection(attached); | |
| 245 | 266 | } |
| 246 | 267 | |
| 247 | 268 | // Headless/e2e: the first-run background-service prompt and the systemd/ |
| @@ -254,32 +275,41 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 6 unmodified lines | ||
| 254 | 275 | const status = await supervisedServiceStatus(); |
| 255 | 276 | if (!status.supported) return null; |
| 256 | 277 | |
| 257 | − // 2. Registered but not currently serving → kick it and wait. | |
| 278 | + // 2. Registered and running, but the first attach missed it. Kick once and | |
| 279 | + // wait only when the kick command succeeds. | |
| 258 | 280 | if (status.registered) { |
| 259 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn | |
| 281 | + if (status.running) { | |
| 282 | + const attachedAfterStatus = await attachToSupervisedDaemon(); | |
| 283 | + if (attachedAfterStatus) return acceptSupervisedConnection(attachedAfterStatus); | |
| 284 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn | |
| 285 | + try { | |
| 286 | + await restartSupervisedService(); | |
| 287 | + } catch (error) { | |
| 288 | + log.warn( | |
| 289 | + "Failed to restart running supervised service after attach failed; using managed sidecar", | |
| 290 | + error, | |
| 291 | + ); | |
| 292 | + return null; | |
| 293 | + } | |
| 294 | + return waitForSupervisedAttach(15_000); | |
| 295 | + } | |
| 296 | + | |
| 297 | + // 3. Registered but not running usually means a stale plist/unit/task. Try | |
| 298 | + // restart first, then reinstall the service if the OS manager cannot start it. | |
| 299 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: restart failure triggers supervised service self-heal | |
| 260 | 300 | try { |
| 261 | 301 | await restartSupervisedService(); |
| 262 | 302 | } catch (error) { |
| 263 | − log.warn("Failed to kickstart supervised service", error); | |
| 303 | + log.warn("Failed to restart registered supervised service; reinstalling", error); | |
| 304 | + return installSupervisedAndWait("registered service restart failure"); | |
| 264 | 305 | } |
| 265 | 306 | return waitForSupervisedAttach(15_000); |
| 266 | 307 | } |
| 267 | 308 | |
| 268 | − // 3. First run → ask, then install + start. The unit carries no secret; the | |
| 309 | + // 4. First run → ask, then install + start. The unit carries no secret; the | |
| 269 | 310 | // supervised daemon mints/loads its bearer from auth.json under DESKTOP_DATA_DIR. |
| 270 | 311 | if (!(await confirmEnableBackgroundService())) return null; |
| 271 | − const settings = getServerSettings(); | |
| 272 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches | |
| 273 | − try { | |
| 274 | − await installSupervisedService({ | |
| 275 | − port: settings.port, | |
| 276 | − dataDir: DESKTOP_DATA_DIR, | |
| 277 | − }); | |
| 278 | − } catch (error) { | |
| 279 | − log.error("Failed to install supervised service; using managed sidecar", error); | |
| 280 | − return null; | |
| 281 | − } | |
| 282 | − return waitForSupervisedAttach(15_000); | |
| 312 | + return installSupervisedAndWait("first-run prompt"); | |
| 283 | 313 | }; |
| 284 | 314 | |
| 285 | 315 | // Crash monitor for the supervised daemon: the OS service manager restarts it |
| @@ -308,8 +338,7 @@ const armSupervisedMonitor = () => { · 22 unmodified lines | ||
| 308 | 338 | if (supervisedMonitorMisses < SUPERVISED_MONITOR_MISSES_BEFORE_DOWN) return; |
| 309 | 339 | if (!supervisedDaemonDown && window) { |
| 310 | 340 | supervisedDaemonDown = true; |
| 311 | − const html = sidecarCrashHtml({ reported: errorReportingEnabled }); | |
| 312 | − void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); | |
| 341 | + showCrashScreen(window); | |
| 313 | 342 | } |
| 314 | 343 | return; |
| 315 | 344 | } |
| @@ -408,14 +437,12 @@ const installDockIcon = () => { · 92 unmodified lines | ||
| 408 | 437 | log.info(`[dock-icon] set to ${iconPath} (${image.getSize().width}×${image.getSize().height})`); |
| 409 | 438 | }; |
| 410 | 439 | |
| 411 | −const createWindow = async (conn: SidecarConnection) => { | |
| 440 | +const createMainBrowserWindow = (options: { readonly show: boolean }): BrowserWindow => { | |
| 412 | 441 | const windowState = windowStateKeeper({ |
| 413 | 442 | defaultWidth: 1280, |
| 414 | 443 | defaultHeight: 800, |
| 415 | 444 | }); |
| 416 | 445 | |
| 417 | − installBearerAuthHeader(conn.baseUrl, conn.authToken); | |
| 418 | − | |
| 419 | 446 | const linuxIcon = resolveLinuxIcon(); |
| 420 | 447 | |
| 421 | 448 | const window = new BrowserWindow({ |
| @@ -431,7 +458,7 @@ const createWindow = async (conn: SidecarConnection) => { · 9 unmodified lines | ||
| 431 | 458 | // header, which is offset to clear them (.desktop-macos-titlebar). |
| 432 | 459 | minWidth: 768, |
| 433 | 460 | minHeight: 480, |
| 434 | − show: false, | |
| 461 | + show: options.show, | |
| 435 | 462 | backgroundColor: "#0a0a0a", |
| 436 | 463 | autoHideMenuBar: true, |
| 437 | 464 | ...(process.platform === "darwin" |
| @@ -488,15 +515,45 @@ const createWindow = async (conn: SidecarConnection) => { · 50 unmodified lines | ||
| 488 | 515 | return { action: "deny" }; |
| 489 | 516 | }); |
| 490 | 517 | |
| 518 | + return window; | |
| 519 | +}; | |
| 520 | + | |
| 521 | +const showStartupWindow = async (): Promise<void> => { | |
| 522 | + const window = liveMainWindow() ?? createMainBrowserWindow({ show: true }); | |
| 523 | + if (window.isMinimized()) window.restore(); | |
| 524 | + if (!window.isVisible()) window.show(); | |
| 525 | + window.focus(); | |
| 526 | + | |
| 527 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local startup HTML should not block service startup | |
| 528 | + try { | |
| 529 | + await window.loadURL(htmlDataUrl(startupWindowHtml())); | |
| 530 | + } catch (error) { | |
| 531 | + log.warn("Failed to load startup window", error); | |
| 532 | + } | |
| 533 | +}; | |
| 534 | + | |
| 535 | +const showCrashScreen = (window: BrowserWindow | null = liveMainWindow()): void => { | |
| 536 | + if (!window) return; | |
| 537 | + const html = sidecarCrashHtml({ reported: errorReportingEnabled }); | |
| 538 | + void window.loadURL(htmlDataUrl(html)); | |
| 539 | +}; | |
| 540 | + | |
| 541 | +const createWindow = async (conn: SidecarConnection) => { | |
| 542 | + installBearerAuthHeader(conn.baseUrl, conn.authToken); | |
| 543 | + | |
| 544 | + const existingWindow = liveMainWindow(); | |
| 545 | + const window = existingWindow ?? createMainBrowserWindow({ show: false }); | |
| 546 | + | |
| 491 | 547 | // A supervised daemon can pass the health probe and still disappear before |
| 492 | 548 | // navigation begins. Treat that as a failed connection instead of leaving the |
| 493 | 549 | // user with a visible BrowserWindow that only shows the black background. |
| 494 | 550 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects when the sidecar vanishes during startup |
| 495 | 551 | try { |
| 496 | 552 | await window.loadURL(webUrlForConnection(conn)); |
| 553 | + if (!window.isDestroyed() && !window.isVisible()) window.show(); | |
| 497 | 554 | } catch (error) { |
| 498 | 555 | log.error("Failed to load Executor web UI", error); |
| 499 | − destroyWindow(window); | |
| 556 | + if (!existingWindow) destroyWindow(window); | |
| 500 | 557 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure |
| 501 | 558 | throw error; |
| 502 | 559 | } |
| @@ -916,6 +973,7 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 413 unmodified lines | ||
| 916 | 973 | // here, the boot-time update check never runs (it sits after a successful |
| 917 | 974 | // sidecar start), so a broken app could never self-update its way out. |
| 918 | 975 | const handleFatalSidecarFailure = async (error: unknown) => { |
| 976 | + showCrashScreen(); | |
| 919 | 977 | if (app.isPackaged) { |
| 920 | 978 | // Install whatever finishes downloading by the time the user quits the |
| 921 | 979 | // failure dialog; if it downloads while the dialog is open, the regular |
| @@ -1035,6 +1093,7 @@ const installApplicationMenu = () => { · 113 unmodified lines | ||
| 1035 | 1093 | const boot = async () => { |
| 1036 | 1094 | installDockIcon(); |
| 1037 | 1095 | installApplicationMenu(); |
| 1096 | + await showStartupWindow(); | |
| 1038 | 1097 | setupAutoUpdater(); |
| 1039 | 1098 | applyFakeUpdateFromEnv(); |
| 1040 | 1099 | registerIpcHandlers(); |
| @@ -1042,10 +1101,7 @@ const boot = async () => { · 1 unmodified line | ||
| 1042 | 1101 | // every request with no explanation. Swap in the crash screen — its |
| 1043 | 1102 | // buttons drive the regular preload bridge (restart / export diagnostics). |
| 1044 | 1103 | onUnexpectedSidecarExit(() => { |
| 1045 | − const window = liveMainWindow(); | |
| 1046 | − if (!window) return; | |
| 1047 | − const html = sidecarCrashHtml({ reported: errorReportingEnabled }); | |
| 1048 | − void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); | |
| 1104 | + showCrashScreen(); | |
| 1049 | 1105 | // A crashing sidecar may be a broken release — quietly stage any |
| 1050 | 1106 | // available update so the install prompt appears on its own (same |
| 1051 | 1107 | // self-heal as the fatal startup path). |