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:
114
desktop/src/lib/api.ts
Normal file
114
desktop/src/lib/api.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// Minimal API client for the scaffold. Mirrors the mobile client-app's
|
||||
// lib/api.ts semantics (endpoints, wire shapes) so the two can hit the
|
||||
// same node. As we grow the desktop client, more methods move in here;
|
||||
// for now we only need net-stats + identity + devices + submit-tx +
|
||||
// broadcast-envelope + inbox to drive the shell + pairing.
|
||||
|
||||
const DEFAULT_URL = 'http://localhost:8080';
|
||||
let nodeUrl = DEFAULT_URL;
|
||||
let apiToken: string | null = null;
|
||||
|
||||
const listeners: ((url: string) => void)[] = [];
|
||||
|
||||
export function setNodeUrl(url: string): void {
|
||||
nodeUrl = url.replace(/\/$/, '') || DEFAULT_URL;
|
||||
listeners.forEach(fn => fn(nodeUrl));
|
||||
}
|
||||
|
||||
export function getNodeUrl(): string {
|
||||
return nodeUrl;
|
||||
}
|
||||
|
||||
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||
listeners.push(fn);
|
||||
return () => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function setApiToken(t: string | null): void { apiToken = t; }
|
||||
|
||||
function headers(): HeadersInit {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiToken) h['Authorization'] = `Bearer ${apiToken}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function parse<T>(resp: Response): Promise<T> {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`${resp.status} ${resp.statusText} → ${body.slice(0, 200)}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${nodeUrl}${path}`, { headers: headers() });
|
||||
return parse<T>(resp);
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${nodeUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parse<T>(resp);
|
||||
}
|
||||
|
||||
// ─── Thin wrappers for the shell ─────────────────────────────────────────
|
||||
|
||||
export interface NetStats {
|
||||
total_blocks: number;
|
||||
total_txs: number;
|
||||
total_supply: number;
|
||||
validator_count: number;
|
||||
relay_count: number;
|
||||
}
|
||||
|
||||
export async function getNetStats(): Promise<NetStats> {
|
||||
return get<NetStats>('/api/netstats');
|
||||
}
|
||||
|
||||
export interface IdentityInfo {
|
||||
pub_key: string;
|
||||
address: string;
|
||||
x25519_pub: string;
|
||||
nickname: string;
|
||||
registered: boolean;
|
||||
device_count?: number;
|
||||
}
|
||||
|
||||
export async function getIdentity(pub: string): Promise<IdentityInfo | null> {
|
||||
try { return await get<IdentityInfo>(`/api/identity/${pub}`); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
x25519_pub_key: string;
|
||||
device_name: string;
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
interface DevicesResponse {
|
||||
master_pub: string;
|
||||
count: number;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||
try {
|
||||
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||
return resp.devices ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBalance(pub: string): Promise<number> {
|
||||
try {
|
||||
const r = await get<{ balance_ut: number }>(`/api/address/${pub}`);
|
||||
return r.balance_ut ?? 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
114
desktop/src/lib/storage.ts
Normal file
114
desktop/src/lib/storage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
desktop/src/lib/store.ts
Normal file
47
desktop/src/lib/store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Zustand store — same pattern as client-app/lib/store.ts, just lighter.
|
||||
// Holds identity, node settings, UI nav state (current section), and the
|
||||
// bootstrapped flag so the Welcome screen can redirect only once boot
|
||||
// has run.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { KeyFile, NodeSettings, Contact } from './types';
|
||||
|
||||
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
||||
|
||||
interface State {
|
||||
booted: boolean;
|
||||
keyFile: KeyFile | null;
|
||||
settings: NodeSettings;
|
||||
contacts: Contact[];
|
||||
section: Section;
|
||||
|
||||
setBooted: (v: boolean) => void;
|
||||
setKeyFile: (k: KeyFile | null) => void;
|
||||
setSettings: (s: Partial<NodeSettings>) => void;
|
||||
setContacts: (cs: Contact[]) => void;
|
||||
upsertContact: (c: Contact) => void;
|
||||
setSection: (s: Section) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<State>((set) => ({
|
||||
booted: false,
|
||||
keyFile: null,
|
||||
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||
contacts: [],
|
||||
section: 'messages',
|
||||
|
||||
setBooted: (v) => set({ booted: v }),
|
||||
setKeyFile: (k) => set({ keyFile: k }),
|
||||
setSettings: (s) => set((st) => ({ settings: { ...st.settings, ...s } })),
|
||||
setContacts: (cs) => set({ contacts: cs }),
|
||||
upsertContact: (c) => set((st) => {
|
||||
const i = st.contacts.findIndex((x) => x.address === c.address);
|
||||
if (i >= 0) {
|
||||
const next = [...st.contacts];
|
||||
next[i] = c;
|
||||
return { contacts: next };
|
||||
}
|
||||
return { contacts: [...st.contacts, c] };
|
||||
}),
|
||||
setSection: (s) => set({ section: s }),
|
||||
}));
|
||||
41
desktop/src/lib/types.ts
Normal file
41
desktop/src/lib/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Mirrors client-app/lib/types.ts — keep wire formats identical so the
|
||||
// two codebases can share a single node. Copied (not imported) on
|
||||
// purpose: we want the desktop build isolated from React-Native deps,
|
||||
// and the drift window between this file and the mobile one is small
|
||||
// enough to hand-sync. When we consolidate into a shared package
|
||||
// (post-v2.2.0), this file goes away.
|
||||
|
||||
export interface KeyFile {
|
||||
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||
priv_key: string; // hex Ed25519 secret key (64 bytes)
|
||||
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||
x25519_priv: string; // hex X25519 secret key (32 bytes)
|
||||
}
|
||||
|
||||
export interface NodeSettings {
|
||||
nodeUrl: string;
|
||||
contractId: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
address: string; // Ed25519 master pub hex
|
||||
x25519Pub: string; // legacy single-X25519; device registry superseded on v2.2.0
|
||||
username?: string;
|
||||
alias?: string;
|
||||
addedAt: number; // unix ms
|
||||
kind?: 'direct' | 'group';
|
||||
unread?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
from: string; // X25519 hex (sender device)
|
||||
text: string;
|
||||
timestamp: number;
|
||||
mine: boolean;
|
||||
read: boolean;
|
||||
edited: boolean;
|
||||
attachment?: unknown;
|
||||
replyTo?: { id: string; text: string; author: string };
|
||||
}
|
||||
Reference in New Issue
Block a user