PR #4 of the multi-device roadmap — desktop client groundwork. The shell compiles and runs end-to-end on top of a v2.2.0 node; sections are placeholders that later alphas fill in with real chat / feed / wallet / contacts / settings content shared with the mobile client-app. Scaffold: * Vite + React + TypeScript renderer; Electron main/preload TS compiled via a separate tsconfig. * npm scripts — `dev` (concurrent Vite + Electron), `build` (installer via electron-builder), `typecheck`. * electron-builder targets: .dmg / .exe / .AppImage + .deb. * CSP pins script-src 'self'; connect-src left open so the renderer can hit any configured node. Electron main + preload: * Frame-less window, hiddenInset on macOS, custom-overlay on Windows, drag region via CSS -webkit-app-region: drag on our TitleBar. * contextIsolation on, nodeIntegration off, sandbox off (needed for safeStorage in preload). * window.dchain.keyfile.{load,save,delete,encryptionAvailable} — keyfile lives in the OS keychain via Electron safeStorage, with a plaintext fallback for OSes without an encryption backend. * window.dchain.dialog.{openFile,saveFile}, .fs.{readText,writeText}, .app.{version,platform}. Everything else still goes over plain fetch() in the renderer. Shell (src/shell/): * TitleBar — draggable 32px strip; DChain brand. * NavBar — left 72px rail, six sections + Cmd+1..5 keybinds. * StatusBar — ● online/connecting/offline dot, node URL, current chain height (polls /api/netstats every 5s). * Shell — composes the 3 panes; picks { List, Detail } by active section. Sections (all stubs — filling in alpha5+): * Messages, Feed, Contacts, Profile — SectionPlaceholder with notes. * Wallet — shows the balance reading from /api/address/{pub} as a first real data binding. * Settings — node-URL card with live ping + commit, identity card (shows pub key), about card (reads Electron app.version via IPC). Auth (src/auth/Welcome.tsx): * Create — generates Ed25519 + X25519 via tweetnacl, saves via IPC. * Import — Electron dialog.openFile → parses node.json → saves. * Pair — stub routed; real poll loop reuses the mobile flow in alpha5. Lib (src/lib/): * types.ts — KeyFile / Contact / Message / NodeSettings mirroring client-app wire formats. * storage.ts — keyfile via IPC, settings + contacts + device-registered marker via localStorage. * api.ts — fetch wrapper with setNodeUrl + onNodeUrlChange; getNetStats, getIdentity, fetchDevices, getBalance bindings. * store.ts — zustand { booted, keyFile, settings, contacts, section }. docs/ROADMAP.md — desktop subsection updated with per-alpha breakdown. Next (alpha5): Messages section wired to the relay mailbox, full conversation view, and the pairing poll loop.
146 lines
5.1 KiB
TypeScript
146 lines
5.1 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 } 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;
|
|
|
|
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(() => {
|
|
createWindow();
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit();
|
|
});
|