4 Commits

Author SHA1 Message Date
vsecoder
b55486775e 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.
2026-04-22 17:03:06 +03:00
vsecoder
af7223b93c feat(client): device pairing flow (v2.2.0-alpha3)
Completes PR #3 of the multi-device roadmap. Two devices of the same
identity can now be linked via a six-digit code + relay envelope
handshake. Chain-level fan-out (alpha2) and self-wipe on revoke (this
release, earlier commit) already work end-to-end.

New device — app/(auth)/pair.tsx:
  * Generates fresh X25519 keypair + six-digit code locally.
  * Displays them for transcription on the primary device.
  * Polls /relay/inbox on its own x25519 pub every 2.5s.
  * Decrypts each envelope with the session priv + sender_pub.
  * Accepts only payloads where {v=1, type=pair-handshake, code matches}.
  * On success, assembles a KeyFile (master Ed25519 from envelope,
    x25519 from session) and redirects into (app).

Primary device — app/(app)/devices.tsx:
  * "Link new device" opens a modal asking for {code, device key, name}.
  * On submit: builds+submits LINK_DEVICE tx for the new pub, then
    sends a one-shot relay envelope carrying {master_pub, master_priv,
    master_x25519_pub, code} encrypted for the new device's x25519 pub.
  * Optimistic local insert so the new row appears immediately.

Entry point — app/index.tsx:
  * Third button on welcome slide 3: "Pair". Routes to /auth/pair.
    Create / Import remain unchanged for fresh identities.

Security:
  * Master Ed25519 priv leaves the source device ONLY inside an envelope
    sealed with NaCl box for the new device's X25519 pub. The relay node
    sees only ciphertext.
  * Six-digit code (~20 bits) gates acceptance — an attacker who guesses
    both a session pub AND the code is still filtered by the X25519
    decryption itself (code match is belt-and-suspenders).
  * Envelope stays in relay mailbox until TTL — no DELETE call yet;
    idempotent on our side (saveKeyFile overwrites, session pub
    never polled after redirect).

Known trade-offs:
  * Manual transcription of a 64-char hex key is ugly. Alpha4 will
    offer a QR fallback on phones with cameras; desktop keeps typing.
  * No rate limit on the polling. Fine for a 1-minute handshake, needs
    cap-on-stale if a user leaves the screen open.
2026-04-22 16:32:34 +03:00
vsecoder
8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come.

Devices screen — app/(app)/devices.tsx:
  * Lists every active device from /api/devices/{self}.
  * "THIS DEVICE" badge on our own row, Unlink button on every other.
  * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal.
  * Pull-to-refresh; empty state when balance is too low for auto-link.
  * Placeholder row for "Link new device" — wired in next commit.

Settings → Devices entry row: added under a new "Devices" section.

Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx:
  * New AsyncStorage marker `dchain_device_registered` tracks whether
    this install ever made it into the on-chain registry.
  * wipeAllLocalState() zeroes secure-store key + contacts + settings +
    chats cache + marker. Safe-idempotent.
  * Bootstrap effect in app layout splits three branches by
    (our_pub in chain's active list × marker_set):
      - in list      → mark registered, done.
      - not in list + was registered → REVOKED → wipe + redirect to auth.
      - not in list + never registered → first boot, LINK_DEVICE.
  * Network errors never trigger wipe — only an explicit "pub missing
    from chain response" decides it. Belt-and-suspenders against a
    misbehaving node spuriously dropping records.

Next: pairing flow so a second device (desktop, tablet, new phone)
can come online, show a 6-digit code, receive master priv via a
one-shot relay envelope encrypted to its fresh device X25519 pub,
then self-link.
2026-04-22 16:28:16 +03:00
vsecoder
423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
PR #2 of the multi-device roadmap — wires the messenger pipeline
against the on-chain registry landed in v2.2.0-alpha1.

lib/api.ts:
  - DeviceInfo type mirroring blockchain.DeviceInfo.
  - IdentityInfo.device_count (optional; populated from /api/identity).
  - fetchDevices(masterPub) → /api/devices/{master_pub}, returns [].
    Errors swallowed so a downed endpoint doesn't block messaging.
  - resolveRecipientKeys(masterPub) — the routing primitive. Returns
    devices[] if registered, else falls back to IdentityInfo.x25519_pub
    (pre-v2.2.0 path). Empty only when recipient has published nothing.
  - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519,
    min-fee cost, canonical JSON payload matching the chain-side
    LinkDevicePayload / UnlinkDevicePayload.

app/(app)/chats/[id].tsx:
  - sendCore now fans out: encrypts once per recipient device pub
    (Promise.all, any failure rejects the batch), falls back to the
    cached contact.x25519Pub if the registry lookup returns nothing.
  - Saved Messages short-circuit preserved; no devices lookup for self.

app/(app)/_layout.tsx:
  - On every sign-in, auto-submit LINK_DEVICE for this device if its
    X25519 pub isn't already in the master's registry. Device name
    picks "iPhone" / "Android phone" / "Device" by Platform. Errors
    (insufficient balance / legacy chain without LINK_DEVICE support)
    are silent — next launch retries.

Backward compatibility: senders fall back to identity.x25519_pub when
the recipient has no registry entries, so pre-v2.2.0 clients still
receive messages. Chain-side already gates new validation on the event
types existing; old clients simply never emit LINK_DEVICE and keep
working with a single X25519.

Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side
self-wipe on revoke detection).
2026-04-22 16:24:36 +03:00
38 changed files with 10029 additions and 20 deletions

View File

@@ -13,7 +13,7 @@
* один раз; переходы между tab'ами их не перезапускают. * один раз; переходы между tab'ами их не перезапускают.
*/ */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View } from 'react-native'; import { View, Platform } from 'react-native';
import { router, usePathname } from 'expo-router'; import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
@@ -25,7 +25,13 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar'; import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot'; import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage'; import {
saveContact,
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
} from '@/lib/storage';
import {
fetchDevices, buildLinkDeviceTx, submitTx,
} from '@/lib/api';
export default function AppLayout() { export default function AppLayout() {
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -73,6 +79,79 @@ export default function AppLayout() {
else ws.setAuthCreds(null); else ws.setAuthCreds(null);
}, [keyFile]); }, [keyFile]);
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
//
// Three branches, by (chain list × local "was registered" flag):
//
// 1. Our pub is in the chain's active list →
// mark us registered locally (idempotent), done.
//
// 2. Our pub is NOT in the active list, AND we've registered before →
// another device issued UNLINK_DEVICE against us. Wipe ALL local
// state (master priv, contacts, chats, marker) and redirect to
// the auth screen. This is the security-critical path: without
// the wipe, a stolen phone after revoke would still decrypt
// historical messages.
//
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
// first boot on this chain; submit LINK_DEVICE so senders can
// target us. Failures (fee, offline) are swallowed; next launch
// retries.
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
let chainList;
try {
chainList = await fetchDevices(keyFile.pub_key);
} catch {
// Network unavailable — leave state unchanged; we'll resync on
// the next launch. Do NOT wipe on network error.
return;
}
if (cancelled) return;
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
const previouslyRegistered = await isDeviceRegistered();
if (cancelled) return;
if (inActive) {
// Branch #1 — ensure the local marker is set.
if (!previouslyRegistered) await markDeviceRegistered();
return;
}
if (previouslyRegistered) {
// Branch #2 — REVOKED. Self-wipe.
await wipeAllLocalState();
useStore.getState().setKeyFile(null);
// The redirect-on-null-keyFile effect below will push the user
// back to the welcome screen automatically.
return;
}
// Branch #3 — first-boot link. Best-effort.
try {
const deviceName = Platform.select({
ios: 'iPhone',
android: 'Android phone',
default: 'Device',
}) ?? 'Device';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
await markDeviceRegistered();
} catch {
/* next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
useEffect(() => { useEffect(() => {
if (keyFile === null) { if (keyFile === null) {
const t = setTimeout(() => { const t = setTimeout(() => {

View File

@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages'; import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto'; import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api'; import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage'; import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils'; import { randomId, safeBack } from '@/lib/utils';
@@ -212,15 +212,34 @@ export default function ChatScreen() {
// leaves the device, so no encryption/fee/network round-trip is needed. // leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below. // Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) { if (hasText && !isSavedMessages) {
// Multi-device fan-out (v2.2.0): resolve the recipient's active
// device X25519 pubs via /api/devices. Legacy identities (no
// devices registered) fall back to their published identity
// x25519 pub, preserving the pre-v2.2.0 single-device path.
// `contact.x25519Pub` stays the floor — if both network calls
// fail we still attempt delivery to the cached pub so a flaky
// connection doesn't block outgoing messages.
let recipientPubs = await resolveRecipientKeys(contact.address);
if (recipientPubs.length === 0 && contact.x25519Pub) {
recipientPubs = [contact.x25519Pub];
}
if (recipientPubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
// One sealed envelope per recipient device. Parallel — slow
// relays don't block each other; any individual failure
// rejects the whole send (user retries).
await Promise.all(recipientPubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage( const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, actualText.trim(), keyFile.x25519_priv, rpub,
); );
await sendEnvelope({ await sendEnvelope({
senderPub: keyFile.x25519_pub, senderPub: keyFile.x25519_pub,
recipientPub: contact.x25519Pub, recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key, senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext, nonce, ciphertext,
}); });
}));
} }
const msg: Message = { const msg: Message = {

View File

@@ -0,0 +1,490 @@
/**
* Devices screen — Settings → Linked devices.
*
* Multi-device registry (v2.2.0). Lists every X25519 device published
* on-chain under this identity's master Ed25519 key. Operators can:
* - see added-at timestamps
* - rename this device (local alias for now; rename via LINK_DEVICE
* with same pub + new name is a v2.3 polish)
* - revoke a remote device via UNLINK_DEVICE (requires fee)
* - pair a new device (Phase 3 — separate modal, stub for now)
*
* This device is NEVER listed with an Unlink button — revoking yourself
* is a footgun (you'd wipe your own state on next launch). Export/import
* your key first, then revoke from the new device.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
TextInput, KeyboardAvoidingView, Platform, Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import {
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
sendEnvelope, humanizeTxError,
type DeviceInfo,
} from '@/lib/api';
import { encryptMessage } from '@/lib/crypto';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { safeBack } from '@/lib/utils';
function shortPub(p: string, n = 8): string {
if (!p) return '—';
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}${p.slice(-n)}`;
}
function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
const load = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const list = await fetchDevices(keyFile.pub_key);
setDevices(list);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [keyFile]);
useEffect(() => { load(false); }, [load]);
const onUnlink = useCallback((dev: DeviceInfo) => {
if (!keyFile) return;
Alert.alert(
'Unlink device?',
`"${dev.device_name}" will stop receiving messages sent to you. ` +
`This costs a small network fee. The revoked device wipes its ` +
`local state the next time it checks in.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unlink',
style: 'destructive',
onPress: async () => {
setUnlinking(dev.x25519_pub_key);
try {
const tx = buildUnlinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: dev.x25519_pub_key,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Optimistic — drop from local list immediately; next load
// reconciles. Chain tx takes ~1 block to commit.
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
} catch (e: any) {
Alert.alert('Unlink failed', humanizeTxError(e));
} finally {
setUnlinking(null);
}
},
},
],
);
}, [keyFile]);
const meX25519 = keyFile?.x25519_pub ?? '';
// Pairing modal state — filled by the operator reading values off the
// new device's /auth/pair screen.
const [pairOpen, setPairOpen] = useState(false);
const [pairCode, setPairCode] = useState('');
const [pairKey, setPairKey] = useState('');
const [pairName, setPairName] = useState('');
const [pairBusy, setPairBusy] = useState(false);
const submitPair = useCallback(async () => {
if (!keyFile) return;
const code = pairCode.replace(/\s+/g, '').trim();
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
if (!/^\d{6}$/.test(code)) {
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
return;
}
if (!/^[0-9a-f]{64}$/.test(key)) {
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
return;
}
const name = pairName.trim() || 'New device';
setPairBusy(true);
try {
// 1. LINK_DEVICE on-chain so senders learn the new pub.
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: key,
deviceName: name,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// 2. Ship the handshake payload to the new device. Encrypted for
// its x25519 pub so only it can read — master priv in plaintext
// would be catastrophic, E2E is the whole point.
const payload = JSON.stringify({
v: 1,
type: 'pair-handshake',
code,
master_pub: keyFile.pub_key,
master_priv: keyFile.priv_key,
master_x25519_pub: keyFile.x25519_pub,
});
const { nonce, ciphertext } = encryptMessage(
payload, keyFile.x25519_priv, key,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: key,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
// 3. Optimistic local insert so the row shows up without waiting
// for the next pull/refresh round-trip.
setDevices(prev => {
if (prev.some(d => d.x25519_pub_key === key)) return prev;
return [...prev, {
x25519_pub_key: key,
device_name: name,
added_at: Math.floor(Date.now() / 1000),
}];
});
setPairOpen(false);
setPairCode(''); setPairKey(''); setPairName('');
Alert.alert(
'Pairing sent',
'The new device should finish pairing in a few seconds.',
);
} catch (e: any) {
Alert.alert('Pairing failed', humanizeTxError(e));
} finally {
setPairBusy(false);
}
}, [keyFile, pairCode, pairKey, pairName]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Devices"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
Every linked device has its own encryption key. Messages sent to you
are delivered to all active devices.
</Text>
{loading ? (
<View style={{ paddingTop: 60, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : devices.length === 0 ? (
<View style={{
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
}}>
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
<Text style={{
color: '#ffffff', fontSize: 15, fontWeight: '700',
marginTop: 10,
}}>
No devices registered yet
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
marginTop: 6, lineHeight: 19,
}}>
This device auto-registers when the next network-fee is available.
Top up your balance and pull to refresh.
</Text>
</View>
) : (
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}>
{devices.map((d, i) => {
const isMe = d.x25519_pub_key === meX25519;
const busy = unlinking === d.x25519_pub_key;
return (
<View key={d.x25519_pub_key}>
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
}}>
<Ionicons
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
size={22}
color={isMe ? '#1d9bf0' : '#d0d0d0'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
numberOfLines={1}
>
{d.device_name || 'Unnamed device'}
</Text>
{isMe && (
<View style={{
paddingHorizontal: 6, paddingVertical: 1,
borderRadius: 6, backgroundColor: '#0d2540',
}}>
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
THIS DEVICE
</Text>
</View>
)}
</View>
<Text
style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 3,
}}
numberOfLines={1}
>
{shortPub(d.x25519_pub_key)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
Linked {formatDate(d.added_at)}
</Text>
</View>
{!isMe && (
<Pressable
onPress={() => onUnlink(d)}
disabled={busy}
style={({ pressed }) => ({
paddingHorizontal: 12, paddingVertical: 7,
borderRadius: 999,
borderWidth: 1, borderColor: '#3a2020',
backgroundColor: pressed ? '#2a1414' : 'transparent',
opacity: busy ? 0.5 : 1,
})}
>
{busy ? (
<ActivityIndicator size="small" color="#ff6b6b" />
) : (
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
Unlink
</Text>
)}
</Pressable>
)}
</View>
</View>
);
})}
</View>
)}
{/* Link new device — opens a modal with Code + DeviceKey inputs
that the operator transcribes from the new device's
/auth/pair screen. */}
<View style={{ marginTop: 18 }}>
<Pressable
onPress={() => setPairOpen(true)}
style={({ pressed }) => ({
paddingVertical: 13, paddingHorizontal: 16,
borderRadius: 14,
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', gap: 10,
})}
>
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link new device
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
Enter the 6-digit code from the new device
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
</Pressable>
</View>
</ScrollView>
{/* ── Pair new device modal ───────────────────────────────────── */}
<Modal
visible={pairOpen}
animationType="fade"
transparent
onRequestClose={() => !pairBusy && setPairOpen(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center', alignItems: 'center',
paddingHorizontal: 20,
}}
>
<View style={{
width: '100%', maxWidth: 420,
backgroundColor: '#0a0a0a',
borderRadius: 18,
borderWidth: 1, borderColor: '#1f1f1f',
padding: 20, gap: 12,
}}>
<View style={{
flexDirection: 'row', alignItems: 'center',
justifyContent: 'space-between', marginBottom: 4,
}}>
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
Link new device
</Text>
<Pressable
onPress={() => !pairBusy && setPairOpen(false)}
hitSlop={8}
>
<Ionicons name="close" size={22} color="#8b8b8b" />
</Pressable>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
the welcome screen, then transcribe the code + device key shown there.
</Text>
<PairInput
label="6-digit code"
value={pairCode}
onChangeText={setPairCode}
placeholder="000000"
keyboardType="number-pad"
maxLength={6}
monospace
/>
<PairInput
label="Device key"
value={pairKey}
onChangeText={setPairKey}
placeholder="64 hex chars"
autoCapitalize="none"
maxLength={64}
monospace
/>
<PairInput
label="Name for this device (optional)"
value={pairName}
onChangeText={setPairName}
placeholder="e.g. Alice's laptop"
maxLength={64}
/>
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
gap: 10, marginTop: 8,
}}>
<Pressable
onPress={() => setPairOpen(false)}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 16, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
opacity: pairBusy ? 0.5 : 1,
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
Cancel
</Text>
</Pressable>
<Pressable
onPress={submitPair}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
opacity: pairBusy ? 0.7 : 1,
minWidth: 90, alignItems: 'center',
})}
>
{pairBusy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link
</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
function PairInput({
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
maxLength, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'number-pad';
autoCapitalize?: 'none' | 'sentences';
maxLength?: number;
monospace?: boolean;
}) {
return (
<View>
<Text style={{
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
}}>
{label}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#3a3a3a"
keyboardType={keyboardType}
autoCapitalize={autoCapitalize ?? 'sentences'}
autoCorrect={false}
maxLength={maxLength}
style={{
color: '#ffffff', fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: '#1f1f1f',
}}
/>
</View>
);
}

View File

@@ -492,6 +492,18 @@ export default function SettingsScreen() {
/> />
</Card> </Card>
{/* ── Devices — multi-device registry (v2.2.0) ── */}
<SectionLabel>Devices</SectionLabel>
<Card>
<Row
icon="phone-portrait-outline"
label="Linked devices"
value="Manage the devices that receive your messages"
onPress={() => router.push('/(app)/devices' as never)}
first
/>
</Card>
{/* ── Account ── */} {/* ── Account ── */}
<SectionLabel>Account</SectionLabel> <SectionLabel>Account</SectionLabel>
<Card> <Card>

View File

@@ -0,0 +1,300 @@
/**
* Pair — secondary-device onboarding.
*
* Flow (new device, this screen):
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
* 2. Display { code, device x25519 pub }. User enters both on their
* primary device (Settings → Devices → Link new device).
* 3. Primary device:
* - submits LINK_DEVICE tx to publish our pub under its master,
* - sends a relay envelope to our x25519 pub, encrypted with its
* own x25519 priv, containing { code, master_pub, master_priv,
* master_x25519_pub }.
* 4. We poll /relay/inbox every few seconds; when a decryptable
* envelope arrives whose payload.code matches our displayed code,
* we treat it as the handshake, assemble a KeyFile, save it, and
* redirect into (app).
*
* Security notes:
* - Master Ed25519 priv travels only via this envelope, encrypted for
* this device's X25519 pub (which only this device holds the priv
* for). Exposure is limited to one successful decrypt; we DELETE
* the envelope from the mailbox immediately.
* - The 6-digit code defends against a confused primary device paired
* with a different victim, or a race with an attacker who guesses
* our X25519 pub. Envelope without matching code → ignored.
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
* and the relay node has its own TTL.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, Pressable, ActivityIndicator, ScrollView,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import nacl from 'tweetnacl';
import { useStore } from '@/lib/store';
import { bytesToHex, decryptMessage } from '@/lib/crypto';
import { fetchInbox } from '@/lib/api';
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types';
// Protocol constant — bump if payload shape changes.
const PAIR_ENVELOPE_VERSION = 1;
interface PairEnvelopePayload {
v: number;
type: 'pair-handshake';
code: string;
master_pub: string; // Ed25519 pub, hex
master_priv: string; // Ed25519 priv, hex
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
}
function randomCode(): string {
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
// rendezvous code gated by an out-of-band delivery channel (envelope
// targeted at a freshly-generated X25519 pub that only this device
// holds the priv for).
const n = Math.floor(Math.random() * 1_000_000);
return n.toString().padStart(6, '0');
}
export default function PairScreen() {
const insets = useSafeAreaInsets();
const setKeyFile = useStore(s => s.setKeyFile);
// One-shot keypair + code for this pairing session. Regenerate only on
// manual Retry (unmount+remount).
const session = useRef(genSession()).current;
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
const [err, setErr] = useState<string | null>(null);
const copyCode = useCallback(async () => {
await Clipboard.setStringAsync(session.code);
}, [session.code]);
const copyPub = useCallback(async () => {
await Clipboard.setStringAsync(session.x25519Pub);
}, [session.x25519Pub]);
// Poll mailbox until a matching handshake envelope arrives, or user
// backs out. Interval 2.5s — conservative on battery, fine for a
// flow the user is staring at for a minute.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
if (cancelled) return;
try {
const envs = await fetchInbox(session.x25519Pub);
for (const env of envs) {
// Decrypt each envelope with our session priv. We don't know
// the primary's x25519 pub up front — it's inside the envelope
// metadata. decryptMessage needs both pubs, so we pass the
// envelope's sender_pub directly.
const plain = decryptMessage(
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
);
if (!plain) continue;
let payload: PairEnvelopePayload;
try {
payload = JSON.parse(plain);
} catch { continue; }
if (
payload.v !== PAIR_ENVELOPE_VERSION ||
payload.type !== 'pair-handshake' ||
payload.code !== session.code ||
!payload.master_pub || !payload.master_priv
) continue;
// Success — materialise a KeyFile and redirect.
const kf: KeyFile = {
pub_key: payload.master_pub,
priv_key: payload.master_priv,
x25519_pub: session.x25519Pub,
x25519_priv: session.x25519Priv,
};
await saveKeyFile(kf);
// The root layout auto-links us on first boot if needed, but
// the primary device already submitted LINK_DEVICE for our
// pub as part of the pairing, so the registry is already
// correct. Mark ourselves registered so the revoke-detection
// branch doesn't spuriously wipe on next launch.
await markDeviceRegistered();
setKeyFile(kf);
// Envelope stays in mailbox until relay TTL eviction; the
// single-shot handshake is idempotent (saveKeyFile overwrites)
// and our session pub won't be polled again after redirect.
setStatus('success');
setTimeout(() => {
if (!cancelled) router.replace('/(app)/chats' as never);
}, 600);
return;
}
} catch {
/* transient — retry */
}
if (!cancelled) timer = setTimeout(tick, 2_500);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [session, setKeyFile]);
return (
<View style={{
flex: 1, backgroundColor: '#000000',
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 20),
}}>
<ScrollView
contentContainerStyle={{ padding: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Pressable
onPress={() => safeBack('/')}
hitSlop={8}
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
>
<Ionicons name="chevron-back" size={28} color="#ffffff" />
</Pressable>
<View style={{ alignItems: 'center', marginBottom: 28 }}>
<View style={{
width: 80, height: 80, borderRadius: 22,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="link" size={40} color="#1d9bf0" />
</View>
<Text style={{
color: '#ffffff', fontSize: 22, fontWeight: '800',
marginTop: 14, textAlign: 'center',
}}>
Pair with your other device
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
marginTop: 8, textAlign: 'center', maxWidth: 300,
}}>
On a device where you're already signed in,
open Settings → Devices → Link new device,
and enter these values.
</Text>
</View>
{/* Code */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 18, paddingHorizontal: 16,
marginBottom: 14, alignItems: 'center',
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
1. Code
</Text>
<Text style={{
color: '#ffffff', fontSize: 38, fontWeight: '800',
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
}}>
{session.code.slice(0, 3)} {session.code.slice(3)}
</Text>
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy code
</Text>
</Pressable>
</View>
{/* Device key */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 16, paddingHorizontal: 16,
marginBottom: 20,
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
2. Device key
</Text>
<Text
selectable
style={{
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
marginTop: 6, lineHeight: 17,
}}
>
{session.x25519Pub}
</Text>
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy key
</Text>
</Pressable>
</View>
{/* Status */}
<View style={{ alignItems: 'center', minHeight: 56 }}>
{status === 'waiting' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color="#1d9bf0" />
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
Waiting for your other device…
</Text>
</View>
)}
{status === 'success' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
Paired. Opening your chats…
</Text>
</View>
)}
{status === 'error' && (
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
{err ?? 'Something went wrong. Please retry.'}
</Text>
)}
</View>
</ScrollView>
</View>
);
}
// ─── session helper ──────────────────────────────────────────────────────
interface PairSession {
x25519Pub: string; // hex
x25519Priv: string; // hex
code: string;
}
function genSession(): PairSession {
const kp = nacl.box.keyPair();
return {
x25519Pub: bytesToHex(kp.publicKey),
x25519Priv: bytesToHex(kp.secretKey),
code: randomCode(),
};
}

View File

@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
{/* CTA — прижата к правому нижнему краю. */} {/* CTA — прижата к правому нижнему краю. */}
<View style={{ <View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10, flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
}}> }}>
<CTASecondary
label="Pair"
icon="link"
onPress={() => router.push('/(auth)/pair' as never)}
/>
<CTASecondary <CTASecondary
label="Import" label="Import"
onPress={() => router.push('/(auth)/import' as never)} onPress={() => router.push('/(auth)/import' as never)}

View File

@@ -365,6 +365,25 @@ export interface IdentityInfo {
x25519_pub: string; // hex Curve25519 key; empty string if not published x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string; nickname: string;
registered: boolean; registered: boolean;
/**
* Number of active (non-revoked) devices linked to this master identity
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
* a single X25519 via REGISTER_KEY — senders should fall back to
* `x25519_pub` above and skip the device fan-out path.
*/
device_count?: number;
}
/**
* One active device in an identity's multi-device registry. Returned by
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
* list to fan out one sealed envelope per X25519 pub so all of the
* recipient's devices receive the message.
*/
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number; // unix seconds
} }
/** /**
@@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
} }
} }
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
/**
* GET /api/devices/{master_pub} — all active (non-revoked) device records
* for the given master identity. Returns an empty array for a legacy
* identity (device_count == 0) or a network error — callers should treat
* both the same way and fall back to IdentityInfo.x25519_pub so the
* pre-v2.2.0 single-device path keeps working.
*/
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
/**
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
* Two paths, in priority order:
*
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
* (pre-v2.2.0 clients). Send to just that one.
*
* Returns an empty array only when the recipient has published nothing
* at all — caller must surface "no encryption key" to the user rather
* than drop the message on the floor.
*/
export async function resolveRecipientKeys(
recipientMasterPub: string,
): Promise<string[]> {
const devs = await fetchDevices(recipientMasterPub);
if (devs.length > 0) {
return devs.map(d => d.x25519_pub_key);
}
const identity = await getIdentity(recipientMasterPub);
if (identity?.x25519_pub) {
return [identity.x25519_pub];
}
return [];
}
// ─── Contract API ───────────────────────────────────────────────────────────── // ─── Contract API ─────────────────────────────────────────────────────────────
/** /**
@@ -712,6 +781,68 @@ export function buildTransferTx(params: {
}; };
} }
/**
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
* identity's device registry so senders can fan out envelopes across
* every active device. Signed by the master Ed25519 (= `from`).
*
* `deviceName` is a short human label shown in Settings → Devices
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
*/
export function buildLinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
deviceName: string;
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
x25519_pub_key: params.x25519Pub,
device_name: params.deviceName,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
* stop shipping envelopes to its X25519 pub. The revoked device itself,
* when it next comes online and sees its own pub in the revoked list,
* is expected to wipe local state (master priv + cached chats).
*/
export function buildUnlinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // pub to revoke
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = { x25519_pub_key: params.x25519Pub };
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/** /**
* CONTACT_REQUEST transaction. * CONTACT_REQUEST transaction.
* *

View File

@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts'; const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings'; const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats'; const CHATS_KEY = 'dchain_chats';
// Remembers (locally, per install) that this device's X25519 pub has been
// successfully linked on-chain at least once. Distinguishes "first boot,
// not registered yet" from "we were registered and then revoked by another
// device". The second case triggers self-wipe. Stored in AsyncStorage —
// if it's missing, we simply re-link.
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
/** Save the key file in secure storage (encrypted on device). */ /** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> { export async function saveKeyFile(kf: KeyFile): Promise<void> {
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
const trimmed = msgs.slice(-500); const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed)); await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
} }
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
/**
* isDeviceRegistered returns true if this device has ever successfully
* linked its X25519 pub on-chain under the current master identity.
* A true-then-absent transition (registered → not in chain's active list)
* is interpreted as a remote revoke and triggers self-wipe.
*/
export async function isDeviceRegistered(): Promise<boolean> {
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
}
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
observed in the registry on startup. */
export async function markDeviceRegistered(): Promise<void> {
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
}
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
explicit logout. */
export async function clearDeviceRegistered(): Promise<void> {
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
}
/**
* wipeAllLocalState zeroes out every on-device artifact tied to the
* current identity: secure-store key, settings, contacts, chats cache,
* registered-device marker. Safe to call multiple times.
*
* Called in two scenarios:
* 1. Explicit "Delete account" in Settings.
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
* in the active registry but we previously marked it registered,
* so another device issued UNLINK_DEVICE against us. We must not
* keep using the master priv any more — it still works at the
* crypto level, but the social contract is that we're revoked.
*/
export async function wipeAllLocalState(): Promise<void> {
// Secure store (key).
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
// AsyncStorage — enumerate our known keys. We don't clear() the whole
// store because a share-provider or other app shard could live there.
const ks = await AsyncStorage.getAllKeys();
const ours = ks.filter(k =>
k === CONTACTS_KEY ||
k === SETTINGS_KEY ||
k === DEVICE_REGISTERED_KEY ||
k.startsWith(`${CHATS_KEY}_`),
);
if (ours.length > 0) {
await AsyncStorage.multiRemove(ours);
}
}

9
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
dist-electron/
release/
*.log
.DS_Store
# electron-builder output
out/

76
desktop/README.md Normal file
View 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
View 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();
});

View 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);

View 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
View 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

File diff suppressed because it is too large Load Diff

45
desktop/package.json Normal file
View 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
View 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 />;
}

View 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
View 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
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);
}
}
}

47
desktop/src/lib/store.ts Normal file
View 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
View 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
View 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>,
);

View 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 />;
}

View 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 />;
}

View 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
/>
);
}

View 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
/>
);
}

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>
);
}

View 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 />;
}

View 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',
};

View 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>
);
}

View 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 },
};

View 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>
);
}

View 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
View 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
View 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
View 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,
},
});

View File

@@ -186,14 +186,22 @@ desktop/
└── package.json └── package.json
``` ```
### План работ (отдельно, после v2.2.0-alpha3) ### План работ
- [ ] Boilerplate: Electron + Vite + React + TS, frame-less window, 3-panel shell. - [x] **v2.2.0-alpha4** Boilerplate: Electron + Vite + React + TS,
- [ ] Сопрягание `lib/` из client-app (заменить expo-* на Electron-эквиваленты). frame-less window, 3-panel shell, nav + status bar, safeStorage
- [ ] Sections по порядку: Messages → Feed → Wallet → Contacts → Settings → Profile. for keyfile via IPC, Welcome + Create/Import auth flow, section
- [ ] Multi-device pairing flow (использует v2.2.0 registry). stubs that the rest of the alphas will fill in.
- [ ] Auto-update через electron-updater или через тот же `/api/update-check`. - [ ] **v2.2.0-alpha5** — Messages section (chat list + conversation)
- [ ] Packaging: `electron-builder``.dmg`, `.exe`, `.AppImage`, `.deb`. 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) ### Открытые вопросы (desktop)