/**
 * 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";

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;
  }

  // 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 external URLs open directly in the system browser.
    if (
      !url.startsWith("http://localhost") &&
      !url.startsWith("http://127.0.0.1")
    ) {
      shell.openExternal(url);
      return { action: "deny" };
    }
    return { action: "allow" };
  });

  // 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" &&
        !url.startsWith("http://localhost") &&
        !url.startsWith("http://127.0.0.1")
      ) {
        _event.preventDefault();
        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();
  }
});
