mirror of
https://github.com/different-ai/openwork
synced 2026-05-05 06:42:03 +02:00
* feat(desktop): electron 1:1 port alongside Tauri, fix workspace-create visibility Adds an Electron shell that mirrors the Tauri desktop runtime (bridge, dialogs, deep links, runtime supervision for openwork-server / opencode / opencode-router / orchestrator, packaging via electron-builder). Tauri dev/build scripts remain the default; Electron runs via dev:electron and package:electron. Also fixes the "workspace I just created is invisible until I restart the app" bug: the React routes only wrote to desktop-side state, so the running openwork-server never learned about the new workspace and the sidebar (which is populated from the server list) dropped it. The create flow now also calls openworkClient.createLocalWorkspace so POST /workspaces/local registers the workspace at runtime. Other small fixes included: - Clears the "OpenWork server Disconnected" flash caused by React 18 StrictMode double-invoking the connection stores' start/dispose pair. - Real app icon wired into Electron (dock + BrowserWindow + builder). - Fix a latent rm-import bug in runtime.mjs that silently skipped orchestrator auth cleanup. - Locale copy updated to say "OpenWork desktop app" instead of "Tauri app". - Adds description/author to apps/desktop/package.json to silence electron-builder warnings. * docs(prds): Tauri → Electron migration plan Describes how we'll cut every current Tauri user over to the Electron build via the existing Tauri updater (one last migration release that downloads + launches the Electron installer), how we unify app identity so Electron reads the same userData Tauri wrote (zero-copy data migration), and how ongoing auto-updates switch to electron-updater publishing to the same GitHub releases. --------- Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
205 lines
5.6 KiB
JavaScript
205 lines
5.6 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 pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
const nodeCmd = process.execPath;
|
|
const portValue = Number.parseInt(process.env.PORT ?? "", 10);
|
|
const devPort = Number.isFinite(portValue) && portValue > 0 ? portValue : 5173;
|
|
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 run(command, args, options = {}) {
|
|
return spawn(command, args, {
|
|
stdio: "inherit",
|
|
shell: process.platform === "win32",
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function runSync(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
stdio: "inherit",
|
|
shell: process.platform === "win32",
|
|
...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 killTree(child) {
|
|
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, "SIGTERM");
|
|
} catch {
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
let uiChild = null;
|
|
let electronChild = null;
|
|
let stopping = false;
|
|
|
|
function stopAll(exitCode = 0) {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
killTree(electronChild);
|
|
killTree(uiChild);
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
process.once("SIGINT", () => stopAll(0));
|
|
process.once("SIGTERM", () => stopAll(0));
|
|
|
|
runSync(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force"], { cwd: desktopRoot });
|
|
|
|
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,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
PORT: String(devPort),
|
|
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
|
|
},
|
|
});
|
|
}
|
|
|
|
const resolvedStartUrl = await waitForVite(startUrl);
|
|
|
|
// Default Electron CDP on a stable dev port so chrome-devtools MCP / raw CDP
|
|
// clients can attach without each launch picking a random port. Override with
|
|
// OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=<port> or set to "0" to disable.
|
|
const defaultCdpPort = "9823";
|
|
const cdpPortRaw = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT?.trim() ?? defaultCdpPort;
|
|
const cdpPort = cdpPortRaw === "" || cdpPortRaw === "0" ? "" : cdpPortRaw;
|
|
|
|
electronChild = run(pnpmCmd, ["exec", "electron", "./electron/main.mjs"], {
|
|
cwd: desktopRoot,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
|
|
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) => {
|
|
stopAll(code ?? 0);
|
|
});
|