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:
152
desktop/src/auth/Welcome.tsx
Normal file
152
desktop/src/auth/Welcome.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
// Welcome — shown when no key is loaded.
|
||||
//
|
||||
// Three options, matching mobile parity:
|
||||
// * Create — generate a new Ed25519 + X25519 keypair.
|
||||
// * Import — load node.json file (dialog).
|
||||
// * Pair — pair with an existing phone/desktop (QR-less, 6-digit code
|
||||
// + device key, symmetrical with mobile's /auth/pair flow).
|
||||
//
|
||||
// v2.2.0-alpha4 wires the first two functionally and stubs Pair with a
|
||||
// button that routes to a placeholder — the pairing poll loop shared
|
||||
// with mobile comes in alpha5.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import nacl from 'tweetnacl';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function generateKeyFile(): KeyFile {
|
||||
const signKP = nacl.sign.keyPair();
|
||||
const boxKP = nacl.box.keyPair();
|
||||
return {
|
||||
pub_key: bytesToHex(signKP.publicKey),
|
||||
priv_key: bytesToHex(signKP.secretKey),
|
||||
x25519_pub: bytesToHex(boxKP.publicKey),
|
||||
x25519_priv: bytesToHex(boxKP.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function Welcome(): React.ReactElement {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onCreate = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const kf = generateKeyFile();
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const file = await window.dchain.dialog.openFile({
|
||||
title: 'Select node.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!file) return;
|
||||
const contents = await window.dchain.fs.readText(file);
|
||||
const parsed = JSON.parse(contents) as KeyFile;
|
||||
if (!parsed.pub_key || !parsed.priv_key) {
|
||||
throw new Error('file doesn\'t look like a key file');
|
||||
}
|
||||
await saveKeyFile(parsed);
|
||||
setKeyFile(parsed);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPair = () => {
|
||||
setErr('Pair flow lands in v2.2.0-alpha5. For now use Import from a key file exported on your phone.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, background: '#000', color: '#fff',
|
||||
}}>
|
||||
<div style={{ maxWidth: 400, width: '100%', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: 22,
|
||||
background: '#1d9bf0', margin: '0 auto',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 36, fontWeight: 800,
|
||||
}}>
|
||||
D
|
||||
</div>
|
||||
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: -0.5, margin: '16px 0 6px' }}>
|
||||
DChain
|
||||
</h1>
|
||||
<p style={{ color: '#8b8b8b', fontSize: 14, margin: 0, lineHeight: 1.5 }}>
|
||||
Decentralised messenger + social feed. Your keys stay on this device.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 32 }}>
|
||||
<PrimaryBtn label="Create account" onClick={onCreate} disabled={busy} />
|
||||
<SecondaryBtn label="Import key file" onClick={onImport} disabled={busy} />
|
||||
<SecondaryBtn label="Pair with another device" onClick={onPair} disabled={busy} />
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 20, padding: 10, borderRadius: 10,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ label, onClick, disabled }: {
|
||||
label: string; onClick: () => void; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 46, borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryBtn({ label, onClick, disabled }: {
|
||||
label: string; onClick: () => void; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 46, borderRadius: 999,
|
||||
background: '#0a0a0a', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
border: '1px solid #1f1f1f',
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user