import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, session, type MenuItemConstructorOptions, } from "electron"; import { spawn, type ChildProcess } from "node:child_process"; import { join, resolve, basename } from "node:path"; import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync, appendFileSync, } from "node:fs"; import { homedir } from "node:os"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const DEFAULT_PORT = 14788; const DEV_SERVER_URL = process.env.EXECUTOR_DEV_URL || "http://executor-local.localhost:1355"; const SERVER_STARTUP_TIMEOUT_MS = 30_000; const SETTINGS_DIR = join(homedir(), ".executor"); const SETTINGS_PATH = join(SETTINGS_DIR, "desktop-settings.json"); const CLI_BIN_DIR = join(SETTINGS_DIR, "bin"); const CLI_BIN_PATH = join(CLI_BIN_DIR, process.platform === "win32" ? "executor.exe" : "executor"); const htmlEscape = (value: string): string => value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); // --------------------------------------------------------------------------- // CLI install — copy sidecar to ~/.executor/bin and patch shell PATH // --------------------------------------------------------------------------- const getCliVersion = (path: string): string | null => { if (!existsSync(path)) return null; try { const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); return execFileSync(path, ["--version"], { timeout: 5000, encoding: "utf-8", }).trim(); } catch { return null; } }; const getInstalledCliVersion = (): string | null => getCliVersion(CLI_BIN_PATH); const installCli = (): void => { if (isDev) return; const sidecar = join(process.resourcesPath, binaryName); if (!existsSync(sidecar)) return; // Check if installed version is already same or newer const bundledVersion = getCliVersion(sidecar) ?? app.getVersion(); const installedVersion = getInstalledCliVersion(); if (installedVersion) { const parse = (v: string) => v .replace(/^v/, "") .split(/[.-]/) .map((s) => { const n = parseInt(s, 10); return isNaN(n) ? 0 : n; }); const installed = parse(installedVersion); const bundled = parse(bundledVersion); const len = Math.max(installed.length, bundled.length); let cmp = 0; for (let i = 0; i < len && cmp === 0; i++) { cmp = (installed[i] ?? 0) - (bundled[i] ?? 0); } if (cmp >= 0) return; // Already up to date or newer } // Copy binary mkdirSync(CLI_BIN_DIR, { recursive: true }); copyFileSync(sidecar, CLI_BIN_PATH); try { chmodSync(CLI_BIN_PATH, 0o755); } catch {} console.log( `Installed executor CLI ${bundledVersion} to ${CLI_BIN_PATH}` + (installedVersion ? ` (was ${installedVersion})` : ""), ); // Copy WASM if present const wasm = join(process.resourcesPath, "emscripten-module.wasm"); if (existsSync(wasm)) { copyFileSync(wasm, join(CLI_BIN_DIR, "emscripten-module.wasm")); } // Patch shell profiles with PATH if (process.platform === "win32") { // Add bin dir to the user PATH via registry so new terminals pick it up const result = spawn("reg", ["query", "HKCU\\Environment", "/v", "Path"], { stdio: "pipe" }); let out = ""; result.stdout?.on("data", (d: Buffer) => (out += d.toString())); result.on("close", (code) => { // Exit codes: 0 = value exists, 1 = value missing (treat as empty). // Anything else (2 = access denied, etc.) is an unexpected failure — // bail rather than risk writing a malformed PATH. if (code !== 0 && code !== 1) { console.warn(`executor: reg query failed (code ${code}), skipping PATH update`); return; } let current = ""; if (code === 0) { const match = out.match(/Path\s+REG(?:_EXPAND)?_SZ\s+(.+)/i); if (!match) { // reg query succeeded but the output didn't parse — this means the // format changed and we can't safely rewrite PATH. Bail. console.warn("executor: could not parse reg query output, skipping PATH update"); return; } current = match[1].trim(); } if (!current.toLowerCase().includes(CLI_BIN_DIR.toLowerCase())) { const updated = current ? `${current};${CLI_BIN_DIR}` : CLI_BIN_DIR; spawn("reg", [ "add", "HKCU\\Environment", "/v", "Path", "/t", "REG_EXPAND_SZ", "/d", updated, "/f", ]); } }); return; } const pathLine = `export PATH="${CLI_BIN_DIR}:$PATH"`; const profiles = [ join(homedir(), ".zshrc"), join(homedir(), ".bashrc"), join(homedir(), ".bash_profile"), ]; // Fish uses a different syntax const fishConfig = join(homedir(), ".config", "fish", "config.fish"); const fishLine = `fish_add_path "${CLI_BIN_DIR}"`; for (const profile of profiles) { if (!existsSync(profile)) continue; const content = readFileSync(profile, "utf-8"); if (content.includes(CLI_BIN_DIR)) continue; appendFileSync(profile, `\n# Added by Executor desktop app\n${pathLine}\n`); } if (existsSync(fishConfig)) { const content = readFileSync(fishConfig, "utf-8"); if (!content.includes(CLI_BIN_DIR)) { appendFileSync(fishConfig, `\n# Added by Executor desktop app\n${fishLine}\n`); } } }; // --------------------------------------------------------------------------- // Settings persistence // --------------------------------------------------------------------------- interface Settings { recentScopes: string[]; lastScope: string | null; windowBounds?: { x: number; y: number; width: number; height: number }; } const loadSettings = (): Settings => { try { if (existsSync(SETTINGS_PATH)) { return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")); } } catch {} return { recentScopes: [], lastScope: null }; }; const saveSettings = (settings: Settings): void => { mkdirSync(SETTINGS_DIR, { recursive: true }); writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)); }; const addRecentScope = (settings: Settings, scopePath: string): void => { settings.recentScopes = [ scopePath, ...settings.recentScopes.filter((s) => s !== scopePath), ].slice(0, 10); settings.lastScope = scopePath; saveSettings(settings); }; // --------------------------------------------------------------------------- // Server process management // --------------------------------------------------------------------------- let serverProcess: ChildProcess | null = null; let currentScope: string | null = null; let currentPort = DEFAULT_PORT; const isDev = !app.isPackaged; const binaryName = process.platform === "win32" ? "executor.exe" : "executor"; /** * Returns { command, args, cwd } for spawning the server. * - In dev mode: uses `bun run` with the CLI source * - In production: uses the bundled sidecar binary */ const resolveServerCommand = (): { command: string; args: string[] } => { if (isDev) { const cliMain = resolve(__dirname, "../../cli/src/main.ts"); if (existsSync(cliMain)) { return { command: "bun", args: ["run", cliMain] }; } throw new Error("Could not find executor CLI entry point for dev mode"); } // Production: sidecar binary bundled via extraResources const sidecar = join(process.resourcesPath, binaryName); if (existsSync(sidecar)) { return { command: sidecar, args: [] }; } throw new Error(`Sidecar binary not found at ${sidecar}`); }; const isServerReady = async (port: number): Promise => { try { const res = await fetch(`http://127.0.0.1:${port}/docs`, { signal: AbortSignal.timeout(2000), }); return res.ok; } catch { return false; } }; const stopServer = (): Promise => { return new Promise((resolve) => { if (!serverProcess) { resolve(); return; } const proc = serverProcess; serverProcess = null; proc.once("exit", () => resolve()); proc.kill("SIGTERM"); // Force kill after 5s setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} resolve(); }, 5000); }); }; const startServer = async (scopePath: string, port: number): Promise => { await stopServer(); currentScope = scopePath; currentPort = port; const server = resolveServerCommand(); const args = [...server.args, "web", "--port", String(port)]; // In dev mode, run from repo root so bun can resolve workspace deps. // In production, the sidecar is self-contained. const cwd = isDev ? resolve(__dirname, "../../..") : scopePath; serverProcess = spawn(server.command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, EXECUTOR_SCOPE_DIR: scopePath }, }); serverProcess.stdout?.on("data", (data: Buffer) => { console.log(`[server] ${data.toString().trim()}`); }); serverProcess.stderr?.on("data", (data: Buffer) => { console.error(`[server] ${data.toString().trim()}`); }); serverProcess.on("exit", (code) => { console.log(`[server] exited with code ${code}`); if (serverProcess === null) return; // intentional stop serverProcess = null; // Server died unexpectedly — quit the app app.quit(); }); // Wait for server to become ready const deadline = Date.now() + SERVER_STARTUP_TIMEOUT_MS; while (Date.now() < deadline) { if (await isServerReady(port)) return; await new Promise((r) => setTimeout(r, 200)); } throw new Error(`Server failed to start within ${SERVER_STARTUP_TIMEOUT_MS / 1000}s`); }; // --------------------------------------------------------------------------- // Window management // --------------------------------------------------------------------------- let mainWindow: BrowserWindow | null = null; let settings = loadSettings(); const createWindow = (): BrowserWindow => { const bounds = settings.windowBounds ?? { width: 1200, height: 800, }; const win = new BrowserWindow({ ...bounds, minWidth: 800, minHeight: 600, title: "Executor", titleBarStyle: "hidden", trafficLightPosition: { x: 12, y: 12 }, webPreferences: { contextIsolation: true, nodeIntegration: false, }, backgroundColor: nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff", }); win.on("close", () => { const b = win.getBounds(); settings.windowBounds = b; saveSettings(settings); }); // Open DevTools in development if (process.env.NODE_ENV === "development") { win.webContents.openDevTools(); } // Log renderer errors to main process console win.webContents.on("console-message", (_event, level, message, line, sourceId) => { if (level >= 2) console.error(`[renderer] ${message} (${sourceId}:${line})`); }); // Inject CSS to account for traffic light buttons and enable scrolling win.webContents.on("did-finish-load", () => { win.webContents.insertCSS(` /* Drag region for the titlebar area */ body::before { content: ''; display: block; position: fixed; top: 0; left: 0; right: 0; height: 38px; -webkit-app-region: drag; z-index: 9999; pointer-events: none; } /* Make interactive elements inside the titlebar area clickable */ a, button, input, select, textarea, [role="button"] { -webkit-app-region: no-drag; } /* Push sidebar content below traffic lights */ aside > :first-child { margin-top: 38px !important; } /* Push main content mobile bar below traffic lights */ main > :first-child { margin-top: 38px !important; } `); }); return win; }; const loadScope = async (scopePath: string): Promise => { if (!mainWindow) return; mainWindow.setTitle(`Executor — ${basename(scopePath)}`); if (isDev) { // In dev mode, the Vite dev server (via portless) handles both UI and API. // Just set the scope env var and load the dev URL. currentScope = scopePath; process.env.EXECUTOR_SCOPE_DIR = scopePath; addRecentScope(settings, scopePath); buildMenu(); mainWindow.loadURL(DEV_SERVER_URL); return; } // Show loading state mainWindow.loadURL(`data:text/html,${encodeURIComponent(loadingHTML(scopePath))}`); try { await startServer(scopePath, currentPort); addRecentScope(settings, scopePath); buildMenu(); mainWindow.loadURL(`http://127.0.0.1:${currentPort}`); } catch (err) { mainWindow.loadURL(`data:text/html,${encodeURIComponent(errorHTML(String(err)))}`); } }; const selectFolder = async (): Promise => { if (!mainWindow) return; const result = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory"], title: "Select Scope Directory", message: "Choose a folder to use as the executor scope", }); if (result.canceled || result.filePaths.length === 0) return; await loadScope(result.filePaths[0]); }; // --------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------- const buildMenu = (): void => { const recentItems: MenuItemConstructorOptions[] = settings.recentScopes.map((scopePath) => ({ label: `${basename(scopePath)} — ${scopePath}`, click: () => loadScope(scopePath), enabled: scopePath !== currentScope, })); const template: MenuItemConstructorOptions[] = [ { role: "appMenu", submenu: [ { role: "about" }, { type: "separator" }, { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }, ], }, { label: "File", submenu: [ { label: "Open Scope...", accelerator: "CmdOrCtrl+O", click: selectFolder, }, { type: "separator" }, ...(recentItems.length > 0 ? [ { label: "Recent Scopes", enabled: false } as MenuItemConstructorOptions, ...recentItems, { type: "separator" as const }, { label: "Clear Recent", click: () => { settings.recentScopes = []; saveSettings(settings); buildMenu(); }, } as MenuItemConstructorOptions, ] : []), { type: "separator" }, { role: "close" as const }, ], }, { role: "editMenu" }, { label: "View", submenu: [ { role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, ], }, { role: "windowMenu" }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); }; // --------------------------------------------------------------------------- // IPC handlers // --------------------------------------------------------------------------- const setupIPC = (): void => { ipcMain.handle("select-scope", async () => { await selectFolder(); return currentScope; }); ipcMain.handle("get-current-scope", () => currentScope); ipcMain.handle("get-recent-scopes", () => settings.recentScopes); ipcMain.handle("switch-scope", async (_event, scopePath: string) => { if (existsSync(scopePath)) { await loadScope(scopePath); } return currentScope; }); }; // --------------------------------------------------------------------------- // HTML templates for loading/error states // --------------------------------------------------------------------------- const loadingHTML = (scopePath: string): string => { const isDark = nativeTheme.shouldUseDarkColors; const bg = isDark ? "#0a0a0a" : "#ffffff"; const fg = isDark ? "#e5e5e5" : "#171717"; const muted = isDark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.25)"; const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)"; const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)"; const barTrack = isDark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"; const folder = htmlEscape(basename(scopePath.replace(/[/\\]+$/, "")) || scopePath); return `
executor
${folder}
`; }; const errorHTML = (message: string): string => `

Failed to start server

${htmlEscape(message)}
`; // --------------------------------------------------------------------------- // Welcome screen // --------------------------------------------------------------------------- const welcomeHTML = (): string => { const isDark = nativeTheme.shouldUseDarkColors; const bg = isDark ? "#0a0a0a" : "#ffffff"; const fg = isDark ? "#e5e5e5" : "#171717"; const muted = isDark ? "#666" : "#999"; const cardBg = isDark ? "#141414" : "#f9f9f9"; const borderColor = isDark ? "#262626" : "#e5e5e5"; const recentItems = settings.recentScopes .map( (s) => ``, ) .join(""); return `

Executor

Select a folder to set as your scope

\u2318O

${ recentItems ? `
Recent
${recentItems}
` : "" }
`; }; // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- app.whenReady().then(async () => { // Clear cached web content so we always load the latest UI await session.defaultSession.clearCache(); // Install/update CLI binary to ~/.executor/bin installCli(); settings = loadSettings(); setupIPC(); buildMenu(); mainWindow = createWindow(); // If we have a last scope and it still exists, open it directly if (settings.lastScope && existsSync(settings.lastScope)) { await loadScope(settings.lastScope); } else { mainWindow.loadURL(`data:text/html,${encodeURIComponent(welcomeHTML())}`); } }); app.on("window-all-closed", () => { // Synchronously kill the server process before quitting if (serverProcess) { serverProcess.kill("SIGTERM"); serverProcess = null; } app.quit(); }); app.on("before-quit", () => { if (serverProcess) { serverProcess.kill("SIGTERM"); serverProcess = null; } }); // Last resort: kill server on exit process.on("exit", () => { if (serverProcess) { serverProcess.kill("SIGKILL"); serverProcess = null; } }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(); mainWindow.loadURL(`data:text/html,${encodeURIComponent(welcomeHTML())}`); } });