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

114
desktop/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,114 @@
// Persistence for the desktop shell.
//
// Two tiers, both different from the mobile client:
// * KeyFile lives in the OS keychain (via Electron safeStorage in main.ts,
// exposed as `window.dchain.keyfile`). We never touch it here from
// renderer code except through that IPC.
// * Everything else — settings, contacts, chat cache, "this device was
// registered" marker — lives in localStorage. It's synchronous,
// origin-isolated inside the renderer, and plenty durable for
// per-install state. A future polish could move chats to IndexedDB
// for streaming writes, but localStorage is fine for v2.2.0.
import type { KeyFile, NodeSettings, Contact } from './types';
import type { DChainAPI } from '../../electron/preload';
declare global {
interface Window {
dchain: DChainAPI;
}
}
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await window.dchain.keyfile.load();
if (!raw) return null;
try {
return JSON.parse(raw) as KeyFile;
} catch {
return null;
}
}
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await window.dchain.keyfile.save(JSON.stringify(kf));
}
export async function deleteKeyFile(): Promise<void> {
await window.dchain.keyfile.delete();
}
// ─── Settings ─────────────────────────────────────────────────────────────
const SETTINGS_KEY = 'dchain_settings';
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8080',
contractId: '',
};
export function loadSettings(): NodeSettings {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return DEFAULT_SETTINGS;
}
}
export function saveSettings(s: Partial<NodeSettings>): void {
const cur = loadSettings();
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────
const CONTACTS_KEY = 'dchain_contacts';
export function loadContacts(): Contact[] {
const raw = localStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
try {
return JSON.parse(raw) as Contact[];
} catch {
return [];
}
}
export function saveContacts(list: Contact[]): void {
localStorage.setItem(CONTACTS_KEY, JSON.stringify(list));
}
export function upsertContact(c: Contact): void {
const cs = loadContacts();
const i = cs.findIndex(x => x.address === c.address);
if (i >= 0) cs[i] = c; else cs.push(c);
saveContacts(cs);
}
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
export function isDeviceRegistered(): boolean {
return localStorage.getItem(DEVICE_REGISTERED_KEY) === '1';
}
export function markDeviceRegistered(): void {
localStorage.setItem(DEVICE_REGISTERED_KEY, '1');
}
export async function wipeAllLocalState(): Promise<void> {
await deleteKeyFile();
// Everything else in localStorage we control; iterate + clear our prefix.
const ours = [
SETTINGS_KEY, CONTACTS_KEY, DEVICE_REGISTERED_KEY,
];
for (const key of Object.keys(localStorage)) {
if (ours.includes(key) || key.startsWith('dchain_chats_')) {
localStorage.removeItem(key);
}
}
}