Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reticulum.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`):

- Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions.
- Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage.
- **`[whispers]`:** `/msg NICK text` opens the synthetic whispers room and pins the reply peer. Plain text in `[whispers]` sends a NOTICE to that peer (no `/msg` prefix). Inbound whispers update the reply target only when the user has not pinned a peer via `/msg` or a prior plain reply. With no target yet, send shows `rrc.whisperNoTarget`.
- **Per-peer DMs (`@<identity-hash>`):** `/msg NICK text` opens an IRC-style query tab for that peer. Wire delivery is a direct NOTICE with `K_DST` and no `K_ROOM` / room JOIN — this requires the hub to advertise **`CAP_DIRECT_NOTICE`** (`capabilities.direct_notice`). When the hub does not advertise that capability, `/msg` and plain replies in a DM tab show `rrc.directNoticeUnsupported` and do not send. Sidebar/header show the nick. Leave closes that DM locally; open DMs persist in localStorage until left. Legacy `[whispers]` inbox is migrated best-effort into per-peer rooms.
- Chat virtualization pins to the bottom while reading live traffic; **Jump to latest** appears when scrolled up; leaving/re-entering RRC restores the prior scroll pin when possible (`RrcChatView` + TanStack Virtual).
- Unintended link drops enter **reconnecting** (backoff 2–30 s), preserve desired rooms (including join keys), and rejoin after WELCOME. Explicit **Disconnect** / **Cancel** clears that hub (`will_reconnect: false`).
- **Involuntary PART:** hub/self `PARTED` while the room is still desired queues a silent re-JOIN; UI banner uses neutral `rrc.moderation.hubParted` (not kick/ban wording). Member-fanout `PARTED` (another peer left) updates the nicklist only — must not be treated as self-leave.
Expand Down
35 changes: 32 additions & 3 deletions src/main/index.ipc-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => {
const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'");
expect(handlerIdx).toBeGreaterThan(-1);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1200);
expect(handlerBody).toContain('meshtasticTcpSocket.destroy()');
// Null the active ref before destroy so the superseded close does not emit
// meshtastic:tcp-disconnected against a healthy replacement (#792).
expect(handlerBody).toMatch(
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/,
);
});

it('emits meshtastic:tcp-disconnected only for the active socket (PR #792)', () => {
Expand All @@ -138,6 +142,15 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => {
expect(guardIdx).toBeGreaterThan(-1);
expect(emitIdx).toBeGreaterThan(guardIdx);
});

it('nulls meshtasticTcpSocket before destroy on disconnect (PR #792)', () => {
const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-disconnect'");
expect(handlerIdx).toBeGreaterThan(-1);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400);
expect(handlerBody).toMatch(
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/,
);
});
});

// ─── meshcore:tcp-write byte element validation ──────────────────────
Expand Down Expand Up @@ -339,7 +352,7 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => {
it('normalizes bracketed IPv6 before net.Socket.connect', () => {
const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'");
expect(handlerIdx).toBeGreaterThan(-1);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 800);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400);
expect(handlerBody).toContain('formatHostForSocket(');
});

Expand All @@ -358,6 +371,22 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => {
expect(guardIdx).toBeGreaterThan(-1);
expect(emitIdx).toBeGreaterThan(guardIdx);
});

it('nulls meshcoreTcpSocket before destroy on connect-replace and disconnect (PR #792)', () => {
const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'");
expect(connectIdx).toBeGreaterThan(-1);
const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1200);
expect(connectBody).toMatch(
/const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/,
);

const disconnectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-disconnect'");
expect(disconnectIdx).toBeGreaterThan(-1);
const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 400);
expect(disconnectBody).toMatch(
/const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/,
);
});
});

// ─── meshtastic:tcp-connect hostname validation ──────────────────────
Expand All @@ -373,7 +402,7 @@ describe('meshtastic:tcp-connect hostname validation (source contract)', () => {
it('normalizes bracketed IPv6 before net.Socket.connect', () => {
const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'");
expect(handlerIdx).toBeGreaterThan(-1);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 800);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400);
expect(handlerBody).toContain('formatHostForSocket(');
});

Expand Down
22 changes: 16 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6093,17 +6093,20 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
return;
}
if (meshcoreTcpSocket) {
meshcoreTcpSocket.destroy();
// Null before destroy so the superseded socket's 'close' does not emit
// meshcore:tcp-disconnected (renderer reconnect is driven by that event — #792).
const prev = meshcoreTcpSocket;
meshcoreTcpSocket = null;
prev.destroy();
}
const socketHost = formatHostForSocket(host);
const socket = new net.Socket();
meshcoreTcpSocket = socket;
const connectTimeout = setTimeout(() => {
if (settled) return;
settled = true;
socket.destroy();
if (meshcoreTcpSocket === socket) meshcoreTcpSocket = null;
socket.destroy();
reject(new Error('meshcore:tcp-connect: connection timeout'));
}, MESHCORE_TCP_CONNECT_TIMEOUT_MS);
socket.connect(p, socketHost, () => {
Expand Down Expand Up @@ -6179,8 +6182,10 @@ ipcMain.handle('meshcore:tcp-disconnect', (event) => {
assertIpcSender(event, 'meshcore:tcp-disconnect');
if (meshcoreTcpSocket) {
console.debug('[IPC] meshcore:tcp-disconnect');
meshcoreTcpSocket.destroy();
// Null before destroy so this teardown close is not reported as a live link drop.
const prev = meshcoreTcpSocket;
meshcoreTcpSocket = null;
prev.destroy();
}
});

Expand All @@ -6206,17 +6211,20 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
return;
}
if (meshtasticTcpSocket) {
meshtasticTcpSocket.destroy();
// Null before destroy so the superseded socket's 'close' does not emit
// meshtastic:tcp-disconnected (renderer reconnect is driven by that event — #792).
const prev = meshtasticTcpSocket;
meshtasticTcpSocket = null;
prev.destroy();
}
const socketHost = formatHostForSocket(host);
const socket = new net.Socket();
meshtasticTcpSocket = socket;
const connectTimeout = setTimeout(() => {
if (settled) return;
settled = true;
socket.destroy();
if (meshtasticTcpSocket === socket) meshtasticTcpSocket = null;
socket.destroy();
reject(new Error('meshtastic:tcp-connect: connection timeout'));
}, MESHTASTIC_TCP_CONNECT_TIMEOUT_MS);
socket.connect(p, socketHost, () => {
Expand Down Expand Up @@ -6292,8 +6300,10 @@ ipcMain.handle('meshtastic:tcp-disconnect', (event) => {
assertIpcSender(event, 'meshtastic:tcp-disconnect');
if (meshtasticTcpSocket) {
console.debug('[IPC] meshtastic:tcp-disconnect');
meshtasticTcpSocket.destroy();
// Null before destroy so this teardown close is not reported as a live link drop.
const prev = meshtasticTcpSocket;
meshtasticTcpSocket = null;
prev.destroy();
}
});

Expand Down
20 changes: 20 additions & 0 deletions src/renderer/App.peer-detail-error-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Source contract: Reticulum peer-detail modal must be isolated so a React #185 /
* render failure cannot take down the App shell; resetKeys recover on peer switch.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

const TEST_DIR = import.meta.dirname ?? __dirname;
const SOURCE = readFileSync(join(TEST_DIR, 'App.tsx'), 'utf-8');

describe('App ReticulumPeerDetailModal ErrorBoundary (regression)', () => {
it('wraps ReticulumPeerDetailModal in ReticulumPeerDetailErrorBoundary with Suspense fallback', () => {
expect(SOURCE).toContain('ReticulumPeerDetailErrorBoundary');
expect(SOURCE).toMatch(
/hasReticulumPeerDetailModal && selectedPeerHash !== null && \(\s*<ReticulumPeerDetailErrorBoundary[\s\S]*?peerHash=\{selectedPeerHash\}[\s\S]*?suspenseFallback=\{<DialogLazyFallback \/>\}[\s\S]*?ReticulumPeerDetailModal/,
);
});
});
11 changes: 9 additions & 2 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { ProtocolSwitcher } from './components/ProtocolSwitcher';
import { RncpEnableRequestModal } from './components/remote/RncpEnableRequestModal';
import RemoteAdminErrorNotifier from './components/RemoteAdminErrorNotifier';
import { ReticulumVoiceOverlay } from './components/reticulum/ReticulumVoiceOverlay';
import { ReticulumPeerDetailErrorBoundary } from './components/ReticulumPeerDetailErrorBoundary';
import { ReticulumStackAutostartCoordinator } from './components/ReticulumStackAutostartCoordinator';
import Sidebar from './components/Sidebar';
import { LinkIcon } from './components/SignalBars';
Expand Down Expand Up @@ -4575,15 +4576,21 @@ function AppContent() {
)}

{capabilities.hasReticulumPeerDetailModal && selectedPeerHash !== null && (
<Suspense fallback={<DialogLazyFallback />}>
<ReticulumPeerDetailErrorBoundary
peerHash={selectedPeerHash}
onClose={() => {
setSelectedPeerHash(null);
}}
suspenseFallback={<DialogLazyFallback />}
>
<ReticulumPeerDetailModal
peerHash={selectedPeerHash}
onClose={() => {
setSelectedPeerHash(null);
}}
onSendMessage={handleMessageNode}
/>
</Suspense>
</ReticulumPeerDetailErrorBoundary>
)}
</>
);
Expand Down
45 changes: 42 additions & 3 deletions src/renderer/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@ import { Component } from 'react';
import { errLikeToLogString } from '../lib/errLikeToLogString';
import i18n from '../lib/i18n';

export interface ErrorBoundaryFallbackProps {
error: Error | null;
resetError: () => void;
}

interface Props {
children: ReactNode;
/** When any value changes after an error, clear the error state (re-mount children). */
resetKeys?: readonly unknown[];
/** Optional custom fallback; default is the centered Try again UI. */
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode;
/** Called when the default Try again button (or fallback `resetError`) recovers. */
onReset?: () => void;
}

interface State {
hasError: boolean;
error: Error | null;
}

function resetKeysChanged(
prev: readonly unknown[] | undefined,
next: readonly unknown[] | undefined,
): boolean {
if (prev === next) return false;
if (!prev || !next) return prev !== next;
if (prev.length !== next.length) return true;
for (let i = 0; i < prev.length; i++) {
if (!Object.is(prev[i], next[i])) return true;
}
return false;
}

export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
Expand All @@ -32,8 +56,25 @@ export default class ErrorBoundary extends Component<Props, State> {
);
}

componentDidUpdate(prevProps: Props): void {
if (this.state.hasError && resetKeysChanged(prevProps.resetKeys, this.props.resetKeys)) {
this.resetError();
}
}

private resetError = (): void => {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
};

render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback({
error: this.state.error,
resetError: this.resetError,
});
}
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<div className="text-xl font-semibold text-red-400">{i18n.t('errorBoundary.title')}</div>
Expand All @@ -47,9 +88,7 @@ export default class ErrorBoundary extends Component<Props, State> {
</div>
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null });
}}
onClick={this.resetError}
className="rounded-lg bg-gray-700 px-6 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-600"
>
{i18n.t('errorBoundary.tryAgain')}
Expand Down
102 changes: 102 additions & 0 deletions src/renderer/components/ReticulumPeerDetailErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type ReactElement, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';

import { ReticulumPeerDetailErrorBoundary } from './ReticulumPeerDetailErrorBoundary';

vi.mock('@/renderer/lib/i18n', () => ({
default: {
t: (key: string) => key,
},
}));

function ThrowingPeerModal({ peerHash }: { peerHash: string }): ReactElement {
if (peerHash === 'boom') {
throw new Error('peer detail boom');
}
return <div>Peer {peerHash}</div>;
}

describe('ReticulumPeerDetailErrorBoundary', () => {
it('shows fallback on throw, Close clears selection, and another peer can open', async () => {
const user = userEvent.setup();
vi.spyOn(console, 'error').mockImplementation(() => {});

function Host() {
const [selectedPeerHash, setSelectedPeerHash] = useState<string | null>('boom');
if (selectedPeerHash === null) {
return <div>No peer selected</div>;
}
return (
<ReticulumPeerDetailErrorBoundary
peerHash={selectedPeerHash}
onClose={() => {
setSelectedPeerHash(null);
}}
suspenseFallback={<div>Loading…</div>}
>
<ThrowingPeerModal peerHash={selectedPeerHash} />
</ReticulumPeerDetailErrorBoundary>
);
}

const { rerender } = render(<Host />);
expect(screen.getByRole('alert')).toHaveTextContent('peer detail boom');

await user.click(screen.getByRole('button', { name: 'aria.closeDialog' }));
expect(screen.getByText('No peer selected')).toBeInTheDocument();

// Remount host with a healthy peer after close.
function HealthyHost() {
return (
<ReticulumPeerDetailErrorBoundary
peerHash="safepeer"
onClose={() => {}}
suspenseFallback={<div>Loading…</div>}
>
<ThrowingPeerModal peerHash="safepeer" />
</ReticulumPeerDetailErrorBoundary>
);
}
rerender(<HealthyHost />);
await waitFor(() => {
expect(screen.getByText('Peer safepeer')).toBeInTheDocument();
});
});

it('resets error state when peerHash changes (resetKeys)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
function Switchable() {
const [hash, setHash] = useState('boom');
return (
<div>
<button
type="button"
aria-label="Switch peer"
onClick={() => {
setHash('safepeer');
}}
>
Switch
</button>
<ReticulumPeerDetailErrorBoundary
peerHash={hash}
onClose={() => {}}
suspenseFallback={<div>Loading…</div>}
>
<ThrowingPeerModal peerHash={hash} />
</ReticulumPeerDetailErrorBoundary>
</div>
);
}
const user = userEvent.setup();
render(<Switchable />);
expect(screen.getByRole('alert')).toHaveTextContent('peer detail boom');
await user.click(screen.getByRole('button', { name: 'Switch peer' }));
await waitFor(() => {
expect(screen.getByText('Peer safepeer')).toBeInTheDocument();
});
});
});
Loading