agent-orchestrator/ui-electrobun/src/bun/index.ts
Hibryda 6002a379e4 feat(electrobun): wire persistence — SQLite, 17 themes, font system
Persistence:
- bun:sqlite at ~/.config/agor/settings.db (WAL mode, 500ms busy_timeout)
- 4 tables: schema_version, settings, projects, custom_themes
- 5 RPC handlers: settings.get/set/getAll, projects get/set

Theme system (LIVE switching):
- All 17 themes ported from Tauri (4 Catppuccin + 7 Editor + 6 Deep Dark)
- applyCssVars() sets 26 --ctp-* vars on document.documentElement
- Parallel xterm ITheme mapping per theme
- theme-store.svelte.ts: Svelte 5 rune store, persists to SQLite

Font system:
- font-store.svelte.ts: UI/terminal font family + size
- Live CSS var application (--ui-font-family/size, --term-font-family/size)
- onTermFontChange() callback registry for terminal instances
- Persists all 4 font settings to SQLite

AppearanceSettings wired: 17-theme grouped dropdown, font steppers
Init on startup: restores saved theme + fonts from SQLite
2026-03-20 05:29:03 +01:00

221 lines
7.5 KiB
TypeScript

import path from "path";
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
// ── PTY daemon client ────────────────────────────────────────────────────────
// Resolve daemon socket directory. agor-ptyd writes ptyd.sock and ptyd.token
// into $XDG_RUNTIME_DIR/agor or ~/.local/share/agor/run.
const ptyClient = new PtyClient();
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}`);
console.error("[agor-ptyd] Terminals will not work. Start agor-ptyd and restart the app.");
}
}
}
return false;
}
// ── RPC definition ────────────────────────────────────────────────────────────
/**
* BrowserView.defineRPC defines handlers that the WebView can call.
* The schema type parameter describes what the Bun side handles (requests from
* WebView) and what messages Bun sends to the WebView.
*
* Pattern: handlers.requests = WebView→Bun calls
* handlers.messages = Bun→WebView fire-and-forget
* To push a message to the WebView: rpc.send["pty.output"](payload)
*/
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
maxRequestTime: 10_000,
handlers: {
requests: {
"pty.create": async ({ sessionId, cols, rows, cwd }) => {
if (!ptyClient.isConnected) {
return { ok: false, error: "PTY daemon not connected" };
}
try {
ptyClient.createSession({ id: sessionId, cols, rows, cwd });
// Don't call subscribe() — CreateSession already auto-subscribes
// the creating client and starts a fanout task.
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error(`[pty.create] ${sessionId}: ${error}`);
return { ok: false, error };
}
},
"pty.write": ({ sessionId, data }) => {
if (!ptyClient.isConnected) return { ok: false };
try {
ptyClient.writeInput(sessionId, data);
return { ok: true };
} catch (err) {
console.error(`[pty.write] ${sessionId}:`, err);
return { ok: false };
}
},
"pty.resize": ({ sessionId, cols, rows }) => {
if (!ptyClient.isConnected) return { ok: true }; // Best-effort
try {
ptyClient.resize(sessionId, cols, rows);
} catch { /* ignore */ }
return { ok: true };
},
"pty.unsubscribe": ({ sessionId }) => {
try {
ptyClient.unsubscribe(sessionId);
} catch { /* ignore */ }
return { ok: true };
},
"pty.close": ({ sessionId }) => {
try {
ptyClient.closeSession(sessionId);
} catch { /* ignore */ }
return { ok: true };
},
// ── Settings handlers ─────────────────────────────────────────────────
"settings.get": ({ key }) => {
try {
return { value: settingsDb.getSetting(key) };
} catch (err) {
console.error("[settings.get]", err);
return { value: null };
}
},
"settings.set": ({ key, value }) => {
try {
settingsDb.setSetting(key, value);
return { ok: true };
} catch (err) {
console.error("[settings.set]", err);
return { ok: false };
}
},
"settings.getAll": () => {
try {
return { settings: settingsDb.getAll() };
} catch (err) {
console.error("[settings.getAll]", err);
return { settings: {} };
}
},
"settings.getProjects": () => {
try {
const projects = settingsDb.listProjects().map((p) => ({
id: p.id,
config: JSON.stringify(p),
}));
return { projects };
} catch (err) {
console.error("[settings.getProjects]", err);
return { projects: [] };
}
},
"settings.setProject": ({ id, config }) => {
try {
const parsed = JSON.parse(config);
settingsDb.setProject(id, { id, ...parsed });
return { ok: true };
} catch (err) {
console.error("[settings.setProject]", err);
return { ok: false };
}
},
},
messages: {
// Messages section defines what the WebView can *send* to Bun (fire-and-forget).
// We don't expect any inbound messages from the WebView in this direction.
},
},
});
// ── Forward daemon events to WebView ────────────────────────────────────────
// session_output: forward each chunk to the WebView as a "pty.output" message.
ptyClient.on("session_output", (msg) => {
if (msg.type !== "session_output") return;
try {
// data is already base64 from the daemon — pass it straight through.
rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data });
} catch (err) {
console.error("[pty.output] forward error:", err);
}
});
// session_closed: notify the WebView so it can display "[Process exited]".
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);
}
});
// ── 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";
}
// Connect to daemon (non-blocking — window opens regardless).
connectToDaemon();
const url = await getMainViewUrl();
const mainWindow = new BrowserWindow({
title: "Agent Orchestrator — Electrobun",
url,
rpc,
frame: {
width: 1400,
height: 900,
x: 100,
y: 100,
},
});
console.log("Agent Orchestrator (Electrobun) started!");