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

View File

@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
export function SettingsList(): React.ReactElement {
return (
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
<GroupLabel>Node</GroupLabel>
<NodeCard />
<GroupLabel>Identity</GroupLabel>
<IdentityCard />
<GroupLabel>About</GroupLabel>
<AboutCard />
</div>
);
}
export function SettingsDetail(): React.ReactElement {
return (
<SectionPlaceholder
title="Settings"
note="Pick a setting from the list. Devices, notifications, privacy — coming soon."
centered
/>
);
}
function GroupLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase',
}}>
{children}
</div>
);
}
function NodeCard(): React.ReactElement {
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const [url, setUrl] = useState(settings.nodeUrl);
const [ok, setOk] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => { setUrl(settings.nodeUrl); }, [settings.nodeUrl]);
const apply = async () => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setBusy(true); setOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setOk(true);
setSettings({ nodeUrl: clean });
saveSettings({ nodeUrl: clean });
} catch {
setOk(false);
} finally {
setBusy(false);
}
};
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a', display: 'flex', flexDirection: 'column', gap: 8,
}}>
<label style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
NODE URL
</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
<input
value={url}
onChange={e => { setUrl(e.target.value); setOk(null); }}
onBlur={apply}
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
placeholder="http://node.example:8080"
spellCheck={false}
style={{
flex: 1, background: '#000',
border: '1px solid #1f1f1f', borderRadius: 8,
padding: '8px 10px', color: '#fff', fontSize: 13,
fontFamily: 'monospace',
}}
/>
{busy && <span style={{ fontSize: 11, color: '#8b8b8b' }}></span>}
</div>
</div>
);
}
function IdentityCard(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
if (!keyFile) return <></>;
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a',
}}>
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
PUB KEY
</div>
<div className="selectable" style={{
color: '#fff', fontSize: 11, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.pub_key}
</div>
</div>
);
}
function AboutCard(): React.ReactElement {
const [v, setV] = useState<string>('dev');
useEffect(() => {
window.dchain?.app.version().then(setV).catch(() => {});
}, []);
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a', color: '#8b8b8b', fontSize: 12,
}}>
DChain Desktop v{v}
</div>
);
}