6.5 KiB
Configuration Guide
This document covers how to configure the Electron desktop wrapper, manage the target web application, and handle production SPA routing.
Table of Contents
- Target App Configuration
- Environment Variables
- Production SPA Routing (Custom
app://Protocol) - Break Glass: Reverting to
file://+ HashRouter
Target App Configuration
The desktop app wraps any web application in the monorepo. The target is configured via environment variables in apps/desktop/.env.
.env File
# The workspace name of the target web app to wrap.
# Must match a directory under apps/ (e.g., "web", "docs-dev", "admin").
DESKTOP_TARGET_APP=web
# The dev server URL for the target web app.
# This is the URL that Vite serves during development.
DESKTOP_DEV_SERVER_URL=http://localhost:5173
Switching to a Different App
To wrap apps/admin instead of apps/web:
-
Update
.env:DESKTOP_TARGET_APP=admin DESKTOP_DEV_SERVER_URL=http://localhost:3001 -
Ensure the target app has a
buildscript that outputs todist/. -
Run the desktop build:
pnpm build --filter=admin && cd apps/desktop && pnpm run build
The scripts/copy-web-dist.ts prebuild script reads DESKTOP_TARGET_APP and copies apps/<target>/dist/ into apps/desktop/web-dist/, which is then bundled by electron-builder.
How Path Resolution Works
Prebuild (copy-web-dist.ts):
monorepo-root/apps/<DESKTOP_TARGET_APP>/dist/ → apps/desktop/web-dist/
Development (main process):
__dirname (out/main/) → ../../ → apps/ → apps/<target>/dist/
Production (packaged app):
process.resourcesPath → Contents/Resources/web-dist/
Environment Variables
| Variable | Default | Used By | Description |
|---|---|---|---|
DESKTOP_TARGET_APP |
web |
copy-web-dist.ts |
Workspace name of the web app to embed |
DESKTOP_DEV_SERVER_URL |
http://localhost:5173 |
src/main/index.ts |
URL of the target app's Vite dev server |
GH_TOKEN |
— | electron-builder |
GitHub token for publishing releases |
CSC_LINK |
— | electron-builder |
Base64-encoded code signing certificate |
CSC_KEY_PASSWORD |
— | electron-builder |
Password for the signing certificate |
Production SPA Routing (Custom app:// Protocol)
The Problem
React apps using BrowserRouter rely on the server to always return index.html for any URL path (e.g., /dashboard, /auth/login). With Electron's file:// protocol, requesting file:///app/dashboard looks for an actual file at that path — which doesn't exist — resulting in a blank screen or "file not found" error.
The Solution
The main process registers a custom app:// protocol with a handler that:
- Receives a request like
app://-/dashboard. - Strips the protocol and hostname to get the path:
dashboard. - Checks if a real file exists at
web-dist/dashboard. - If yes → serves the file with the correct MIME type.
- If no → serves
web-dist/index.htmlinstead (SPA fallback).
This allows React Router to handle all client-side routing normally. Deep links, page refreshes, and direct URL entry all work because every unknown path falls back to index.html.
Security Measures
- Path traversal protection: The resolved file path is validated to stay within
web-dist/usingnormalize()+startsWith()check. Requests likeapp://-/../../etc/passwdreturn403 Forbidden. - CSP headers: Content-Security-Policy headers are injected on every HTML response served by the protocol handler.
- Scheme privileges: The
appscheme is registered withstandard: true,secure: true,supportFetchAPI: true, andcorsEnabled: true— making it behave likehttps://to the renderer process.
Break Glass: Reverting to file:// + HashRouter
If the custom app:// protocol ever causes issues (e.g., a third-party library incompatibility), you can fall back to the standard file:// protocol with HashRouter. This requires two changes.
Step 1: Switch Router in the React App
In the target web app (e.g., apps/web/src/apps/index.tsx):
- import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
+ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
export default function App() {
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
- <BrowserRouter>
+ <HashRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
{/* All route definitions remain unchanged */}
</Routes>
</Suspense>
- </BrowserRouter>
+ </HashRouter>
</ThemeProvider>
);
}
Routes will now use hash-based URLs: #/app/dashboard, #/auth/login, #/showcase.
Step 2: Switch to file:// in the Main Process
In apps/desktop/src/main/index.ts:
a) Remove the scheme registration at the top of the file:
- protocol.registerSchemesAsPrivileged([
- {
- scheme: 'app',
- privileges: { standard: true, secure: true, ... },
- },
- ]);
b) Remove the registerAppProtocol() function entirely.
c) Remove the registerAppProtocol() call in app.whenReady().
d) Change the production content loading in createWindow():
if (IS_DEV) {
mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
- mainWindow.loadURL('app://-/index.html');
+ const webDistPath = getWebDistPath();
+ mainWindow.loadFile(join(webDistPath, 'index.html'));
}
e) Add a CSP <meta> tag to the web app's index.html since there's no protocol handler to inject headers:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' file:; script-src 'self' file:; style-src 'self' 'unsafe-inline' file:; connect-src 'self' https:; img-src 'self' file: data: https:; font-src 'self' file: data:;" />
Comparison
| Aspect | Custom app:// |
file:// + HashRouter |
|---|---|---|
| URL appearance | /app/dashboard |
#/app/dashboard |
| React Router | BrowserRouter (no change) |
Must use HashRouter |
| Deep linking | Full support | Hash-based |
| Implementation complexity | Higher | Lower |
| CSP delivery | Via response headers | Via <meta> tag |
| Third-party compatibility | Rare edge cases | Maximum compatibility |