/** * @file Dashboard window creation + state persistence. * * We persist size/position to a JSON file under `app.getPath('userData')`. * Avoids the `electron-window-state` dependency for ~30 lines of code. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `./constants` * - `./logger` * * ## Public surface * - `appIconPath` — exported API; see TSDoc on the symbol for behavior. * - `createDashboardWindow` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **appIconPath** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **createDashboardWindow** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { BrowserWindow, app, shell } from "electron"; import * as fs from "node:fs"; import * as path from "node:path"; import { APP_NAME, DEFAULT_WINDOW } from "./constants"; import { log } from "./logger"; /** Persisted window geometry. `x`/`y` are omitted until the window has been * moved at least once — a fresh install lets Electron pick the OS default * placement rather than forcing `(0, 0)`. */ interface WindowState { width: number; height: number; x?: number; y?: number; } /** Absolute path to the JSON file geometry is persisted to, under this * platform's `userData` directory (e.g. `~/Library/Application Support/…` * on macOS, `%APPDATA%` on Windows). */ function statePath(): string { return path.join(app.getPath("userData"), "window-state.json"); } /** * Absolute path to the colored application icon used for the window title bar * and the Windows taskbar / Linux launcher — the same logo the macOS app shows * in its Dock (rendered from `assets/icon.svg`). Without this, an unpackaged * `electron out/main.js` run falls back to the generic Electron icon. * * Windows wants the multi-size `.ico` (crisp at every taskbar scale); other * platforms take the `.png`. macOS ignores `BrowserWindow#icon` entirely (its * window has no icon and the Dock uses the bundle's `.icns`), so the value is * harmless there. Resolves dev (`desktop/assets`) vs packaged * (`Resources/assets`, shipped via `extraResources`); returns `undefined` if * the file is absent so we cleanly fall back instead of throwing. */ export function appIconPath(): string | undefined { const file = process.platform === "win32" ? "icon.ico" : "icon.png"; const base = app.isPackaged ? path.join(process.resourcesPath, "assets") : path.join(__dirname, "..", "assets"); const p = path.join(base, file); return fs.existsSync(p) ? p : undefined; } /** * Read the persisted window geometry, falling back field-by-field to * `DEFAULT_WINDOW` (and to `undefined` for position) whenever the file is * missing, unreadable, or contains a field of the wrong type — so a * corrupted or partially-written state file degrades gracefully instead of * preventing the window from opening at all. */ function loadState(): WindowState { try { const raw = fs.readFileSync(statePath(), "utf8"); const parsed = JSON.parse(raw) as Partial; return { width: typeof parsed.width === "number" ? parsed.width : DEFAULT_WINDOW.width, height: typeof parsed.height === "number" ? parsed.height : DEFAULT_WINDOW.height, x: typeof parsed.x === "number" ? parsed.x : undefined, y: typeof parsed.y === "number" ? parsed.y : undefined, }; } catch { return { width: DEFAULT_WINDOW.width, height: DEFAULT_WINDOW.height }; } } /** * Write the window's current bounds to `statePath()`. Skipped while the * window is destroyed or minimized, since `getBounds()` on a minimized * window reports the pre-minimize size on some platforms — persisting it * would silently discard the user's last real resize/move. Failures (e.g. * a read-only `userData` dir) are logged, not thrown — losing the saved * geometry is cosmetic, not fatal. */ function saveState(win: BrowserWindow): void { if (win.isDestroyed() || win.isMinimized()) return; const { width, height, x, y } = win.getBounds(); try { fs.writeFileSync(statePath(), JSON.stringify({ width, height, x, y })); } catch (err) { log.warn("could not persist window state", err); } } /** * Create the single dashboard `BrowserWindow` and point it at the embedded * server's origin. Restores the last persisted size/position (see * `loadState`), re-saves it (debounced) on every resize/move/close, routes * all external navigation to the system browser instead of inside Electron, * and defers `show()` until `ready-to-show` so the window never flashes an * unstyled blank frame while the page loads. * * @param targetUrl The embedded server's origin, e.g. `http://127.0.0.1:4820`. * @returns The newly created, not-yet-visible `BrowserWindow`. */ export function createDashboardWindow(targetUrl: string): BrowserWindow { const state = loadState(); const win = new BrowserWindow({ width: state.width, height: state.height, x: state.x, y: state.y, minWidth: 720, minHeight: 480, show: false, title: APP_NAME, // Colored app logo for the title bar + taskbar (matches the macOS Dock // icon). No-op on macOS; falls through to the Electron default if missing. icon: appIconPath(), // Use the standard macOS title bar rather than `hiddenInset`. With a hidden // title bar the traffic-light buttons float directly over the React app's // top edge and visually blend into the dashboard chrome; a native title bar // gives them their own clearly-separated row, shows the app name, and // restores the conventional double-click-to-maximize / drag-from-anywhere // behaviour without needing custom drag regions in the renderer. titleBarStyle: "default", backgroundColor: "#0b0f1a", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: false, // We're loading our own localhost-only origin, never remote content. webSecurity: true, }, }); win.once("ready-to-show", () => win.show()); // Persist size/position on resize/move (debounced via the close handler too). let saveTimer: NodeJS.Timeout | null = null; const debounced = () => { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => saveState(win), 400); }; win.on("resize", debounced); win.on("move", debounced); win.on("close", () => saveState(win)); // External links open in the user's browser, not inside Electron. win.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); return { action: "deny" }; }); win.webContents.on("will-navigate", (event, url) => { if (!url.startsWith(targetUrl)) { event.preventDefault(); void shell.openExternal(url); } }); win.loadURL(targetUrl).catch((err) => log.error("failed to load dashboard URL", err)); return win; }