import { spawn, spawnSync } from "node:child_process"; import net from "node:net"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopRoot = resolve(__dirname, ".."); const repoRoot = resolve(desktopRoot, "../.."); const electronSidecarDir = resolve(desktopRoot, "resources", "sidecars"); const electronHelperDir = resolve(desktopRoot, "resources", "helpers"); const defaultDevDataDir = resolve( process.env.HOME ?? process.env.USERPROFILE ?? repoRoot, ".openwork", "openwork-server-dev", ); const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; const nodeCmd = process.execPath; async function findFreeTcpPort() { return new Promise((resolvePort, rejectPort) => { const server = net.createServer(); server.once("error", rejectPort); server.listen({ port: 0, host: "127.0.0.1" }, () => { const address = server.address(); server.close(() => { if (address && typeof address === "object") { resolvePort(address.port); return; } rejectPort(new Error("Unable to resolve a free Vite dev port")); }); }); }); } const rawPort = process.env.PORT?.trim() ?? ""; const portValue = Number.parseInt(rawPort, 10); const devPort = rawPort === "0" ? await findFreeTcpPort() : Number.isFinite(portValue) && portValue > 0 ? portValue : 5173; if (rawPort === "0") { console.log(`[electron-dev] Vite dev server will use free port ${devPort}`); } const explicitStartUrl = process.env.OPENWORK_ELECTRON_START_URL?.trim() || ""; const startUrl = explicitStartUrl || `http://localhost:${devPort}`; const viteProbeUrls = explicitStartUrl ? [explicitStartUrl] : [ `http://127.0.0.1:${devPort}`, `http://[::1]:${devPort}`, `http://localhost:${devPort}`, ]; function needsShell(command) { return process.platform === "win32" && /\.(cmd|bat)$/i.test(command); } function run(command, args, options = {}) { return spawn(command, args, { stdio: ["ignore", "inherit", "inherit"], shell: needsShell(command), ...options, }); } function runSync(command, args, options = {}) { const result = spawnSync(command, args, { stdio: "inherit", shell: needsShell(command), ...options, }); if (result.status !== 0) { process.exit(result.status ?? 1); } } async function fetchWithTimeout(url, timeoutMs = 4000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } } async function probeHost(host, port) { return new Promise((resolveCheck) => { const socket = net.createConnection({ host, port }); const onDone = (ready) => { socket.removeAllListeners(); socket.destroy(); resolveCheck(ready); }; socket.setTimeout(1200); socket.once("connect", () => onDone(true)); socket.once("timeout", () => onDone(false)); socket.once("error", () => onDone(false)); }); } async function looksLikeVite(url) { try { const response = await fetchWithTimeout(`${url}/@vite/client`); if (!response.ok) return false; const body = await response.text(); return body.includes("@vite/client") || body.includes("import.meta.hot"); } catch { return false; } } async function portIsOpenForVite(url) { try { const parsed = new URL(url); const host = parsed.hostname.replace(/^\[|\]$/g, ""); const port = Number.parseInt(parsed.port || (parsed.protocol === "https:" ? "443" : "80"), 10); if (!Number.isFinite(port)) return false; return probeHost(host, port); } catch { return false; } } async function waitForVite(url, timeoutMs = 60_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) { if (await looksLikeVite(candidate)) { return candidate; } } for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) { if (await portIsOpenForVite(candidate)) { return candidate; } } await new Promise((resolveDelay) => setTimeout(resolveDelay, 500)); } throw new Error(`Timed out waiting for Vite dev server at ${viteProbeUrls.join(", ")}`); } function signalTree(child, signal) { if (!child?.pid) return; if (process.platform === "win32") { try { spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); } catch { // ignore } return; } try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch { // ignore } } } function restoreTerminal() { try { if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { process.stdin.setRawMode(false); } } catch { // ignore } } function waitForExit(child, timeoutMs) { if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); return new Promise((resolveWait) => { let settled = false; const finish = (clean) => { if (settled) return; settled = true; clearTimeout(timer); child.off("exit", onExit); resolveWait(clean); }; const onExit = () => finish(true); const timer = setTimeout(() => finish(false), timeoutMs); child.once("exit", onExit); }); } async function waitForChildren(children, timeoutMs) { const results = await Promise.all(children.map((child) => waitForExit(child, timeoutMs))); return results.every(Boolean); } let uiChild = null; let electronChild = null; let stopping = false; async function stopAll(exitCode = 0) { if (stopping) return; stopping = true; restoreTerminal(); const children = [electronChild, uiChild].filter(Boolean); for (const child of children) signalTree(child, "SIGINT"); const stoppedCleanly = await waitForChildren(children, 2_000); if (!stoppedCleanly) { for (const child of children) signalTree(child, "SIGTERM"); await waitForChildren(children, 1_000); } restoreTerminal(); process.exit(exitCode); } process.once("SIGINT", () => void stopAll(130)); process.once("SIGTERM", () => void stopAll(143)); if (process.env.OPENWORK_ELECTRON_SKIP_SHARED_PREPARE !== "1") { runSync(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force", "--outdir", electronSidecarDir], { cwd: desktopRoot }); runSync(nodeCmd, [resolve(__dirname, "prepare-computer-use-helper.mjs"), "--force", "--outdir", electronHelperDir], { cwd: desktopRoot }); } // Build workspace packages that Electron imports from their dist output. console.log("[electron-dev] Building Electron workspace dependencies..."); runSync(pnpmCmd, ["--filter", "@openwork/headless-threads", "build"], { cwd: repoRoot }); runSync(pnpmCmd, ["--filter", "openwork-server", "build"], { cwd: repoRoot }); const initialProbeUrls = [startUrl, ...viteProbeUrls].filter(Boolean); let viteReady = false; for (const candidate of initialProbeUrls) { if (await looksLikeVite(candidate)) { viteReady = true; break; } } if (!viteReady) { for (const candidate of initialProbeUrls) { if (await portIsOpenForVite(candidate)) { viteReady = true; break; } } } if (!viteReady) { uiChild = run(pnpmCmd, ["-w", "dev:ui"], { cwd: repoRoot, env: { ...process.env, PORT: String(devPort), OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1", OPENWORK_DATA_DIR: process.env.OPENWORK_DATA_DIR ?? defaultDevDataDir, }, }); } const resolvedStartUrl = await waitForVite(startUrl); // Native dependencies installed for the host Node ABI must be rebuilt before // Electron loads the embedded server and terminal runtime. if (process.env.OPENWORK_ELECTRON_SKIP_NATIVE_REBUILD === "1") { console.log("[electron-dev] Using prebuilt Electron native dependencies."); } else { console.log("[electron-dev] Rebuilding native dependencies for Electron..."); runSync(pnpmCmd, ["--filter", "@openwork/desktop", "run", "rebuild:electron-native"], { cwd: repoRoot }); } // Optional Electron CDP for external debugging / raw CDP clients. // NOT required for the built-in browser (uses native webContents APIs). // Set OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9823 to enable. const cdpPortRaw = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT?.trim() ?? ""; const cdpPort = cdpPortRaw === "" || cdpPortRaw === "0" ? "" : cdpPortRaw; const blankSlateArgs = process.argv.includes("--blank-slate") ? ["--blank-slate"] : []; electronChild = run(pnpmCmd, ["exec", "electron", "./electron/main.mjs", ...blankSlateArgs], { cwd: desktopRoot, env: { ...process.env, OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1", OPENWORK_DATA_DIR: process.env.OPENWORK_DATA_DIR ?? defaultDevDataDir, OPENWORK_ELECTRON_START_URL: resolvedStartUrl, ...(cdpPort ? { OPENWORK_ELECTRON_REMOTE_DEBUG_PORT: cdpPort } : {}), }, }); if (cdpPort) { console.log(`[openwork] Electron CDP exposed at http://127.0.0.1:${cdpPort}`); } electronChild.on("exit", (code) => { if (stopping) return; void stopAll(code ?? 0); });