feat(desktop): Electron scaffold, shell, auth + section stubs (v2.2.0-alpha4)
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.
This commit is contained in:
145
desktop/electron/main.ts
Normal file
145
desktop/electron/main.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// 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();
|
||||
});
|
||||
50
desktop/electron/preload.ts
Normal file
50
desktop/electron/preload.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Preload — the thin bridge between renderer and main.
|
||||
//
|
||||
// Everything exposed here is visible in the renderer as `window.dchain`.
|
||||
// We explicitly pick which IPC channels to surface rather than exposing
|
||||
// `ipcRenderer` wholesale, so a compromised renderer can't spam
|
||||
// arbitrary channels.
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
interface OpenDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
filters?: { name: string; extensions: string[] }[];
|
||||
properties?: ('openFile' | 'multiSelections')[];
|
||||
}
|
||||
|
||||
interface SaveDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
filters?: { name: string; extensions: string[] }[];
|
||||
}
|
||||
|
||||
const api = {
|
||||
keyfile: {
|
||||
load: (): Promise<string | null> => ipcRenderer.invoke('keyfile:load'),
|
||||
save: (json: string): Promise<void> => ipcRenderer.invoke('keyfile:save', json),
|
||||
delete: (): Promise<void> => ipcRenderer.invoke('keyfile:delete'),
|
||||
encryptionAvailable: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('keyfile:encryption-available'),
|
||||
},
|
||||
dialog: {
|
||||
openFile: (opts: OpenDialogOptions): Promise<string | null> =>
|
||||
ipcRenderer.invoke('dialog:open-file', opts),
|
||||
saveFile: (opts: SaveDialogOptions): Promise<string | null> =>
|
||||
ipcRenderer.invoke('dialog:save-file', opts),
|
||||
},
|
||||
fs: {
|
||||
readText: (p: string): Promise<string> => ipcRenderer.invoke('fs:read-text', p),
|
||||
writeText: (p: string, c: string): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:write-text', p, c),
|
||||
},
|
||||
app: {
|
||||
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
|
||||
platform: (): Promise<string> => ipcRenderer.invoke('app:platform'),
|
||||
},
|
||||
};
|
||||
|
||||
export type DChainAPI = typeof api;
|
||||
|
||||
contextBridge.exposeInMainWorld('dchain', api);
|
||||
16
desktop/electron/tsconfig.json
Normal file
16
desktop/electron/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["main.ts", "preload.ts", "menu.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user