Electron Integration
Key files:
electron/main.cts— main process entryelectron/preload.cts— preload bridgeelectron/license/manager.cts— license IPC registryelectron-builder.yml— packaging configpackage.jsonscripts:electron:dev,package:linux/mac/win
1. Goals
- One React renderer for both targets: browser and desktop share the same
dist/. Electron is a privileged BrowserWindow + main process around it. - Zero runtime third-party deps in main: license, machine-id, and crypto all use Node's built-in
crypto. - Strict sandbox:
contextIsolation: true+nodeIntegration: false+sandbox: true. The renderer never reachesrequire/process.
2. Process topology
- Main: 1 BrowserWindow, single-instance lock, license state broadcast.
- Renderer: 1 React app; license IPC goes through
licenseBridge.
3. Main: main.cts
electron/main.cts:18-54 creates the window:
const mainWindow = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 700,
title: 'Apollo Map Studio',
backgroundColor: '#101318',
show: false,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
show: false+ready-to-showlistener avoids the white flash.setWindowOpenHandlerdenies allwindow.opencalls; HTTP/HTTPS links open in the system browser viashell.openExternal.
3.1 Single-instance lock
// main.cts:62-79
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
const [mainWindow] = BrowserWindow.getAllWindows();
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
...
}2
3
4
5
6
7
8
9
10
11
12
13
A second launch focuses the existing window — two LicenseManagers would otherwise race for the userData lock.
3.2 Vite dev URL handoff
// main.cts:6, 47-54
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
...
if (rendererUrl) {
await mainWindow.loadURL(rendererUrl);
mainWindow.webContents.openDevTools({ mode: 'detach' });
return;
}
await mainWindow.loadFile(getRendererIndexPath());2
3
4
5
6
7
8
9
pnpm electron:dev orchestrates with concurrently:
"electron:dev": "concurrently -k -n vite,electron -c cyan,magenta
\"vite --host 127.0.0.1\"
\"wait-on tcp:127.0.0.1:5173 && pnpm build:electron &&
cross-env ELECTRON_RENDERER_URL=http://127.0.0.1:5173 electron .\""2
3
4
- vite serves on port 5173;
wait-onblocks until the port is reachable;- electron picks up the URL via env,
loadURLenables HMR; - detached DevTools auto-open.
Production builds use loadFile(dist/index.html) — no environment variables involved.
3.3 License startup ordering
// main.cts:81-94
app.whenReady().then(() => {
// wire LicenseManager BEFORE creating any window so the renderer
// can request state from a fully-initialised IPC surface.
licenseManager = new LicenseManager();
licenseManager.start();
void createMainWindow();
app.on('activate', () => { ... });
});2
3
4
5
6
7
8
9
LicenseManager registers four IPC handlers before the renderer appears, so the renderer's first frame never sees an undefined IPC.
app.on('before-quit', () => licenseManager?.stop()) lets TimeGuard persist its final timestamp.
4. preload.cts — contextBridge
electron/preload.cts exposes exactly two read-only globals:
// preload.cts:13-20
contextBridge.exposeInMainWorld('apolloMapStudio', {
platform: process.platform,
versions: { chrome, electron, node },
});
// preload.cts:22-47
contextBridge.exposeInMainWorld('apolloMapStudioLicense', {
getState(): ipcRenderer.invoke('license:get-state'),
getMachineCode(): ipcRenderer.invoke('license:get-machine-code'),
activate(code): ipcRenderer.invoke('license:activate', code),
deactivate(): ipcRenderer.invoke('license:deactivate'),
onChange(handler): ipcRenderer.on('license:state', listener); return unsub;
});2
3
4
5
6
7
8
9
10
11
12
13
14
Design highlights:
- Never expose ipcRenderer itself: the renderer can only call the four named methods.
- No Node API:
fs,path,child_processare all gone. - Broadcast over named channel:
license:stateis the channel for state pushes;onChangereturns an unsubscribe closure.
5. IPC channel inventory
| Channel | Direction | Payload | Implementation |
|---|---|---|---|
license:get-state | renderer→main | — | LicenseManager.start() registers invoke |
license:get-machine-code | renderer→main | — | same |
license:activate | renderer→main | code: string | → LicenseManager.activate |
license:deactivate | renderer→main | — | → LicenseManager.deactivate |
license:state | main→renderer | LicenseState | LicenseManager.broadcast() |
See License System for full semantics.
6. Renderer-side consumer: license-bridge.ts
src/lib/license-bridge.ts:77-101 wraps the preload-exposed API with a "works in browser too" fallback:
export const licenseBridge: LicenseApi = {
async getState() {
return window.apolloMapStudioLicense?.getState() ?? Promise.resolve(fallbackState());
},
...
};
export function isDesktopBuild(): boolean {
return typeof window !== 'undefined' && Boolean(window.apolloMapStudioLicense);
}2
3
4
5
6
7
8
9
10
fallbackState() returns a permanent canEdit: true 7-day trial mock in the browser so Storybook / web preview is not blocked by licensing.
7. Packaging: electron-builder
electron-builder.yml:
appId: com.apollo-map-studio.app
productName: Apollo Map Studio
directories: { output: release }
files:
- dist/**/*
- dist-electron/**/*
- package.json
- '!node_modules/**/*'
asar: true
extraMetadata:
main: dist-electron/main.cjs
dependencies: {}
mac: { target: [{ target: dmg, arch: [x64, arm64] }, { target: zip, arch: [x64, arm64] }] }
win: { target: [{ target: nsis, arch: [x64] }, { target: zip, arch: [x64] }] }
linux: { target: [{ target: AppImage, arch: [x64] }, { target: deb, arch: [x64] }] }2
3
4
5
6
7
8
9
10
11
12
13
14
15
Highlights:
extraMetadata.dependencies: {}— overwrites the packagedpackage.jsondeps so the asar archive does not double-ship React / proj4 / etc., which Vite already bundled intodist/.asar: true— compression + a basic reverse-engineering hurdle.npmRebuild: false— every native module is prebuilt; onlyelectronandelectron-winstallerare inpnpm.onlyBuiltDependencies.
Scripts:
"package": "pnpm build:desktop && electron-builder --dir --publish never",
"package:linux": "pnpm build:desktop && electron-builder --linux --x64 --publish never",
"package:mac": "pnpm build:desktop && electron-builder --mac --x64 --arm64 --publish never",
"package:win": "pnpm build:desktop && electron-builder --win --x64 --publish never",
"build:desktop": "pnpm build:web && pnpm build:electron",
"build:electron": "tsc -p tsconfig.electron.json"2
3
4
5
6
build:weboutputs todist/.build:electronrunstsc -p tsconfig.electron.jsonand emits todist-electron/*.cjs.extraMetadata.mainpoints atdist-electron/main.cjs.
8. Security model
| Defense layer | Implementation |
|---|---|
contextIsolation | renderer & preload run in separate V8 isolates; preload exposes one-way |
nodeIntegration: false | renderer cannot require |
sandbox: true | renderer process sits in the OS sandbox |
setWindowOpenHandler | popups blocked; outlinks delegated to system browser |
| Single-instance lock | only one process touches userData |
| ASAR | not encryption — packaging |
| License verification | Ed25519 signature + AES-GCM at-rest storage |
9. Platform notes
| Platform | App ID / nuance |
|---|---|
| Windows | app.setAppUserModelId('com.apollo-map-studio.app') for taskbar grouping |
| macOS | darwin does not quit on window-all-closed, matching dock-app convention |
| Linux | AppImage + deb; maintainer Apollo Map Studio <maintainers@apollo-map-studio.local> |
10. Performance notes
BrowserWindow.backgroundColormatches the CSS theme background to avoid white flashes on launch.- The main process does no CPU-intensive work — license calculation is < 1 ms per IPC call; TimeGuard ticks once per minute.
- preload is kept under ~100 LOC with zero third-party imports to avoid sandbox conflicts.
11. Debugging
electron:devopens detached DevTools — convenient on dual screens.- Main-process logs go straight to stdout; the
pnpm electron:devterminal sees them. - Renderer errors land in the DevTools console / network tab.
- Main-process crashes:
crashReporteris not wired yet (TBD).
12. Pitfalls
- Editing main.cts without
pnpm build:electron— the stale.cjskeeps running.electron:devrebuilds before launching. - Importing third-party modules in preload triggers sandbox warnings — any
require()can breaknodeIntegrationInWorker: false. window.requirefrom renderer is unreachable; always go through the contextBridge-exposed API.- electron-builder produces a non-launching binary — 99% of the time it's a missing
extraMetadata.mainor a forgottendist-electron/**/*infiles. - userData differs across builds — dev userData on macOS is
~/Library/Application Support/Electron, prod is~/Library/Application Support/Apollo Map Studio. License storage does not roam across them.
13. Public API (renderer side)
// window.apolloMapStudio
{
platform: NodeJS.Platform;
versions: { chrome: string; electron: string; node: string };
}
// window.apolloMapStudioLicense
{
getState(): Promise<LicenseState>;
getMachineCode(): Promise<string>;
activate(code): Promise<ActivationResult>;
deactivate(): Promise<LicenseState>;
onChange(h): () => void; // returns unsubscribe
}2
3
4
5
6
7
8
9
10
11
12
13
14
Types: electron/license/types.cts (canonical) and src/lib/license-bridge.ts (mirror).
14. Tests
- Renderer/Vitest: browser tests keep using the license fallback for renderer stores, hooks, and UI branches.
- Electron unit tests:
pnpm test:electroncompileselectron/**/*.ctsand runs main/preload/license tests withnode --test. - Electron E2E:
pnpm test:electron:e2euses Playwright Electron to launch the real desktop shell and covers the preload bridge, window controls, native menu callback wiring, Help/About, and the basic license activation dialog flow. - Linux without
DISPLAYneedsxvfb-run; the script uses it automatically when available and prints an install hint when it is missing. - Before release, still run a manual smoke on the
pnpm packageoutput to cover the packaged/hardened artifact import → export path.
15. Source map
electron/
├── main.cts ← BrowserWindow + LicenseManager startup
├── preload.cts ← contextBridge exposure
├── license/ ← see license-system.md
│ ├── manager.cts
│ ├── machine-id.cts
│ ├── time-guard.cts
│ ├── storage.cts
│ ├── crypto.cts
│ ├── public-key.cts
│ └── types.cts
└── (no third-party deps)
src/lib/license-bridge.ts ← renderer adapter
src/lib/editable-guard.ts ← assertEditable cross-cutting helper
tsconfig.electron.json ← tsc main-process config
electron-builder.yml ← packaging config
.github/workflows/ci.yml ← desktop-package matrix job2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19