import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { existsSync, promises as fsp } from 'node:fs'; // Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works. // Prod-mode loads from in-process custom scheme `vojo://app/index.html`. // `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the // scheme + service-worker path can be validated without re-running // electron-builder. The packaged app sets `app.isPackaged === true`. const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1'; const DEV_URL = 'http://localhost:8080'; const APP_SCHEME = 'vojo'; const APP_HOST = 'app'; // Extensions that look like real web assets; for these, a missing file is a // genuine 404. Anything else (including Matrix-flavoured `roomId/userId` // segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is // treated as a SPA route and falls back to `index.html`. The allowlist is // intentionally narrow — extend only when adding a new bundled asset kind. const WEB_ASSET_EXTENSIONS = new Set([ '.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.map', '.json', '.txt', '.xml', '.svg', '.ico', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.woff', '.woff2', '.ttf', '.otf', '.wasm', ]); protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, allowServiceWorkers: true, corsEnabled: true, stream: true, codeCache: true, }, }, ]); const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']); const isSafeExternal = (raw: unknown): raw is string => { if (typeof raw !== 'string' || raw.length > 8 * 1024) return false; try { return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol); } catch { return false; } }; const distDir = path.resolve(__dirname, '..', '..', 'dist'); // React Router defaults to BrowserRouter against `window.location.pathname`, // which for `vojo://app/...` would treat URL segments as routes (e.g. // `vojo://app/index.html` resolved as a space alias `index.html`). Vojo // already supports HashRouter via `clientConfig.hashRouter.enabled` in // `config.json` — we override that to `true` for the Electron renderer so // every route lives in `window.location.hash`, leaving the pathname stable // for the protocol handler. The web/Android bundles see the unmodified // config (hash router off). const patchConfigForElectron = (raw: string): string => { try { const config: Record = JSON.parse(raw); const existing = (config.hashRouter as { basename?: string } | undefined) ?? {}; config.hashRouter = { enabled: true, basename: typeof existing.basename === 'string' ? existing.basename : '/', }; return JSON.stringify(config); } catch { return raw; } }; const registerAppProtocol = () => { protocol.handle(APP_SCHEME, async (request) => { let url: URL; try { url = new URL(request.url); } catch { return new Response(null, { status: 400 }); } // Reject foreign origins under the same scheme. `protocol.handle` does // not validate the URL host, so without this check a renderer-side // `window.location = 'vojo://evil/...'` would resolve into a separate // (cookie/SW/IndexedDB-isolated) copy of the bundle — same content but // detached storage. Only `vojo://app` is accepted. if (url.hostname !== APP_HOST) { return new Response(null, { status: 403 }); } const rel = (url.pathname || '/').replace(/^\/+/, '') || 'index.html'; let filePath = path.normalize(path.join(distDir, rel)); // Path-traversal guard. `filePath.startsWith(distDir)` is unsafe — a // sibling directory `/a/dist_evil` passes the prefix check against // `/a/dist`. `path.relative` yields `..`-leading or absolute output // for paths escaping `distDir`, which is the canonical Node check. let relFromDist = path.relative(distDir, filePath); if (relFromDist.startsWith('..') || path.isAbsolute(relFromDist)) { return new Response(null, { status: 403 }); } // SPA fallback for paths without a real file on disk. Under HashRouter // (the Electron default — see `patchConfigForElectron`) reloads arrive // with pathname `/`, so this rarely fires; kept as a safety net for // manual URL edits and if someone ever reverts the HashRouter patch. // We MUST NOT gate via `path.extname() !== ''` — Matrix room/user IDs // like `!foo:vojo.chat` parse as having extension `.chat` and would // wrongly 404. Use a narrow allowlist of real web-asset extensions. if (!existsSync(filePath)) { const ext = path.extname(rel).toLowerCase(); if (ext !== '' && WEB_ASSET_EXTENSIONS.has(ext)) { return new Response('Not Found', { status: 404 }); } filePath = path.join(distDir, 'index.html'); relFromDist = 'index.html'; } // Override top-level `config.json` to force HashRouter on in Electron. // Exact-match via `path.relative` is Windows-safe (case + separator // normalization), unlike `path.dirname(filePath) === distDir`. if (relFromDist === 'config.json') { const raw = await fsp.readFile(filePath, 'utf-8'); return new Response(patchConfigForElectron(raw), { headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } return net.fetch(pathToFileURL(filePath).toString()); }); }; const createWindow = async () => { const win = new BrowserWindow({ width: 1280, height: 800, minWidth: 640, minHeight: 480, backgroundColor: '#0d0e11', autoHideMenuBar: true, // Native-looking chrome: Windows draws real min/max/close buttons via // Window Controls Overlay, the rest of the bar matches our Dawn palette. // Same pattern as Discord/Slack/VS Code/Element Desktop. On macOS // `hiddenInset` keeps traffic lights but inset over a clean dark area. // Linux keeps the system frame to avoid breaking GTK-decoration UX. ...(process.platform === 'win32' && { titleBarStyle: 'hidden' as const, titleBarOverlay: { color: '#0d0e11', symbolColor: '#e0e0e8', height: 32, }, }), ...(process.platform === 'darwin' && { titleBarStyle: 'hiddenInset' as const, }), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, // sandbox: true is safe here — preload uses only `contextBridge` and // `ipcRenderer`, both of which are exposed inside the Electron sandbox // per https://www.electronjs.org/docs/latest/tutorial/sandbox. sandbox: true, }, }); // With `titleBarStyle: 'hidden'` Windows draws the min/max/close buttons // but the rest of the bar is the renderer's pixel area — and Electron does // NOT mark it draggable automatically. Inject a CSS drag-region overlay // sized by the Window Controls Overlay API env() variables (the canonical // way Microsoft / Chromium expose the safe drag area excluding the OS // button strip). Body gets matching padding-top so content shifts down // by 32px instead of sitting under the drag region. if (process.platform === 'win32') { win.webContents.on('dom-ready', () => { win.webContents.insertCSS(` body { padding-top: env(titlebar-area-height, 32px) !important; } body::before { content: ""; position: fixed; top: env(titlebar-area-y, 0); left: env(titlebar-area-x, 0); width: env(titlebar-area-width, calc(100vw - 138px)); height: env(titlebar-area-height, 32px); -webkit-app-region: drag; z-index: 2147483647; } `); }); } win.webContents.setWindowOpenHandler(({ url }) => { if (isSafeExternal(url)) shell.openExternal(url); return { action: 'deny' }; }); win.webContents.on('will-navigate', (event, url) => { let target: URL; try { target = new URL(url); } catch { event.preventDefault(); return; } // Strict match for the in-app origin. `target.protocol === 'vojo:'` alone // would treat `vojo://evil/...` as internal even though the protocol // handler now rejects it; preventing navigation upstream avoids the // round-trip and keeps the renderer pinned to the canonical origin. const isInternal = (target.protocol === `${APP_SCHEME}:` && target.hostname === APP_HOST) || (isDev && target.origin === DEV_URL); if (isInternal) return; event.preventDefault(); if (isSafeExternal(url)) shell.openExternal(url); }); if (isDev) { await win.loadURL(DEV_URL); win.webContents.openDevTools({ mode: 'detach' }); } else { await win.loadURL(`${APP_SCHEME}://${APP_HOST}/`); } }; app.whenReady().then(() => { if (!isDev) registerAppProtocol(); ipcMain.handle('vojo:open-external', async (_event, url: unknown) => { if (isSafeExternal(url)) await shell.openExternal(url); }); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });