Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3706e38 | ||
|
|
7e6fe2c2a0 | ||
|
|
481d4d2fa8 |
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
desktop/src/shell/PaneBoundary.tsx
Normal file
62
desktop/src/shell/PaneBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}}>
|
||||
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
|
||||
<List />
|
||||
</PaneBoundary>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
|
||||
<Detail />
|
||||
</PaneBoundary>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner />
|
||||
|
||||
Reference in New Issue
Block a user