Two problems from the first alpha4 run reported as "blank window + CSP
warning in devtools":
1. CSP was set via <meta> in index.html with a strict policy (script-src
'self'). Vite's dev server uses eval() for HMR, which the strict CSP
blocked at module-load time, so the renderer never ran. The meta CSP
also conflicted with Electron's own security heuristics (hence the
warning even though *we* had a policy — Electron was looking for it
on the HTTP response).
Moved the CSP to electron/main.ts via session.webRequest
.onHeadersReceived. Dev profile enables 'unsafe-eval' + ws:/wss: for
HMR; production profile stays strict (no eval, no remote scripts,
connect-src still wide because the user picks arbitrary node URLs).
2. When window.dchain isn't available (preload failed to load, dev
misconfig, etc.), loadKeyFile() throws inside a useEffect. React
swallows async-effect throws, so the app renders blank forever.
Added:
- requireDchain() guard in storage.ts with an explicit error.
- App.tsx catches boot-effect errors and renders them inline.
- ErrorBoundary.tsx for render-time throws.
- window.addEventListener('error') in main.tsx as a last-resort
paint for throws that escape React entirely.
Also: npm script electron:dev now rebuilds main.ts before spawning
Electron (was a silent concurrency bug — TypeScript errors in main.ts
would produce stale dist-electron/).
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
// Electron main process.
|
|
//
|
|
// Responsibilities:
|
|
// * Create the BrowserWindow with a frameless + custom title bar so
|
|
// the renderer owns the chrome (matches macOS traffic lights and
|
|
// draws our 3-panel shell without OS padding).
|
|
// * Bridge safe native APIs to the renderer through preload.ts using
|
|
// contextBridge — keeps the renderer sandboxed (contextIsolation on,
|
|
// nodeIntegration off).
|
|
// * Deep-link handler for dchain://chat/<pub> and similar. Stub for now.
|
|
//
|
|
// Everything chain-related (HTTP / WS / crypto) still runs in the
|
|
// renderer — Electron main stays a thin shell + native capabilities.
|
|
|
|
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
|
|
import * as path from 'node:path';
|
|
import * as fs from 'node:fs/promises';
|
|
|
|
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
|
|
|
let mainWindow: BrowserWindow | null = null;
|
|
|
|
// Content-Security-Policy is set here (not in <meta>) so we can diverge
|
|
// dev vs. production: Vite's HMR uses eval() which needs 'unsafe-eval',
|
|
// but shipping that in a release build would earn us a security warning
|
|
// from Electron and weaken XSS defence for no good reason.
|
|
function installCSP(): void {
|
|
const policy = isDev
|
|
? // Dev: permissive enough for Vite HMR (eval + WS) while still
|
|
// denying random remote scripts. connect-src is wide-open because
|
|
// the user picks their own node URL at runtime.
|
|
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " +
|
|
"connect-src 'self' ws: wss: http: https:; " +
|
|
"img-src 'self' data: blob: http: https:;"
|
|
: // Prod: no eval, no remote scripts. connect-src stays open so the
|
|
// user can target any node they configure.
|
|
"default-src 'self'; " +
|
|
"script-src 'self'; " +
|
|
"style-src 'self' 'unsafe-inline'; " +
|
|
"connect-src 'self' ws: wss: http: https:; " +
|
|
"img-src 'self' data: blob: http: https:;";
|
|
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
|
cb({
|
|
responseHeaders: {
|
|
...details.responseHeaders,
|
|
'Content-Security-Policy': [policy],
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
function createWindow(): void {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1280,
|
|
height: 820,
|
|
minWidth: 900,
|
|
minHeight: 600,
|
|
backgroundColor: '#000000',
|
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
|
// Expose traffic-light buttons on macOS; Windows/Linux use a custom
|
|
// title-bar painted by the renderer.
|
|
titleBarOverlay: process.platform === 'win32' ? {
|
|
color: '#000000',
|
|
symbolColor: '#ffffff',
|
|
height: 32,
|
|
} : undefined,
|
|
frame: process.platform === 'darwin',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: false, // safeStorage requires non-sandboxed preload
|
|
},
|
|
show: false,
|
|
});
|
|
|
|
mainWindow.once('ready-to-show', () => mainWindow?.show());
|
|
|
|
if (isDev) {
|
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!);
|
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
|
}
|
|
|
|
// Open external links (http/https in <a target=_blank>) in the default
|
|
// browser rather than a new Electron window — safer, and a desktop
|
|
// user's muscle memory expects this.
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:\/\//.test(url)) {
|
|
shell.openExternal(url);
|
|
return { action: 'deny' };
|
|
}
|
|
return { action: 'allow' };
|
|
});
|
|
}
|
|
|
|
// ── IPC — safe subset bridged into the renderer via preload ────────────
|
|
|
|
// Keys are persisted encrypted by the OS keychain via safeStorage.
|
|
// Fallback to plaintext file only if the user's OS lacks an encryption
|
|
// backend (surfaced as a warning in Settings → Advanced).
|
|
const KEYFILE_PATH = () => path.join(app.getPath('userData'), 'keyfile.bin');
|
|
|
|
ipcMain.handle('keyfile:load', async (): Promise<string | null> => {
|
|
try {
|
|
const raw = await fs.readFile(KEYFILE_PATH());
|
|
if (safeStorage.isEncryptionAvailable()) {
|
|
return safeStorage.decryptString(raw);
|
|
}
|
|
// File was stored without encryption — treat as plaintext.
|
|
return raw.toString('utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('keyfile:save', async (_e, json: string): Promise<void> => {
|
|
await fs.mkdir(path.dirname(KEYFILE_PATH()), { recursive: true });
|
|
if (safeStorage.isEncryptionAvailable()) {
|
|
await fs.writeFile(KEYFILE_PATH(), safeStorage.encryptString(json));
|
|
} else {
|
|
// Surface the insecure path loudly in the renderer's Settings,
|
|
// but don't refuse — on some Linux boxes libsecret isn't installed
|
|
// and the user explicitly wants a fallback.
|
|
await fs.writeFile(KEYFILE_PATH(), json, 'utf8');
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('keyfile:delete', async (): Promise<void> => {
|
|
await fs.rm(KEYFILE_PATH(), { force: true });
|
|
});
|
|
|
|
ipcMain.handle('keyfile:encryption-available', async (): Promise<boolean> => {
|
|
return safeStorage.isEncryptionAvailable();
|
|
});
|
|
|
|
ipcMain.handle('dialog:open-file', async (_e, opts: Electron.OpenDialogOptions) => {
|
|
if (!mainWindow) return null;
|
|
const res = await dialog.showOpenDialog(mainWindow, opts);
|
|
if (res.canceled || res.filePaths.length === 0) return null;
|
|
return res.filePaths[0];
|
|
});
|
|
|
|
ipcMain.handle('dialog:save-file', async (_e, opts: Electron.SaveDialogOptions) => {
|
|
if (!mainWindow) return null;
|
|
const res = await dialog.showSaveDialog(mainWindow, opts);
|
|
if (res.canceled || !res.filePath) return null;
|
|
return res.filePath;
|
|
});
|
|
|
|
ipcMain.handle('fs:read-text', async (_e, filePath: string) => {
|
|
return fs.readFile(filePath, 'utf8');
|
|
});
|
|
|
|
ipcMain.handle('fs:write-text', async (_e, filePath: string, contents: string) => {
|
|
return fs.writeFile(filePath, contents, 'utf8');
|
|
});
|
|
|
|
ipcMain.handle('app:version', async () => app.getVersion());
|
|
ipcMain.handle('app:platform', async () => process.platform);
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
|
|
app.whenReady().then(() => {
|
|
installCSP();
|
|
createWindow();
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit();
|
|
});
|