File size: 31,809 Bytes
c0af099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
/**
 * Electron Main Process β€” Agent Canvas Desktop
 *
 * Starts the full Agent Canvas stack (agent-server + automation via uvx,
 * static frontend, ingress proxy), then opens a native BrowserWindow once
 * the ingress is ready. Shows a loading screen while backends start.
 *
 * Path layout (electron-builder uses directories.app: 'electron'):
 *
 *   Packaged (macOS example):
 *     Contents/Resources/app/     ← __dirname (main.mjs lives here)
 *       main.mjs
 *       loading.html
 *       scripts/                  ← copied from repo scripts/
 *       config/                   ← copied from repo config/
 *       build/                    ← static frontend
 *     Contents/Resources/bin/     ← process.resourcesPath/bin
 *       uv  uvx                   ← bundled via extraResources
 *
 *   Dev (npm run desktop  β†’  electron electron):
 *     electron/main.mjs           ← __dirname = <repo>/electron/
 *     scripts/ config/ build/     ← one level up: <repo>/
 *     system uvx from PATH
 *
 * When packaged, scripts/config/build are siblings of main.mjs so
 * projectRoot === __dirname. In dev they are one level up.
 *
 * The dev command points electron at the electron/ DIRECTORY, not at
 * main.mjs directly. Electron's default_app only reads name/productName/
 * version out of <arg>/package.json, so passing the file makes it look for
 * electron/main.mjs/package.json, miss, and leave app.name at the host
 * bundle's default β€” "Electron" in the menu bar and userData path.
 */

import {
  app,
  BrowserWindow,
  clipboard,
  dialog,
  ipcMain,
  nativeImage,
  nativeTheme,
  shell,
} from "electron";
import { chmodSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";

import { isExternalBrowsableUrl, isLoopbackAppUrl } from "./lib/window-url-policy.mjs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ── Path resolution ───────────────────────────────────────────────────────────
// Packaged (directories.app: 'electron'): scripts/config/build are SIBLINGS of
// main.mjs inside Resources/app/, so projectRoot === __dirname.
// Dev (electron electron): those directories are one level UP in the
// repo root, so projectRoot === join(__dirname, '..').
// Both branches key off __dirname (always <repo>/electron in dev), not
// app.getAppPath(), so the entry-point form doesn't affect them.

const projectRoot = app.isPackaged ? __dirname : join(__dirname, "..");
const buildDir = join(projectRoot, "build");
const scriptsDir = join(projectRoot, "scripts");

// OpenHands raised-hands app icon, used as the BrowserWindow.icon option.
// Windows gets the multi-size icon.ico (16β†’256, small sizes as classic BMP
// entries β€” the Windows shell needs those); Linux uses the 1024Γ—1024 PNG
// for its taskbar. On macOS the dock icon comes from the .app bundle's
// icon.icns, so this path is unused there. Both files live next to main.mjs
// in dev and are copied into Resources/app/build-resources/ via the
// `files:` array. Regenerate with `npm run generate-icons`.
const appIconPath = join(
  __dirname,
  "build-resources",
  process.platform === "win32" ? "icon.ico" : "icon.png",
);

// electron-builder's NSIS shortcuts are stamped with AppUserModelId
// ${APP_ID} (WinShell::SetLnkAUMI in installer.nsh). Declare the same id so
// running/pinned taskbar entries group with the shortcut and inherit its
// icon. Must match appId in electron-builder.config.mjs, and must be set
// before any BrowserWindow is created.
if (process.platform === "win32") {
  app.setAppUserModelId("dev.openhands.agent-canvas");
}

// ── Bundled uv ────────────────────────────────────────────────────────────────

/**
 * Inject the bundled uv binary into PATH so that uvx calls inside
 * dev-with-automation.mjs resolve to our bundled binary.
 * No-op in dev mode (falls back to system uv).
 */
function injectBundledUv() {
  if (!app.isPackaged) return;

  const isWin = process.platform === "win32";
  const uvName = isWin ? "uv.exe" : "uv";
  const uvxName = isWin ? "uvx.exe" : "uvx";
  const binDir = join(process.resourcesPath, "bin");
  const uvPath = join(binDir, uvName);

  // We only probe for `uv` here β€” `uv` and `uvx` ship together in the
  // bundle (`download-uv.mjs` writes both), so if `uv` is present we
  // assume `uvx` is too. `uvxAvailable()` is called separately by
  // start-up code to confirm the resolved binary actually runs.
  if (!existsSync(uvPath)) {
    console.warn("[desktop] Bundled uv not found at", uvPath);
    return;
  }

  // electron-builder copies files without preserving the +x bit on Unix.
  if (!isWin) {
    try {
      chmodSync(uvPath, 0o755);
      const uvxPath = join(binDir, uvxName);
      if (existsSync(uvxPath)) chmodSync(uvxPath, 0o755);
    } catch {}
  }

  const sep = isWin ? ";" : ":";
  process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
  console.log("[desktop] Injected bundled uv from", binDir);
}

/**
 * Verify uvx is reachable (either bundled or system).
 * Returns true/false β€” callers show a dialog on false.
 */
function uvxAvailable() {
  const cmd = process.platform === "win32" ? "uvx.exe" : "uvx";
  const r = spawnSync(cmd, ["--version"], { stdio: "pipe" });
  return r.status === 0;
}

/**
 * Inject the bundled Node.js distribution into PATH so subsequent spawns
 * can find `node`, `npm`, and `npx`.
 *
 * When the app runs as a packaged .app on macOS, the system PATH is minimal
 * (/usr/bin:/bin only) β€” Homebrew, nvm, asdf etc. installs of Node are
 * invisible. Two breakages flow from that:
 *
 *   1. The dev-with-automation.mjs stack spawns `node scripts/ingress.mjs`
 *      and `node scripts/static-server.mjs`; if `node` is not found those
 *      processes fail silently and port 8000 never responds.
 *   2. Most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.)
 *      use `command: "npx"`. When the agent-server tries to spawn one the
 *      missing `npx` makes the spawn fail with ENOENT; the SDK reports it
 *      as an `error_kind: "connection"` MCP test failure, surfaced in the
 *      install modal as "Could not reach the server".
 *
 * We tried bridging via Electron-as-Node (ELECTRON_RUN_AS_NODE=1) wrappers
 * first. That fixed the ENOENT but introduced a new failure: stdio MCP
 * servers spawned through the wrapper exited with "McpError: Connection
 * closed" before the JSON-RPC handshake completed. Electron-as-Node is
 * fine for our networking helper scripts but its stdin/stdout semantics
 * differ enough from a vanilla `node` binary that stdio JSON-RPC servers
 * are not reliable under it. The robust fix is to ship a real Node.js
 * runtime as an extraResource (see scripts/download-node.mjs and the
 * `resources/node/` entry in electron-builder.config.mjs) and just put
 * its bin dir on PATH.
 *
 * No-op in dev mode (`npm run desktop`): the user's terminal PATH already
 * has Node tooling and `app.isPackaged` is false. If the bundled dir is
 * somehow missing (e.g. the download step was skipped during packaging),
 * we log a loud warning and leave PATH untouched so the failure mode is
 * obvious in the console rather than confusing downstream.
 */
function injectBundledNode() {
  if (!app.isPackaged) return;

  const isWin = process.platform === "win32";
  const nodeRoot = join(process.resourcesPath, "node");
  // POSIX Node distributions put binaries in bin/; Windows zips put node.exe
  // and the npm.cmd / npx.cmd wrappers at the distribution root.
  const binDir = isWin ? nodeRoot : join(nodeRoot, "bin");
  const nodeExe = isWin ? join(nodeRoot, "node.exe") : join(binDir, "node");

  if (!existsSync(nodeExe)) {
    console.warn(
      `[desktop] Bundled Node.js not found at ${nodeExe} β€” backend ` +
        "scripts and stdio MCP servers will fail. Run `npm run download-node` " +
        "and rebuild.",
    );
    return;
  }

  // node.exe alone is not enough. npm / npx are wrapper scripts that exec
  // npm's JS entry points out of the distribution's own node_modules, and
  // that directory is the one piece electron-builder drops on Windows (see
  // restoreBundledNodeNpm in electron-builder.config.mjs). Since we PREPEND
  // this dir to PATH, a half-copied bundle doesn't just fail to help β€” it
  // shadows the user's working npm with shims that die on MODULE_NOT_FOUND.
  // Warn loudly, but still inject: `node` itself works and the backend
  // launcher scripts need it.
  const npmCli = isWin
    ? join(nodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
    : join(nodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
  if (!existsSync(npmCli)) {
    console.warn(
      `[desktop] Bundled npm is incomplete β€” ${npmCli} is missing. ` +
        "`npx`-launched subprocesses (stdio MCP servers, ACP servers) will " +
        "fail with MODULE_NOT_FOUND, and this bundle shadows any npm already " +
        "on PATH. Rebuild with `npm run download-node`.",
    );
  }

  // electron-builder doesn't always preserve the +x bit on POSIX. node, npm,
  // and npx need to be executable for shell PATH lookup to consider them.
  if (!isWin) {
    const required = ["node", "npm", "npx"];
    for (const name of required) {
      const p = join(binDir, name);
      try {
        if (existsSync(p)) chmodSync(p, 0o755);
      } catch {
        // best-effort: a stale read-only mount or test fixture is fine to skip
      }
    }
  }

  const sep = isWin ? ";" : ":";
  process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
  console.log("[desktop] Injected bundled Node from", binDir);
}

// ── Readiness polling ─────────────────────────────────────────────────────────

/**
 * Wait until `url` responds at all (status < 500). Used to confirm the
 * ingress proxy is bound β€” not a guarantee that the agent-server behind it
 * is ready. Use {@link waitForAgentServer} for that.
 */
async function waitForUrl(url, timeoutMs = 120_000, intervalMs = 600) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
      if (res.status < 500) return;
    } catch {}
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(
    `Timed out waiting for ${url} to become ready (${timeoutMs / 1000}s).`,
  );
}

/**
 * Wait until `url` returns HTTP 200 β€” meaning the agent-server itself is
 * serving requests, not just that the ingress proxy is up.
 *
 * On first launch, `uvx` has to download a Python toolchain and install
 * `openhands-agent-server` and its workspace deps from PyPI, which can
 * easily take a few minutes on a slow network. We poll the route end-to-end
 * (through ingress on port 8000, so a missing or restarted ingress is also
 * caught) instead of just probing the static-server fallback that
 * `waitForUrl` would accept.
 */
async function waitForAgentServer(
  url = "http://localhost:8000/server_info",
  timeoutMs = 10 * 60_000,
  intervalMs = 1_000,
) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
      // Only 200 is success here. 502 from ingress means the upstream agent
      // server isn't bound yet; 401 means auth is required and the bundled
      // key didn't reach us β€” we still treat that as "the agent server is
      // up", because the proxy got a real HTTP response from it.
      if (res.status === 200 || res.status === 401) return;
    } catch {
      // Transient network / DNS / timeout β€” keep polling until the deadline.
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(
    `Agent server at ${url} never came up (${Math.round(timeoutMs / 1000)}s). ` +
      "Check the terminal log for errors from uvx / the agent-server process.",
  );
}

// ── Windows ───────────────────────────────────────────────────────────────────

let loadingWin = null;
let mainWin = null;

// Collapsed splash size β€” loading.html's .container height must match. The
// expanded height reveals the startup-log console below it ("Show details").
const LOADING_WIN_WIDTH = 460;
const LOADING_WIN_HEIGHT = 360;
const LOADING_WIN_EXPANDED_HEIGHT = 560;

/**
 * Grow or shrink the loading window to reveal/hide the startup-log console.
 * Keeps the top edge fixed so the splash content doesn't jump. Invoked from
 * the renderer ("Show details" toggle) and from showStartupFailure().
 */
function setLoadingWindowExpanded(expanded) {
  if (!loadingWin || loadingWin.isDestroyed()) return;
  const bounds = loadingWin.getBounds();
  const height = expanded ? LOADING_WIN_EXPANDED_HEIGHT : LOADING_WIN_HEIGHT;
  if (bounds.height === height) return;
  // macOS ignores programmatic resizes of resizable:false windows on some
  // Electron versions β€” lift the flag around the change.
  loadingWin.setResizable(true);
  loadingWin.setBounds({ ...bounds, height }, true);
  loadingWin.setResizable(false);
}

function createLoadingWindow() {
  loadingWin = new BrowserWindow({
    width: LOADING_WIN_WIDTH,
    // Tall enough to fit the streaming status line + the "first launch can
    // take a few minutes" hint without scrollbars.
    height: LOADING_WIN_HEIGHT,
    resizable: false,
    frame: false,
    center: true,
    show: false,
    // Pre-paint window color; must match --oh-background in loading.html.
    backgroundColor: "#0b0e14",
    icon: appIconPath,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      // Bridges the startup-log console over IPC (see preload.cjs).
      preload: join(__dirname, "preload.cjs"),
    },
  });

  // The renderer can only receive IPC once the page has loaded β€” replay the
  // lines buffered until now, then stream live batches (see appendBootLog).
  loadingWin.webContents.on("did-finish-load", () => {
    if (!loadingWin || loadingWin.isDestroyed()) return;
    clearTimeout(bootLogFlushTimer);
    bootLogFlushTimer = null;
    bootLogPending = [];
    if (bootLog.length) {
      loadingWin.webContents.send("boot-log:batch", bootLog.slice());
    }
    bootLogReady = true;
    if (fatalSummary) {
      loadingWin.webContents.send("boot-log:fatal", fatalSummary);
    }
  });

  loadingWin.loadFile(join(__dirname, "loading.html"));
  loadingWin.once("ready-to-show", () => loadingWin?.show());
}

function createMainWindow() {
  mainWin = new BrowserWindow({
    width: 1440,
    height: 900,
    minWidth: 800,
    minHeight: 600,
    show: false,
    // App-shell background (--oh-background in src/index.css) β€” avoids white
    // flashes during the show β†’ maximize repaint after the splash closes.
    backgroundColor: "#0b0e14",
    titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
    icon: appIconPath,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
    },
  });

  mainWin.loadURL("http://localhost:8000");

  mainWin.once("ready-to-show", () => {
    loadingWin?.destroy();
    loadingWin = null;
    mainWin?.show();
    mainWin?.maximize();
  });

  // Route window.open() calls appropriately.
  mainWin.webContents.setWindowOpenHandler(({ url }) => {
    // The "Login with OpenHands Cloud" device-flow opens about:blank immediately
    // on the user's click (to beat popup blockers), then navigates the popup to
    // the OAuth verification URL once it has one.  We must allow about:blank
    // through so window.open() returns a non-null WindowProxy; the did-create-window
    // handler below redirects the popup to the system browser when it navigates.
    if (url === "about:blank") {
      return {
        action: "allow",
        overrideBrowserWindowOptions: { width: 800, height: 700 },
      };
    }
    // All other URLs open directly in the system browser. The loopback test
    // goes through URL parsing: prefix matching would also accept
    // attacker-controlled hosts like http://localhost.evil.com (or
    // http://localhost@evil.com) and render them in a chromeless native
    // window. Schemes outside the openExternal allowlist are denied
    // outright β€” shell.openExternal would forward them to OS protocol
    // handlers.
    if (isLoopbackAppUrl(url)) {
      return { action: "allow" };
    }
    if (isExternalBrowsableUrl(url)) {
      shell.openExternal(url);
    }
    return { action: "deny" };
  });

  // When the renderer opens a popup (the about:blank above), watch for its
  // first navigation away from about:blank.  That navigation will be to the
  // OAuth verification URL β€” open it in the system browser and close the
  // now-unneeded Electron popup.
  mainWin.webContents.on("did-create-window", (popupWin) => {
    popupWin.webContents.on("will-navigate", (_event, url) => {
      if (url !== "about:blank" && !isLoopbackAppUrl(url)) {
        _event.preventDefault();
        if (isExternalBrowsableUrl(url)) {
          shell.openExternal(url);
        }
        popupWin.close();
      }
    });
  });

  mainWin.on("closed", () => {
    mainWin = null;
  });
}

// ── Startup log buffer ────────────────────────────────────────────────────────
//
// Every service log line (all services, all levels, sanitized) is kept in a
// bounded buffer and streamed to the loading window's console in batches over
// IPC (see preload.cjs + loading.html). The buffer is the single source of
// truth: it is replayed once the page loads (lines emitted earlier would
// otherwise be lost) and it backs the "Copy logs" action. In a packaged app
// this console is the only log surface β€” stdout/stderr go to /dev/null when
// launched from Finder, and the winston file logger is a no-op there (see
// AGENTS.md on the node_modules strip).

const BOOT_LOG_MAX_LINES = 2000;
const BOOT_LOG_FLUSH_MS = 200;

const bootLog = []; // {name, line, level}[] β€” level: stdout|stderr|info|warn|error
let bootLogPending = [];
let bootLogFlushTimer = null;
let bootLogReady = false; // true once loading.html has loaded and can receive
let fatalSummary = null;

// SGR color codes AND cursor-control CSI sequences (uv/uvicorn can emit
// either when they mis-detect a TTY).
const ANSI_CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;

/**
 * Strip ANSI escapes and reduce carriage-return progress redraws (e.g. uv
 * download bars arrive as one chunk of "\r"-separated frames) to the final
 * frame β€” what a real terminal would have settled on.
 */
function sanitizeLogLine(line) {
  const frames = String(line ?? "")
    .replace(ANSI_CSI_RE, "")
    .split("\r")
    .map((s) => s.trim())
    .filter(Boolean);
  return frames.length ? frames[frames.length - 1] : "";
}

function appendBootLog(name, line, level) {
  const entry = { name, line, level };
  bootLog.push(entry);
  if (bootLog.length > BOOT_LOG_MAX_LINES) {
    bootLog.splice(0, bootLog.length - BOOT_LOG_MAX_LINES);
  }
  bootLogPending.push(entry);
  if (!bootLogFlushTimer) {
    bootLogFlushTimer = setTimeout(flushBootLog, BOOT_LOG_FLUSH_MS);
  }
}

function flushBootLog() {
  clearTimeout(bootLogFlushTimer);
  bootLogFlushTimer = null;
  if (!bootLogPending.length) return;
  const batch = bootLogPending;
  bootLogPending = [];
  // Not ready / window gone: drop the batch β€” the entries stay in bootLog,
  // which did-finish-load replays wholesale.
  if (bootLogReady && loadingWin && !loadingWin.isDestroyed()) {
    loadingWin.webContents.send("boot-log:batch", batch);
  }
}

/**
 * Switch the splash into its failure state: expand the console and show the
 * error summary with Copy logs / Quit actions, keeping the window open so the
 * user can actually read why startup failed. Returns false when the loading
 * window is gone (caller falls back to a native dialog).
 */
function showStartupFailure(summary) {
  if (!loadingWin || loadingWin.isDestroyed()) return false;
  fatalSummary = summary;
  setLoadingWindowExpanded(true);
  if (bootLogReady) {
    flushBootLog();
    loadingWin.webContents.send("boot-log:fatal", summary);
  }
  // If the page hasn't loaded yet, did-finish-load replays the buffer and
  // then delivers fatalSummary.
  return true;
}

// IPC surface for the loading window (see preload.cjs). Guarded to that
// window's webContents so the main app window can never reach these.
function isLoadingWinEvent(event) {
  return (
    loadingWin !== null &&
    !loadingWin.isDestroyed() &&
    event.sender === loadingWin.webContents
  );
}

ipcMain.handle("boot-log:set-expanded", (event, expanded) => {
  if (!isLoadingWinEvent(event)) return;
  setLoadingWindowExpanded(Boolean(expanded));
});

ipcMain.handle("boot-log:copy", (event) => {
  if (!isLoadingWinEvent(event)) return 0;
  clipboard.writeText(bootLog.map((e) => `[${e.name}] ${e.line}`).join("\n"));
  return bootLog.length;
});

// The frameless splash has no close control; the failure state shows a Quit
// button instead.
ipcMain.handle("boot-log:quit", (event) => {
  if (!isLoadingWinEvent(event)) return;
  app.quit();
});

// ── Backend stack ─────────────────────────────────────────────────────────────

/**
 * Update the status line on the loading window, if it's still alive.
 *
 * The loading screen exposes a global `window.__setLoadingStatus(line)`
 * function (see loading.html) that swaps the status text. We call it via
 * `executeJavaScript` so no preload script / IPC plumbing is needed.
 *
 * Best-effort: any failure (window destroyed, JS not loaded yet, etc.) is
 * swallowed β€” this is purely a UX nicety and must never crash the launcher.
 */
function setLoadingStatus(line) {
  if (!loadingWin || loadingWin.isDestroyed()) return;
  // Limit to a single line, max ~120 chars, to keep the splash readable.
  const oneLine = String(line ?? "")
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 120);
  if (!oneLine) return;
  const safe = JSON.stringify(oneLine);
  loadingWin.webContents
    .executeJavaScript(
      `window.__setLoadingStatus && window.__setLoadingStatus(${safe});`,
      true,
    )
    .catch(() => {});
}

/**
 * Phase marker: headline + a line in the startup-log console, so the log
 * records which stage a failed boot died in.
 */
function setBootPhase(message) {
  appendBootLog("desktop", message, "info");
  setLoadingStatus(message);
}

/**
 * Last few `level: "error"` service log lines (spawn failures, non-zero
 * exits). Appended to the startup-failure dialog: a packaged app launched
 * from Finder has stdout/stderr wired to /dev/null, so without this a
 * crashed ingress/static-server surfaces only as an opaque "timed out
 * waiting for http://localhost:8000" message.
 */
const recentServiceErrors = [];

/**
 * Forward dev-stack service log lines to (a) the loading screen and (b) the
 * terminal log. The terminal already receives them via `logService`; we add
 * a tee here so the user can see what's happening on first launch when uvx
 * is downloading Python + agent-server.
 */
function handleServiceLog(name, line, level) {
  if (!line) return;
  const clean = sanitizeLogLine(line);
  if (!clean) return;
  // Full-fidelity stream: every service and level goes to the console buffer.
  // The one-line headline below stays filtered to the interesting services.
  appendBootLog(name, clean, level);
  if (name === "agent-server" || name === "automation") {
    setLoadingStatus(`${name}: ${clean}`);
  }
  // Mirror errors to a `[desktop]` terminal line so dev runs stay grep-friendly.
  if (level === "error") {
    console.error(`[desktop] [${name}] ${clean}`);
    // Errors from ANY service (including ingress/static, which the headline
    // filter above skips) are worth showing β€” a dead ingress is exactly the
    // case where the user would otherwise stare at a silent 120 s timeout.
    setLoadingStatus(`${name}: ${clean}`);
    recentServiceErrors.push(`${name}: ${clean}`);
    if (recentServiceErrors.length > 5) recentServiceErrors.shift();
  }
}

async function startStack() {
  const entryUrl = pathToFileURL(
    join(scriptsDir, "dev-with-automation.mjs"),
  ).href;
  const { main } = await import(entryUrl);

  // main() starts agent-server + automation backend + static server + ingress.
  //   skipNpmCheck: npm is not needed at runtime in static mode.
  //   agentServerReadyTimeoutMs: dev defaults to 60 s (warm uvx cache); a
  //     packaged binary on a fresh machine can spend several minutes inside
  //     uvx the first time, downloading Python + installing openhands-
  //     agent-server from PyPI. 10 minutes is generous but bounded.
  //   onServiceLog: stream uvx/agent-server output to the loading window so
  //     the user sees progress instead of an indefinite spinner.
  const result = await main({
    bannerTitle: "OpenHands Agent Canvas",
    staticMode: true,
    staticDir: buildDir,
    mode: "agent-canvas",
    isPublic: false,
    skipNpmCheck: true,
    agentServerReadyTimeoutMs: 10 * 60_000,
    onServiceLog: handleServiceLog,
  });

  // main() returns { config, agentServerReady } β€” treat a timeout as a fatal
  // startup error so the splash shows a clear dialog instead of dropping the
  // user into a half-booted UI that will only emit "Request timeout" popups.
  if (result?.agentServerReady === false) {
    throw new Error(
      "The agent server did not finish starting in time. " +
        "On first launch this can take several minutes while uvx downloads " +
        "Python and the OpenHands agent-server from PyPI. " +
        "Check your internet connection and try again.",
    );
  }
}

// ── App lifecycle ─────────────────────────────────────────────────────────────

app.whenReady().then(async () => {
  nativeTheme.themeSource = "dark";

  // Set the dock icon explicitly on macOS so `npm run desktop` shows the
  // OpenHands logo instead of the default Electron logo. In a packaged
  // build the .app bundle's icon.icns already provides this, but
  // app.dock.setIcon() is a cheap idempotent override that also fixes
  // the dev workflow.
  if (process.platform === "darwin" && app.dock && existsSync(appIconPath)) {
    app.dock.setIcon(nativeImage.createFromPath(appIconPath));
  }

  injectBundledUv();
  injectBundledNode();

  if (!uvxAvailable()) {
    dialog.showErrorBox(
      "Missing prerequisite: uv",
      app.isPackaged
        ? "The bundled uv binary could not be found. Please reinstall OpenHands Agent Canvas."
        : "uv (uvx) is not installed.\n\nInstall it from https://docs.astral.sh/uv/ then restart.",
    );
    app.quit();
    return;
  }

  createLoadingWindow();

  try {
    setBootPhase("Starting backend services…");
    await startStack();

    // Stage 1: ingress proxy is bound (anything < 500 on /).
    setBootPhase("Waiting for proxy…");
    await waitForUrl("http://localhost:8000");

    // Stage 2: the agent-server behind the proxy is actually serving
    // requests. `startStack()` already waited for this internally, but we
    // re-probe end-to-end here so that if the user closes the splash race
    // window between processes binding, we still open the main window with
    // a live backend. Cheap (a single 200 response) when everything is up.
    setBootPhase("Connecting to agent server…");
    await waitForAgentServer("http://localhost:8000/server_info", 60_000);

    setBootPhase("Ready.");
    createMainWindow();
  } catch (err) {
    const summary =
      err.message +
      " Ensure ports 8000, 18000, and 18001 are free, then try again.";
    // Record the failure in the terminal and the startup-log buffer so it
    // shows (and copies) as the final console line.
    console.error("[desktop] Startup failed:", err);
    appendBootLog("desktop", summary, "error");
    // Keep the splash open in its failure state so the full startup log can
    // be read and copied; the app quits via the splash's Quit button (or
    // Cmd+Q / closing the window).
    if (showStartupFailure(summary)) return;
    // Loading window already gone β€” fall back to the old dialog-and-quit.
    const errorTail = recentServiceErrors.length
      ? `\n\nRecent service errors:\n${recentServiceErrors.join("\n")}`
      : "";
    dialog.showErrorBox("OpenHands Agent Canvas failed to start", summary + errorTail);
    app.quit();
  }
});

// ── Graceful shutdown ─────────────────────────────────────────────────────────
//
// dev-with-automation.mjs spawns the backend processes with detached:true so
// they form their own OS process groups and survive the parent's death by
// default. We must explicitly kill them when the app quits.
//
// createShutdownHookRegistry (dev-process-utils.mjs) already registered a
// SIGTERM handler that iterates every tracked process, calls signalProcessTree
// on its group, waits for exit, then calls process.exit(0). We just need to
// fire that handler before Electron lets the process die.
//
// Flow:
//   user closes window / Cmd+Q
//     β†’ window-all-closed β†’ app.quit()
//     β†’ before-quit fires (first time)  β†’ we preventDefault + send SIGTERM
//     β†’ SIGTERM handler kills all children, calls process.exit(0)
//     β†’ before-quit fires again (cleanupStarted=true) β†’ we return, Electron exits
//
// Windows has no real POSIX signals: process.kill(pid, "SIGTERM") would
// terminate this process WITHOUT running the "SIGTERM" listener, skipping
// cleanup and orphaning the children on ports 8000/18000/18001 (the next
// launch then fails at startup). process.emit("SIGTERM") runs the same
// registered handler in-process instead.

let cleanupStarted = false;

app.on("before-quit", (event) => {
  if (cleanupStarted) return; // SIGTERM cleanup already running β€” allow exit

  cleanupStarted = true;
  event.preventDefault();

  console.log("[desktop] Stopping backend services…");
  if (process.platform === "win32") {
    // Run the cleanup handler in-process (see header note). emit() returns
    // false when no listener is registered β€” the stack never started, so
    // there is nothing to clean up and we can exit immediately.
    if (!process.emit("SIGTERM")) app.exit(0);
  } else {
    process.kill(process.pid, "SIGTERM");
  }

  // Safety net: if the SIGTERM handler doesn't finish within 6 s, force-quit.
  const t = setTimeout(() => {
    console.warn("[desktop] Cleanup timed out β€” forcing exit");
    app.exit(0);
  }, 6000);
  if (t.unref) t.unref();
});

app.on("window-all-closed", () => {
  app.quit();
});

// macOS: clicking the dock icon when no window is open re-launches the app.
app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    // The backend is already running β€” just open a new renderer window.
    if (mainWin === null) createMainWindow();
  }
});