Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3706e38 | ||
|
|
7e6fe2c2a0 | ||
|
|
481d4d2fa8 |
@@ -9,6 +9,13 @@
|
|||||||
dev vs. production rules cleanly. -->
|
dev vs. production rules cleanly. -->
|
||||||
<title>DChain</title>
|
<title>DChain</title>
|
||||||
<style>
|
<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; }
|
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
@@ -23,6 +30,11 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
-webkit-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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||||
import { setNodeUrl } from '@/lib/api';
|
import { setNodeUrl } from '@/lib/api';
|
||||||
|
import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap';
|
||||||
import { Shell } from '@/shell/Shell';
|
import { Shell } from '@/shell/Shell';
|
||||||
import { Welcome } from '@/auth/Welcome';
|
import { Welcome } from '@/auth/Welcome';
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ export function App(): React.ReactElement {
|
|||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const [bootError, setBootError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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 React, { useState } from 'react';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import {
|
import {
|
||||||
buildAcceptContactTx, buildBlockContactTx, submitTx, humanizeTxError,
|
buildAcceptContactTx, buildBlockContactTx, buildLinkDeviceTx,
|
||||||
|
submitTx, humanizeTxError,
|
||||||
} from '@/lib/tx';
|
} from '@/lib/tx';
|
||||||
import { upsertContact as persistContact } from '@/lib/storage';
|
import { upsertContact as persistContact, markDeviceRegistered, isDeviceRegistered } from '@/lib/storage';
|
||||||
import { getIdentity, type ContactRequestRaw } from '@/lib/api';
|
import { getIdentity, fetchDevices, type ContactRequestRaw } from '@/lib/api';
|
||||||
import { shortAddr } from '@/lib/crypto';
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
export function RequestsList({
|
export function RequestsList({
|
||||||
@@ -45,6 +46,8 @@ function RequestRow({
|
|||||||
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const upsertContact = useStore(s => s.upsertContact);
|
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 [busy, setBusy] = useState<'accept' | 'block' | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -65,6 +68,34 @@ function RequestRow({
|
|||||||
privKey: keyFile.priv_key,
|
privKey: keyFile.priv_key,
|
||||||
});
|
});
|
||||||
await submitTx(tx);
|
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 = {
|
const c = {
|
||||||
address: req.requester_pub,
|
address: req.requester_pub,
|
||||||
x25519Pub: identity?.x25519_pub ?? '',
|
x25519Pub: identity?.x25519_pub ?? '',
|
||||||
@@ -74,6 +105,10 @@ function RequestRow({
|
|||||||
};
|
};
|
||||||
upsertContact(c);
|
upsertContact(c);
|
||||||
persistContact(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 {
|
} else {
|
||||||
const tx = buildBlockContactTx({
|
const tx = buildBlockContactTx({
|
||||||
from: keyFile.pub_key,
|
from: keyFile.pub_key,
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
|||||||
import { appendMessage as persist } from '@/lib/storage';
|
import { appendMessage as persist } from '@/lib/storage';
|
||||||
import type { Message } from '@/lib/types';
|
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 {
|
export function Conversation({ address }: { address: string }): React.ReactElement {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
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 clearUnread = useStore(s => s.clearUnread);
|
||||||
const appendMsg = useStore(s => s.appendMessage);
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
@@ -57,7 +65,15 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
|||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
const pubs = await resolveRecipientKeys(address);
|
const pubs = await resolveRecipientKeys(address);
|
||||||
if (pubs.length === 0) {
|
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) => {
|
await Promise.all(pubs.map(async (rpub) => {
|
||||||
const { nonce, ciphertext } = encryptMessage(
|
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
|
||||||
? contact.alias
|
? contact.alias
|
||||||
: isSelf
|
: isSelf
|
||||||
? 'Saved Messages'
|
? 'Saved Messages'
|
||||||
: shortAddr(address, 8);
|
: shortAddr(address || '', 8)) || shortAddr(address || '', 8);
|
||||||
|
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<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,
|
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
{isSelf ? '★' : firstLetter}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
|
<div style={{
|
||||||
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
|
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
{shortAddr(address, 6)}
|
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>
|
</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 { NavBar } from './NavBar';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { UpdateBanner } from './UpdateBanner';
|
import { UpdateBanner } from './UpdateBanner';
|
||||||
|
import { PaneBoundary } from './PaneBoundary';
|
||||||
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||||
import { FeedList, FeedDetail } from '@/sections/feed';
|
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||||
import { WalletList, WalletDetail } from '@/sections/wallet';
|
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||||
@@ -51,10 +52,14 @@ export function Shell(): React.ReactElement {
|
|||||||
borderRight: '1px solid #1f1f1f',
|
borderRight: '1px solid #1f1f1f',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}>
|
}}>
|
||||||
|
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
|
||||||
<List />
|
<List />
|
||||||
|
</PaneBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
|
||||||
<Detail />
|
<Detail />
|
||||||
|
</PaneBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
|
|||||||
Reference in New Issue
Block a user