264 lines
9.2 KiB
TypeScript
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();
|
|
});
|