834 lines
29 KiB
TypeScript
834 lines
29 KiB
TypeScript
import {
|
|
app,
|
|
BrowserWindow,
|
|
clipboard,
|
|
dialog,
|
|
ipcMain,
|
|
net,
|
|
Notification as ElectronNotification,
|
|
protocol,
|
|
shell,
|
|
WebContentsView,
|
|
type OpenDialogOptions,
|
|
} from "electron";
|
|
import { updateElectronApp } from "update-electron-app";
|
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch";
|
|
import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery";
|
|
import type { DaemonStatus } from "./shared/daemon-status";
|
|
import {
|
|
type DaemonProbe,
|
|
expectedDaemonPort,
|
|
parseDaemonProbe,
|
|
resolveDaemonFromPort,
|
|
resolveDaemonFromRunFile,
|
|
} from "./shared/daemon-attach";
|
|
import { shouldReplacePortHolder } from "./shared/daemon-takeover";
|
|
import { buildDaemonEnv, resolveShellEnv, type ShellRunner } from "./shared/shell-env";
|
|
import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config";
|
|
import { buildTelemetryBootstrap } from "./shared/telemetry";
|
|
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
|
|
|
|
// Globals injected at compile time by @electron-forge/plugin-vite.
|
|
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
|
declare const MAIN_WINDOW_VITE_NAME: string;
|
|
|
|
// Windows GUI launches (e.g. from a Start-menu/desktop shortcut) have no attached
|
|
// console, so process.stdout and process.stderr are dead pipes. The daemon-output
|
|
// console.log/console.error calls
|
|
// below then fail with EPIPE, and with no "error" listener that surfaces as an
|
|
// uncaught exception that crashes the main process. Swallow broken-pipe write
|
|
// errors on the std streams: a dropped log line is harmless, the crash is not.
|
|
const ignoreStdStreamError = (err: NodeJS.ErrnoException): void => {
|
|
if (err.code === "EPIPE") return;
|
|
};
|
|
process.stdout.on("error", ignoreStdStreamError);
|
|
process.stderr.on("error", ignoreStdStreamError);
|
|
|
|
// Must run before app ready so the About panel and default-menu role labels use it.
|
|
app.setName("Agent Orchestrator");
|
|
|
|
// Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage,
|
|
// crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS
|
|
// default ~/Library/Application Support/<name>. Keeps the app's entire footprint
|
|
// inside ~/.ao alongside the daemon's data dir and running.json. sessionData and
|
|
// crashDumps derive from userData, so this one override reparents them all.
|
|
// Must run before app ready.
|
|
app.setPath("userData", path.join(os.homedir(), ".ao", "electron"));
|
|
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
|
|
let daemonStoppingProcess: ChildProcessWithoutNullStreams | null = null;
|
|
let daemonStartPromise: Promise<DaemonStatus> | null = null;
|
|
let daemonStartEpoch = 0;
|
|
let daemonStatus: DaemonStatus = { state: "stopped" };
|
|
let browserViewHost: BrowserViewHost | null = null;
|
|
|
|
const isDev = !app.isPackaged;
|
|
|
|
const RENDERER_SCHEME = "app";
|
|
const RENDERER_HOST = "renderer";
|
|
const RENDERER_ORIGIN = `${RENDERER_SCHEME}://${RENDERER_HOST}`;
|
|
|
|
// The packaged renderer is served from a custom standard scheme, not file://.
|
|
// A file:// page has the opaque "null" origin, which the daemon must never
|
|
// trust (every sandboxed iframe on any website also presents "null"), so its
|
|
// fetch/EventSource calls to the loopback API would be CORS-blocked.
|
|
// app://renderer is an origin only this app can present, so the daemon's CORS
|
|
// allowlist can name it. A standard scheme also makes the build's absolute
|
|
// asset URLs (/assets/…) and history-API routing resolve, which file:// breaks.
|
|
// Must run before app ready.
|
|
protocol.registerSchemesAsPrivileged([
|
|
{
|
|
scheme: RENDERER_SCHEME,
|
|
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
|
},
|
|
]);
|
|
|
|
// Maps app://renderer/<path> to the built renderer in dist/. Paths without a
|
|
// file extension are client-side routes and fall back to index.html (SPA).
|
|
function registerRendererProtocol(): void {
|
|
const distRoot = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
|
|
protocol.handle(RENDERER_SCHEME, async (request) => {
|
|
const url = new URL(request.url);
|
|
if (url.host !== RENDERER_HOST) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
const resolved = path.resolve(path.join(distRoot, decodeURIComponent(url.pathname)));
|
|
if (resolved !== distRoot && !resolved.startsWith(distRoot + path.sep)) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
const target = path.extname(resolved) === "" ? path.join(distRoot, "index.html") : resolved;
|
|
try {
|
|
return await net.fetch(pathToFileURL(target).toString());
|
|
} catch {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
});
|
|
}
|
|
|
|
function rendererUrl(): string {
|
|
if (typeof MAIN_WINDOW_VITE_DEV_SERVER_URL !== "undefined" && MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
|
return MAIN_WINDOW_VITE_DEV_SERVER_URL;
|
|
}
|
|
|
|
return `${RENDERER_ORIGIN}/index.html`;
|
|
}
|
|
|
|
function preloadPath(): string {
|
|
return path.join(__dirname, "preload.js");
|
|
}
|
|
|
|
function annotatePreloadPath(): string {
|
|
return path.join(__dirname, "annotate-preload.js");
|
|
}
|
|
|
|
// Runtime window/taskbar icon for Linux and Windows. macOS ignores this and
|
|
// uses the .app bundle's .icns instead. Packaged: shipped via extraResource to
|
|
// resources/icon.png; dev: the source asset under frontend/assets.
|
|
function windowIconPath(): string | undefined {
|
|
const candidate = app.isPackaged
|
|
? path.join(process.resourcesPath, "icon.png")
|
|
: path.join(__dirname, "../../assets/icon.png");
|
|
return existsSync(candidate) ? candidate : undefined;
|
|
}
|
|
|
|
function setDaemonStatus(nextStatus: DaemonStatus): void {
|
|
daemonStatus = nextStatus;
|
|
mainWindow?.webContents.send("daemon:status", daemonStatus);
|
|
}
|
|
|
|
function createWindow(): void {
|
|
browserViewHost?.dispose();
|
|
browserViewHost = null;
|
|
mainWindow = new BrowserWindow({
|
|
width: 1320,
|
|
height: 860,
|
|
minWidth: 960,
|
|
minHeight: 640,
|
|
title: "Agent Orchestrator",
|
|
icon: windowIconPath(),
|
|
backgroundColor: "#0f1014",
|
|
titleBarStyle: "hiddenInset",
|
|
// Lights visually centered at y=28 — the 56px topbar/.titlebar-nav center
|
|
// line — so lights + nav cluster + header content share one row. macOS
|
|
// draws the 12pt disc 2pt below the given y (measured: center = y + 8),
|
|
// hence 20, not 22.
|
|
trafficLightPosition: { x: 14, y: 20 },
|
|
webPreferences: {
|
|
preload: preloadPath(),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
},
|
|
});
|
|
|
|
// Harden navigation: never let renderer/terminal content open in-app windows or
|
|
// navigate the privileged window away from the app origin. External links go to
|
|
// the OS browser. Keep this in place before exposing any daemon output to the renderer.
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:\/\//.test(url)) {
|
|
void shell.openExternal(url);
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
|
if (url !== mainWindow?.webContents.getURL()) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
browserViewHost = createBrowserViewHost({
|
|
mainWindow,
|
|
ipcMain,
|
|
shell,
|
|
WebContentsView,
|
|
annotatePreloadPath: annotatePreloadPath(),
|
|
rendererOrigin: RENDERER_ORIGIN,
|
|
});
|
|
|
|
void mainWindow.loadURL(rendererUrl());
|
|
|
|
if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") {
|
|
mainWindow.webContents.once("did-frame-finish-load", () => {
|
|
mainWindow?.webContents.openDevTools({ mode: "detach" });
|
|
});
|
|
}
|
|
|
|
mainWindow.on("closed", () => {
|
|
browserViewHost?.dispose();
|
|
browserViewHost = null;
|
|
mainWindow = null;
|
|
});
|
|
}
|
|
|
|
// How long the supervisor waits for the daemon to confirm its bound port (via
|
|
// the listen log line or running.json) before reporting the configured port as
|
|
// a best-effort fallback.
|
|
const PORT_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
const RUN_FILE_POLL_MS = 300;
|
|
// Accept run-files stamped slightly before our spawn timestamp: the daemon's
|
|
// clock reading and ours race within normal scheduling jitter.
|
|
const RUN_FILE_FRESHNESS_SKEW_MS = 2_000;
|
|
const DAEMON_PROBE_TIMEOUT_MS = 2_000;
|
|
|
|
function runFilePath(): string | null {
|
|
if (process.env.AO_RUN_FILE) return process.env.AO_RUN_FILE;
|
|
return defaultRunFilePath(process.platform, process.env, os.homedir());
|
|
}
|
|
|
|
// How long to wait for the login shell to print its env before giving up. A
|
|
// misconfigured rc that blocks (or a slow nvm/pyenv chain) must not hang startup;
|
|
// the daemon then falls back to the static PATH floor.
|
|
const SHELL_ENV_TIMEOUT_MS = 3_000;
|
|
|
|
// The login-shell env resolved once at startup (see docs/daemon-environment.md),
|
|
// or null when the probe failed/timed out. Read synchronously by daemonEnv().
|
|
let cachedShellEnv: Record<string, string> | null = null;
|
|
// Memoize the in-flight resolution so concurrent/repeat awaits are cheap.
|
|
let shellEnvPromise: Promise<void> | null = null;
|
|
|
|
// Telemetry defaults stamped on the daemon env on every platform; explicit env
|
|
// always wins.
|
|
function telemetryOverrides(): Record<string, string> {
|
|
return {
|
|
AO_TELEMETRY_EVENTS: process.env.AO_TELEMETRY_EVENTS ?? "on",
|
|
AO_TELEMETRY_REMOTE: process.env.AO_TELEMETRY_REMOTE ?? "posthog",
|
|
AO_TELEMETRY_POSTHOG_KEY: process.env.AO_TELEMETRY_POSTHOG_KEY ?? DEFAULT_POSTHOG_PROJECT_KEY,
|
|
AO_TELEMETRY_POSTHOG_HOST: process.env.AO_TELEMETRY_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST,
|
|
};
|
|
}
|
|
|
|
// Run the user's login shell to dump its env. stdin is ignored so an rc that
|
|
// reads input hits EOF instead of hanging; stderr is ignored to drop banner
|
|
// noise. Never rejects: resolves null on spawn error, non-zero exit, or timeout
|
|
// (SIGKILLed), so the caller degrades to the static PATH floor.
|
|
const runLoginShell: ShellRunner = (shellPath, args) =>
|
|
new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (value: string | null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(value);
|
|
};
|
|
let child: ReturnType<typeof spawn>;
|
|
try {
|
|
child = spawn(shellPath, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
} catch {
|
|
finish(null);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
finish(null);
|
|
}, SHELL_ENV_TIMEOUT_MS);
|
|
let stdout = "";
|
|
// stdout may be typed Readable | null under this stdio config; guard it.
|
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
stdout += chunk.toString("utf8");
|
|
});
|
|
child.once("error", () => {
|
|
clearTimeout(timer);
|
|
finish(null);
|
|
});
|
|
child.once("exit", (code) => {
|
|
clearTimeout(timer);
|
|
finish(code === 0 ? stdout : null);
|
|
});
|
|
});
|
|
|
|
// Resolve the login-shell env once and cache it. No-op on Windows (the launchd
|
|
// shell split does not apply; a static PATH floor suffices). Awaited at the
|
|
// daemon-spawn chokepoint so the cache is populated before the first spawn.
|
|
function ensureShellEnv(): Promise<void> {
|
|
if (process.platform === "win32") return Promise.resolve();
|
|
if (!shellEnvPromise) {
|
|
shellEnvPromise = resolveShellEnv(process.env, runLoginShell).then((resolved) => {
|
|
cachedShellEnv = resolved;
|
|
if (!resolved) {
|
|
console.error("AO: could not read the login-shell environment; falling back to a static PATH floor.");
|
|
}
|
|
});
|
|
}
|
|
return shellEnvPromise;
|
|
}
|
|
|
|
function daemonEnv(): NodeJS.ProcessEnv {
|
|
// Windows keeps the old behavior exactly: no shell probe, no unix PATH floor.
|
|
if (process.platform === "win32") {
|
|
return { ...process.env, ...telemetryOverrides() };
|
|
}
|
|
return buildDaemonEnv(process.env, cachedShellEnv, telemetryOverrides());
|
|
}
|
|
|
|
function pathKey(value: string): string {
|
|
const resolved = path.resolve(value);
|
|
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
}
|
|
|
|
function samePath(a: string, b: string): boolean {
|
|
return pathKey(a) === pathKey(b);
|
|
}
|
|
|
|
function pathInside(child: string, parent: string): boolean {
|
|
const childKey = pathKey(child);
|
|
const parentKey = pathKey(parent);
|
|
return childKey === parentKey || childKey.startsWith(parentKey + path.sep);
|
|
}
|
|
|
|
function processAlive(pid: number): boolean {
|
|
if (!pid) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readDaemonProbe(port: number, endpoint: "healthz" | "readyz"): Promise<DaemonProbe | null> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), DAEMON_PROBE_TIMEOUT_MS);
|
|
try {
|
|
const response = await net.fetch(`http://127.0.0.1:${port}/${endpoint}`, { signal: controller.signal });
|
|
if (!response.ok) return null;
|
|
return parseDaemonProbe(endpoint, await response.json());
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function daemonIdentityError(launch: DaemonLaunchSpec, probe: DaemonProbe): string | null {
|
|
if (launch.source === "dev") {
|
|
const cwdMatches = probe.workingDirectory ? samePath(probe.workingDirectory, launch.cwd) : false;
|
|
const executableMatches = probe.executablePath ? pathInside(probe.executablePath, launch.cwd) : false;
|
|
if (!probe.workingDirectory && !probe.executablePath) {
|
|
return "An older AO daemon is already running, but it does not report its checkout identity. Stop it and restart this app.";
|
|
}
|
|
if (!cwdMatches && !executableMatches) {
|
|
const actual = probe.workingDirectory ?? probe.executablePath ?? "an unknown location";
|
|
return `Another AO daemon is already running from ${actual}; expected this checkout at ${launch.cwd}. Stop the other daemon before using this checkout.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (launch.source === "bundled") {
|
|
if (!probe.executablePath) {
|
|
return "An older AO daemon is already running, but it does not report its binary path. Stop it and restart this app.";
|
|
}
|
|
if (!samePath(probe.executablePath, launch.command)) {
|
|
return `Another AO daemon is already running from ${probe.executablePath}; expected ${launch.command}. Stop the other daemon before using this app.`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function inspectExistingDaemon(launch: DaemonLaunchSpec): Promise<DaemonStatus | null> {
|
|
const handshakePath = runFilePath();
|
|
let runFileContents: string | null = null;
|
|
if (handshakePath) {
|
|
try {
|
|
runFileContents = await readFile(handshakePath, "utf8");
|
|
} catch {
|
|
runFileContents = null;
|
|
}
|
|
}
|
|
return resolveDaemonFromRunFile({
|
|
runFileContents,
|
|
isProcessAlive: processAlive,
|
|
probe: readDaemonProbe,
|
|
identityError: (probe) => daemonIdentityError(launch, probe),
|
|
});
|
|
}
|
|
|
|
async function refreshDaemonStatus(): Promise<DaemonStatus> {
|
|
if (daemonProcess) {
|
|
return daemonStatus;
|
|
}
|
|
const launch = resolveDaemonLaunch(
|
|
process.env,
|
|
app.isPackaged,
|
|
process.resourcesPath,
|
|
app.getAppPath(),
|
|
process.platform,
|
|
);
|
|
if (!launch) return daemonStatus;
|
|
const existing = await inspectExistingDaemon(launch);
|
|
if (existing) {
|
|
setDaemonStatus(existing);
|
|
} else if (
|
|
daemonStatus.state === "ready" ||
|
|
(daemonStatus.state === "error" && (daemonStatus.pid || daemonStatus.port))
|
|
) {
|
|
setDaemonStatus({
|
|
state: "stopped",
|
|
message: "AO daemon is no longer reachable.",
|
|
});
|
|
}
|
|
return daemonStatus;
|
|
}
|
|
|
|
async function startDaemon(): Promise<DaemonStatus> {
|
|
if (daemonStartPromise) {
|
|
return daemonStartPromise;
|
|
}
|
|
const startEpoch = daemonStartEpoch;
|
|
const promise = startDaemonInner(startEpoch).finally(() => {
|
|
if (daemonStartPromise === promise) {
|
|
daemonStartPromise = null;
|
|
}
|
|
});
|
|
daemonStartPromise = promise;
|
|
return daemonStartPromise;
|
|
}
|
|
|
|
async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
|
if (daemonProcess) {
|
|
return daemonStatus;
|
|
}
|
|
|
|
// Single chokepoint: make sure the login-shell env is resolved before the
|
|
// daemon is spawned, so a Finder/Dock launch hands the daemon a real PATH and
|
|
// shell-exported credentials rather than launchd's minimal env.
|
|
await ensureShellEnv();
|
|
|
|
const launch = resolveDaemonLaunch(
|
|
process.env,
|
|
app.isPackaged,
|
|
process.resourcesPath,
|
|
app.getAppPath(),
|
|
process.platform,
|
|
);
|
|
if (!launch) {
|
|
setDaemonStatus({
|
|
state: "stopped",
|
|
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
|
|
});
|
|
return daemonStatus;
|
|
}
|
|
|
|
const existing = await inspectExistingDaemon(launch);
|
|
if (startEpoch !== daemonStartEpoch) {
|
|
return daemonStatus;
|
|
}
|
|
if (existing) {
|
|
setDaemonStatus(existing);
|
|
return daemonStatus;
|
|
}
|
|
|
|
// Defensive: inspectExistingDaemon only attaches when the run-file agrees with
|
|
// a live daemon. Any divergence (missing/stale/unparseable run-file, dead PID,
|
|
// health.pid mismatch) makes it return null — yet a daemon may still be serving
|
|
// the port. Spawning then would just make the Go child refuse and exit 1. Probe
|
|
// the expected port directly, independent of the run-file, and attach if a
|
|
// daemon answers. The expected port (AO_PORT or the default) is exactly the
|
|
// port the Go child would bind and collide on — probing a hardcoded 3001 would
|
|
// miss an AO_PORT override.
|
|
const directDaemon = await resolveDaemonFromPort({
|
|
expectedPort: expectedDaemonPort(process.env),
|
|
probe: readDaemonProbe,
|
|
identityError: (probe) => daemonIdentityError(launch, probe),
|
|
});
|
|
if (startEpoch !== daemonStartEpoch) {
|
|
return daemonStatus;
|
|
}
|
|
if (directDaemon) {
|
|
setDaemonStatus(directDaemon);
|
|
return daemonStatus;
|
|
}
|
|
|
|
// Wedged-orphan kill+replace: both attach paths returned null, but a process
|
|
// may still be holding the port. The only reachable case here is a hung/wedged
|
|
// holder whose run-file PID is still alive but is not answering /healthz (e.g.
|
|
// our own daemon that bound the port and then deadlocked). Two cases are
|
|
// intentionally NOT handled: an identity-mismatched but healthy AO daemon is
|
|
// already surfaced as an error status upstream by resolveDaemonFromPort (not
|
|
// killed here), and a foreign non-AO process holding the port with a dead
|
|
// run-file PID is not replaced (out of scope). When no holder is detectable,
|
|
// skip straight to spawn.
|
|
const orphanProbe = await readDaemonProbe(expectedDaemonPort(process.env), "healthz");
|
|
const runFilePath_ = runFilePath();
|
|
let runFilePid: number | null = null;
|
|
if (runFilePath_) {
|
|
try {
|
|
runFilePid = parseRunFile(await readFile(runFilePath_, "utf8"))?.pid ?? null;
|
|
} catch {
|
|
// run-file absent or unreadable; proceed without a PID.
|
|
}
|
|
}
|
|
// process.kill(pid, 0) does not kill; it throws iff the PID is not live.
|
|
let holderPidAlive = false;
|
|
if (runFilePid) {
|
|
try {
|
|
process.kill(runFilePid, 0);
|
|
holderPidAlive = true;
|
|
} catch {
|
|
holderPidAlive = false;
|
|
}
|
|
}
|
|
if (shouldReplacePortHolder(orphanProbe, holderPidAlive)) {
|
|
// Use the run-file PID when available; fall back to the probe's reported
|
|
// PID as a last resort (a wedged daemon may not have written a fresh run-file).
|
|
const pidToKill = runFilePid ?? orphanProbe?.pid ?? null;
|
|
if (pidToKill) {
|
|
try {
|
|
process.kill(-pidToKill, "SIGTERM");
|
|
} catch {
|
|
try {
|
|
process.kill(pidToKill, "SIGTERM");
|
|
} catch {
|
|
// process already gone; proceed
|
|
}
|
|
}
|
|
}
|
|
// Poll until the port is free (probe returns null) or 8 s elapses.
|
|
const TAKEOVER_TIMEOUT_MS = 8_000;
|
|
const TAKEOVER_POLL_MS = 200;
|
|
const deadline = Date.now() + TAKEOVER_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
const still = await readDaemonProbe(expectedDaemonPort(process.env), "healthz");
|
|
if (!still) break;
|
|
await new Promise<void>((r) => setTimeout(r, TAKEOVER_POLL_MS));
|
|
}
|
|
// Remove the stale run-file so the new daemon can write a fresh one.
|
|
if (runFilePath_) {
|
|
await rm(runFilePath_, { force: true });
|
|
}
|
|
}
|
|
|
|
if (launch.source === "bundled" && !existsSync(launch.command)) {
|
|
setDaemonStatus({
|
|
state: "error",
|
|
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
|
|
});
|
|
return daemonStatus;
|
|
}
|
|
|
|
setDaemonStatus({ state: "starting" });
|
|
|
|
// Capture the spawned handle locally so the async lifecycle listeners act only
|
|
// on THIS process. Without this, a stale exit from an already-stopped daemon
|
|
// could null out a newer daemonProcess started in the meantime, orphaning it.
|
|
//
|
|
// `detached` makes the child its own process-group leader. Because shell:true
|
|
// runs the command through /bin/sh, a plain kill() would only signal the shell
|
|
// wrapper and orphan the real daemon (which keeps holding the port). Killing
|
|
// the whole group via killDaemon() reaches the daemon and any PTY children.
|
|
const child = spawn(launch.command, launch.args, {
|
|
cwd: launch.cwd,
|
|
env: daemonEnv(),
|
|
shell: launch.shell,
|
|
detached: true,
|
|
// Hide the daemon's console on a Windows GUI launch (no flashing terminal).
|
|
windowsHide: true,
|
|
});
|
|
daemonProcess = child;
|
|
|
|
// Discover the port the daemon ACTUALLY bound rather than trusting AO_PORT:
|
|
// the daemon may fall back to a different port than the one requested. Two
|
|
// confirmed sources race — the "daemon listening" slog line (stderr, but both
|
|
// streams are scanned) and the running.json handshake — first one wins.
|
|
const spawnedAtMs = Date.now();
|
|
let portConfirmed = false;
|
|
let runFileTimer: ReturnType<typeof setInterval> | undefined;
|
|
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const stopDiscovery = () => {
|
|
if (runFileTimer) clearInterval(runFileTimer);
|
|
runFileTimer = undefined;
|
|
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
fallbackTimer = undefined;
|
|
};
|
|
|
|
const reportBoundPort = (port: number) => {
|
|
if (portConfirmed || daemonProcess !== child || daemonStoppingProcess === child) return;
|
|
portConfirmed = true;
|
|
stopDiscovery();
|
|
setDaemonStatus({ state: "ready", port });
|
|
};
|
|
|
|
// One scanner per stream: each keeps its own partial-line buffer.
|
|
const scanStdout = createListenPortScanner(reportBoundPort);
|
|
const scanStderr = createListenPortScanner(reportBoundPort);
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
const text = chunk.toString("utf8");
|
|
console.log(text.trimEnd());
|
|
scanStdout(text);
|
|
});
|
|
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
const text = chunk.toString("utf8");
|
|
console.error(text.trimEnd());
|
|
scanStderr(text);
|
|
});
|
|
|
|
const handshakePath = runFilePath();
|
|
if (handshakePath) {
|
|
runFileTimer = setInterval(() => {
|
|
readFile(handshakePath, "utf8")
|
|
.then((contents) => {
|
|
const info = parseRunFile(contents);
|
|
// Ignore a stale handshake left by a previous daemon: only trust a
|
|
// file written at/after this spawn.
|
|
if (info && info.startedAtMs >= spawnedAtMs - RUN_FILE_FRESHNESS_SKEW_MS) {
|
|
reportBoundPort(info.port);
|
|
}
|
|
})
|
|
.catch(() => undefined); // absent until the daemon binds; keep polling
|
|
}, RUN_FILE_POLL_MS);
|
|
}
|
|
|
|
// Last resort: neither source confirmed (e.g. an older daemon build). Report
|
|
// the configured port so the renderer is not stuck on "starting" forever.
|
|
fallbackTimer = setTimeout(() => {
|
|
if (portConfirmed || daemonProcess !== child || daemonStoppingProcess === child) return;
|
|
stopDiscovery();
|
|
setDaemonStatus({
|
|
state: "ready",
|
|
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
|
|
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
|
|
});
|
|
}, PORT_DISCOVERY_TIMEOUT_MS);
|
|
|
|
child.once("error", (error) => {
|
|
stopDiscovery();
|
|
if (daemonProcess !== child) return;
|
|
daemonProcess = null;
|
|
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
|
setDaemonStatus({ state: "error", message: error.message });
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
stopDiscovery();
|
|
if (daemonProcess !== child) return;
|
|
daemonProcess = null;
|
|
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
|
setDaemonStatus({
|
|
state: "stopped",
|
|
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
|
|
});
|
|
});
|
|
|
|
return daemonStatus;
|
|
}
|
|
|
|
// Signal the daemon's whole process group so the kill reaches the real daemon
|
|
// behind the /bin/sh wrapper (and any PTY children it forked), not just the
|
|
// shell. Falls back to a direct kill if the group signal can't be delivered
|
|
// (e.g. the process already exited).
|
|
function killDaemon(child: ChildProcessWithoutNullStreams): void {
|
|
if (child.pid === undefined) return;
|
|
try {
|
|
process.kill(-child.pid, "SIGTERM");
|
|
} catch {
|
|
child.kill("SIGTERM");
|
|
}
|
|
}
|
|
|
|
function stopDaemon(): DaemonStatus {
|
|
daemonStartEpoch += 1;
|
|
daemonStartPromise = null;
|
|
if (!daemonProcess) {
|
|
setDaemonStatus({ state: "stopped" });
|
|
return daemonStatus;
|
|
}
|
|
|
|
daemonStoppingProcess = daemonProcess;
|
|
killDaemon(daemonProcess);
|
|
setDaemonStatus({ state: "stopped" });
|
|
return daemonStatus;
|
|
}
|
|
|
|
ipcMain.handle("daemon:getStatus", () => refreshDaemonStatus());
|
|
ipcMain.handle("daemon:start", () => startDaemon());
|
|
ipcMain.handle("daemon:stop", () => stopDaemon());
|
|
ipcMain.handle("app:getVersion", () => app.getVersion());
|
|
ipcMain.handle("telemetry:getBootstrap", () =>
|
|
buildTelemetryBootstrap(process.env, app.getVersion(), process.platform),
|
|
);
|
|
ipcMain.handle("app:chooseDirectory", async () => {
|
|
const options: OpenDialogOptions = {
|
|
properties: ["openDirectory"],
|
|
title: "Choose a git repository",
|
|
};
|
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
|
|
|
if (result.canceled) return null;
|
|
return result.filePaths[0] ?? null;
|
|
});
|
|
ipcMain.handle("clipboard:writeText", (_event, text: string) => {
|
|
clipboard.writeText(text, "clipboard");
|
|
if (process.platform === "linux") {
|
|
clipboard.writeText(text, "selection");
|
|
}
|
|
});
|
|
ipcMain.handle("clipboard:readText", () => clipboard.readText());
|
|
|
|
ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => {
|
|
if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return;
|
|
const toast = new ElectronNotification({
|
|
title: notification.title,
|
|
body: notification.body,
|
|
});
|
|
toast.on("click", () => {
|
|
if (!mainWindow) return;
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
mainWindow.webContents.send("notifications:click", notification.id);
|
|
});
|
|
toast.show();
|
|
});
|
|
|
|
// Auto-update only runs for packaged builds reading the GitHub Releases feed
|
|
// (see forge.config.ts publishers). In dev there is no feed, so it is skipped.
|
|
// A live updater additionally requires a signed + notarized build — see
|
|
// frontend/docs/desktop-release.md.
|
|
function initAutoUpdates(): void {
|
|
if (!app.isPackaged) return;
|
|
updateElectronApp();
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
registerRendererProtocol();
|
|
createWindow();
|
|
void startDaemon();
|
|
initAutoUpdates();
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Re-entrancy guard: the first before-quit fires, prevents default, does async
|
|
// work, then calls app.exit(). If app.quit() is called concurrently (e.g. from
|
|
// window-all-closed on non-darwin), the second before-quit fires while the first
|
|
// is still in flight. Without a guard it would preventDefault again and loop.
|
|
// With the guard set to true, the second invocation falls through and lets the
|
|
// quit proceed. app.exit() itself does NOT re-fire before-quit, so the guard
|
|
// mainly protects against a concurrent app.quit() race.
|
|
let quitting = false;
|
|
|
|
app.on("before-quit", (event) => {
|
|
browserViewHost?.dispose();
|
|
browserViewHost = null;
|
|
|
|
// Re-entrancy: if we already started the async quit sequence, let this
|
|
// invocation fall through so the app actually exits.
|
|
if (quitting) return;
|
|
quitting = true;
|
|
|
|
// Capture the current daemon handle and port before any async gap so that
|
|
// a race with stopDaemon() cannot null them out underneath us.
|
|
const child = daemonProcess;
|
|
const port = daemonStatus.state === "ready" ? daemonStatus.port : undefined;
|
|
|
|
if (!child) {
|
|
// No daemon we own: nothing to shut down.
|
|
return;
|
|
}
|
|
|
|
// Prevent the synchronous quit so we can ask the daemon to save gracefully
|
|
// before killing it.
|
|
event.preventDefault();
|
|
|
|
const doQuit = async () => {
|
|
// Best-effort graceful shutdown: POST /shutdown so the daemon flushes
|
|
// its session state before exiting. An ~8s timeout prevents a hung or
|
|
// absent daemon from blocking quit indefinitely.
|
|
// Note: the daemon's internal save bound is 30s (shutdownSaveTimeout), so
|
|
// if this fetch times out and we proceed to killDaemon (SIGTERM), the first
|
|
// SIGTERM only cancels the daemon's listen context; the daemon's in-flight
|
|
// save (on a fresh context) still runs to completion or its own 30s bound.
|
|
if (port !== undefined) {
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/shutdown`, {
|
|
method: "POST",
|
|
signal: AbortSignal.timeout(8_000),
|
|
});
|
|
} catch {
|
|
// Timeout, network error, or daemon already gone: proceed to kill.
|
|
console.log(`AO: /shutdown fetch failed (port ${port}); proceeding with SIGTERM.`);
|
|
}
|
|
}
|
|
|
|
// Kill the daemon process group (reaches the daemon behind any shell
|
|
// wrapper and its PTY children).
|
|
killDaemon(child);
|
|
|
|
// Exit without re-firing before-quit (app.exit bypasses the event).
|
|
app.exit(0);
|
|
};
|
|
|
|
void doQuit();
|
|
});
|
|
|
|
// Last-resort teardown. before-quit covers the normal quit path, but app.exit()
|
|
// and some shutdown routes skip it, which would orphan the detached daemon and
|
|
// leave it holding the port for the next launch. The Node 'exit' event fires
|
|
// synchronously on those paths too, so the daemon's process group is always
|
|
// signalled when the supervisor goes away. (A hard SIGKILL/crash still can't run
|
|
// JS; the daemon's port-conflict fallback covers the orphan that leaves behind.)
|
|
process.on("exit", () => {
|
|
if (daemonProcess) {
|
|
killDaemon(daemonProcess);
|
|
}
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|