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:
vsecoder
2026-04-22 17:03:06 +03:00
parent af7223b93c
commit b55486775e
30 changed files with 8920 additions and 7 deletions

145
desktop/electron/main.ts Normal file
View 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();
});

View 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);

View 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"]
}