Skip to content

Commit 5023a2a

Browse files
committed
Desktop boot: show a window immediately and self-heal stale launchd state
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.
1 parent 7f61805 commit 5023a2a

2 files changed

Lines changed: 199 additions & 45 deletions

File tree

apps/desktop/src/main/crash-screen.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,104 @@ export interface CrashScreenOptions {
1111
readonly reported: boolean;
1212
}
1313

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&hellip;</h1>
106+
<p>Preparing your local workspace.</p>
107+
<div class="activity" aria-hidden="true"></div>
108+
</main>
109+
</body>
110+
</html>`;
111+
14112
export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<!doctype html>
15113
<html>
16114
<head>

apps/desktop/src/main/index.ts

Lines changed: 101 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
initErrorReporting,
3535
reportAProblem,
3636
} from "./diagnostics";
37-
import { sidecarCrashHtml } from "./crash-screen";
37+
import { sidecarCrashHtml, startupWindowHtml } from "./crash-screen";
3838
import { replaceSupervisedDaemonForDesktop } from "./supervised-connection";
3939
import {
4040
bundledExecutorPath,
@@ -137,6 +137,9 @@ const webUrlForConnection = (conn: SidecarConnection): string => {
137137
return url.toString();
138138
};
139139

140+
const htmlDataUrl = (html: string): string =>
141+
`data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
142+
140143
// The supervised daemon (and the desktop sidecar) own this data dir — the same
141144
// path the CLI's `executor web`/daemon uses, so desktop and CLI share state.
142145
const DESKTOP_DATA_DIR = join(homedir(), ".executor");
@@ -216,6 +219,40 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => {
216219
return response === 0;
217220
};
218221

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+
219256
/**
220257
* Resolve a connection to the OS-supervised daemon, installing it on first run
221258
* (with consent). Returns null when supervision is unavailable or the user
@@ -225,23 +262,7 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> =
225262
// 1. Already running → attach.
226263
const attached = await attachToSupervisedDaemon();
227264
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);
245266
}
246267

247268
// Headless/e2e: the first-run background-service prompt and the systemd/
@@ -254,32 +275,41 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> =
254275
const status = await supervisedServiceStatus();
255276
if (!status.supported) return null;
256277

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.
258280
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
260300
try {
261301
await restartSupervisedService();
262302
} 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");
264305
}
265306
return waitForSupervisedAttach(15_000);
266307
}
267308

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
269310
// supervised daemon mints/loads its bearer from auth.json under DESKTOP_DATA_DIR.
270311
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");
283313
};
284314

285315
// Crash monitor for the supervised daemon: the OS service manager restarts it
@@ -308,8 +338,7 @@ const armSupervisedMonitor = () => {
308338
if (supervisedMonitorMisses < SUPERVISED_MONITOR_MISSES_BEFORE_DOWN) return;
309339
if (!supervisedDaemonDown && window) {
310340
supervisedDaemonDown = true;
311-
const html = sidecarCrashHtml({ reported: errorReportingEnabled });
312-
void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
341+
showCrashScreen(window);
313342
}
314343
return;
315344
}
@@ -408,14 +437,12 @@ const installDockIcon = () => {
408437
log.info(`[dock-icon] set to ${iconPath} (${image.getSize().width}×${image.getSize().height})`);
409438
};
410439

411-
const createWindow = async (conn: SidecarConnection) => {
440+
const createMainBrowserWindow = (options: { readonly show: boolean }): BrowserWindow => {
412441
const windowState = windowStateKeeper({
413442
defaultWidth: 1280,
414443
defaultHeight: 800,
415444
});
416445

417-
installBearerAuthHeader(conn.baseUrl, conn.authToken);
418-
419446
const linuxIcon = resolveLinuxIcon();
420447

421448
const window = new BrowserWindow({
@@ -431,7 +458,7 @@ const createWindow = async (conn: SidecarConnection) => {
431458
// header, which is offset to clear them (.desktop-macos-titlebar).
432459
minWidth: 768,
433460
minHeight: 480,
434-
show: false,
461+
show: options.show,
435462
backgroundColor: "#0a0a0a",
436463
autoHideMenuBar: true,
437464
...(process.platform === "darwin"
@@ -488,15 +515,45 @@ const createWindow = async (conn: SidecarConnection) => {
488515
return { action: "deny" };
489516
});
490517

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+
491547
// A supervised daemon can pass the health probe and still disappear before
492548
// navigation begins. Treat that as a failed connection instead of leaving the
493549
// user with a visible BrowserWindow that only shows the black background.
494550
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects when the sidecar vanishes during startup
495551
try {
496552
await window.loadURL(webUrlForConnection(conn));
553+
if (!window.isDestroyed() && !window.isVisible()) window.show();
497554
} catch (error) {
498555
log.error("Failed to load Executor web UI", error);
499-
destroyWindow(window);
556+
if (!existingWindow) destroyWindow(window);
500557
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure
501558
throw error;
502559
}
@@ -916,6 +973,7 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {
916973
// here, the boot-time update check never runs (it sits after a successful
917974
// sidecar start), so a broken app could never self-update its way out.
918975
const handleFatalSidecarFailure = async (error: unknown) => {
976+
showCrashScreen();
919977
if (app.isPackaged) {
920978
// Install whatever finishes downloading by the time the user quits the
921979
// failure dialog; if it downloads while the dialog is open, the regular
@@ -1035,17 +1093,15 @@ const installApplicationMenu = () => {
10351093
const boot = async () => {
10361094
installDockIcon();
10371095
installApplicationMenu();
1096+
await showStartupWindow();
10381097
setupAutoUpdater();
10391098
applyFakeUpdateFromEnv();
10401099
registerIpcHandlers();
10411100
// A sidecar that dies under a live window would leave the web UI failing
10421101
// every request with no explanation. Swap in the crash screen — its
10431102
// buttons drive the regular preload bridge (restart / export diagnostics).
10441103
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();
10491105
// A crashing sidecar may be a broken release — quietly stage any
10501106
// available update so the install prompt appears on its own (same
10511107
// self-heal as the fatal startup path).

0 commit comments

Comments
 (0)