agent-orchestrator/ui-electrobun/src/bun/index.ts
Hibryda e9fcd8401e feat(electrobun): native GTK resize via button-press-event signal (tao pattern)
All previous approaches failed because they initiated resize from JS
(too late, wrong timestamp, WebKit steals grab). The correct approach
(proven by Tauri/tao) is to connect button-press-event DIRECTLY on the
GtkWindow at the GTK level, BEFORE WebKitGTK processes events.

New: gtk-resize.ts
- JSCallback for button-press-event + motion-notify-event
- Hit-test in 8px border zone (same as tao's 5*scale_factor)
- begin_resize_drag with REAL event timestamp (not GDK_CURRENT_TIME)
- Returns TRUE to STOP propagation (WebKit never sees the press)
- Cursor updates on motion-notify in border zone

Removed: all JS resize handles (divs, mousemove, mouseup, RPC calls)
2026-03-25 15:25:25 +01:00

274 lines
12 KiB
TypeScript

/**
* 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<boolean> {
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<string, (...args: unknown[]) => 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<PtyRPCSchema>({
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<string> {
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!");