diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index dd16548..f836250 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'; import { useStore } from '@/lib/store'; import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage'; import { setNodeUrl } from '@/lib/api'; +import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap'; import { Shell } from '@/shell/Shell'; import { Welcome } from '@/auth/Welcome'; @@ -16,6 +17,11 @@ export function App(): React.ReactElement { const keyFile = useStore(s => s.keyFile); const [bootError, setBootError] = useState(null); + // Multi-device registry bootstrap — publishes THIS device on the + // chain so senders can fan out envelopes to us, and self-wipes if + // another device has since revoked us. See hooks/useDeviceBootstrap. + useDeviceBootstrap(); + useEffect(() => { (async () => { try { diff --git a/desktop/src/hooks/useDeviceBootstrap.ts b/desktop/src/hooks/useDeviceBootstrap.ts new file mode 100644 index 0000000..fbdfac7 --- /dev/null +++ b/desktop/src/hooks/useDeviceBootstrap.ts @@ -0,0 +1,92 @@ +// Mirror of mobile's _layout.tsx bootstrap effect (v2.2.0-alpha2): +// ensures this device is visible to senders via the on-chain device +// registry, and detects remote revoke so a revoked laptop wipes its +// state the moment it sees it's no longer active. +// +// Three branches by (chain list × local "was registered" flag): +// +// 1. Our X25519 pub IS in the active list — flip the local marker +// (idempotent), done. Next sign-in is a no-op. +// +// 2. Our X25519 pub is NOT in the active list, but we had marked +// ourselves registered before → another device issued +// UNLINK_DEVICE against us. Wipe master priv + local caches and +// bounce back to the Welcome screen. Not fatal: user can import +// the key again if that was a mistake. +// +// 3. Our X25519 pub is NOT in the active list, and we've never +// registered before → first sign-in. Submit LINK_DEVICE. On +// a zero-balance wallet the tx bounces; next launch retries. +// No user-facing error — this is best-effort plumbing. +// +// Network errors never trigger the wipe path: we only act when the +// chain explicitly reports the absence. + +import { useEffect } from 'react'; +import { useStore } from '@/lib/store'; +import { fetchDevices } from '@/lib/api'; +import { buildLinkDeviceTx, submitTx } from '@/lib/tx'; +import { + isDeviceRegistered, markDeviceRegistered, wipeAllLocalState, +} from '@/lib/storage'; + +export function useDeviceBootstrap(): void { + const keyFile = useStore(s => s.keyFile); + + useEffect(() => { + if (!keyFile) return; + let cancelled = false; + + (async () => { + let chainList; + try { + chainList = await fetchDevices(keyFile.pub_key); + } catch { + // Network issue — leave state alone; try again next sign-in. + return; + } + if (cancelled) return; + + const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub); + const previouslyRegistered = isDeviceRegistered(); + + if (inActive) { + if (!previouslyRegistered) markDeviceRegistered(); + return; + } + + if (previouslyRegistered) { + // Revoked from another device. Wipe and send the user back to + // onboarding; the App-level render branch will route to Welcome + // as soon as keyFile flips to null. + await wipeAllLocalState(); + useStore.getState().setKeyFile(null); + return; + } + + // First boot — publish this device. Desktop is almost always a + // "second" device (paired from a phone), so a balance is normally + // available; we just need to ship the tx. Failures (insufficient + // balance, a node that doesn't grok v2.2.0) are swallowed. + try { + const platform = await window.dchain.app.platform().catch(() => 'unknown'); + const deviceName = platform === 'darwin' ? 'Mac' + : platform === 'win32' ? 'Windows' + : platform === 'linux' ? 'Linux' + : 'Desktop'; + const tx = buildLinkDeviceTx({ + from: keyFile.pub_key, + x25519Pub: keyFile.x25519_pub, + deviceName, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + markDeviceRegistered(); + } catch { + /* next launch retries */ + } + })(); + + return () => { cancelled = true; }; + }, [keyFile]); +} diff --git a/desktop/src/sections/contacts/RequestsList.tsx b/desktop/src/sections/contacts/RequestsList.tsx index 8d23eb4..4265425 100644 --- a/desktop/src/sections/contacts/RequestsList.tsx +++ b/desktop/src/sections/contacts/RequestsList.tsx @@ -9,10 +9,11 @@ import React, { useState } from 'react'; import { useStore } from '@/lib/store'; import { - buildAcceptContactTx, buildBlockContactTx, submitTx, humanizeTxError, + buildAcceptContactTx, buildBlockContactTx, buildLinkDeviceTx, + submitTx, humanizeTxError, } from '@/lib/tx'; -import { upsertContact as persistContact } from '@/lib/storage'; -import { getIdentity, type ContactRequestRaw } from '@/lib/api'; +import { upsertContact as persistContact, markDeviceRegistered, isDeviceRegistered } from '@/lib/storage'; +import { getIdentity, fetchDevices, type ContactRequestRaw } from '@/lib/api'; import { shortAddr } from '@/lib/crypto'; export function RequestsList({ @@ -43,8 +44,10 @@ export function RequestsList({ function RequestRow({ req, onChanged, }: { req: ContactRequestRaw; onChanged: () => void }) { - const keyFile = useStore(s => s.keyFile); - const upsertContact = useStore(s => s.upsertContact); + const keyFile = useStore(s => s.keyFile); + const upsertContact = useStore(s => s.upsertContact); + const setSection = useStore(s => s.setSection); + const setActiveChat = useStore(s => s.setActiveChat); const [busy, setBusy] = useState<'accept' | 'block' | null>(null); const [err, setErr] = useState(null); @@ -65,6 +68,34 @@ function RequestRow({ privKey: keyFile.priv_key, }); await submitTx(tx); + + // Make sure OUR device is published on-chain too. The + // useDeviceBootstrap effect tries this on sign-in, but if the + // user had zero balance then the tx bounced; now that the + // incoming CONTACT_REQUEST has paid us the contact fee, we + // have the µT needed. Without this, the peer couldn't encrypt + // to us — they'd see "recipient has no encryption key" even + // though we just accepted. + try { + const ownDevices = await fetchDevices(keyFile.pub_key); + const alreadyLinked = ownDevices.some(d => d.x25519_pub_key === keyFile.x25519_pub); + if (!alreadyLinked && !isDeviceRegistered()) { + const platform = await window.dchain.app.platform().catch(() => 'unknown'); + const deviceName = platform === 'darwin' ? 'Mac' + : platform === 'win32' ? 'Windows' + : platform === 'linux' ? 'Linux' + : 'Desktop'; + const linkTx = buildLinkDeviceTx({ + from: keyFile.pub_key, + x25519Pub: keyFile.x25519_pub, + deviceName, + privKey: keyFile.priv_key, + }); + await submitTx(linkTx); + markDeviceRegistered(); + } + } catch { /* best-effort — next sign-in retries */ } + const c = { address: req.requester_pub, x25519Pub: identity?.x25519_pub ?? '', @@ -74,6 +105,10 @@ function RequestRow({ }; upsertContact(c); persistContact(c); + // Jump the user straight into the new chat — mirrors mobile's + // router.replace(/chats/) after accept. + setActiveChat(req.requester_pub); + setSection('messages'); } else { const tx = buildBlockContactTx({ from: keyFile.pub_key, diff --git a/desktop/src/sections/messages/Conversation.tsx b/desktop/src/sections/messages/Conversation.tsx index 455dd38..54d0007 100644 --- a/desktop/src/sections/messages/Conversation.tsx +++ b/desktop/src/sections/messages/Conversation.tsx @@ -65,7 +65,15 @@ export function Conversation({ address }: { address: string }): React.ReactEleme if (!isSelf) { const pubs = await resolveRecipientKeys(address); if (pubs.length === 0) { - throw new Error('recipient has no encryption key published'); + // Most common cause: the peer's device hasn't published a + // LINK_DEVICE yet (they accepted just now and haven't had the + // fee debited, or they haven't re-opened the app). Clearer + // copy than "recipient has no encryption key". + throw new Error( + 'Recipient has no device key published on-chain yet. ' + + 'Ask them to re-open their app so the LINK_DEVICE tx commits, ' + + 'then try again.', + ); } await Promise.all(pubs.map(async (rpub) => { const { nonce, ciphertext } = encryptMessage(