diff --git a/dashboard/src/electron-api.d.ts b/dashboard/src/electron-api.d.ts index 6296bd2556..5e534fe21c 100644 --- a/dashboard/src/electron-api.d.ts +++ b/dashboard/src/electron-api.d.ts @@ -23,6 +23,7 @@ declare global { filePath: string | null; }>; setZoom?(factor: number): Promise; + openUpdates?(): Promise; window?: CodeUxWindowApi; }; } diff --git a/docs-web/architecture/security.md b/docs-web/architecture/security.md index 3c75e514b7..06f0cdfe51 100644 --- a/docs-web/architecture/security.md +++ b/docs-web/architecture/security.md @@ -50,6 +50,10 @@ Two listeners: The stdio transport exists only when stdin is not a TTY. Since the MCP client launches the process, the trust boundary is the same as the launching client. +## Electron desktop shell + +The desktop BrowserWindow keeps context isolation, renderer sandboxing, and Node integration disabled. Its preload bridge exposes only fixed dashboard capabilities such as directory selection, zoom, window controls, and the `openUpdates()` action. `openUpdates()` opens the official latest GitHub Releases page for Code UX and does not give renderer code a generic external URL opener. + ## Authentication & authorisation There is no in-process user model. All authenticated callers (including `manage_code_ux`) have the same level of access — read, mutate, destroy. diff --git a/docs-web/content/docs/architecture-security.mdx b/docs-web/content/docs/architecture-security.mdx index 89501b6ce0..51e3081f4a 100644 --- a/docs-web/content/docs/architecture-security.mdx +++ b/docs-web/content/docs/architecture-security.mdx @@ -50,6 +50,10 @@ Two listeners: The stdio transport exists only when stdin is not a TTY. Since the MCP client launches the process, the trust boundary is the same as the launching client. +## Electron desktop shell + +The desktop BrowserWindow keeps context isolation, renderer sandboxing, and Node integration disabled. Its preload bridge exposes only fixed dashboard capabilities such as directory selection, zoom, window controls, and the `openUpdates()` action. `openUpdates()` opens the official latest GitHub Releases page for Code UX and does not give renderer code a generic external URL opener. + ## Authentication & authorisation There is no in-process user model. All authenticated callers (including `manage_code_ux`) have the same level of access — read, mutate, destroy. diff --git a/docs/deployment/electron-desktop.md b/docs/deployment/electron-desktop.md index 5394bd27de..21786d8c3c 100644 --- a/docs/deployment/electron-desktop.md +++ b/docs/deployment/electron-desktop.md @@ -14,16 +14,18 @@ Code UX can run as an installable Electron desktop app while preserving the exis - Windows packaged builds keep the active WebGL context cap at 16 so the persistent shell canvas, avatar canvases, and route-scoped chart canvases have enough headroom during long navigation sessions while old Chromium contexts are waiting for garbage collection. - External links are opened through the host operating system. In-app dashboard and sprint-preview URLs remain inside the Electron app. - The desktop shell renders only the resolved dashboard origin and same-port sprint preview origins that match `preview-.localhost:` internally. Other `http`, `https`, and `mailto` navigations are denied in the renderer and opened through the host operating system after scheme validation; all other schemes are blocked. -- The compact title bar version label polls `/api/system/update-status` on startup and every 30 minutes. When the response reports a newer version without an error, the title bar shows a no-drag "Update available" download link with a download icon; activating it opens the official GitHub desktop release download page in the user's default browser through Electron's external navigation handler. No update action is shown for failed checks or current installations. +- The compact title bar version label polls `/api/system/update-status` on startup and every 30 minutes. When the response reports a newer version without an error, the title bar shows a no-drag "Update available" download link with a download icon; activating it opens the official GitHub desktop release download page in the user's default browser. The preload bridge also exposes `window.codeUxDesktop.openUpdates()`, a fixed IPC action that opens `https://github.com/codeux-ai/codeux/releases/latest` without giving the renderer a generic external URL opener. No update action is shown for failed checks or current installations. ## Native Desktop Integration The Add Project dialog uses a native Electron directory picker when running in the desktop app. Browser-only dashboard sessions keep the existing HTTP directory browser fallback. When no current path is typed, the native picker opens at the user's home directory; relative defaults are resolved from the user's home directory before opening the dialog. -The native picker is exposed through the isolated preload bridge: +The native picker and desktop-only commands are exposed through the isolated preload bridge: - `window.codeUxDesktop.pickDirectory(defaultPath?)` - returns `{ canceled, filePath }` +- `window.codeUxDesktop.openUpdates()` +- returns `true` when Electron accepted the request to open the official latest releases page, otherwise `false` Renderer Node access remains disabled. The preload exposes only this narrow IPC surface. diff --git a/docs/operations/security-hardening.md b/docs/operations/security-hardening.md index 2c6c4adfcc..b5a9db885a 100644 --- a/docs/operations/security-hardening.md +++ b/docs/operations/security-hardening.md @@ -64,9 +64,9 @@ While Code UX trusts the developer and any connected systems, several specific p - **Preview CORS Compatibility:** Preview-host traffic answers CORS preflights and overrides upstream `Access-Control-*` headers at the proxy boundary. The dashboard API origin keeps its CSRF guard; only preview-host origins get permissive local-app CORS behavior. ### Electron Desktop Shell -- **Sandboxed Renderer:** The desktop BrowserWindow runs with context isolation, renderer sandboxing, and Node integration disabled. The isolated preload exposes only the directory picker, zoom, and window-control IPC bridge required by the dashboard. +- **Sandboxed Renderer:** The desktop BrowserWindow runs with context isolation, renderer sandboxing, and Node integration disabled. The isolated preload exposes only the directory picker, zoom, fixed update-opening action, and window-control IPC bridge required by the dashboard. - **Internal Navigation Allowlist:** Electron allows internal rendering only for the resolved dashboard origin and canonical same-port preview hosts in the form `preview-.localhost:`. All other renderer navigations are denied. -- **External Link Handling:** Non-internal `http`, `https`, and `mailto` targets are opened with the operating system through `shell.openExternal` after scheme validation. Unsafe schemes such as `file:`, `javascript:`, and `data:` are blocked instead of being rendered in the desktop shell. +- **External Link Handling:** Non-internal `http`, `https`, and `mailto` targets are opened with the operating system through `shell.openExternal` after scheme validation. Unsafe schemes such as `file:`, `javascript:`, and `data:` are blocked instead of being rendered in the desktop shell. The renderer's explicit update action is fixed to the official latest GitHub Releases page and does not expose arbitrary URL opening. - **Permission Denial:** Electron permission requests from dashboard and preview pages are denied by default, including camera, microphone, geolocation, notifications, and media prompts. Preview origins currently have no permission exception. - **IPC Input Validation:** Desktop IPC handlers reject invalid renderer input before invoking native APIs. Directory picker defaults must be strings without control characters, and zoom factors must be finite numbers. diff --git a/src/electron/main.ts b/src/electron/main.ts index 95d9fa2cdf..897c91012f 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -13,6 +13,7 @@ import { shouldAllowPermissionCheck, shouldAllowPermissionRequest, } from "./dashboard-network-policy.js"; +import { openCodeUxUpdatesPage, toggleWindowMaximized } from "./window-controls.js"; import { createDebouncedSaver, loadWindowState, saveWindowState } from "./window-state.js"; const __filename = fileURLToPath(import.meta.url); @@ -334,12 +335,7 @@ ipcMain.handle("codeux:window-minimize", (event) => { ipcMain.handle("codeux:window-toggle-maximize", (event) => { const target = resolveWindow(event); if (!target) return false; - if (target.isMaximized()) { - target.unmaximize(); - return false; - } - target.maximize(); - return true; + return toggleWindowMaximized(target); }); ipcMain.handle("codeux:window-close", (event) => { @@ -364,6 +360,10 @@ ipcMain.handle("codeux:set-zoom", (event, factor: unknown) => { return clamped; }); +ipcMain.handle("codeux:open-updates", () => { + return openCodeUxUpdatesPage(shell); +}); + ipcMain.handle("codeux:pick-directory", async (event, defaultPath: unknown) => { const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow ?? undefined; const options: Electron.OpenDialogOptions = { diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 298ed9abc5..8be2d427ae 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("codeUxDesktop", { setZoom: (factor: number): Promise => { return ipcRenderer.invoke("codeux:set-zoom", factor); }, + openUpdates: (): Promise => { + return ipcRenderer.invoke("codeux:open-updates"); + }, window: { minimize: (): Promise => ipcRenderer.invoke("codeux:window-minimize"), toggleMaximize: (): Promise => ipcRenderer.invoke("codeux:window-toggle-maximize"), diff --git a/src/electron/window-controls.ts b/src/electron/window-controls.ts new file mode 100644 index 0000000000..984c59e0b8 --- /dev/null +++ b/src/electron/window-controls.ts @@ -0,0 +1,30 @@ +export const CODE_UX_RELEASES_LATEST_URL = "https://github.com/codeux-ai/codeux/releases/latest"; + +export interface MaximizableWindowTarget { + isMaximized(): boolean; + maximize(): void; + unmaximize(): void; +} + +export interface ExternalShellTarget { + openExternal(url: string): Promise | unknown; +} + +export const toggleWindowMaximized = (target: MaximizableWindowTarget): boolean => { + if (target.isMaximized()) { + target.unmaximize(); + } else { + target.maximize(); + } + + return target.isMaximized(); +}; + +export const openCodeUxUpdatesPage = async (target: ExternalShellTarget): Promise => { + try { + await target.openExternal(CODE_UX_RELEASES_LATEST_URL); + return true; + } catch { + return false; + } +}; diff --git a/tests/backend/electron/window-controls.test.ts b/tests/backend/electron/window-controls.test.ts new file mode 100644 index 0000000000..13967c6b57 --- /dev/null +++ b/tests/backend/electron/window-controls.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CODE_UX_RELEASES_LATEST_URL, + openCodeUxUpdatesPage, + toggleWindowMaximized, + type MaximizableWindowTarget, +} from "../../../src/electron/window-controls.js"; + +class FakeWindow implements MaximizableWindowTarget { + public maximizeCalls = 0; + public unmaximizeCalls = 0; + + public constructor(private maximized: boolean) {} + + public isMaximized(): boolean { + return this.maximized; + } + + public maximize(): void { + this.maximizeCalls += 1; + this.maximized = true; + } + + public unmaximize(): void { + this.unmaximizeCalls += 1; + this.maximized = false; + } +} + +describe("Electron window controls", () => { + it("maximizes a restored window and returns the resulting maximized state", () => { + const window = new FakeWindow(false); + + expect(toggleWindowMaximized(window)).toBe(true); + expect(window.isMaximized()).toBe(true); + expect(window.maximizeCalls).toBe(1); + expect(window.unmaximizeCalls).toBe(0); + }); + + it("restores a maximized window and returns the resulting maximized state", () => { + const window = new FakeWindow(true); + + expect(toggleWindowMaximized(window)).toBe(false); + expect(window.isMaximized()).toBe(false); + expect(window.maximizeCalls).toBe(0); + expect(window.unmaximizeCalls).toBe(1); + }); + + it("opens the official latest releases URL for desktop updates", async () => { + const openExternal = vi.fn<(url: string) => Promise>(async () => undefined); + + await expect(openCodeUxUpdatesPage({ openExternal })).resolves.toBe(true); + + expect(CODE_UX_RELEASES_LATEST_URL).toBe("https://github.com/codeux-ai/codeux/releases/latest"); + expect(new URL(CODE_UX_RELEASES_LATEST_URL).hostname).toBe("github.com"); + expect(new URL(CODE_UX_RELEASES_LATEST_URL).pathname).toBe("/codeux-ai/codeux/releases/latest"); + expect(openExternal).toHaveBeenCalledTimes(1); + expect(openExternal).toHaveBeenCalledWith(CODE_UX_RELEASES_LATEST_URL); + }); + + it("reports rejected update open requests without throwing", async () => { + const openExternal = vi.fn<(url: string) => Promise>(async () => { + throw new Error("external opener unavailable"); + }); + + await expect(openCodeUxUpdatesPage({ openExternal })).resolves.toBe(false); + + expect(openExternal).toHaveBeenCalledTimes(1); + expect(openExternal).toHaveBeenCalledWith(CODE_UX_RELEASES_LATEST_URL); + }); +});