Minimal monorepo template: an Electron desktop app shell + a Vite/React renderer that also runs as a plain web app. Renderer and main process talk through a strongly typed window.desktopBridge defined once in packages/contracts.
apps/
desktop/ Electron main + preload (tsdown -> dist-electron/*.cjs)
web/ Vite + React renderer (also runnable as a standalone web app)
packages/
contracts/ Shared DesktopBridge interface + IPC channel constants
bun install
bun run devThis starts Vite on http://localhost:5173 and Electron, which loads the dev server. Edit web code and HMR works; edit desktop code and the launcher restarts Electron.
bun run build # builds web -> apps/web/dist and desktop -> apps/desktop/dist-electron
bun run start # runs the built Electron app against the built rendererbun run --cwd apps/desktop package # current platform
bun run --cwd apps/desktop package:mac # DMG (arm64 + x64)
bun run --cwd apps/desktop package:win # NSIS installer
bun run --cwd apps/desktop package:linux # AppImageOutput lands in apps/desktop/release/. Config: apps/desktop/electron-builder.config.cjs.
- Renderer: Vite 5, React 19, TanStack Router (file-based routing in
apps/web/src/routes/), Tailwind v4 (@tailwindcss/vite). - Main: Electron 33, bundled with tsdown to CJS in
dist-electron/. - Contract: shared
DesktopBridgeinterface inpackages/contracts. - Packaging: electron-builder.
- Add the method to
DesktopBridgeand add a channel constant inpackages/contracts/src/index.ts. - Expose it in
apps/desktop/src/preload.ts(one line forwarding toipcRenderer.invoke). - Handle it in
apps/desktop/src/ipc/handlers.tswithipcMain.handle(CHANNEL, ...). - Call it from the renderer via
window.desktopBridge.yourMethod(...).
The interface is the contract — TypeScript will tell you if any of the four sides drifts.