Two coordinated changes:
1. Desktop client gets a functional Messages section and working pairing
flow, putting it at feature parity with mobile for the v2.2.0 line.
2. Server + both clients teach each other to use the sender's master
Ed25519 (not just their X25519) to address conversations, so a peer
writing from a different linked device still rolls into the same chat.
This is the "new API logic" the desktop scaffold was waiting on.
Server (node/api_relay.go, cmd/node/main.go):
* /relay/inbox items now carry `sender_ed25519_pub` alongside the
per-device `sender_pub`. Empty string for pre-v2.2.0 senders.
* WS `inbox` push summary also includes `sender_ed25519_pub`, so the
client can skip the refetch when the envelope plainly isn't for
the chat they're watching.
* Both existing tests pass.
Mobile client:
* lib/types.ts Envelope grew `sender_ed25519_pub`; fetchInbox normalises
it (default '') for older nodes.
* hooks/useGlobalInbox matches contacts by (master Ed25519 OR legacy
X25519) so an incoming message from a peer's desktop reuses the
existing chat instead of creating a duplicate placeholder.
* hooks/useMessages now takes an optional `contactMasterEd25519` and
exposes a matchesChat() predicate; WS inbox handler uses it too to
avoid spurious refetches.
* chats/[id].tsx passes `contact.address` (master) along with x25519.
Desktop client — all new:
* src/lib/crypto.ts — tweetnacl hex/base64 helpers, generateKeyFile,
encryptMessage/decryptMessage, signBase64, shortAddr. Same signatures
as the mobile lib; uses Chromium's window.crypto, no expo-crypto dep.
* src/lib/tx.ts — buildTransferTx / buildLinkDeviceTx / buildUnlinkDeviceTx
+ submitTx + humanizeTxError, canonical-bytes identical to mobile.
* src/lib/relay.ts — fetchInbox, sendEnvelope, resolveRecipientKeys
(multi-device fan-out with legacy identity.x25519 fallback).
* src/lib/store.ts — zustand state gets messages{}, unread{},
activeChat.
* src/lib/storage.ts — per-chat cache via localStorage (500-msg cap).
* src/hooks/useInboxPoll — 4s polling loop, addresses conversations
by master Ed25519, bumps unread unless chat is active.
* src/sections/messages/* — ChatList (sorted tiles, unread badges),
Conversation (auto-scroll messages + composer + fan-out send,
Enter-to-send / Shift+Enter for newline), EmptyConversation.
* src/auth/Pair.tsx — 6-digit code + device key screen, polls inbox
for a handshake envelope, assembles the KeyFile on arrival.
* Welcome.tsx: Pair button now actually routes to <Pair>; imports
generateKeyFile from lib/crypto (was inlined).
docs/ROADMAP.md delta: alpha5 row flipped to done inline. Alpha6
(feed + wallet) and rc1 (contacts + devices UI + profile) still
pending.
160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
// 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, Message } from './types';
|
|
import type { DChainAPI } from '../../electron/preload';
|
|
|
|
declare global {
|
|
interface Window {
|
|
dchain: DChainAPI;
|
|
}
|
|
}
|
|
|
|
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
|
|
//
|
|
// All keyfile operations go through window.dchain.keyfile — the preload
|
|
// script bridges them to Electron's safeStorage. If preload failed to
|
|
// load (dev misconfig, broken build), we surface a loud error rather
|
|
// than silently failing, since a missing keyfile layer means nothing
|
|
// else in the app can work.
|
|
|
|
function requireDchain() {
|
|
if (typeof window === 'undefined' || !window.dchain) {
|
|
throw new Error(
|
|
'window.dchain is not available — the Electron preload failed to ' +
|
|
'load. Check dist-electron/preload.js exists and that main.ts is ' +
|
|
'pointing at it.',
|
|
);
|
|
}
|
|
return window.dchain;
|
|
}
|
|
|
|
export async function loadKeyFile(): Promise<KeyFile | null> {
|
|
const raw = await requireDchain().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 requireDchain().keyfile.save(JSON.stringify(kf));
|
|
}
|
|
|
|
export async function deleteKeyFile(): Promise<void> {
|
|
await requireDchain().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);
|
|
}
|
|
|
|
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
|
|
|
|
const CHATS_PREFIX = 'dchain_chats_';
|
|
const CHAT_CAP = 500;
|
|
|
|
export function loadMessages(chatAddr: string): Message[] {
|
|
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
|
|
if (!raw) return [];
|
|
try {
|
|
return JSON.parse(raw) as Message[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
|
|
* in the UI should prefer zustand's store.appendMessage for reactivity
|
|
* and call this from effects to flush to disk.
|
|
*/
|
|
export function appendMessage(chatAddr: string, m: Message): void {
|
|
const cur = loadMessages(chatAddr);
|
|
if (cur.some(x => x.id === m.id)) return;
|
|
cur.push(m);
|
|
const trimmed = cur.slice(-CHAT_CAP);
|
|
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
|
|
}
|
|
|
|
// ─── 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);
|
|
}
|
|
}
|
|
}
|