const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); const path = require("node:path"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); const net = require("node:net"); const http = require("node:http"); const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 8787; const APP_DISPLAY_NAME = "oclaw"; const APP_ROOT = path.resolve(__dirname, "..", ".."); const APP_ICON_PATH = (() => { const candidates = [ path.join(APP_ROOT, "_local", "branding", "desktop.ico"), path.join(APP_ROOT, "_local", "branding", "logo.png"), path.join(APP_ROOT, "_local", "branding", "logo.svg"), path.join(APP_ROOT, "interfaces", "admin", "static", "oliver.svg"), ]; for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch (_) {} } return candidates[candidates.length - 1]; })(); const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data"); const LOG_ROOT = path.join(app.getPath("userData"), "logs"); const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log"); const STARTUP_TIMEOUT_MS = 30000; const POLL_INTERVAL_MS = 600; let mainWindow = null; let backendProc = null; let channelProc = null; let runtimeState = null; let backendStopping = false; let channelStopping = false; let backendCrashDialogOpen = false; let quitAfterCleanup = false; let channelStartWarningShown = false; // Improve first-paint reliability on Windows: avoid renderer backgrounding/timer throttling // that can delay UI updates until the first user interaction (e.g. click/focus). try { app.commandLine.appendSwitch("disable-renderer-backgrounding"); app.commandLine.appendSwitch("disable-background-timer-throttling"); } catch (_) {} function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function nowIso() { return new Date().toISOString(); } function logDesktop(msg) { try { ensureDir(LOG_ROOT); fs.appendFileSync(path.join(LOG_ROOT, "desktop.log"), `[${nowIso()}] ${String(msg || "")}\n`, "utf8"); } catch (_) {} } function resolvePythonBin() { const fromEnv = String(process.env.PYTHON_EXECUTABLE || "").trim(); if (fromEnv) return fromEnv; const venvPy = process.platform === "win32" ? path.join(APP_ROOT, "oclaw", ".venv", "Scripts", "python.exe") : path.join(APP_ROOT, "oclaw", ".venv", "bin", "python"); try { if (fs.existsSync(venvPy)) return venvPy; } catch (_) {} if (process.platform === "win32") return "python"; return "python3"; } function buildPythonEnv(extra = {}) { return { ...process.env, PYTHONPATH: APP_ROOT, PYTHONUTF8: "1", ...extra, }; } function checkPythonAvailable(pythonBin) { return new Promise((resolve) => { const probe = spawn(pythonBin, ["--version"], { cwd: APP_ROOT, windowsHide: true, stdio: "ignore", }); probe.once("error", () => resolve(false)); probe.once("close", (code) => resolve(code === 0)); }); } function pickPort(preferredPort) { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on("error", reject); server.listen(preferredPort, DEFAULT_HOST, () => { const addr = server.address(); const chosenPort = typeof addr === "object" && addr ? addr.port : preferredPort; server.close(() => resolve(chosenPort)); }); }); } function envFlagOff(v) { return ["0", "false", "no", "off"].includes(String(v ?? "").trim().toLowerCase()); } /** When false, connect to OCLAW_DESKTOP_GATEWAY_BASE_URL (or default :8787) instead of spawning a child gateway. */ function shouldEmbedBackend() { if (envFlagOff(process.env.OCLAW_DESKTOP_EMBED_BACKEND)) return false; return !String(process.env.OCLAW_DESKTOP_GATEWAY_BASE_URL || "").trim(); } function normalizeGatewayBaseUrl(raw) { const s = String(raw || "").trim(); if (!s) throw new Error("gateway base URL is empty"); const u = new URL(s); const host = u.hostname || DEFAULT_HOST; const port = u.port ? parseInt(u.port, 10) : u.protocol === "https:" ? 443 : 80; const baseUrl = `${u.protocol}//${u.host}`; return { host, port, baseUrl }; } async function attachToExistingGateway() { const raw = String(process.env.OCLAW_DESKTOP_GATEWAY_BASE_URL || "").trim() || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`; const parsed = normalizeGatewayBaseUrl(raw); runtimeState = { host: parsed.host, port: parsed.port, baseUrl: parsed.baseUrl, pythonBin: resolvePythonBin(), externalAttach: true, }; const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS; await waitForBackend(`${runtimeState.baseUrl}/health`, deadlineAtMs); return runtimeState; } function shouldSkipDesktopChannel() { return ["1", "true", "yes", "on"].includes(String(process.env.OCLAW_DESKTOP_SKIP_CHANNEL || "").trim().toLowerCase()); } function waitForBackend(url, deadlineAtMs) { return new Promise((resolve, reject) => { const attempt = () => { const req = http.get(url, (res) => { res.resume(); if (res.statusCode && res.statusCode < 500) { resolve(); return; } if (Date.now() >= deadlineAtMs) { reject(new Error(`backend not ready: HTTP ${res.statusCode || "unknown"}`)); return; } setTimeout(attempt, POLL_INTERVAL_MS); }); req.on("error", () => { if (Date.now() >= deadlineAtMs) { reject(new Error("backend did not become reachable in time")); return; } setTimeout(attempt, POLL_INTERVAL_MS); }); req.setTimeout(2500, () => { req.destroy(new Error("backend readiness probe timeout")); }); }; attempt(); }); } /** Avoid hanging forever on session.clearStorageData / clearCache (seen on some Windows setups). */ function awaitWithTimeout(promise, timeoutMs, logLabel) { const ms = Math.max(200, Number(timeoutMs) || 8000); return Promise.race([ promise, new Promise((resolve) => { setTimeout(() => { try { logDesktop(`${logLabel}:timeout_after_${ms}ms`); } catch (_) {} resolve(); }, ms); }), ]); } function loadingHtml(text) { const safe = String(text || "").replace(//g, ">"); return ` ${APP_DISPLAY_NAME}
${APP_DISPLAY_NAME}
${safe}
`; } async function startBackendProcess() { if (backendProc) return runtimeState; backendStopping = false; ensureDir(DATA_ROOT); ensureDir(LOG_ROOT); const preferredPortRaw = Number.parseInt(String(process.env.AIA_DESKTOP_BACKEND_PORT || ""), 10); const preferredPort = Number.isFinite(preferredPortRaw) ? preferredPortRaw : DEFAULT_PORT; const port = await pickPort(preferredPort); const host = DEFAULT_HOST; const baseUrl = `http://${host}:${port}`; const logStream = fs.createWriteStream(BACKEND_LOG_FILE, { flags: "a" }); const pythonBin = resolvePythonBin(); const pythonOk = await checkPythonAvailable(pythonBin); if (!pythonOk) { logStream.end(); throw new Error( `Python not found: ${pythonBin}. 请先安装 Python 3.10+,或设置环境变量 PYTHON_EXECUTABLE 指向可用解释器。` ); } const env = buildPythonEnv({ AIA_ASSISTANT_GATEWAY_HOST: host, AIA_ASSISTANT_GATEWAY_PORT: String(port), AIA_DATA_DIR: DATA_ROOT, AIA_DESKTOP_MODE: "1", }); const args = ["-m", "runtime.operations", "gateway", "start", "--host", host, "--port", String(port)]; backendProc = spawn(pythonBin, args, { cwd: APP_ROOT, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); runtimeState = { host, port, baseUrl, pythonBin, externalAttach: false }; backendProc.stdout.on("data", (chunk) => { logStream.write(chunk); }); backendProc.stderr.on("data", (chunk) => { logStream.write(chunk); }); backendProc.on("close", async (code, signal) => { const msg = `[backend-exit] code=${code} signal=${signal || "none"}\n`; logStream.write(msg); logStream.end(); const crashed = !backendStopping; backendProc = null; if (crashed) { await showBackendCrashedDialog(code, signal); } }); const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS; await waitForBackend(`${baseUrl}/health`, deadlineAtMs); return runtimeState; } function waitForProcessHealthy(proc, deadlineAtMs) { return new Promise((resolve, reject) => { const check = () => { if (!proc) { resolve(false); return; } if (proc.exitCode !== null) { resolve(false); return; } if (Date.now() >= deadlineAtMs) { resolve(true); return; } setTimeout(check, 250); }; check(); }); } function runPythonInline(pythonBin, code, extraEnv = {}) { return new Promise((resolve) => { const proc = spawn(pythonBin, ["-c", code], { cwd: APP_ROOT, env: buildPythonEnv(extraEnv), windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); let out = ""; let err = ""; proc.stdout.on("data", (c) => { out += String(c || ""); }); proc.stderr.on("data", (c) => { err += String(c || ""); }); proc.once("error", (e) => { resolve({ ok: false, code: -1, out, err: `${err}\n${String(e && e.message ? e.message : e)}` }); }); proc.once("close", (code0) => { resolve({ ok: code0 === 0, code: Number(code0 ?? -1), out, err }); }); }); } async function startChannelProcess() { if (channelProc) return true; ensureDir(LOG_ROOT); if (!runtimeState || !runtimeState.host || !runtimeState.port) { throw new Error("runtime_state_missing"); } const channelLogFile = path.join(LOG_ROOT, "channel-wecom.log"); const logStream = fs.createWriteStream(channelLogFile, { flags: "a" }); const pythonBin = resolvePythonBin(); // Kill stale/orphan WeCom workers first to avoid single-instance lock conflicts. try { const cleanupRes = await runPythonInline( pythonBin, "from runtime.operations.runtime import cleanup_orphan_service_processes; k=cleanup_orphan_service_processes('channel:wecom'); print('killed=' + ','.join(str(x) for x in k))", { AIA_DATA_DIR: DATA_ROOT }, ); if (!cleanupRes.ok) { logStream.write(`[channel-cleanup-warn] code=${cleanupRes.code} err=${String(cleanupRes.err || "").trim()}\n`); } else { const line = String(cleanupRes.out || "").trim(); if (line) logStream.write(`[channel-cleanup] ${line}\n`); } } catch (_) {} const env = buildPythonEnv({ AIA_ASSISTANT_GATEWAY_HOST: String(runtimeState.host), AIA_ASSISTANT_GATEWAY_PORT: String(runtimeState.port), AIA_DATA_DIR: DATA_ROOT, AIA_DESKTOP_MODE: "1", }); const args = ["-m", "runtime.operations", "channel", "wecom", "start", "--mode", "ws", "--interval", "3.0", "--deliver-outbound"]; channelStopping = false; channelProc = spawn(pythonBin, args, { cwd: APP_ROOT, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); channelProc.stdout.on("data", (chunk) => { logStream.write(chunk); }); channelProc.stderr.on("data", (chunk) => { logStream.write(chunk); }); channelProc.on("close", (code, signal) => { logStream.write(`[channel-exit] code=${code} signal=${signal || "none"}\n`); logStream.end(); const crashed = !channelStopping; channelProc = null; if (crashed) { setTimeout(() => { if (!quitAfterCleanup) { startChannelProcess().catch(() => {}); } }, 1200); } }); const healthy = await waitForProcessHealthy(channelProc, Date.now() + 2000); if (!healthy) { const msg = `[channel-start-warn] process_exit_${channelProc && channelProc.exitCode !== null ? channelProc.exitCode : "unknown"}\n`; try { fs.appendFileSync(BACKEND_LOG_FILE, msg, { encoding: "utf-8" }); } catch (_) {} return false; } return true; } function waitProcessClose(proc, timeoutMs) { return new Promise((resolve) => { if (!proc || proc.exitCode !== null || proc.killed) { resolve(); return; } let done = false; const finish = () => { if (done) return; done = true; resolve(); }; const timer = setTimeout(finish, Math.max(200, Number(timeoutMs) || 6000)); proc.once("close", () => { clearTimeout(timer); finish(); }); proc.once("exit", () => { clearTimeout(timer); finish(); }); }); } function taskkillTree(pid) { return new Promise((resolve) => { const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore", }); killer.once("error", () => resolve(false)); killer.once("close", (code) => resolve(code === 0)); }); } async function stopBackendProcess() { if (!backendProc) return; const proc = backendProc; backendStopping = true; if (process.platform === "win32") { const ok = await taskkillTree(proc.pid); if (!ok) { try { proc.kill("SIGTERM"); } catch (_) {} } await waitProcessClose(proc, 8000); return; } try { proc.kill("SIGTERM"); } catch (_) {} await waitProcessClose(proc, 5000); if (proc.exitCode === null && !proc.killed) { try { proc.kill("SIGKILL"); } catch (_) {} await waitProcessClose(proc, 3000); } } async function stopChannelProcess() { if (!channelProc) return; const proc = channelProc; channelStopping = true; if (process.platform === "win32") { const ok = await taskkillTree(proc.pid); if (!ok) { try { proc.kill("SIGTERM"); } catch (_) {} } await waitProcessClose(proc, 6000); return; } try { proc.kill("SIGTERM"); } catch (_) {} await waitProcessClose(proc, 4000); } async function showBackendCrashedDialog(code, signal) { if (backendCrashDialogOpen) return; backendCrashDialogOpen = true; try { const result = await dialog.showMessageBox({ type: "error", title: "Backend stopped unexpectedly", message: "本地后端进程已退出", detail: `exit_code=${code ?? "unknown"}, signal=${signal || "none"}\n\n日志文件:${BACKEND_LOG_FILE}`, buttons: ["重启后端", "打开日志目录", "退出应用"], defaultId: 0, cancelId: 2, }); if (result.response === 0) { await startBackendProcess(); if (mainWindow && !mainWindow.isDestroyed()) { await mainWindow.loadURL(`${runtimeState.baseUrl}/chat`); } return; } if (result.response === 1) { shell.showItemInFolder(BACKEND_LOG_FILE); await showBackendCrashedDialog(code, signal); return; } app.quit(); } finally { backendCrashDialogOpen = false; } } function createMainWindow() { mainWindow = new BrowserWindow({ title: APP_DISPLAY_NAME, width: 1400, height: 900, minWidth: 1100, minHeight: 700, icon: APP_ICON_PATH, show: false, backgroundColor: "#0d0d0d", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, devTools: true, backgroundThrottling: false, }, }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (String(url || "").startsWith(runtimeState?.baseUrl || "")) { return { action: "allow" }; } shell.openExternal(url); return { action: "deny" }; }); mainWindow.webContents.on("will-navigate", (event, url) => { const base = runtimeState?.baseUrl || ""; if (!base || !String(url).startsWith(base)) { event.preventDefault(); } }); mainWindow.on("closed", () => { mainWindow = null; }); mainWindow.webContents.on("did-fail-load", (event, code, desc, url, isMainFrame) => { if (!isMainFrame) return; logDesktop(`did-fail-load: code=${code} url=${url} desc=${desc}`); }); mainWindow.webContents.on("did-start-navigation", (event, url, isInPlace, isMainFrame) => { if (!isMainFrame) return; logDesktop(`did-start-navigation: url=${url} inPlace=${Boolean(isInPlace)}`); }); mainWindow.webContents.on("dom-ready", () => { logDesktop("dom-ready"); }); mainWindow.webContents.on("did-finish-load", () => { logDesktop("did-finish-load"); }); mainWindow.webContents.on("did-stop-loading", () => { logDesktop("did-stop-loading"); }); } async function showStartupError(error) { const message = String(error && error.message ? error.message : error || "unknown startup failure"); await dialog.showMessageBox({ type: "error", title: "Desktop startup failed", message: "无法启动本地后端服务", detail: `${message}\n\n日志文件:${BACKEND_LOG_FILE}`, }); } async function boot() { try { app.setName(APP_DISPLAY_NAME); createMainWindow(); // Show window immediately to avoid "black screen" during backend startup. const embedGateway = shouldEmbedBackend(); try { await mainWindow.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent( loadingHtml(embedGateway ? "Starting local gateway…" : "Connecting to existing gateway…"), )}`, ); } catch (_) {} mainWindow.show(); try { mainWindow.focus(); } catch (_) {} const t0 = Date.now(); logDesktop("boot:start"); // Desktop policy: every restart requires explicit login. // Clear persisted web storage before first page load. Do not block window display. const clearStoragePromise = (async () => { try { await mainWindow.webContents.session.clearStorageData(); logDesktop(`boot:clearStorage:ok:${Date.now() - t0}ms`); } catch (e) { logDesktop(`boot:clearStorage:err:${Date.now() - t0}ms:${String(e && e.message ? e.message : e)}`); } })(); const clearCachePromise = (async () => { try { await mainWindow.webContents.session.clearCache(); if (typeof mainWindow.webContents.session.clearHostResolverCache === "function") { mainWindow.webContents.session.clearHostResolverCache(); } logDesktop(`boot:clearCache:ok:${Date.now() - t0}ms`); } catch (e) { logDesktop(`boot:clearCache:err:${Date.now() - t0}ms:${String(e && e.message ? e.message : e)}`); } })(); if (embedGateway) { await startBackendProcess(); logDesktop(`boot:backendReady:embedded:${Date.now() - t0}ms`); } else { await attachToExistingGateway(); logDesktop(`boot:backendReady:external:${Date.now() - t0}ms`); } try { await mainWindow.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml(`Backend ready at ${runtimeState.baseUrl}\nStarting channel…`))}`, ); } catch (_) {} let channelOk = true; if (shouldSkipDesktopChannel()) { logDesktop(`boot:channel:skipped:${Date.now() - t0}ms`); } else { channelOk = await startChannelProcess(); logDesktop(`boot:channel:${channelOk ? "ok" : "fail"}:${Date.now() - t0}ms`); if (!channelOk && !channelStartWarningShown) { channelStartWarningShown = true; await dialog.showMessageBox({ type: "warning", title: "Channel startup warning", message: "企微通道启动失败(不影响桌面端启动)", detail: "你可以在 Admin -> 运行时中查看并重试服务。", }); } } try { await mainWindow.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent( loadingHtml( `Backend ready at ${runtimeState.baseUrl}\nClearing login/session storage…\nThen opening chat…`, ), )}`, ); } catch (_) {} // Do not block the window forever if Electron's clearStorageData/clearCache never settle. await awaitWithTimeout(Promise.all([clearStoragePromise, clearCachePromise]), 12000, "boot:storageCleanup"); // Desktop policy: always require fresh login on every app restart. logDesktop(`boot:loadChat:start:${Date.now() - t0}ms`); await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); logDesktop(`boot:loadChat:done:${Date.now() - t0}ms`); try { mainWindow.show(); mainWindow.focus(); } catch (_) {} } catch (error) { await showStartupError(error); app.quit(); } } ipcMain.handle("desktop:getRuntimeInfo", async () => { const state = runtimeState || {}; return { host: state.host || DEFAULT_HOST, port: state.port || DEFAULT_PORT, baseUrl: state.baseUrl || "", logFile: BACKEND_LOG_FILE, }; }); ipcMain.handle("desktop:restartBackend", async () => { if (runtimeState && runtimeState.externalAttach) { if (mainWindow && !mainWindow.isDestroyed()) { try { await mainWindow.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml("Reloading chat (external gateway)…"))}`, ); mainWindow.show(); } catch (_) {} try { await waitForBackend(`${runtimeState.baseUrl}/health`, Date.now() + 15000); } catch (_) {} try { await awaitWithTimeout(mainWindow.webContents.session.clearCache(), 8000, "restartBackend:clearCache"); } catch (_) {} await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); try { mainWindow.show(); mainWindow.focus(); } catch (_) {} } return true; } if (mainWindow && !mainWindow.isDestroyed()) { try { await mainWindow.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml("Restarting local gateway…"))}`, ); mainWindow.show(); try { mainWindow.focus(); } catch (_) {} } catch (_) {} } await stopChannelProcess(); await stopBackendProcess(); await startBackendProcess(); if (!shouldSkipDesktopChannel()) { await startChannelProcess(); } if (mainWindow && !mainWindow.isDestroyed()) { try { await awaitWithTimeout(mainWindow.webContents.session.clearCache(), 8000, "restartBackend:clearCache"); } catch (_) {} await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); try { mainWindow.show(); mainWindow.focus(); } catch (_) {} } return true; }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", (event) => { if (quitAfterCleanup) return; event.preventDefault(); quitAfterCleanup = true; // Turn quit into graceful async shutdown so backend child tree is fully reaped. (async () => { await stopChannelProcess(); await stopBackendProcess(); app.quit(); })().catch(() => { app.quit(); }); }); app.whenReady().then(boot);