vojo/electron/main.ts

264 lines
9.2 KiB
TypeScript

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<string, unknown> = 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();
});