/** * Electrobun Bun process — thin router that delegates to domain handler modules. * * Fix #15: Extracted handlers into src/bun/handlers/ for SRP. * Fix #2: Path traversal guard on files.list/read/write via path-guard.ts. */ import { BrowserWindow, BrowserView, Updater, Electrobun } from "electrobun/bun"; import { PtyClient } from "./pty-client.ts"; import { settingsDb } from "./settings-db.ts"; import { sessionDb } from "./session-db.ts"; import { btmsgDb } from "./btmsg-db.ts"; import { createBttaskDb } from "./bttask-db.ts"; import { SidecarManager } from "./sidecar-manager.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import { SearchDb } from "./search-db.ts"; import { RelayClient } from "./relay-client.ts"; import { initTelemetry, telemetry } from "./telemetry.ts"; // Handler modules import { createPtyHandlers } from "./handlers/pty-handlers.ts"; import { createFilesHandlers } from "./handlers/files-handlers.ts"; import { createSettingsHandlers } from "./handlers/settings-handlers.ts"; import { createAgentHandlers } from "./handlers/agent-handlers.ts"; import { createBtmsgHandlers, createBttaskHandlers } from "./handlers/btmsg-handlers.ts"; import { createSearchHandlers } from "./handlers/search-handlers.ts"; import { createPluginHandlers } from "./handlers/plugin-handlers.ts"; import { createRemoteHandlers } from "./handlers/remote-handlers.ts"; import { createGitHandlers } from "./handlers/git-handlers.ts"; import { createProviderHandlers } from "./handlers/provider-handlers.ts"; import { createMiscHandlers } from "./handlers/misc-handlers.ts"; /** Current app version — sourced from electrobun.config.ts at build time. */ const APP_VERSION = "0.0.1"; const DEV_SERVER_PORT = 9760; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; // ── Services ─────────────────────────────────────────────────────────────── const ptyClient = new PtyClient(); const sidecarManager = new SidecarManager(); const searchDb = new SearchDb(); const relayClient = new RelayClient(); const bttaskDb = createBttaskDb(btmsgDb.getHandle()); initTelemetry(); async function connectToDaemon(retries = 5, delayMs = 500): Promise { for (let attempt = 1; attempt <= retries; attempt++) { try { await ptyClient.connect(); console.log("[agor-ptyd] Connected to PTY daemon"); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (attempt < retries) { console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms...`); await new Promise((r) => setTimeout(r, delayMs)); delayMs = Math.min(delayMs * 2, 4000); } else { console.error(`[agor-ptyd] Could not connect after ${retries} attempts: ${msg}`); } } } return false; } // ── Build handler maps ─────────────────────────────────────────────────── // Placeholder rpc for agent handler — will be set after rpc creation const rpcRef: { send: Record void> } = { send: {} }; const ptyHandlers = createPtyHandlers(ptyClient); const filesHandlers = createFilesHandlers(); const settingsHandlers = createSettingsHandlers(settingsDb); const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef); const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef); const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef); const searchHandlers = createSearchHandlers(searchDb); const pluginHandlers = createPluginHandlers(); const remoteHandlers = createRemoteHandlers(relayClient, settingsDb); const gitHandlers = createGitHandlers(); const providerHandlers = createProviderHandlers(); const miscHandlers = createMiscHandlers({ settingsDb, ptyClient, relayClient, sidecarManager, telemetry, appVersion: APP_VERSION, }); // Window ref — handlers use closure; set after mainWindow creation let mainWindow: BrowserWindow; // ── RPC definition ───────────────────────────────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 120_000, handlers: { requests: { ...ptyHandlers, ...filesHandlers, ...settingsHandlers, ...agentHandlers, ...btmsgHandlers, ...bttaskHandlers, ...searchHandlers, ...pluginHandlers, ...remoteHandlers, ...gitHandlers, ...providerHandlers, ...miscHandlers, // GTK native drag/resize — delegates to window manager (zero CPU) "window.beginResize": ({ edge, button, rootX, rootY }: { edge: string; button: number; rootX: number; rootY: number }) => { try { const { beginResizeDrag, edgeStringToGdk } = require("./gtk-window.ts"); const gdkEdge = edgeStringToGdk(edge); if (gdkEdge === null) return { ok: false, error: `Unknown edge: ${edge}` }; console.log(`[resize] edge=${edge} gdkEdge=${gdkEdge} btn=${button} rootX=${rootX} rootY=${rootY} ptr=${(mainWindow as any).ptr}`); const ok = beginResizeDrag((mainWindow as any).ptr, gdkEdge, button, rootX, rootY); console.log(`[resize] result: ${ok}`); return { ok }; } catch (err) { console.error("[window.beginResize]", err); return { ok: false }; } }, "window.beginMove": ({ button, rootX, rootY }: { button: number; rootX: number; rootY: number }) => { try { const { beginMoveDrag } = require("./gtk-window.ts"); const ok = beginMoveDrag((mainWindow as any).ptr, button, rootX, rootY); return { ok }; } catch (err) { console.error("[window.beginMove]", err); return { ok: false }; } }, // Window controls — need mainWindow closure, stay inline "window.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } }, "window.maximize": () => { try { const frame = mainWindow.getFrame(); if (frame.x <= 0 && frame.y <= 0) mainWindow.unmaximize(); else mainWindow.maximize(); return { ok: true }; } catch (err) { console.error("[window.maximize]", err); return { ok: false }; } }, "window.close": () => { try { mainWindow.close(); return { ok: true }; } catch (err) { console.error("[window.close]", err); return { ok: false }; } }, "window.getFrame": () => { try { return mainWindow.getFrame(); } catch { return { x: 0, y: 0, width: 1400, height: 900 }; } }, "window.setPosition": ({ x, y }: { x: number; y: number }) => { try { mainWindow.setPosition(x, y); return { ok: true }; } catch { return { ok: false }; } }, "window.setFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => { try { mainWindow.setPosition(x, y); mainWindow.setSize(width, height); return { ok: true }; } catch (err) { console.error("[window.setFrame]", err); return { ok: false }; } }, "window.clearMinSize": () => { try { const { ensureResizable } = require("./gtk-window.ts"); ensureResizable((mainWindow as any).ptr); return { ok: true }; } catch (err) { console.error("[window.clearMinSize]", err); return { ok: false }; } }, "window.gtkSetFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => { try { const { gtkSetFrame } = require("./gtk-window.ts"); const ok = gtkSetFrame((mainWindow as any).ptr, x, y, width, height); return { ok }; } catch (err) { console.error("[window.gtkSetFrame]", err); return { ok: false }; } }, "window.x11SetFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => { try { const { x11SetFrame } = require("./gtk-window.ts"); const ok = x11SetFrame((mainWindow as any).ptr, x, y, width, height); return { ok }; } catch (err) { console.error("[window.x11SetFrame]", err); return { ok: false }; } }, }, messages: {}, }, }); // Wire rpcRef so agent handlers can forward events to webview rpcRef.send = rpc.send; // ── Forward daemon events to WebView ────────────────────────────────────── ptyClient.on("session_output", (msg) => { if (msg.type !== "session_output") return; try { rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); } catch (err) { console.error("[pty.output] forward error:", err); } }); ptyClient.on("session_closed", (msg) => { if (msg.type !== "session_closed") return; try { rpc.send["pty.closed"]({ sessionId: msg.session_id, exitCode: msg.exit_code }); } catch (err) { console.error("[pty.closed] forward error:", err); } }); // ── Forward relay events to WebView ─────────────────────────────────────── relayClient.onEvent((machineId, event) => { try { rpc.send["remote.event"]({ machineId, eventType: event.type, sessionId: event.sessionId, payload: event.payload }); } catch (err) { console.error("[remote.event] forward error:", err); } }); relayClient.onStatus((machineId, status, error) => { try { rpc.send["remote.statusChange"]({ machineId, status, error }); } catch (err) { console.error("[remote.statusChange] forward error:", err); } }); // ── App window ──────────────────────────────────────────────────────────── async function getMainViewUrl(): Promise { const channel = await Updater.localInfo.channel(); if (channel === "dev") { try { await fetch(DEV_SERVER_URL, { method: "HEAD" }); console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`); return DEV_SERVER_URL; } catch { console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support."); } } return "views://mainview/index.html"; } connectToDaemon(); const url = await getMainViewUrl(); const savedX = Number(settingsDb.getSetting("win_x") ?? 100); const savedY = Number(settingsDb.getSetting("win_y") ?? 100); const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400); const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900); mainWindow = new BrowserWindow({ title: "Agent Orchestrator", titleBarStyle: "hidden", url, rpc, frame: { width: isNaN(savedWidth) ? 1400 : savedWidth, height: isNaN(savedHeight) ? 900 : savedHeight, x: isNaN(savedX) ? 100 : savedX, y: isNaN(savedY) ? 100 : savedY, }, }); // Install native GTK resize handlers (tao/Tauri pattern) // This connects button-press-event directly on the GtkWindow, // handling resize BEFORE WebKitGTK processes events. { const { ensureResizable } = require("./gtk-window.ts"); ensureResizable((mainWindow as any).ptr); const { installNativeResize } = require("./gtk-resize.ts"); installNativeResize((mainWindow as any).ptr); } // Prevent GTK's false Ctrl+click detection from closing the window on initial load. // WebKitGTK reports stale modifier state (0x14 = Ctrl+Alt) after SIGTERM of previous instance, // which Electrobun interprets as a Cmd+click → "open in new window" → closes the main window. // Fix: intercept new-window-open globally and suppress it. try { // @ts-ignore — electrobunEventEmitter is an internal export const mod = await import("electrobun/bun"); const emitter = (mod as any).electrobunEventEmitter ?? (mod as any).default?.electrobunEventEmitter; if (emitter?.on) { emitter.on("new-window-open", (event: any) => { console.log(`[new-window-open] Blocked false Ctrl+click: ${JSON.stringify(event?.detail ?? event)}`); if (event?.preventDefault) event.preventDefault(); }); console.log("[new-window-open] Handler registered successfully"); } else { console.warn("[new-window-open] Could not find event emitter in electrobun/bun exports"); } } catch (err) { console.warn("[new-window-open] Could not register handler:", err); } console.log("Agent Orchestrator (Electrobun) started!");