Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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: direct NOTICE + `K_DST`, no room JOIN). Plain text in that tab replies to the same peer. 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: 11 additions & 9 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4575,15 +4575,17 @@ function AppContent() {
)}

{capabilities.hasReticulumPeerDetailModal && selectedPeerHash !== null && (
<Suspense fallback={<DialogLazyFallback />}>
<ReticulumPeerDetailModal
peerHash={selectedPeerHash}
onClose={() => {
setSelectedPeerHash(null);
}}
onSendMessage={handleMessageNode}
/>
</Suspense>
<ErrorBoundary>
<Suspense fallback={<DialogLazyFallback />}>
<ReticulumPeerDetailModal
peerHash={selectedPeerHash}
onClose={() => {
setSelectedPeerHash(null);
}}
onSendMessage={handleMessageNode}
/>
</Suspense>
</ErrorBoundary>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
)}
</>
);
Expand Down
130 changes: 130 additions & 0 deletions src/renderer/components/ReticulumPeerDetailModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,133 @@ describe('ReticulumPeerDetailModal — Network route hydrate', () => {
});
});
});

/** Contact/history + live path-table route mismatch — bare getPeer selectors throw React #185. */
function seedContactLiveRouteMismatch(hash: string): void {
useReticulumPeerStore.setState({
peers: new Map([
[
hash,
{
destination_hash: hash,
hops: 2,
interface: 'RMAP World',
path_hash: 'bb'.repeat(16),
via_hash: 'bb'.repeat(16),
last_seen: 1_700_000_000,
},
],
]),
contacts: new Map([
[
hash,
{
destination_hash: hash,
display_name: 'Saved Contact',
last_heard: 100,
is_contact: true,
hops: null,
interface: null,
},
],
]),
history: new Map(),
peerAppearanceByHash: new Map(),
lastRefreshAt: null,
});
}

function seedHistoryLiveRouteMismatch(hash: string): void {
useReticulumPeerStore.setState({
peers: new Map([
[
hash,
{
destination_hash: hash,
display_name: 'History Peer',
hops: 3,
interface: 'TCP Hub',
path_hash: 'cc'.repeat(16),
via_hash: 'cc'.repeat(16),
last_seen: 1_700_000_100,
},
],
]),
contacts: new Map(),
history: new Map([
[
hash,
{
destination_hash: hash,
display_name: 'History Peer',
last_heard: 200,
is_contact: false,
hops: null,
interface: null,
},
],
]),
peerAppearanceByHash: new Map(),
lastRefreshAt: null,
});
}

describe('ReticulumPeerDetailModal — getPeer selector stability (React #185)', () => {
beforeEach(() => {
addToast.mockClear();
refreshReticulumPeerRouteFromPathsMock.mockClear();
refreshReticulumPeerRouteFromPathsMock.mockResolvedValue({ ok: false, paths: [] });
refreshReticulumPeersFromSidecarMock.mockClear();
refreshReticulumPeersFromSidecarMock.mockResolvedValue(undefined);
requestReticulumPeerPathMock.mockReset();
probeReticulumPeerMock.mockReset();
vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]);
vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]);
vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined);
});

it('mounts when contact and live path-table route fields differ', async () => {
seedContactLiveRouteMismatch(PEER_HASH);
expect(useReticulumPeerStore.getState().getPeer(PEER_HASH)).not.toBe(
useReticulumPeerStore.getState().getPeer(PEER_HASH),
);

render(
<ReticulumPeerDetailModal peerHash={PEER_HASH} onClose={vi.fn()} onSendMessage={vi.fn()} />,
);

expect(
await screen.findByRole('button', { name: 'peerDetailModal.copyHash' }),
).toBeInTheDocument();
expect(screen.getByText('Saved Contact')).toBeInTheDocument();
});

it('Save as contact keeps the modal mounted when refresh leaves a route mismatch', async () => {
const user = userEvent.setup();
seedHistoryLiveRouteMismatch(PEER_HASH);

refreshReticulumPeersFromSidecarMock.mockImplementation(() => {
seedContactLiveRouteMismatch(PEER_HASH);
return Promise.resolve(undefined);
});

render(
<ReticulumPeerDetailModal peerHash={PEER_HASH} onClose={vi.fn()} onSendMessage={vi.fn()} />,
);

expect(screen.getByRole('button', { name: 'peerDetailModal.saveContact' })).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'peerDetailModal.saveContact' }));

await waitFor(() => {
expect(refreshReticulumPeersFromSidecarMock).toHaveBeenCalled();
});

// After refresh the peer is a contact — Save is gone, but the modal must still be up.
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'peerDetailModal.saveContact' })).toBeNull();
});
expect(screen.getByRole('button', { name: 'peerDetailModal.copyHash' })).toBeInTheDocument();
expect(screen.getByText('Saved Contact')).toBeInTheDocument();
});
});
3 changes: 2 additions & 1 deletion src/renderer/components/ReticulumPeerDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Copy, MessageCircle, Star, X } from 'lucide-react-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useReticulumPeer } from '@/renderer/hooks/useReticulumPeer';
import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString';
import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate';
import { getIdentityIdForProtocol } from '@/renderer/lib/identityByProtocol';
Expand Down Expand Up @@ -61,7 +62,7 @@ export default function ReticulumPeerDetailModal({
const { t } = useTranslation();
const { addToast } = useToast();
const dialogRef = useRef<HTMLDivElement>(null);
const peer = useReticulumPeerStore((s) => s.getPeer(peerHash));
const peer = useReticulumPeer(peerHash);
const isContact = useReticulumPeerStore((s) => s.isContact(peerHash));
const toggleFavorite = useReticulumPeerStore((s) => s.toggleFavorite);
const setCustomDisplayName = useReticulumPeerStore((s) => s.setCustomDisplayName);
Expand Down
Loading
Loading