300 lines
9.1 KiB
JavaScript
300 lines
9.1 KiB
JavaScript
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);
|
|
});
|