3 Commits
v2.2.0 ... main

Author SHA1 Message Date
vsecoder
82d3706e38 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.
2026-04-22 19:13:29 +03:00
vsecoder
7e6fe2c2a0 fix(desktop): infinite render loop opening a chat (Maximum update depth)
The Conversation selector `useStore(s => s.messages[address] ?? [])`
allocates a fresh empty array on every call when the chat has no cached
messages. Zustand compares selector results with `===`, so the new []
is different from the previous [], marking the slice as "changed",
which re-renders Conversation, which calls the selector again, which
produces another new []... "Maximum update depth exceeded" inside
seconds.

Fix: module-level `const EMPTY_MESSAGES: Message[] = []` returned as the
fallback. Same object reference every render, zustand's === bails
early, no re-render.

This crash only showed up after opening a chat whose messages hadn't
been cached yet — picking any entry in ChatList that hadn't received
an envelope would hang the renderer. PaneBoundary (added in the prior
commit) now catches it visibly instead of blacking out the whole
window, but we still want the real fix.
2026-04-22 18:58:57 +03:00
vsecoder
481d4d2fa8 fix(desktop): global box-sizing + per-pane error boundary + Conversation defensives
Two bugs reported after v2.2.0:

1. Input fields and textareas overflowed their container — typing in
   Settings / SendModal / NewContactModal would push the border past
   the card edge because the renderer's default box-sizing was
   content-box and `width: 100%` + padding pushed widths past parents.
   Added `*, *::before, *::after { box-sizing: border-box; }` to
   index.html. Removes the need for per-element `boxSizing: 'border-box'`
   (the existing sprinkles stay for clarity but are now redundant).

2. App went blank when opening a chat — any throw inside Conversation
   propagated up through Shell and wiped the whole window, with no way
   to navigate out. Added PaneBoundary, a React error boundary scoped
   to one Shell pane, keyed on `${section}-(list|detail)` so it resets
   when the user switches section. Now a crash shows an inline error
   card with message + stack + Retry, while NavBar + StatusBar stay
   usable.

   Also hardened Conversation against edge cases that were candidates
   for the original crash:
     * `name` always falls back to shortAddr(address) if all other
       branches produce an empty string.
     * first letter used for the avatar is computed once, guarded
       against empty input with a `?` fallback.
     * Header name + short-address line get whiteSpace/overflow/ellipsis
       so very long contacts no longer escape the 32px wide sub-column
       the way they did for the reporter.

Fonts normalised in the global CSS too — inputs/textareas/buttons now
inherit `font-family` instead of the browser default, which was
breaking the visual rhythm in the Settings cards.
2026-04-22 18:53:37 +03:00
7 changed files with 250 additions and 15 deletions

View File

@@ -9,6 +9,13 @@
dev vs. production rules cleanly. -->
<title>DChain</title>
<style>
/* Global box-sizing — every padding+border counts toward the declared
width, not on top of it. Without this, `<input style="width:100%">`
inside a padded flex container visibly overflows its parent on
every modal / Settings card. Applied universally because almost
every per-element override was forgetting it anyway. */
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -23,6 +30,11 @@
user-select: text;
-webkit-user-select: text;
}
/* Form elements should never paint their own background/border over
our dark theme. Each component still sets its own explicit colours. */
input, textarea, button {
font-family: inherit;
}
</style>
</head>
<body>

View File

@@ -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 {

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

View File

@@ -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<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,

View File

@@ -16,10 +16,18 @@ import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
import { appendMessage as persist } from '@/lib/storage';
import type { Message } from '@/lib/types';
// A module-level stable reference for "no messages yet". Without this the
// selector `s.messages[address] ?? []` allocates a fresh empty array on
// every render when the conversation has no cached entries, which zustand
// sees as a changed value → triggers another render → new empty array
// again → "Maximum update depth exceeded". Returning the exact same
// reference every time breaks the cycle.
const EMPTY_MESSAGES: Message[] = [];
export function Conversation({ address }: { address: string }): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const contact = useStore(s => s.contacts.find(c => c.address === address));
const messages = useStore(s => s.messages[address] ?? []);
const messages = useStore(s => s.messages[address] ?? EMPTY_MESSAGES);
const clearUnread = useStore(s => s.clearUnread);
const appendMsg = useStore(s => s.appendMessage);
@@ -57,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(
@@ -98,12 +114,13 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
}
};
const name = contact?.username ? `@${contact.username}`
const name = (contact?.username ? `@${contact.username}`
: contact?.alias
? contact.alias
: isSelf
? 'Saved Messages'
: shortAddr(address, 8);
: shortAddr(address || '', 8)) || shortAddr(address || '', 8);
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -118,12 +135,18 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
color: '#fff', fontWeight: 700, fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
{isSelf ? '★' : firstLetter}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
{shortAddr(address, 6)}
<div style={{
color: '#fff', fontSize: 14, fontWeight: 700,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</div>
<div style={{
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{shortAddr(address || '', 6)}
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
// PaneBoundary — ErrorBoundary scoped to one Shell pane. A crash in
// the Conversation component shouldn't black-out the whole window; it
// should leave NavBar + List + StatusBar usable so the operator can
// switch sections and report the bug. Resets when the keyed section
// changes.
import React from 'react';
interface Props {
/** Used as React key at the callsite; also shown in the panic copy. */
sectionName: string;
children: React.ReactNode;
}
interface State {
error: Error | null;
}
export class PaneBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error(`[PaneBoundary:${this.props.sectionName}]`, error, info);
}
render(): React.ReactNode {
if (!this.state.error) return this.props.children;
return (
<div style={{
padding: 20, height: '100%', overflow: 'auto',
background: '#000', color: '#fff', fontFamily: 'monospace',
}}>
<div style={{
color: '#ff6b6b', fontSize: 14, fontWeight: 700, marginBottom: 8,
}}>
{this.props.sectionName} crashed
</div>
<div style={{ color: '#fff', fontSize: 13, marginBottom: 8 }}>
{this.state.error.message}
</div>
<pre style={{
color: '#8b8b8b', fontSize: 11, lineHeight: 1.4,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{this.state.error.stack}
</pre>
<button
onClick={() => this.setState({ error: null })}
style={{
marginTop: 10, padding: '6px 12px', borderRadius: 999,
border: '1px solid #1f1f1f', background: '#111',
color: '#fff', fontSize: 12, cursor: 'pointer',
}}
>Retry</button>
</div>
);
}
}

View File

@@ -24,6 +24,7 @@ import { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
import { UpdateBanner } from './UpdateBanner';
import { PaneBoundary } from './PaneBoundary';
import { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
@@ -51,10 +52,14 @@ export function Shell(): React.ReactElement {
borderRight: '1px solid #1f1f1f',
overflowY: 'auto',
}}>
<List />
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
<List />
</PaneBoundary>
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Detail />
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
<Detail />
</PaneBoundary>
</div>
</div>
<UpdateBanner />