Files
dchain/desktop/electron/main.ts
vsecoder 3641cb113d fix(desktop): CSP via webRequest + boot error visibility
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/).
2026-04-22 17:13:26 +03:00

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();
});