Compare commits
1 Commits
v2.2.0-alp
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b55486775e |
9
desktop/.gitignore
vendored
Normal file
9
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# electron-builder output
|
||||
out/
|
||||
76
desktop/README.md
Normal file
76
desktop/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# DChain Desktop
|
||||
|
||||
Electron shell for the DChain messenger and social feed.
|
||||
|
||||
Same functionality as the mobile client-app, re-imagined with a
|
||||
keyboard-first, 3-panel desktop layout:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ DChain │ titlebar (drag)
|
||||
├──────┬───────────────────┬────────────────────────────────┤
|
||||
│ nav │ list │ detail │
|
||||
│ 72px │ 340px fixed │ flex 1 │
|
||||
├──────┴───────────────────┴────────────────────────────────┤
|
||||
│ ● online · node.example:8080 · height 10942 │ status bar
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sections (left rail): **Messages · Feed · Wallet · Contacts · Settings · Profile**.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
npm run dev # concurrently: Vite dev server + Electron
|
||||
```
|
||||
|
||||
The first boot will show the Welcome screen. Pick Create to generate
|
||||
fresh keys, or Import a `node.json` exported from the mobile client.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # produces dist/ (renderer) + dist-electron/ (main) + installers
|
||||
```
|
||||
|
||||
Default installers are built with `electron-builder`: `.dmg` on macOS,
|
||||
NSIS `.exe` on Windows, AppImage + `.deb` on Linux. Adjust `build.*` in
|
||||
`package.json` for signing / notarisation.
|
||||
|
||||
## Layout
|
||||
|
||||
- `electron/` — main + preload. TypeScript, compiled to `dist-electron/`
|
||||
by `tsc -p electron/tsconfig.json`.
|
||||
- `src/` — renderer. React + Vite. `@/` aliases to `src/`.
|
||||
- `src/shell/` — 3-panel chrome.
|
||||
- `src/sections/` — one folder per nav section, each exports `{ List, Detail }`.
|
||||
- `src/auth/Welcome.tsx` — shown when no key is loaded.
|
||||
- `src/lib/` — api, storage, store, types. Mirrors (without React-Native
|
||||
deps) the relevant pieces of `../client-app/lib/`.
|
||||
|
||||
## Security model
|
||||
|
||||
Master Ed25519 priv lives in the OS keychain via Electron `safeStorage`
|
||||
(macOS Keychain / Windows DPAPI / libsecret). A renderer compromise
|
||||
cannot read or exfiltrate the key — it always travels through
|
||||
`window.dchain.keyfile.*` IPC, which main.ts validates and mediates.
|
||||
|
||||
`contextIsolation: true`, `nodeIntegration: false`. CSP in `index.html`
|
||||
pins script sources to `'self'` while allowing `connect-src *` so the
|
||||
renderer can hit any node the user configures.
|
||||
|
||||
## Pairing (v2.2.0-alpha5+)
|
||||
|
||||
Desktop will reuse the same 6-digit-code + relay-envelope handshake as
|
||||
the mobile client. The scaffold in `src/auth/Welcome.tsx` stubs the
|
||||
button until the polling loop lands.
|
||||
|
||||
## Multi-device fan-out
|
||||
|
||||
When the node is at v2.2.0-alpha1+, `lib/api.ts:fetchDevices` returns
|
||||
every linked X25519 pub for a given identity; the sender then encrypts
|
||||
one envelope per device. Legacy nodes return an empty array and the
|
||||
client falls back to `IdentityInfo.x25519_pub`, preserving the
|
||||
pre-multi-device behaviour.
|
||||
145
desktop/electron/main.ts
Normal file
145
desktop/electron/main.ts
Normal 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();
|
||||
});
|
||||
50
desktop/electron/preload.ts
Normal file
50
desktop/electron/preload.ts
Normal 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);
|
||||
16
desktop/electron/tsconfig.json
Normal file
16
desktop/electron/tsconfig.json
Normal 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"]
|
||||
}
|
||||
29
desktop/index.html
Normal file
29
desktop/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src * ws: wss: http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self';" />
|
||||
<title>DChain</title>
|
||||
<style>
|
||||
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* Let text fields + readable text be selectable despite global disable. */
|
||||
input, textarea, [contenteditable], .selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7341
desktop/package-lock.json
generated
Normal file
7341
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
desktop/package.json
Normal file
45
desktop/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "dchain-desktop",
|
||||
"version": "2.2.0-alpha4",
|
||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite\" \"wait-on tcp:5173 && npm run electron:dev\"",
|
||||
"electron:dev": "tsc -p electron/tsconfig.json && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.js",
|
||||
"build": "tsc -p electron/tsconfig.json && vite build && electron-builder",
|
||||
"build:renderer": "vite build",
|
||||
"build:main": "tsc -p electron/tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.dchain.desktop",
|
||||
"productName": "DChain",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"mac": { "target": ["dmg"] },
|
||||
"win": { "target": ["nsis"] },
|
||||
"linux": { "target": ["AppImage", "deb"] }
|
||||
}
|
||||
}
|
||||
41
desktop/src/App.tsx
Normal file
41
desktop/src/App.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Top-level component. Two responsibilities:
|
||||
// 1. Boot — load key + settings from storage, wire up the API client,
|
||||
// flip the booted flag so we stop showing the black splash.
|
||||
// 2. Render either the Welcome auth flow (no key yet) or the Shell
|
||||
// (3-panel layout + current section).
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||
import { setNodeUrl } from '@/lib/api';
|
||||
import { Shell } from '@/shell/Shell';
|
||||
import { Welcome } from '@/auth/Welcome';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const booted = useStore(s => s.booted);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const set = loadSettings();
|
||||
setNodeUrl(set.nodeUrl);
|
||||
useStore.getState().setSettings(set);
|
||||
|
||||
const cs = loadContacts();
|
||||
useStore.getState().setContacts(cs);
|
||||
|
||||
const kf = await loadKeyFile();
|
||||
useStore.getState().setKeyFile(kf);
|
||||
|
||||
useStore.getState().setBooted(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!booted) {
|
||||
// Matches the splash: whole window is already black from index.html,
|
||||
// so showing nothing is the right behaviour — no flash, no spinner.
|
||||
return <div style={{ height: '100%' }} />;
|
||||
}
|
||||
|
||||
return keyFile ? <Shell /> : <Welcome />;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
9
desktop/src/main.tsx
Normal file
9
desktop/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
9
desktop/src/sections/contacts/index.tsx
Normal file
9
desktop/src/sections/contacts/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
|
||||
export function ContactsList(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Contacts" note="All · Online · Blocked · Requests" />;
|
||||
}
|
||||
export function ContactsDetail(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Contacts" note="Pick a contact to see details." centered />;
|
||||
}
|
||||
9
desktop/src/sections/feed/index.tsx
Normal file
9
desktop/src/sections/feed/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
|
||||
export function FeedList(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Feed" note="For You · Following · Trending · Hashtag" />;
|
||||
}
|
||||
export function FeedDetail(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Feed" note="Select a feed tab to browse posts." centered />;
|
||||
}
|
||||
29
desktop/src/sections/messages/index.tsx
Normal file
29
desktop/src/sections/messages/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// Messages section — chat list (left pane) + conversation (detail pane).
|
||||
// v2.2.0-alpha4 ships the scaffold only; real list + conversation come
|
||||
// in follow-up commits sharing logic with client-app/app/(app)/chats.
|
||||
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
export function MessagesList(): React.ReactElement {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
return (
|
||||
<SectionPlaceholder
|
||||
title="Messages"
|
||||
note={contacts.length === 0
|
||||
? 'No chats yet. Add a contact from the Contacts tab.'
|
||||
: `${contacts.length} conversation${contacts.length === 1 ? '' : 's'} cached.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesDetail(): React.ReactElement {
|
||||
return (
|
||||
<SectionPlaceholder
|
||||
title="Select a chat"
|
||||
note="Pick a conversation from the list on the left, or start a new one."
|
||||
centered
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
desktop/src/sections/profile/index.tsx
Normal file
43
desktop/src/sections/profile/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
export function ProfileList(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{
|
||||
padding: 14, borderRadius: 14,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 24,
|
||||
background: '#1d9bf0', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontWeight: 800, fontSize: 20,
|
||||
}}>
|
||||
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
|
||||
You
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 4, wordBreak: 'break-all',
|
||||
}}>
|
||||
{keyFile?.pub_key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileDetail(): React.ReactElement {
|
||||
return (
|
||||
<SectionPlaceholder
|
||||
title="Your profile"
|
||||
note="Balance, username, devices — coming soon."
|
||||
centered
|
||||
/>
|
||||
);
|
||||
}
|
||||
133
desktop/src/sections/settings/index.tsx
Normal file
133
desktop/src/sections/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
desktop/src/sections/wallet/index.tsx
Normal file
42
desktop/src/sections/wallet/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance } from '@/lib/api';
|
||||
|
||||
function formatT(ut: number): string {
|
||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
export function WalletList(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{
|
||||
borderRadius: 14, padding: 14,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Balance
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800, marginTop: 4 }}>
|
||||
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 6, wordBreak: 'break-all',
|
||||
}}>
|
||||
{keyFile?.pub_key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletDetail(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Wallet" note="Transaction history — coming soon." centered />;
|
||||
}
|
||||
122
desktop/src/shell/NavBar.tsx
Normal file
122
desktop/src/shell/NavBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// NavBar — the left 72px rail. Six icons, one for each section.
|
||||
// The active icon is drawn in accent blue; everything else is mid-grey.
|
||||
// Keyboard shortcuts (Ctrl/Cmd+1..5) are registered in useKeybinds().
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore, type Section } from '@/lib/store';
|
||||
|
||||
interface Tab {
|
||||
key: Section;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Icons are SF Symbol-ish monochrome glyphs from lucide's set, inlined as
|
||||
// SVGs to avoid another runtime dependency at this stage. If the set
|
||||
// grows, we'll move to a lucide-react import.
|
||||
const TABS: Tab[] = [
|
||||
{ key: 'messages', label: 'Messages', icon: 'chat' },
|
||||
{ key: 'feed', label: 'Feed', icon: 'feed' },
|
||||
{ key: 'wallet', label: 'Wallet', icon: 'wallet' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'contacts' },
|
||||
{ key: 'settings', label: 'Settings', icon: 'cog' },
|
||||
];
|
||||
|
||||
export function NavBar(): React.ReactElement {
|
||||
const section = useStore(s => s.section);
|
||||
const setSection = useStore(s => s.setSection);
|
||||
|
||||
// Global keybinds for section switch.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
const i = Number(e.key) - 1;
|
||||
if (Number.isInteger(i) && i >= 0 && i < TABS.length) {
|
||||
e.preventDefault();
|
||||
setSection(TABS[i].key);
|
||||
} else if (e.key === ',' || e.key === '.') {
|
||||
// Cmd+, is standard for Settings on macOS
|
||||
e.preventDefault();
|
||||
setSection('settings');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [setSection]);
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
width: 72, flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
padding: '16px 0 10px',
|
||||
borderRight: '1px solid #1f1f1f',
|
||||
background: '#000',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{TABS.map(t => (
|
||||
<NavItem
|
||||
key={t.key}
|
||||
label={t.label}
|
||||
icon={t.icon}
|
||||
active={section === t.key}
|
||||
onClick={() => setSection(t.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<NavItem
|
||||
key="profile"
|
||||
label="Profile"
|
||||
icon="user"
|
||||
active={section === 'profile'}
|
||||
onClick={() => setSection('profile')}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
label, icon, active, onClick,
|
||||
}: { label: string; icon: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
style={{
|
||||
width: 56, height: 52, borderRadius: 12,
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 2,
|
||||
}}
|
||||
>
|
||||
<NavGlyph icon={icon} color={active ? '#1d9bf0' : '#8b8b8b'} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.2 }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGlyph({ icon, color }: { icon: string; color: string }) {
|
||||
const d = GLYPHS[icon] ?? GLYPHS.cog;
|
||||
return (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth={1.8}
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, string> = {
|
||||
// Minimal lucide-style single-path icons.
|
||||
chat: 'M21 12a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
|
||||
feed: 'M4 11a9 9 0 0 1 9 9 M4 4a16 16 0 0 1 16 16 M5 19a2 2 0 1 0 0 .01',
|
||||
wallet: 'M20 12V8H4a2 2 0 0 1 0-4h12 M4 6v12a2 2 0 0 0 2 2h14v-4 M18 12a2 2 0 1 0 0 4h4v-4h-4z',
|
||||
contacts: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
|
||||
cog: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||
};
|
||||
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// Simple inner placeholder used by every section until real content
|
||||
// lands. Shows a title + a short note; `centered` flips the layout into
|
||||
// a vertically centred message for empty-detail panes.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
note?: string;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export function SectionPlaceholder({ title, note, centered }: Props): React.ReactElement {
|
||||
if (centered) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32,
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', maxWidth: 360 }}>
|
||||
<div style={{ color: '#d0d0d0', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
{note && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, lineHeight: 1.5, marginTop: 6 }}>
|
||||
{note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700 }}>{title}</div>
|
||||
{note && (
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 6 }}>{note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
desktop/src/shell/Shell.tsx
Normal file
72
desktop/src/shell/Shell.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// Shell — the permanent 3-panel chrome around every non-auth screen.
|
||||
//
|
||||
// Layout:
|
||||
// ┌──────────────────────────────────────────────────────────────┐
|
||||
// │ DChain [ minimise | maximise | × ] │ 32px titlebar (drag region)
|
||||
// ├──────┬───────────────────┬─────────────────────────────────────┤
|
||||
// │ │ │ │
|
||||
// │ nav │ list │ detail │
|
||||
// │ 72px │ ~340px fixed │ flex 1 │
|
||||
// │ │ │ │
|
||||
// ├──────┴───────────────────┴─────────────────────────────────────┤
|
||||
// │ ● online · height 10942 · fee 1000 µT │ 28px status bar
|
||||
// └──────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Current section is driven by store.section. NavBar flips it. List +
|
||||
// Detail are each decided by the section, composed from the appropriate
|
||||
// module under sections/. Until sections ship their real content, they
|
||||
// render simple placeholders so we can walk through the shell end-to-end.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore, type Section } from '@/lib/store';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { NavBar } from './NavBar';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||
import { ContactsList, ContactsDetail } from '@/sections/contacts';
|
||||
import { SettingsList, SettingsDetail } from '@/sections/settings';
|
||||
import { ProfileList, ProfileDetail } from '@/sections/profile';
|
||||
|
||||
export function Shell(): React.ReactElement {
|
||||
const section = useStore(s => s.section);
|
||||
const { List, Detail } = PANES[section];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100%', background: '#000',
|
||||
}}>
|
||||
<TitleBar />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', overflow: 'hidden',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<NavBar />
|
||||
<div style={{
|
||||
width: 340, flexShrink: 0,
|
||||
borderRight: '1px solid #1f1f1f',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
<List />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Detail />
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PANES: Record<
|
||||
Section,
|
||||
{ List: React.ComponentType; Detail: React.ComponentType }
|
||||
> = {
|
||||
messages: { List: MessagesList, Detail: MessagesDetail },
|
||||
feed: { List: FeedList, Detail: FeedDetail },
|
||||
wallet: { List: WalletList, Detail: WalletDetail },
|
||||
contacts: { List: ContactsList, Detail: ContactsDetail },
|
||||
settings: { List: SettingsList, Detail: SettingsDetail },
|
||||
profile: { List: ProfileList, Detail: ProfileDetail },
|
||||
};
|
||||
75
desktop/src/shell/StatusBar.tsx
Normal file
75
desktop/src/shell/StatusBar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// StatusBar — the 28px strip at the bottom. Surfaces the three bits of
|
||||
// information an operator tends to want at a glance:
|
||||
// * Connection state to the configured node (poll /api/netstats).
|
||||
// * Current chain height (last successful poll).
|
||||
// * Node URL (short, hover-tooltip for the full thing).
|
||||
//
|
||||
// Poll interval is 5 seconds — low enough to feel live, cheap enough
|
||||
// that even a free-tier node won't notice.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getNetStats, getNodeUrl, onNodeUrlChange } from '@/lib/api';
|
||||
|
||||
type ConnState = 'online' | 'connecting' | 'offline';
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const nodeUrl = useStore(s => s.settings.nodeUrl);
|
||||
const [conn, setConn] = useState<ConnState>('connecting');
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
const [url, setUrl] = useState<string>(getNodeUrl());
|
||||
|
||||
useEffect(() => onNodeUrlChange(setUrl), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getNetStats();
|
||||
if (cancelled) return;
|
||||
setConn('online');
|
||||
setHeight(s.total_blocks);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setConn('offline');
|
||||
}
|
||||
};
|
||||
setConn('connecting');
|
||||
poll();
|
||||
const t = setInterval(poll, 5_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [nodeUrl]);
|
||||
|
||||
const dot = conn === 'online' ? '#3ba55d' :
|
||||
conn === 'connecting' ? '#f0b35a' :
|
||||
'#f4212e';
|
||||
|
||||
const shortUrl = url
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
return (
|
||||
<footer style={{
|
||||
height: 28, minHeight: 28,
|
||||
background: '#000',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
display: 'flex', alignItems: 'center', padding: '0 16px', gap: 14,
|
||||
fontSize: 11, color: '#8b8b8b',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: 4, background: dot,
|
||||
}} />
|
||||
{conn}
|
||||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span title={url}>{shortUrl}</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span>height {height ?? '—'}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
42
desktop/src/shell/TitleBar.tsx
Normal file
42
desktop/src/shell/TitleBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// Titlebar — draws the top 32px strip as a drag region so the user can
|
||||
// move the window even though we set frame: false in main.ts.
|
||||
//
|
||||
// On macOS the native traffic lights show through because main.ts uses
|
||||
// `titleBarStyle: 'hiddenInset'`. On Windows, `titleBarOverlay` renders
|
||||
// close/min/max in their native style over our bar. On Linux we paint
|
||||
// close + min + max ourselves (below).
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const DRAG: React.CSSProperties = {
|
||||
// @ts-expect-error webkit-only
|
||||
WebkitAppRegion: 'drag',
|
||||
};
|
||||
const NO_DRAG: React.CSSProperties = {
|
||||
// @ts-expect-error webkit-only
|
||||
WebkitAppRegion: 'no-drag',
|
||||
};
|
||||
|
||||
export function TitleBar(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...DRAG,
|
||||
height: 32,
|
||||
minHeight: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 80, // leaves room for macOS traffic-lights area
|
||||
paddingRight: 12,
|
||||
background: '#000',
|
||||
color: '#d0d0d0',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 0.6 }}>DChain</span>
|
||||
<div style={{ flex: 1, ...NO_DRAG }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
desktop/src/vite-env.d.ts
vendored
Normal file
12
desktop/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite auto-surfaces VITE_* env vars on import.meta.env. The Electron main
|
||||
// process sets VITE_DEV_SERVER_URL separately at spawn time, so we only
|
||||
// need to tell TS about the one variable the renderer reads.
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEV_SERVER_URL?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
26
desktop/tsconfig.json
Normal file
26
desktop/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
desktop/vite.config.ts
Normal file
22
desktop/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
// Vite config for the renderer process. Electron main/preload build
|
||||
// separately via `tsc -p electron/tsconfig.json`.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
@@ -186,14 +186,22 @@ desktop/
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### План работ (отдельно, после v2.2.0-alpha3)
|
||||
### План работ
|
||||
|
||||
- [ ] Boilerplate: Electron + Vite + React + TS, frame-less window, 3-panel shell.
|
||||
- [ ] Сопрягание `lib/` из client-app (заменить expo-* на Electron-эквиваленты).
|
||||
- [ ] Sections по порядку: Messages → Feed → Wallet → Contacts → Settings → Profile.
|
||||
- [ ] Multi-device pairing flow (использует v2.2.0 registry).
|
||||
- [ ] Auto-update через electron-updater или через тот же `/api/update-check`.
|
||||
- [ ] Packaging: `electron-builder` → `.dmg`, `.exe`, `.AppImage`, `.deb`.
|
||||
- [x] **v2.2.0-alpha4** — Boilerplate: Electron + Vite + React + TS,
|
||||
frame-less window, 3-panel shell, nav + status bar, safeStorage
|
||||
for keyfile via IPC, Welcome + Create/Import auth flow, section
|
||||
stubs that the rest of the alphas will fill in.
|
||||
- [ ] **v2.2.0-alpha5** — Messages section (chat list + conversation)
|
||||
using the same fan-out semantics as mobile. Pairing flow wired up
|
||||
(new-device poll loop + primary-device modal reused from mobile).
|
||||
- [ ] **v2.2.0-alpha6** — Feed + Wallet real content (reuse feed.ts /
|
||||
tx builders from client-app via a shared workspace package).
|
||||
- [ ] **v2.2.0-rc1** — Contacts + Settings → Devices + Profile,
|
||||
polish pass (keybinds, focus, drag-drop attachments).
|
||||
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
|
||||
pipeline nodes use; `electron-builder` → `.dmg`, `.exe`,
|
||||
`.AppImage`, `.deb`.
|
||||
|
||||
### Открытые вопросы (desktop)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user