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/).
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
// Everything chain-related (HTTP / WS / crypto) still runs in the
|
// Everything chain-related (HTTP / WS / crypto) still runs in the
|
||||||
// renderer — Electron main stays a thin shell + native capabilities.
|
// renderer — Electron main stays a thin shell + native capabilities.
|
||||||
|
|
||||||
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage } from 'electron';
|
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
|
|
||||||
@@ -20,6 +20,35 @@ const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
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 {
|
function createWindow(): void {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
@@ -134,6 +163,7 @@ ipcMain.handle('app:platform', async () => process.platform);
|
|||||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
installCSP();
|
||||||
createWindow();
|
createWindow();
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<!-- CSP is applied at HTTP-response level from main.ts via
|
||||||
content="default-src 'self'; connect-src * ws: wss: http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self';" />
|
session.webRequest — not in a <meta> here. Vite's dev server
|
||||||
|
needs unsafe-eval for HMR, which breaks a strict meta-CSP at
|
||||||
|
module-load time; setting CSP from main lets us flip
|
||||||
|
dev vs. production rules cleanly. -->
|
||||||
<title>DChain</title>
|
<title>DChain</title>
|
||||||
<style>
|
<style>
|
||||||
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite\" \"wait-on tcp:5173 && npm run electron:dev\"",
|
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite\" \"wait-on tcp:5173 && npm run electron:dev\"",
|
||||||
"electron:dev": "tsc -p electron/tsconfig.json && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.js",
|
"electron:dev": "npm run build:main && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.js",
|
||||||
"build": "tsc -p electron/tsconfig.json && vite build && electron-builder",
|
"build": "npm run build:main && vite build && electron-builder",
|
||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"build:main": "tsc -p electron/tsconfig.json",
|
"build:main": "tsc -p electron/tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 2. Render either the Welcome auth flow (no key yet) or the Shell
|
// 2. Render either the Welcome auth flow (no key yet) or the Shell
|
||||||
// (3-panel layout + current section).
|
// (3-panel layout + current section).
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||||
import { setNodeUrl } from '@/lib/api';
|
import { setNodeUrl } from '@/lib/api';
|
||||||
@@ -14,9 +14,11 @@ import { Welcome } from '@/auth/Welcome';
|
|||||||
export function App(): React.ReactElement {
|
export function App(): React.ReactElement {
|
||||||
const booted = useStore(s => s.booted);
|
const booted = useStore(s => s.booted);
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
try {
|
||||||
const set = loadSettings();
|
const set = loadSettings();
|
||||||
setNodeUrl(set.nodeUrl);
|
setNodeUrl(set.nodeUrl);
|
||||||
useStore.getState().setSettings(set);
|
useStore.getState().setSettings(set);
|
||||||
@@ -28,9 +30,31 @@ export function App(): React.ReactElement {
|
|||||||
useStore.getState().setKeyFile(kf);
|
useStore.getState().setKeyFile(kf);
|
||||||
|
|
||||||
useStore.getState().setBooted(true);
|
useStore.getState().setBooted(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Show the error inline — the boundary only catches render
|
||||||
|
// throws, not async-effect throws like this one.
|
||||||
|
setBootError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (bootError) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 24, color: '#ff6b6b', fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#ff6b6b', margin: 0 }}>Boot failed</h2>
|
||||||
|
<p style={{ color: '#fff', marginTop: 8 }}>{bootError}</p>
|
||||||
|
<p style={{ color: '#8b8b8b', fontSize: 12, marginTop: 12 }}>
|
||||||
|
This usually means the Electron preload script didn't load.
|
||||||
|
Check that `npm run build:main` has produced `dist-electron/preload.js`
|
||||||
|
and restart `npm run dev`.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!booted) {
|
if (!booted) {
|
||||||
// Matches the splash: whole window is already black from index.html,
|
// Matches the splash: whole window is already black from index.html,
|
||||||
// so showing nothing is the right behaviour — no flash, no spinner.
|
// so showing nothing is the right behaviour — no flash, no spinner.
|
||||||
|
|||||||
55
desktop/src/ErrorBoundary.tsx
Normal file
55
desktop/src/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Top-level error boundary. React eats thrown errors silently by default,
|
||||||
|
// which in an Electron app with no URL bar means "blank window, nothing
|
||||||
|
// to click" from the user's perspective. This component at least shows
|
||||||
|
// the error text + stack so we can copy-paste it into a bug report.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode }, State
|
||||||
|
> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
// Surface the exception in the devtools console too, for quick
|
||||||
|
// copy-paste when the boundary is blocking the UI.
|
||||||
|
console.error('[ErrorBoundary]', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 24, height: '100%', overflow: 'auto',
|
||||||
|
background: '#000', color: '#fff', fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#ff6b6b', marginTop: 0 }}>Something broke.</h2>
|
||||||
|
<p style={{ color: '#fff' }}>{this.state.error.message}</p>
|
||||||
|
<pre style={{
|
||||||
|
color: '#8b8b8b', fontSize: 12, lineHeight: 1.4,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ error: null })}
|
||||||
|
style={{
|
||||||
|
marginTop: 12, padding: '8px 14px', borderRadius: 999,
|
||||||
|
border: '1px solid #1f1f1f', background: '#111',
|
||||||
|
color: '#fff', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,26 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
|
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// All keyfile operations go through window.dchain.keyfile — the preload
|
||||||
|
// script bridges them to Electron's safeStorage. If preload failed to
|
||||||
|
// load (dev misconfig, broken build), we surface a loud error rather
|
||||||
|
// than silently failing, since a missing keyfile layer means nothing
|
||||||
|
// else in the app can work.
|
||||||
|
|
||||||
|
function requireDchain() {
|
||||||
|
if (typeof window === 'undefined' || !window.dchain) {
|
||||||
|
throw new Error(
|
||||||
|
'window.dchain is not available — the Electron preload failed to ' +
|
||||||
|
'load. Check dist-electron/preload.js exists and that main.ts is ' +
|
||||||
|
'pointing at it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return window.dchain;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadKeyFile(): Promise<KeyFile | null> {
|
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||||
const raw = await window.dchain.keyfile.load();
|
const raw = await requireDchain().keyfile.load();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as KeyFile;
|
return JSON.parse(raw) as KeyFile;
|
||||||
@@ -32,11 +49,11 @@ export async function loadKeyFile(): Promise<KeyFile | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
await window.dchain.keyfile.save(JSON.stringify(kf));
|
await requireDchain().keyfile.save(JSON.stringify(kf));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteKeyFile(): Promise<void> {
|
export async function deleteKeyFile(): Promise<void> {
|
||||||
await window.dchain.keyfile.delete();
|
await requireDchain().keyfile.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
|
||||||
|
// Last-resort fallback: if even rendering ErrorBoundary+App fails (say, a
|
||||||
|
// syntax error in some lazy import), paint a visible message into #root
|
||||||
|
// so the window isn't just black. window.onerror catches async errors
|
||||||
|
// that escape React's boundaries.
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (root && !root.firstChild) {
|
||||||
|
root.innerHTML = `<pre style="color:#ff6b6b;background:#000;padding:20px;font-family:monospace;white-space:pre-wrap;">` +
|
||||||
|
`Fatal: ${String(e.error ?? e.message)}\n\n${e.error?.stack ?? ''}</pre>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user