fix(desktop): auto-link device on sign-in + publish key on accept
Two bugs reported by the user:
1. After accepting a contact request on the desktop, the requester's
"Send message" call errored with "no encryption key published" for
the newly-accepted contact. Root cause: desktop never ran the
device-registry bootstrap (mobile does it from _layout.tsx on
sign-in) — so the desktop's X25519 pub was never published via
LINK_DEVICE, and resolveRecipientKeys returned an empty list.
2. On the accepting device, the new chat didn't appear in Messages
after tapping Accept — accept wrote the contact to store + disk
but didn't switch sections, so the user was stuck in Contacts
watching nothing happen.
Fixes:
* hooks/useDeviceBootstrap — direct port of mobile's _layout.tsx
bootstrap effect. On every sign-in:
- fetchDevices(master) → if our X25519 is listed, mark local
registered flag.
- not listed + was registered before → REVOKED → wipe state +
bounce to Welcome.
- not listed + never registered → submit LINK_DEVICE. Tx may
bounce if balance is zero; next launch retries.
Mounted from App.tsx so it runs once per authenticated session.
* RequestsList.accept — after submitting ACCEPT_CONTACT, check if
OUR X25519 is in the on-chain registry. If not, submit LINK_DEVICE
immediately (balance is now covered by the contact fee the peer
paid us). This closes the window where the peer couldn't encrypt
to us because our key wasn't published yet.
Also: after a successful accept, setSection('messages') +
setActiveChat(requester_pub), matching mobile's
router.replace('/chats/<pub>') flow.
* Conversation.send — nicer error copy when
resolveRecipientKeys returns []. Was: "recipient has no
encryption key published". Now: actionable text asking the peer
to re-open their app so the LINK_DEVICE tx commits.
This commit is contained in:
@@ -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<string | null>(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 {
|
||||
|
||||
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -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({
|
||||
@@ -45,6 +46,8 @@ function RequestRow({
|
||||
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
||||
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<string | null>(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/<pub>) after accept.
|
||||
setActiveChat(req.requester_pub);
|
||||
setSection('messages');
|
||||
} else {
|
||||
const tx = buildBlockContactTx({
|
||||
from: keyFile.pub_key,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user