diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8b60ce399..5f5272981 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1421,31 +1421,9 @@ function AppContent() { [meshcoreUiNodes], ); const meshcorePublicKeyHexByNodeId = useMemo(() => { - const m = new Map(); - if (!meshcoreCapabilities.hasContactImportExport) return m; - const self = meshcoreRuntime.selfInfo; - if (self?.publicKey?.length === 32) { - m.set( - pubkeyToNodeId(self.publicKey), - Array.from(self.publicKey) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''), - ); - } - for (const c of meshcoreRuntime.meshcoreContactsForTelemetry) { - m.set( - pubkeyToNodeId(c.publicKey), - Array.from(c.publicKey) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''), - ); - } - return m; - }, [ - meshcoreCapabilities.hasContactImportExport, - meshcoreRuntime.selfInfo, - meshcoreRuntime.meshcoreContactsForTelemetry, - ]); + if (!meshcoreCapabilities.hasContactImportExport) return new Map(); + return meshcoreRuntime.meshcorePubKeyHexByNodeId; + }, [meshcoreCapabilities.hasContactImportExport, meshcoreRuntime.meshcorePubKeyHexByNodeId]); const capabilities = activeProtocolCapabilities; const nodeCountLabel = capabilities.nodeListTabUsesContactsLabel diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 8e5a4864f..d2f8b64ad 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -25,6 +25,10 @@ vi.mock('../lib/downloadBlob', () => ({ downloadBlob: vi.fn(), })); +vi.mock('@/renderer/lib/writeClipboardText', () => ({ + writeClipboardText: vi.fn().mockResolvedValue(undefined), +})); + const mockNode: MeshNode = { node_id: 0xdeadbeef, short_name: 'TEST', @@ -304,6 +308,67 @@ describe('NodeDetailModal MeshCore actions', () => { expect(screen.getByRole('button', { name: '📊 Request Status' })).toBeDisabled(); }); + it('renders the full public key with a copy button and copies it on click', async () => { + const { writeClipboardText } = await import('@/renderer/lib/writeClipboardText'); + const pubkeyHex = 'ab'.repeat(32); + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: pubkeyHex, + on_radio: 1, + } as unknown as Awaited>); + const user = userEvent.setup(); + const { container } = renderMeshcoreModal(); + + const pubkeyEl = await screen.findByText(pubkeyHex); + expect(pubkeyEl).toBeInTheDocument(); + + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + + const copyButton = screen.getByRole('button', { name: 'Copy public key' }); + await user.click(copyButton); + expect(writeClipboardText).toHaveBeenCalledWith(pubkeyHex); + expect(await screen.findByText('Public key copied to clipboard.')).toBeInTheDocument(); + }); + + it.each(['Chat', 'Sensor'])( + 'shows a DM-capable key badge for a MeshCore %s contact with a public key', + async (hwModel) => { + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: 'ab'.repeat(32), + on_radio: 1, + } as unknown as Awaited>); + const { container } = renderMeshcoreModal({ + node: { ...meshcoreRepeaterNode, hw_model: hwModel }, + }); + + const badge = await screen.findByTitle('Has public key - can send DMs'); + expect(badge).toHaveTextContent('🔑 DM'); + expect(screen.queryByTitle('Has public key (no direct messages)')).not.toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }, + ); + + it.each(['Repeater', 'Room'])( + 'shows a key-only badge (no DM) for a MeshCore %s contact with a public key', + async (hwModel) => { + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: 'ab'.repeat(32), + on_radio: 1, + } as unknown as Awaited>); + const { container } = renderMeshcoreModal({ + node: { ...meshcoreRepeaterNode, hw_model: hwModel }, + }); + + const badge = await screen.findByTitle('Has public key (no direct messages)'); + expect(badge).toHaveTextContent('🔑'); + expect(badge).not.toHaveTextContent('DM'); + expect(screen.queryByTitle('Has public key - can send DMs')).not.toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }, + ); + it('enables Message when live store has pubkey but DB contact row does not', async () => { const chatNode: MeshNode = { ...meshcoreRepeaterNode, hw_model: 'Chat' }; const pubKey = new Uint8Array(32).fill(0xab); diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 3738dd9c9..fc946e64e 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -12,6 +12,7 @@ import { normalizeMeshtasticAdminKeyInput, } from '@/renderer/lib/meshtasticRemoteAdminKeyStorage'; import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolIdentities'; +import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { buildMeshcoreContactAddUri, type MeshcoreContactType } from '@/shared/meshClientDeepLink'; import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; @@ -47,6 +48,7 @@ import { MESHCORE_CONTACTS_CRITICAL_THRESHOLD, MESHCORE_MAX_CONTACTS, meshcoreContactTypeFromHwModel, + meshcorePubkeyShortId, meshcoreTracePathLenToHops, } from '../lib/meshcoreUtils'; import { @@ -602,7 +604,10 @@ export default function NodeDetailModal({ if (!node) return null; - const hexId = formatMeshtasticNodeId(node.node_id); + const hexId = + protocol === 'meshcore' + ? (meshcorePubkeyShortId(contactPubkey) ?? formatMeshtasticNodeId(node.node_id)) + : formatMeshtasticNodeId(node.node_id); const awaitingNodeInfo = protocol === 'meshtastic' && meshtasticNodeAwaitingNodeInfo(node, { isConnected }); const displayName = node.short_name || node.long_name || hexId; @@ -724,9 +729,13 @@ export default function NodeDetailModal({ {protocol === 'meshcore' && contactPubkey && ( - 🔑 DM + {isMeshcoreDmExcludedHwModel(node.hw_model) ? '🔑' : '🔑 DM'} )} {protocol === 'meshcore' && @@ -778,6 +787,32 @@ export default function NodeDetailModal({ )} + {protocol === 'meshcore' && contactPubkey && ( +
+ + {contactPubkey} + + +
+ )}
diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index bce059f27..08892fad1 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -620,6 +620,96 @@ describe('NodeListPanel import contacts', () => { ); expect(screen.getByText(hex)).toBeInTheDocument(); }); + + it('drops the MeshCore ID column (no ID header, no !-prefixed id text)', () => { + const nodeId = 0xdeadbeef; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })], + ]); + render( + , + ); + expect(screen.queryByRole('columnheader', { name: /^ID$/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/^!/)).not.toBeInTheDocument(); + }); + + it.each(['Chat', 'Sensor', 'Repeater', 'Room'])( + 'shows the key icon for any MeshCore %s contact with a known public key', + async (hwModel) => { + const nodeId = 0xdeadbeef; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: hwModel })], + ]); + const { container } = render( + , + ); + expect(screen.getByLabelText('Has public key')).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }, + ); + + it('hides the key icon when the MeshCore contact has no known public key', () => { + const nodeId = 0xdeadbeef; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })], + ]); + render( + , + ); + expect(screen.queryByLabelText('Has public key')).not.toBeInTheDocument(); + }); + + it('labels the first column "Health" for both Meshtastic and MeshCore', () => { + const meshtastic = render( + , + ); + expect(meshtastic.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + meshtastic.unmount(); + + render( + , + ); + expect(screen.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + }); }); describe('NodeListPanel flood advert (MeshCore)', () => { diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index ae60be6e8..3595f8ccb 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -1012,25 +1012,27 @@ export default function NodeListPanel({ {t('nodeListPanel.tableCaptionMeshNodes')} - - {t('nodeListPanel.columnStatus')} + + {t('nodeListPanel.columnHealth')} {t('nodeListPanel.columnFavorite')} - { - handleSort('node_id'); - }} - > - {t('nodeListPanel.columnId')}{' '} - - + {mode !== 'meshcore' && ( + { + handleSort('node_id'); + }} + > + {t('nodeListPanel.columnId')}{' '} + + + )} )} - - {formatMeshtasticNodeId(node.node_id)} - {mode === 'meshcore' && meshcorePublicKeyHexByNodeId?.has(node.node_id) && ( - 🔑 - )} - + {mode !== 'meshcore' && ( + + {formatMeshtasticNodeId(node.node_id)} + + )} @@ -1433,6 +1434,17 @@ export default function NodeListPanel({ )} + {mode === 'meshcore' && + meshcorePublicKeyHexByNodeId?.has(node.node_id) && ( + + 🔑 + + )} {!isSelf && (() => { const routingRow = getRoutingRowForNode( diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.retryRemove.test.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.retryRemove.test.ts new file mode 100644 index 000000000..063e8e15e --- /dev/null +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.retryRemove.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MeshCoreContactRaw } from '../../lib/meshcore/meshcoreHookTypes'; +import { + markMeshcoreLocallyDeletedContact, + resetMeshcoreLocallyDeletedContactsForTests, +} from '../../lib/meshcoreLocallyDeletedContacts'; +import { pubkeyToNodeId } from '../../lib/meshcoreUtils'; +import { retryRadioRemoveDeletedContacts } from './meshcoreHookPreamble'; + +function contact(seed: number): MeshCoreContactRaw { + const publicKey = new Uint8Array(32); + for (let i = 0; i < publicKey.length; i++) publicKey[i] = (seed + i * 7) & 0xff; + return { + publicKey, + type: 1, + advName: `Node-${seed}`, + lastAdvert: 0, + advLat: 0, + advLon: 0, + flags: 0, + }; +} + +describe('retryRadioRemoveDeletedContacts', () => { + beforeEach(() => { + resetMeshcoreLocallyDeletedContactsForTests(); + }); + + it('drops a tombstoned contact when radio removal succeeds', async () => { + const kept = contact(0x11); + const deleted = contact(0x22); + markMeshcoreLocallyDeletedContact(pubkeyToNodeId(deleted.publicKey)); + const removeContact = vi.fn().mockResolvedValue(undefined); + + const result = await retryRadioRemoveDeletedContacts({ removeContact }, [kept, deleted]); + + expect(removeContact).toHaveBeenCalledTimes(1); + expect(removeContact).toHaveBeenCalledWith(deleted.publicKey); + expect(result).toEqual([kept]); + }); + + it('keeps a tombstoned contact when radio removal fails (radio stays authority)', async () => { + const deleted = contact(0x33); + markMeshcoreLocallyDeletedContact(pubkeyToNodeId(deleted.publicKey)); + const removeContact = vi.fn().mockRejectedValue(new Error('offline')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await retryRadioRemoveDeletedContacts({ removeContact }, [deleted]); + + expect(removeContact).toHaveBeenCalledTimes(1); + expect(result).toEqual([deleted]); + }); + + it('never calls removeContact for contacts that are not tombstoned', async () => { + const removeContact = vi.fn().mockResolvedValue(undefined); + const contacts = [contact(0x44), contact(0x55)]; + + const result = await retryRadioRemoveDeletedContacts({ removeContact }, contacts); + + expect(removeContact).not.toHaveBeenCalled(); + expect(result).toEqual(contacts); + }); +}); diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index 0476524a6..b78083641 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -2,7 +2,9 @@ import { sanitizeLogMessage } from '@/main/sanitize-log-message'; import { isValidLatLon } from '../../../shared/geoCoords'; import { meshcoreContactDisplayName } from '../../../shared/meshcoreContactSanitize'; +import { withTimeout } from '../../../shared/withTimeout'; import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../../lib/chatInMemoryBuffer'; +import { errLikeToLogString } from '../../lib/errLikeToLogString'; import type { MeshCoreConnection, MeshcoreContactDbRow, @@ -18,7 +20,10 @@ import { meshcorePayloadIsTapbackEmojiOnly, normalizeMeshcoreIncomingText, } from '../../lib/meshcoreChannelText'; -import { shouldApplyMeshcoreContact } from '../../lib/meshcoreLocallyDeletedContacts'; +import { + isMeshcoreLocallyDeletedContact, + shouldApplyMeshcoreContact, +} from '../../lib/meshcoreLocallyDeletedContacts'; import { CONTACT_TYPE_LABELS, isMeshcoreTransportStatusChatLine, @@ -173,6 +178,8 @@ export function messageToDbRow( export const MESHCORE_INIT_TIMEOUT_MS = 60_000; /** Companion Ok/Err for `sendFloodAdvert` — meshcore.js has no internal timeout. */ export const MESHCORE_SEND_FLOOD_ADVERT_TIMEOUT_MS = 25_000; +/** Companion Ok/Err for `removeContact` — meshcore.js has no internal timeout. */ +export const MESHCORE_REMOVE_CONTACT_TIMEOUT_MS = 25_000; /** Base wait for PathUpdated (129) after a flood advert when priming trace route. */ export const MESHCORE_TRACE_PRIME_WAIT_BASE_MS = 15_000; /** Per-hop add-on for {@link computeMeshcoreTracePrimeWaitMs}. */ @@ -1128,3 +1135,38 @@ export function mergeStubNodesFromMeshcoreMessages( } return next; } + +/** + * Prune contacts that the user deleted locally but the radio still holds: re-request + * `conn.removeContact(pubkey)` so an offline delete propagates on the next sync. + * Returns the contacts minus ids whose radio removal succeeded; failed removals are kept + * so the radio (authority) can revive them via the `fromRadio` apply path. + */ +export async function retryRadioRemoveDeletedContacts( + conn: Pick, + contacts: MeshCoreContactRaw[], +): Promise { + const kept: MeshCoreContactRaw[] = []; + for (const c of contacts) { + const id = pubkeyToNodeId(c.publicKey); + if (id !== 0 && isMeshcoreLocallyDeletedContact(id)) { + try { + await withTimeout( + conn.removeContact(c.publicKey), + MESHCORE_REMOVE_CONTACT_TIMEOUT_MS, + 'removeContact', + ); + console.debug( + `[meshcore] retry removeContact: dropped tombstoned contact 0x${id.toString(16)}`, + ); + continue; + } catch (e) { + console.warn( + '[meshcore] retry removeContact (tombstoned contact) failed ' + errLikeToLogString(e), + ); + } + } + kept.push(c); + } + return kept; +} diff --git a/src/renderer/lib/connection.serial-cleanup.test.ts b/src/renderer/lib/connection.serial-cleanup.test.ts index 95566c5d3..c459e9563 100644 --- a/src/renderer/lib/connection.serial-cleanup.test.ts +++ b/src/renderer/lib/connection.serial-cleanup.test.ts @@ -3,6 +3,11 @@ import { TransportWebSerial } from '@meshtastic/transport-web-serial'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { closeSerialPortIfOpen, reconnectSerial, safeDisconnect } from './connection'; +import { + MESHTASTIC_LATE_CONFIGURE_RETRYABLE_SWALLOW_MS, + resetMeshtasticLateConfigureRetryableSwallowForTests, + shouldSwallowLateMeshtasticConfigureRetryableRejection, +} from './meshtastic/meshtasticConfigureRetry'; import { SERIAL_OPEN_TIMEOUT_MS } from './serialPortRecovery'; vi.mock('@meshtastic/transport-web-serial', () => ({ @@ -166,6 +171,37 @@ describe('connection serial cleanup', () => { expect(device.complete).toHaveBeenCalledTimes(1); }); + it('safeDisconnect arms the late-configure swallow window before device.disconnect()', async () => { + vi.useFakeTimers(); + try { + resetMeshtasticLateConfigureRetryableSwallowForTests(); + const packetGoneError = new Error('Packet does not exist'); + // device.disconnect() clears the SDK queue, so its in-flight sends reject with this + // error; assert the swallow window is already armed when disconnect() is invoked. + const armedAtDisconnect = { value: false }; + const device = { + disconnect: vi.fn().mockImplementation(() => { + armedAtDisconnect.value = + shouldSwallowLateMeshtasticConfigureRetryableRejection(packetGoneError); + return Promise.resolve(); + }), + complete: vi.fn(), + transport: undefined, + } as unknown as MeshDevice; + + await safeDisconnect(device); + + expect(armedAtDisconnect.value).toBe(true); + expect(shouldSwallowLateMeshtasticConfigureRetryableRejection(packetGoneError)).toBe(true); + + vi.advanceTimersByTime(MESHTASTIC_LATE_CONFIGURE_RETRYABLE_SWALLOW_MS + 1); + expect(shouldSwallowLateMeshtasticConfigureRetryableRejection(packetGoneError)).toBe(false); + } finally { + resetMeshtasticLateConfigureRetryableSwallowForTests(); + vi.useRealTimers(); + } + }); + it('safeDisconnect treats undefined transport close as benign during disconnect', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const device = { diff --git a/src/renderer/lib/connection.ts b/src/renderer/lib/connection.ts index bb7ae212c..e06050609 100644 --- a/src/renderer/lib/connection.ts +++ b/src/renderer/lib/connection.ts @@ -14,6 +14,7 @@ import { logMeshtasticSerialStreamDiagnostics, } from './connectionWebStreams'; import { notifyNobleBlePrimaryRfLinkReady } from './meshcoreDualNobleBleInit'; +import { armMeshtasticLateConfigureRetryableSwallow } from './meshtastic/meshtasticConfigureRetry'; import { parseMeshtasticTcpAddress } from './parseMeshtasticTcpAddress'; import { isBlePeripheralConflictErrorMessage, @@ -570,6 +571,10 @@ async function closeMeshtasticTransportStreamsBestEffort( } export async function safeDisconnect(device: MeshDevice): Promise { + // Arm the swallow window before teardown: device.disconnect() clears the SDK queue, so any + // in-flight sendPacket().wait() then rejects with "Packet does not exist". These are expected + // teardown races and must not surface as unhandled-rejection error logs. + armMeshtasticLateConfigureRetryableSwallow(); try { await device.disconnect(); } catch (err) { diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts index b50bf40d9..e36db052b 100644 --- a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts @@ -1,4 +1,7 @@ -import { meshcoreContactRawFromDevice } from '../../hooks/meshcore/meshcoreHookPreamble'; +import { + meshcoreContactRawFromDevice, + retryRadioRemoveDeletedContacts, +} from '../../hooks/meshcore/meshcoreHookPreamble'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; import { errLikeToLogString } from '../errLikeToLogString'; import type { @@ -82,7 +85,10 @@ export async function rebuildMeshcoreContactsAfterPathUpdated( ): Promise { try { const contactsRaw = await deps.conn.getContacts(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + const contacts = await retryRadioRemoveDeletedContacts( + deps.conn, + contactsRaw.map(meshcoreContactRawFromDevice), + ); deps.onContacts(contacts); const newNodes = await deps.buildNodesFromContacts(contacts, { self: deps.self, diff --git a/src/renderer/lib/meshcoreUtils.test.ts b/src/renderer/lib/meshcoreUtils.test.ts index 8e1420e4e..2d8942603 100644 --- a/src/renderer/lib/meshcoreUtils.test.ts +++ b/src/renderer/lib/meshcoreUtils.test.ts @@ -27,6 +27,7 @@ import { meshcoreMergeContactHopsAwayFromPrevious, meshcoreMilliVoltsToApproximateBatteryPercent, meshcoreMinimalNodeFromAdvertEvent, + meshcorePubkeyShortId, meshcoreRemoveContactErrorMessage, meshcoreResolvedTxPowerMax, meshcoreScaledAdvLatLonToDeg, @@ -49,6 +50,24 @@ describe('MeshCore contact capacity thresholds', () => { }); }); +describe('meshcorePubkeyShortId', () => { + it('returns `!` + first 8 hex chars of the key', () => { + expect(meshcorePubkeyShortId('0102030405060708090a0b0c0d0e0f10')).toBe('!01020304'); + }); + + it('normalizes uppercase and whitespace', () => { + expect(meshcorePubkeyShortId(' 01 02 03 04 05 06 ')).toBe('!01020304'); + expect(meshcorePubkeyShortId('ABCDEF0123')).toBe('!abcdef01'); + }); + + it('returns null for missing or too-short keys', () => { + expect(meshcorePubkeyShortId(undefined)).toBeNull(); + expect(meshcorePubkeyShortId(null)).toBeNull(); + expect(meshcorePubkeyShortId('')).toBeNull(); + expect(meshcorePubkeyShortId('abcd')).toBeNull(); + }); +}); + describe('meshcoreResolvedTxPowerMax', () => { it('uses firmware maxTxPower when present', () => { expect(meshcoreResolvedTxPowerMax({ maxTxPower: 14 })).toEqual({ max: 14, fromFirmware: true }); diff --git a/src/renderer/lib/meshcoreUtils.ts b/src/renderer/lib/meshcoreUtils.ts index e03d782ef..2c4b08d17 100644 --- a/src/renderer/lib/meshcoreUtils.ts +++ b/src/renderer/lib/meshcoreUtils.ts @@ -253,6 +253,13 @@ export function pubKeyPrefixHex(publicKey: Uint8Array): string { .join(''); } +/** `!` + first 8 hex chars (4 bytes) of a MeshCore public key hex, for UI identity. */ +export function meshcorePubkeyShortId(publicKeyHex: string | undefined | null): string | null { + const h = (publicKeyHex ?? '').replace(/\s/g, '').toLowerCase(); + if (h.length < 8) return null; + return `!${h.slice(0, 8)}`; +} + /** * XOR-fold pubkey bytes into a stable unsigned 32-bit node ID. * Expects a 32-byte MeshCore public key; returns 0 for any other length. diff --git a/src/renderer/lib/meshtastic/meshtasticConfigureRetry.ts b/src/renderer/lib/meshtastic/meshtasticConfigureRetry.ts index fb420bba6..2ed6935e8 100644 --- a/src/renderer/lib/meshtastic/meshtasticConfigureRetry.ts +++ b/src/renderer/lib/meshtastic/meshtasticConfigureRetry.ts @@ -11,32 +11,6 @@ const CONFIGURE_RETRYABLE_PATTERN = /packet does not exist/i; let lateConfigureRetryableSwallowUntilMs = 0; -/** - * Ref-count of installed Meshtastic session unhandled-rejection swallow handlers. While > 0, the - * capture-phase handler owns `Packet does not exist` teardown-race rejects, so the app-lifetime - * renderer logger must defer instead of logging them as errors (both listeners are at_target on - * `window`, so registration order — not the capture flag — decides who runs first). - */ -let sessionRejectionSwallowDepth = 0; - -export function beginMeshtasticSessionRejectionSwallow(): void { - sessionRejectionSwallowDepth++; -} - -export function endMeshtasticSessionRejectionSwallow(): void { - sessionRejectionSwallowDepth = Math.max(0, sessionRejectionSwallowDepth - 1); -} - -/** True while a Meshtastic session rejection-swallow handler is installed. */ -export function isMeshtasticSessionRejectionSwallowActive(): boolean { - return sessionRejectionSwallowDepth > 0; -} - -/** Test-only: reset the session swallow ref-count. */ -export function resetMeshtasticSessionRejectionSwallowForTests(): void { - sessionRejectionSwallowDepth = 0; -} - export function isMeshtasticConfigureRetryableError(err: unknown): boolean { return CONFIGURE_RETRYABLE_PATTERN.test(errLikeToLogString(err)); } diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 35e452a15..46361c8dc 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -645,10 +645,7 @@ export function attachMeshtasticRuntimeWireEffects( await device.sendPacket(new Uint8Array(), Portnums.PortNum.NODEINFO_APP, from); console.debug(`[useMeshtasticRuntime] NODEINFO request sent for 0x${from.toString(16)}`); } catch (e: unknown) { - console.debug( - '[useMeshtasticRuntime] NODEINFO request failed', - e instanceof Error ? e.message : e, - ); + console.debug('[useMeshtasticRuntime] NODEINFO request failed ' + errLikeToLogString(e)); } })(); }; diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts index 0ffeb533d..f8b72e84d 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts @@ -1,10 +1,4 @@ -import { errLikeToLogString } from '../errLikeToLogString'; -import { - armMeshtasticLateConfigureRetryableSwallow, - beginMeshtasticSessionRejectionSwallow, - endMeshtasticSessionRejectionSwallow, - isMeshtasticConfigureRetryableError, -} from './meshtasticConfigureRetry'; +import { armMeshtasticLateConfigureRetryableSwallow } from './meshtasticConfigureRetry'; import { parseMeshtasticSdkQueueRejection, parseMeshtasticSdkRoutingErrorLog, @@ -60,8 +54,11 @@ export function installMeshtasticSdkRoutingErrorConsoleHook( /** * Swallow unhandled `@meshtastic/core` queue rejections (`{ id, error }`) after applying * outbound chat failure state when a matching row exists. - * Also swallows disconnect mid-send `Packet does not exist` so teardown races are not logged - * as unhandled rejections. + * + * Disconnect mid-send `Packet does not exist` rejects are intentionally NOT swallowed for the + * whole session — only during the short post-teardown window (armed here on cleanup and at + * `safeDisconnect`). The renderer-wide logger owns that window, so genuine mid-session + * `Packet does not exist` anomalies stay visible instead of being hidden as "disconnect mid-send". */ export function installMeshtasticSdkRoutingErrorUnhandledRejectionHandler( onQueueRejection: (reason: unknown) => boolean, @@ -70,23 +67,12 @@ export function installMeshtasticSdkRoutingErrorUnhandledRejectionHandler( if (parseMeshtasticSdkQueueRejection(event.reason)) { const applied = onQueueRejection(event.reason); if (applied) event.preventDefault(); - return; - } - if (isMeshtasticConfigureRetryableError(event.reason)) { - console.debug( - '[Meshtastic] Ignoring disconnect mid-send rejection: ' + errLikeToLogString(event.reason), - ); - event.preventDefault(); } }; // Capture phase so preventDefault runs before the bubble-phase renderer logger. window.addEventListener('unhandledrejection', handler, { capture: true }); - // Mark a session swallow active so the app-lifetime renderer logger defers to this handler - // even though at_target listeners fire in registration order (renderer logger is installed first). - beginMeshtasticSessionRejectionSwallow(); return () => { window.removeEventListener('unhandledrejection', handler, { capture: true }); - endMeshtasticSessionRejectionSwallow(); // Late SDK queue rejects can settle after wire-effects teardown removes this handler. armMeshtasticLateConfigureRetryableSwallow(); }; diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index ef53b95bd..09d160ee1 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -473,7 +473,7 @@ describe('installMeshtasticSdkRoutingErrorUnhandledRejectionHandler', () => { restore(); }); - it('preventDefault for disconnect mid-send Packet does not exist', () => { + it('does not swallow mid-session Packet does not exist (only the late window does)', () => { const onQueueRejection = vi.fn(); const restore = installMeshtasticSdkRoutingErrorUnhandledRejectionHandler(onQueueRejection); const handler = vi.mocked(window.addEventListener).mock.calls[0]?.[1] as (event: { @@ -484,7 +484,7 @@ describe('installMeshtasticSdkRoutingErrorUnhandledRejectionHandler', () => { const preventDefault = vi.fn(); handler({ reason, preventDefault }); expect(onQueueRejection).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); restore(); }); diff --git a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts index 6de8b0171..3b3b21946 100644 --- a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import type { MeshDevice } from '@meshtastic/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { errLikeToLogString } from '../errLikeToLogString'; import { attachMeshtasticTransportLossWatch } from './meshtasticTransportLossDetection'; import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSideEffects'; @@ -15,13 +16,18 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); unsubs = []; window.electronAPI.onNobleBleDisconnected = vi.fn(() => () => {}); }); + afterEach(() => { + vi.useRealTimers(); + }); + function mockDevice(): MeshDevice { return { - setHeartbeatInterval: vi.fn(), + heartbeat: vi.fn().mockResolvedValue(0), } as unknown as MeshDevice; } @@ -36,8 +42,9 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { expect(window.electronAPI.onNobleBleDisconnected).not.toHaveBeenCalled(); expect(attachMeshtasticTransportLossWatch).toHaveBeenCalledWith(device, 'ble', onTransportLost); - expect(device.setHeartbeatInterval).toHaveBeenCalledWith(60_000); - expect(unsubs).toHaveLength(1); + vi.advanceTimersByTime(60_000); + expect(device.heartbeat).toHaveBeenCalledTimes(1); + expect(unsubs).toHaveLength(2); }); it('attaches serialized transport and heartbeat for serial', () => { @@ -55,8 +62,9 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { 'serial', onTransportLost, ); - expect(device.setHeartbeatInterval).toHaveBeenCalledWith(60_000); - expect(unsubs).toHaveLength(1); + vi.advanceTimersByTime(60_000); + expect(device.heartbeat).toHaveBeenCalledTimes(1); + expect(unsubs).toHaveLength(2); }); it('attaches serialized transport but skips heartbeat for HTTP', () => { @@ -77,7 +85,8 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { 'http', onTransportLost, ); - expect(device.setHeartbeatInterval).not.toHaveBeenCalled(); + vi.advanceTimersByTime(60_000); + expect(device.heartbeat).not.toHaveBeenCalled(); expect(unsubs).toHaveLength(1); }); @@ -94,7 +103,52 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { // TCP is a persistent duplex link like serial/BLE, not a polling link like HTTP, // so it gets both the serialized-writer wrap and heartbeat. expect(attachMeshtasticTransportLossWatch).toHaveBeenCalledWith(device, 'tcp', onTransportLost); - expect(device.setHeartbeatInterval).toHaveBeenCalledWith(60_000); - expect(unsubs).toHaveLength(1); + vi.advanceTimersByTime(60_000); + expect(device.heartbeat).toHaveBeenCalledTimes(1); + expect(unsubs).toHaveLength(2); + }); + + it('logs a normalized debug line and does not surface an unhandled rejection when heartbeat rejects with a non-Error', async () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const unhandledSpy = vi.fn(); + window.addEventListener('unhandledrejection', unhandledSpy); + const rejectionValue = 'queue-gone: Packet does not exist'; + const device = { + heartbeat: vi.fn().mockRejectedValue(rejectionValue), + } as unknown as MeshDevice; + try { + pushMeshtasticTransportSideEffectUnsubs( + device, + 'tcp', + (unsub) => unsubs.push(unsub), + onTransportLost, + ); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(device.heartbeat).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith( + `[meshtasticTransportSideEffects] tcp: heartbeat send failed ` + + errLikeToLogString(rejectionValue), + ); + expect(unhandledSpy).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandledSpy); + debugSpy.mockRestore(); + } + }); + + it('stops the heartbeat after its unsubscribe runs', () => { + const device = mockDevice(); + pushMeshtasticTransportSideEffectUnsubs( + device, + 'tcp', + (unsub) => unsubs.push(unsub), + onTransportLost, + ); + + for (const unsub of unsubs) unsub(); + vi.advanceTimersByTime(180_000); + expect(device.heartbeat).not.toHaveBeenCalled(); }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts index 070a0af68..acebc3e0f 100644 --- a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts @@ -4,6 +4,9 @@ import { errLikeToLogString } from '../errLikeToLogString'; import type { ConnectionType } from '../types'; import { attachMeshtasticTransportLossWatch } from './meshtasticTransportLossDetection'; +/** Liveness heartbeat cadence for persistent links (serial/BLE/TCP). */ +const MESHTASTIC_HEARTBEAT_INTERVAL_MS = 60_000; + /** * Transport-level side effects not yet modeled as `DomainEvent`s (Noble disconnect, * serialized toDevice for serial/BLE, heartbeat). Pushed onto the hook unsubscribe @@ -23,13 +26,21 @@ export function pushMeshtasticTransportSideEffectUnsubs( } if (type === 'serial' || type === 'ble' || type === 'tcp') { - try { - device.setHeartbeatInterval(60_000); - } catch (e) { - console.warn( - `[meshtasticTransportSideEffects] ${type}: setHeartbeatInterval failed ` + - errLikeToLogString(e), - ); - } + // Drive the liveness heartbeat ourselves instead of device.setHeartbeatInterval(): the SDK + // fires `this.heartbeat()` from a bare setInterval and discards the promise, so a rejected + // heartbeat send (e.g. a queue "Packet does not exist" teardown race) surfaces as an + // unhandled rejection every interval. Awaiting + catching here keeps it out of the global + // rejection path while preserving the keep-alive. + const heartbeatTimer = setInterval(() => { + void device.heartbeat().catch((e: unknown) => { + console.debug( + `[meshtasticTransportSideEffects] ${type}: heartbeat send failed ` + + errLikeToLogString(e), + ); + }); + }, MESHTASTIC_HEARTBEAT_INTERVAL_MS); + push(() => { + clearInterval(heartbeatTimer); + }); } } diff --git a/src/renderer/lib/rendererUnhandledRejection.test.ts b/src/renderer/lib/rendererUnhandledRejection.test.ts index 32ac3bb8b..0ee8fa70d 100644 --- a/src/renderer/lib/rendererUnhandledRejection.test.ts +++ b/src/renderer/lib/rendererUnhandledRejection.test.ts @@ -3,10 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { armMeshtasticLateConfigureRetryableSwallow, - beginMeshtasticSessionRejectionSwallow, - endMeshtasticSessionRejectionSwallow, resetMeshtasticLateConfigureRetryableSwallowForTests, - resetMeshtasticSessionRejectionSwallowForTests, } from './meshtastic/meshtasticConfigureRetry'; import { installRendererUnhandledRejectionLogger, @@ -53,7 +50,6 @@ function dispatchUnhandledRejection( describe('installRendererUnhandledRejectionLogger', () => { afterEach(() => { resetMeshtasticLateConfigureRetryableSwallowForTests(); - resetMeshtasticSessionRejectionSwallowForTests(); vi.restoreAllMocks(); }); @@ -101,37 +97,19 @@ describe('installRendererUnhandledRejectionLogger', () => { ); }); - it('defers Packet does not exist to the active session swallow handler (no error log)', () => { + it('logs a mid-session Packet does not exist when no teardown window is armed', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); const uninstall = installRendererUnhandledRejectionLogger(); - beginMeshtasticSessionRejectionSwallow(); const event = dispatchUnhandledRejection(new Error('Packet does not exist')); uninstall(); - expect(event.defaultPrevented).toBe(true); - expect(errorSpy).not.toHaveBeenCalled(); - // The bubble logger defers silently; the capture-phase session handler owns the debug line. - expect(debugSpy).not.toHaveBeenCalled(); - - endMeshtasticSessionRejectionSwallow(); - }); - - it('still logs unrelated rejections while a session swallow handler is active', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const uninstall = installRendererUnhandledRejectionLogger(); - beginMeshtasticSessionRejectionSwallow(); - - dispatchUnhandledRejection(new Error('genuine failure')); - uninstall(); - + // No whole-session swallow anymore: a real mid-session anomaly stays visible. + expect(event.defaultPrevented).toBe(false); expect(errorSpy).toHaveBeenCalledWith( '[renderer] Unhandled rejection:', - expect.stringContaining('genuine failure'), + expect.stringContaining('Packet does not exist'), ); - - endMeshtasticSessionRejectionSwallow(); }); it('does not log Packet does not exist during armed late-swallow window', () => { diff --git a/src/renderer/lib/rendererUnhandledRejection.ts b/src/renderer/lib/rendererUnhandledRejection.ts index fb058b866..3fe6508e8 100644 --- a/src/renderer/lib/rendererUnhandledRejection.ts +++ b/src/renderer/lib/rendererUnhandledRejection.ts @@ -1,8 +1,4 @@ -import { - isMeshtasticConfigureRetryableError, - isMeshtasticSessionRejectionSwallowActive, - shouldSwallowLateMeshtasticConfigureRetryableRejection, -} from './meshtastic/meshtasticConfigureRetry'; +import { shouldSwallowLateMeshtasticConfigureRetryableRejection } from './meshtastic/meshtasticConfigureRetry'; /** Log renderer-wide unhandled promise rejections without throwing a second error. */ export function logRendererUnhandledRejection(reason: unknown): void { @@ -17,17 +13,9 @@ export function installRendererUnhandledRejectionLogger(target: Window = window) const handler = (event: PromiseRejectionEvent) => { // Capture-phase Meshtastic handler may have already preventDefault'd queue rejections. if (event.defaultPrevented) return; - // While a Meshtastic session swallow handler is installed, it owns the mid-send - // `Packet does not exist` teardown race. This bubble handler is registered first, so it runs - // before the capture handler at_target — defer (no error log) and let it log + preventDefault. - if ( - isMeshtasticSessionRejectionSwallowActive() && - isMeshtasticConfigureRetryableError(event.reason) - ) { - event.preventDefault(); - return; - } - // Only during a short post-teardown window (armed when Meshtastic session handler unsubscribes). + // Only swallow disconnect mid-send `Packet does not exist` during the short post-teardown + // window (armed at safeDisconnect and when the Meshtastic session handler unsubscribes). + // Outside that window these stay logged so real anomalies remain visible. if (shouldSwallowLateMeshtasticConfigureRetryableRejection(event.reason)) { console.debug( '[renderer] Ignoring Meshtastic disconnect mid-send rejection:', diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 04eb70a95..b039fa693 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -2888,7 +2888,10 @@ "currentRouteHeading": "Aktuální trasa", "hopNameLabel": "{{name}}", "shareContactQr": "Sdílet kontakt", - "shareContactQrAria": "Zobrazit QR kód kontaktu MeshCore pro oficiální import aplikace" + "shareContactQrAria": "Zobrazit QR kód kontaktu MeshCore pro oficiální import aplikace", + "copyPublicKey": "Licenční klíč", + "publicKeyCopied": "Veřejný klíč zkopírován do schránky.", + "hasPublicKeyNoDm": "Má veřejný klíč (žádné přímé zprávy)" }, "nodeInfoBody": { "longName": "Dlouhé jméno", @@ -3010,7 +3013,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Propojené uzly sítě", - "columnStatus": "Postavení", "columnFavorite": "Oblíbený", "columnId": "ID", "columnLongName": "Dlouhé jméno", @@ -3058,7 +3060,9 @@ "emptyHistory": "Zatím žádné přímé zprávy — odešlete nebo přijměte DM, abyste zde viděli peery.", "meshcoreTypeSensor": "Snímač", "meshcoreTypeNone": "neuvedeny", - "meshcoreTypeUnknown": "Neznámo" + "meshcoreTypeUnknown": "Neznámo", + "columnHealth": "Zdravotnictví", + "hasPublicKeyTitle": "Licenční klíč" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 765ed3efa..f850961e8 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Aktuelle Route", "hopNameLabel": "{{name}}", "shareContactQr": "Kontakt teilen", - "shareContactQrAria": "MeshCore-Kontakt-QR-Code für offiziellen App-Import anzeigen" + "shareContactQrAria": "MeshCore-Kontakt-QR-Code für offiziellen App-Import anzeigen", + "copyPublicKey": "Öffentlichen Schlüssel kopieren", + "publicKeyCopied": "Öffentlicher Schlüssel in die Zwischenablage kopiert.", + "hasPublicKeyNoDm": "Verfügt über einen öffentlichen Schlüssel (keine direkten Nachrichten)" }, "nodeInfoBody": { "longName": "Langer Name", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Verbundene Mesh-Knoten", - "columnStatus": "Status", "columnFavorite": "Favorit", "columnId": "ID", "columnLongName": "Langname", @@ -3056,7 +3058,9 @@ "emptyHistory": "Noch keine Direktnachrichten — senden oder empfangen Sie einen DM, um Peers hier zu sehen.", "meshcoreTypeSensor": "Sensor", "meshcoreTypeNone": "None", - "meshcoreTypeUnknown": "Unbekannt" + "meshcoreTypeUnknown": "Unbekannt", + "columnHealth": "Gesundheit", + "hasPublicKeyTitle": "Öffentlicher Schlüssel:" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index fec2e7238..75ed2d24b 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -2887,6 +2887,9 @@ "sensorTelemetryLpp": "Sensor telemetry LPP", "nodeIncomplete": "Node data incomplete - waiting for full NodeInfo packet", "hasPublicKey": "Has public key - can send DMs", + "hasPublicKeyNoDm": "Has public key (no direct messages)", + "copyPublicKey": "Copy public key", + "publicKeyCopied": "Public key copied to clipboard.", "chatOnlyNode": "Chat-only node (no public key)", "dbOnlyContact": "Contact stored in database only, not on radio", "syncedContact": "Contact synced: stored in database and on radio", @@ -3186,9 +3189,10 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Connected mesh nodes", - "columnStatus": "Status", + "columnHealth": "Health", "columnFavorite": "Favorite", "columnId": "ID", + "hasPublicKeyTitle": "Has public key", "columnLongName": "Long Name", "columnShort": "Short", "columnLastHeard": "Last Heard", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 754211852..17901ca54 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Ruta actual", "hopNameLabel": "{{name}}", "shareContactQr": "Compartir contacto", - "shareContactQrAria": "Mostrar el código QR de contacto de MeshCore para la importación oficial de la aplicación" + "shareContactQrAria": "Mostrar el código QR de contacto de MeshCore para la importación oficial de la aplicación", + "copyPublicKey": "Clave Pública", + "publicKeyCopied": "Clave pública copiada al portapapeles.", + "hasPublicKeyNoDm": "Tiene clave pública (sin mensajes directos)" }, "nodeInfoBody": { "longName": "Nombre largo", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} sin conexión", "summaryOffline_other": "{{count}} sin conexión", "tableCaptionMeshNodes": "Nodos de malla conectados", - "columnStatus": "Estado", "columnFavorite": "Favorito", "columnId": "ID", "columnLongName": "Long Name", @@ -3056,7 +3058,9 @@ "emptyHistory": "Aún no hay mensajes directos: envíe o reciba un DM para ver a los peers aquí.", "meshcoreTypeSensor": "Sensor", "meshcoreTypeNone": "Ninguno", - "meshcoreTypeUnknown": "Desconocido" + "meshcoreTypeUnknown": "Desconocido", + "columnHealth": "Health", + "hasPublicKeyTitle": "Clave Pública" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index e93eb2c98..4486caf09 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Itinéraire actuel", "hopNameLabel": "{{name}}", "shareContactQr": "Partager le contact via", - "shareContactQrAria": "Afficher le code QR du contact MeshCore pour l'importation officielle de l'application" + "shareContactQrAria": "Afficher le code QR du contact MeshCore pour l'importation officielle de l'application", + "copyPublicKey": "Copier la clé publique", + "publicKeyCopied": "Clé publique copiée dans le presse-papiers.", + "hasPublicKeyNoDm": "Possède une clé publique (pas de messages directs)" }, "nodeInfoBody": { "longName": "Nom long", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} hors ligne", "summaryOffline_other": "{{count}} hors ligne", "tableCaptionMeshNodes": "Nœuds maillés connectés", - "columnStatus": "Statut", "columnFavorite": "Favori", "columnId": "ID", "columnLongName": "Nom complet", @@ -3056,7 +3058,9 @@ "emptyHistory": "Pas encore de messages directs — envoyez ou recevez un DM pour voir vos pairs ici.", "meshcoreTypeSensor": "Capteur", "meshcoreTypeNone": "Aucune", - "meshcoreTypeUnknown": "Inconnu" + "meshcoreTypeUnknown": "Inconnu", + "columnHealth": "Santé", + "hasPublicKeyTitle": "Clé publique" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 93ff22a8f..5fb8fd1ae 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Rute saat ini", "hopNameLabel": "{{name}}", "shareContactQr": "Bagikan QR kontak", - "shareContactQrAria": "Tampilkan kode QR kontak MeshCore untuk impor aplikasi resmi" + "shareContactQrAria": "Tampilkan kode QR kontak MeshCore untuk impor aplikasi resmi", + "copyPublicKey": "Kunci Publik", + "publicKeyCopied": "Kunci publik disalin ke clipboard.", + "hasPublicKeyNoDm": "Memiliki kunci publik (tidak ada pesan langsung)" }, "nodeInfoBody": { "longName": "Nama Panjang", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} luring", "summaryOffline_other": "{{count}} luring", "tableCaptionMeshNodes": "Node mesh yang terhubung", - "columnStatus": "Status", "columnFavorite": "Favorit", "columnId": "PENGENAL", "columnLongName": "Nama Panjang", @@ -3056,7 +3058,9 @@ "emptyHistory": "Belum ada pesan langsung — kirim atau terima DM untuk melihat peer di sini.", "meshcoreTypeSensor": "Sensor", "meshcoreTypeNone": "Tidak Ada", - "meshcoreTypeUnknown": "Tidak Diketahui" + "meshcoreTypeUnknown": "Tidak Diketahui", + "columnHealth": "Kesehatan", + "hasPublicKeyTitle": "Kunci Publik" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index d4af7a304..52492a518 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Percorso attuale", "hopNameLabel": "{{name}}", "shareContactQr": "Condividi QR contatto", - "shareContactQrAria": "Mostra il codice QR del contatto MeshCore per l'importazione ufficiale dell'app" + "shareContactQrAria": "Mostra il codice QR del contatto MeshCore per l'importazione ufficiale dell'app", + "copyPublicKey": "Public key", + "publicKeyCopied": "Chiave pubblica copiata negli appunti.", + "hasPublicKeyNoDm": "Ha una chiave pubblica (nessun messaggio diretto)" }, "nodeInfoBody": { "longName": "Nome lungo", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Nodi mesh connessi", - "columnStatus": "Stato", "columnFavorite": "Preferito", "columnId": "ID", "columnLongName": "Nome lungo", @@ -3056,7 +3058,9 @@ "emptyHistory": "Ancora nessun messaggio diretto: invia o ricevi un DM per vedere i peer qui.", "meshcoreTypeSensor": "Sensore", "meshcoreTypeNone": "Nessuna", - "meshcoreTypeUnknown": "Sconosciuto" + "meshcoreTypeUnknown": "Sconosciuto", + "columnHealth": "Salute", + "hasPublicKeyTitle": "Public key" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 92944ed8c..96ec567fc 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "現在のルート", "hopNameLabel": "{{name}}", "shareContactQr": "連絡先QRを共有", - "shareContactQrAria": "公式アプリインポートのためにMeshCore連絡先QRコードを表示する" + "shareContactQrAria": "公式アプリインポートのためにMeshCore連絡先QRコードを表示する", + "copyPublicKey": "公開鍵をコピー", + "publicKeyCopied": "公開鍵をクリップボードにコピーしました。", + "hasPublicKeyNoDm": "公開鍵があります(ダイレクトメッセージはありません)" }, "nodeInfoBody": { "longName": "長い名前", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} オフライン", "summaryOffline_other": "{{count}} オフライン", "tableCaptionMeshNodes": "接続されたメッシュノード", - "columnStatus": "状態", "columnFavorite": "お気に入り", "columnId": "ID", "columnLongName": "長い名前", @@ -3056,7 +3058,9 @@ "emptyHistory": "ダイレクトメッセージはまだありません—ここでピアを見るためにDMを送受信します。", "meshcoreTypeSensor": "センサー", "meshcoreTypeNone": "なし", - "meshcoreTypeUnknown": "不明" + "meshcoreTypeUnknown": "不明", + "columnHealth": "健康", + "hasPublicKeyTitle": "公開鍵" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index d6c35e47f..dab5aade0 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "현재 경로", "hopNameLabel": "{{name}}", "shareContactQr": "연락처 공유", - "shareContactQrAria": "공식 앱 가져오기를 위한 MeshCore 연락처 QR 코드 표시" + "shareContactQrAria": "공식 앱 가져오기를 위한 MeshCore 연락처 QR 코드 표시", + "copyPublicKey": "공개 키", + "publicKeyCopied": "공개 키가 클립보드에 복사되었습니다.", + "hasPublicKeyNoDm": "공개 키가 있습니다 (직접 메시지 없음)" }, "nodeInfoBody": { "longName": "긴 이름", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} 오프라인", "summaryOffline_other": "{{count}} 오프라인", "tableCaptionMeshNodes": "연결된 메시 노드", - "columnStatus": "상태", "columnFavorite": "가장 좋아하는", "columnId": "ID", "columnLongName": "긴 이름", @@ -3056,7 +3058,9 @@ "emptyHistory": "아직 다이렉트 메시지가 없습니다. DM을 보내거나 받아 피어를 확인하세요.", "meshcoreTypeSensor": "검지기", "meshcoreTypeNone": "없음", - "meshcoreTypeUnknown": "미상" + "meshcoreTypeUnknown": "미상", + "columnHealth": "체력", + "hasPublicKeyTitle": "공개 키" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index f90433d50..c20451e17 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Huidige route", "hopNameLabel": "{{name}}", "shareContactQr": "Deel contact", - "shareContactQrAria": "Toon MeshCore-contact QR-code voor officiële app-import" + "shareContactQrAria": "Toon MeshCore-contact QR-code voor officiële app-import", + "copyPublicKey": "Openbare sleutel", + "publicKeyCopied": "Openbare sleutel gekopieerd naar klembord.", + "hasPublicKeyNoDm": "Heeft een openbare sleutel (geen directe berichten)" }, "nodeInfoBody": { "longName": "Lange naam", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Verbonden mesh-knooppunten", - "columnStatus": "Status", "columnFavorite": "Favoriet", "columnId": "Identiteitskaart", "columnLongName": "Lange naam", @@ -3056,7 +3058,9 @@ "emptyHistory": "Nog geen directe berichten — stuur of ontvang een DM om peers hier te zien.", "meshcoreTypeSensor": "Sensor", "meshcoreTypeNone": "Geen", - "meshcoreTypeUnknown": "Onbekend" + "meshcoreTypeUnknown": "Onbekend", + "columnHealth": "Gezondheid", + "hasPublicKeyTitle": "Openbare sleutel" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index b101d7044..87041a131 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -2890,7 +2890,10 @@ "currentRouteHeading": "Aktualna trasa", "hopNameLabel": "{{name}}", "shareContactQr": "Udostępnij kontaktowy QR", - "shareContactQrAria": "Pokaż kontaktowy kod QR MeshCore do oficjalnego importu aplikacji" + "shareContactQrAria": "Pokaż kontaktowy kod QR MeshCore do oficjalnego importu aplikacji", + "copyPublicKey": "Klucz publiczny", + "publicKeyCopied": "Klucz publiczny skopiowano do schowka.", + "hasPublicKeyNoDm": "Posiada klucz publiczny (bez bezpośrednich wiadomości)" }, "nodeInfoBody": { "longName": "Długie imię", @@ -3012,7 +3015,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Połączone węzły siatki", - "columnStatus": "Status", "columnFavorite": "Ulubiony", "columnId": "ID", "columnLongName": "Długie imię", @@ -3060,7 +3062,9 @@ "emptyHistory": "Nie ma jeszcze bezpośrednich wiadomości — wyślij lub otrzymaj DM, aby zobaczyć peerów tutaj.", "meshcoreTypeSensor": "Czujnik", "meshcoreTypeNone": "Brak", - "meshcoreTypeUnknown": "Nieznany" + "meshcoreTypeUnknown": "Nieznany", + "columnHealth": "Ochrona zdrowia", + "hasPublicKeyTitle": "Klucz publiczny" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 8c4efc5ba..89156fab4 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Rota atual", "hopNameLabel": "{{name}}", "shareContactQr": "Partilhar contacto", - "shareContactQrAria": "Mostrar código QR de contato MeshCore para importação oficial do aplicativo" + "shareContactQrAria": "Mostrar código QR de contato MeshCore para importação oficial do aplicativo", + "copyPublicKey": "Chave pública", + "publicKeyCopied": "Chave pública copiada para a área de transferência.", + "hasPublicKeyNoDm": "Tem chave pública (sem mensagens diretas)" }, "nodeInfoBody": { "longName": "Nome longo", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Nós de malha conectados", - "columnStatus": "Status", "columnFavorite": "Favorito", "columnId": "ID", "columnLongName": "Nome Completo", @@ -3056,7 +3058,9 @@ "emptyHistory": "Ainda não há mensagens diretas — envie ou receba um DM para ver os peers aqui.", "meshcoreTypeSensor": "Sensor", "meshcoreTypeNone": "Nenhuma.", - "meshcoreTypeUnknown": "Desconhecida" + "meshcoreTypeUnknown": "Desconhecida", + "columnHealth": "Health", + "hasPublicKeyTitle": "Chave pública" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 4cfc55747..85a673168 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -2888,7 +2888,10 @@ "currentRouteHeading": "Текущий маршрут", "hopNameLabel": "{{name}}", "shareContactQr": "Обратная связь", - "shareContactQrAria": "Показать контактный QR-код MeshCore для официального импорта приложения" + "shareContactQrAria": "Показать контактный QR-код MeshCore для официального импорта приложения", + "copyPublicKey": "Копировать открытый ключ", + "publicKeyCopied": "Открытый ключ скопирован в буфер обмена.", + "hasPublicKeyNoDm": "Имеет открытый ключ (нет прямых сообщений)" }, "nodeInfoBody": { "longName": "Длинное имя", @@ -3010,7 +3013,6 @@ "summaryOffline_one": "{{count}} оффлайн", "summaryOffline_other": "{{count}} оффлайн", "tableCaptionMeshNodes": "Связанные узлы сетки", - "columnStatus": "Статус", "columnFavorite": "Любимый", "columnId": "ИДЕНТИФИКАТОР", "columnLongName": "Длинное имя", @@ -3058,7 +3060,9 @@ "emptyHistory": "Прямых сообщений пока нет — отправьте или получите DM, чтобы увидеть пиры здесь.", "meshcoreTypeSensor": "Датчик", "meshcoreTypeNone": "Нет", - "meshcoreTypeUnknown": "Неизвестно" + "meshcoreTypeUnknown": "Неизвестно", + "columnHealth": "Здравоохранение", + "hasPublicKeyTitle": "открытый ключ" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index ce572affe..d0158dc8e 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "Mevcut rota", "hopNameLabel": "{{name}}", "shareContactQr": "Kişi Paylaş", - "shareContactQrAria": "Resmi uygulama içe aktarma için MeshCore iletişim QR kodunu göster" + "shareContactQrAria": "Resmi uygulama içe aktarma için MeshCore iletişim QR kodunu göster", + "copyPublicKey": "genel anahtarı kopyala", + "publicKeyCopied": "Genel anahtar panoya kopyalandı.", + "hasPublicKeyNoDm": "Genel anahtarı var (doğrudan mesaj yok)" }, "nodeInfoBody": { "longName": "Uzun Ad", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}} çevrimdışı", "summaryOffline_other": "{{count}} çevrimdışı", "tableCaptionMeshNodes": "Bağlı ağ düğümleri", - "columnStatus": "Durum", "columnFavorite": "Favori", "columnId": "İD", "columnLongName": "Uzun Ad", @@ -3056,7 +3058,9 @@ "emptyHistory": "Henüz doğrudan mesaj yok — buradaki eşleri görmek için bir DM gönderin veya alın.", "meshcoreTypeSensor": "Sensörü", "meshcoreTypeNone": "Yok", - "meshcoreTypeUnknown": "Bilinmiyor" + "meshcoreTypeUnknown": "Bilinmiyor", + "columnHealth": "Sağlık", + "hasPublicKeyTitle": "Genel anahtar" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index b21cc7946..811d98706 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -2888,7 +2888,10 @@ "currentRouteHeading": "Поточний маршрут", "hopNameLabel": "{{name}}", "shareContactQr": "Поділитися контактним QR-кодом", - "shareContactQrAria": "Показати контактний QR-код MeshCore для офіційного імпорту додатка" + "shareContactQrAria": "Показати контактний QR-код MeshCore для офіційного імпорту додатка", + "copyPublicKey": "Відкритий ключ", + "publicKeyCopied": "Відкритий ключ скопійовано в буфер обміну.", + "hasPublicKeyNoDm": "Має відкритий ключ (немає прямих повідомлень)" }, "nodeInfoBody": { "longName": "Довге ім'я", @@ -3010,7 +3013,6 @@ "summaryOffline_one": "{{count}} в автономному режимі", "summaryOffline_other": "{{count}} в автономному режимі", "tableCaptionMeshNodes": "З'єднані вузли сіті", - "columnStatus": "Статус", "columnFavorite": "Обране", "columnId": "ID", "columnLongName": "Довга назва", @@ -3058,7 +3060,9 @@ "emptyHistory": "Поки немає прямих повідомлень — надішліть або отримайте DM, щоб побачити вузли тут.", "meshcoreTypeSensor": "Прилад виявлення", "meshcoreTypeNone": "None (Немає)", - "meshcoreTypeUnknown": "Невідомо" + "meshcoreTypeUnknown": "Невідомо", + "columnHealth": "Здоров’я", + "hasPublicKeyTitle": "Відкритий ключ" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 24f1e5bdc..240c512d1 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -2886,7 +2886,10 @@ "currentRouteHeading": "当前路线", "hopNameLabel": "{{name}}", "shareContactQr": "分享联系人", - "shareContactQrAria": "显示MeshCore联系人二维码以进行官方应用导入" + "shareContactQrAria": "显示MeshCore联系人二维码以进行官方应用导入", + "copyPublicKey": "公钥", + "publicKeyCopied": "公钥已复制到剪贴板。", + "hasPublicKeyNoDm": "有公钥(无直接消息)" }, "nodeInfoBody": { "longName": "长名称", @@ -3008,7 +3011,6 @@ "summaryOffline_one": "{{count}}离线", "summaryOffline_other": "{{count}}离线", "tableCaptionMeshNodes": "已连接的网格节点", - "columnStatus": "状态", "columnFavorite": "我的最愛", "columnId": "CN", "columnLongName": "长名", @@ -3056,7 +3058,9 @@ "emptyHistory": "还没有私信—发送或接收 DM 以在此处查看对等节点。", "meshcoreTypeSensor": "测传", "meshcoreTypeNone": "无", - "meshcoreTypeUnknown": "未知" + "meshcoreTypeUnknown": "未知", + "columnHealth": "健康", + "hasPublicKeyTitle": "公钥" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index d7d126f2f..0837c4cb3 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -22,6 +22,7 @@ import { } from '@/renderer/lib/meshcoreOffload'; import { NOBLE_BLE_YIELD_RELEASED_EVENT } from '@/renderer/lib/nobleBleYieldReleased'; +import { bytesToHex } from '../../shared/hexBytes'; import { isMeshcorePathHashMode, meshcoreFirmwareSupportsMultibytePathHash, @@ -73,6 +74,7 @@ import { persistMeshcoreMessageSenderRepairs, registerMeshcorePubKeysFromContactDbRows, resolveMeshcoreNodePubKey, + retryRadioRemoveDeletedContacts, serializeErrorLike, upgradeMeshcoreCrossTransportMessage, } from '../hooks/meshcore/meshcoreHookPreamble'; @@ -514,7 +516,35 @@ async function awaitMeshcoreCompanionConfigAck( function meshcorePathUpdatedNodesMergeUpdater( newNodes: Map, ): (prev: Map) => Map { - return (prev) => mergeMeshcoreChatStubNodes(prev, newNodes); + // Do not revive user-deleted contacts on a path-updated rebuild (unlike a full `fromRadio` + // apply, a successful radio remove must keep its tombstone here). + return (prev) => + filterOutMeshcoreLocallyDeletedContacts(mergeMeshcoreChatStubNodes(prev, newNodes)); +} + +/** + * Merge live radio-contact pubkeys into the existing node-id → hex map (same style as + * `offloadContactsFromRadio`). Replacing the map would drop SQLite-hydrated off-radio entries + * that are no longer on the radio but still shown in the Contacts list, so start from `prev`, + * prune locally-deleted ids (so a removed contact's key does not linger), then upsert self + + * current radio contacts. + */ +function mergeMeshcorePubKeyHexFromContacts( + contacts: MeshCoreContactRaw[], + self?: MeshCoreSelfInfo | null, +): (prev: Map) => Map { + return (prev) => { + const next = filterOutMeshcoreLocallyDeletedContacts(new Map(prev)); + if (self?.publicKey?.length === 32) { + const selfId = pubkeyToNodeId(self.publicKey); + if (selfId !== 0) next.set(selfId, bytesToHex(self.publicKey)); + } + for (const c of contacts) { + const id = pubkeyToNodeId(c.publicKey); + if (id !== 0) next.set(id, bytesToHex(c.publicKey)); + } + return next; + }; } export function useMeshcoreRuntime() { @@ -535,6 +565,9 @@ export function useMeshcoreRuntime() { const [meshcoreContactsForTelemetry, setMeshcoreContactsForTelemetry] = useState< MeshCoreContactRaw[] >([]); + const [meshcorePubKeyHexByNodeId, setMeshcorePubKeyHexByNodeId] = useState>( + new Map(), + ); const [meshcoreAutoadd, setMeshcoreAutoadd] = useState(null); const [ourPosition, setOurPosition] = useState(null); const [deviceLogs, setDeviceLogs] = useState([]); @@ -1116,9 +1149,10 @@ export function useMeshcoreRuntime() { const meshcoreRows = dbMsgs; const mappedPreview = mapMeshcoreDbRowsToChatMessages(meshcoreRows); const initial = buildMeshcoreNodeMapFromDb(dbContacts, savedNodes, mappedPreview); + const dbPubKeyHexByNodeId = new Map(); for (const row of dbContacts) { if (row.nickname) nicknameMapRef.current.set(row.node_id, row.nickname); - const hex = row.public_key.replace(/\s/g, ''); + const hex = row.public_key.replace(/\s/g, '').toLowerCase(); if (!meshcoreIsSyntheticPlaceholderPubKeyHex(hex) && hex.length >= 12) { const pairs = hex.match(/.{2}/g); if (!pairs) continue; @@ -1126,8 +1160,15 @@ export function useMeshcoreRuntime() { pubKeyMapRef.current.set(row.node_id, bytes); const prefix = hex.slice(0, 12); pubKeyPrefixMapRef.current.set(prefix, row.node_id); + if (hex.length === 64) dbPubKeyHexByNodeId.set(row.node_id, hex); } } + const selfForHexMap = selfInfoRef.current; + if (selfForHexMap?.publicKey?.length === 32) { + const selfId = pubkeyToNodeId(selfForHexMap.publicKey); + if (selfId !== 0) dbPubKeyHexByNodeId.set(selfId, bytesToHex(selfForHexMap.publicKey)); + } + setMeshcorePubKeyHexByNodeId(dbPubKeyHexByNodeId); const mapped = repairMeshcoreHydratedMessages( mappedPreview, meshcoreRoomServerIdsFromNodes(initial.values()), @@ -1755,7 +1796,12 @@ export function useMeshcoreRuntime() { myNodeId: myNodeNumRef.current, previousNodes: meshcorePreviousNodesBaselineForBuild(), pendingPathUpdateNodeIds: pendingIds, - onContacts: setMeshcoreContactsForTelemetry, + onContacts: (contacts) => { + setMeshcoreContactsForTelemetry(contacts); + setMeshcorePubKeyHexByNodeId( + mergeMeshcorePubKeyHexFromContacts(contacts, selfInfoRef.current), + ); + }, onNodes: (newNodes) => { setNodes(meshcorePathUpdatedNodesMergeUpdater(newNodes)); }, @@ -2526,8 +2572,13 @@ export function useMeshcoreRuntime() { ); } assertInitConnStillLive(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + const contacts = await retryRadioRemoveDeletedContacts( + conn, + contactsRaw.map(meshcoreContactRawFromDevice), + ); + assertInitConnStillLive(); setMeshcoreContactsForTelemetry(contacts); + setMeshcorePubKeyHexByNodeId(mergeMeshcorePubKeyHexFromContacts(contacts, info)); previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); newNodes = await awaitUnlessMeshcoreSetupCancelled( setupGen, @@ -4335,8 +4386,12 @@ export function useMeshcoreRuntime() { await window.electronAPI.db.markAllMeshcoreContactsOffRadio(); const contactsRaw = await connRef.current.getContacts(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + const contacts = await retryRadioRemoveDeletedContacts( + connRef.current, + contactsRaw.map(meshcoreContactRawFromDevice), + ); setMeshcoreContactsForTelemetry(contacts); + setMeshcorePubKeyHexByNodeId(mergeMeshcorePubKeyHexFromContacts(contacts, selfInfo)); const previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); const newNodes = await buildNodesFromContacts(contacts, { self: selfInfo, @@ -4578,6 +4633,9 @@ export function useMeshcoreRuntime() { .map((b) => b.toString(16).padStart(2, '0')) .join(''); pubKeyPrefixMapRef.current.set(prefix, myId); + setMeshcorePubKeyHexByNodeId(new Map([[myId, bytesToHex(pk)]])); + } else { + setMeshcorePubKeyHexByNodeId(new Map()); } }, []); @@ -4618,6 +4676,16 @@ export function useMeshcoreRuntime() { } } throwIfMeshcoreOffloadAborted(signal); + // Offloaded contacts leave the radio but stay in SQLite (on_radio=1) — keep their pubkey + // hex in the map so the Contacts list / node detail can still show/copy the real key. + setMeshcorePubKeyHexByNodeId((prev) => { + const next = new Map(prev); + for (const contact of contacts) { + const id = pubkeyToNodeId(contact.publicKey); + if (id !== 0) next.set(id, bytesToHex(contact.publicKey)); + } + return next; + }); let removed = 0; for (const c of raw) { const id = pubkeyToNodeId(c.publicKey); @@ -7910,6 +7978,7 @@ export function useMeshcoreRuntime() { telemetryDeviceUpdateInterval: undefined as number | undefined, setRadioParams, meshcoreContactsForTelemetry, + meshcorePubKeyHexByNodeId, meshcoreAutoadd, applyMeshcoreContactAutoAdd, refreshMeshcoreAutoaddFromDevice, @@ -8025,6 +8094,7 @@ export function useMeshcoreRuntime() { connectAutomatic, setRadioParams, meshcoreContactsForTelemetry, + meshcorePubKeyHexByNodeId, meshcoreAutoadd, applyMeshcoreContactAutoAdd, refreshMeshcoreAutoaddFromDevice, diff --git a/src/renderer/stores/reticulumRemoteAddressStore.test.ts b/src/renderer/stores/reticulumRemoteAddressStore.test.ts index 37dacc4ac..0779eeb63 100644 --- a/src/renderer/stores/reticulumRemoteAddressStore.test.ts +++ b/src/renderer/stores/reticulumRemoteAddressStore.test.ts @@ -46,6 +46,55 @@ describe('reticulumRemoteAddressStore', () => { expect(result?.id).toBe('addr1'); }); + it('resolves both concurrent upserts once the DB reflects both rows', async () => { + const rowA = { ...ROW, id: 'addrA', destination_hash: 'a'.repeat(32) }; + const rowB = { ...ROW, id: 'addrB', destination_hash: 'b'.repeat(32) }; + // The DB (via the list IPC) only knows about a row after its write lands; the second + // hydrate must re-fetch instead of piggybacking on the first in-flight load. + vi.mocked(window.electronAPI.db.listReticulumRemoteAddresses) + .mockResolvedValueOnce([rowA]) + .mockResolvedValue([rowA, rowB]); + + const [resultA, resultB] = await Promise.all([ + useReticulumRemoteAddressStore.getState().upsert({ + label: rowA.label, + service: rowA.service, + destination_hash: rowA.destination_hash, + }), + useReticulumRemoteAddressStore.getState().upsert({ + label: rowB.label, + service: rowB.service, + destination_hash: rowB.destination_hash, + }), + ]); + + expect(resultA?.id).toBe('addrA'); + expect(resultB?.id).toBe('addrB'); + }); + + it('drops a stale hydrate response when clear() runs before the list IPC resolves', async () => { + let resolveList: (rows: (typeof ROW)[]) => void = () => {}; + vi.mocked(window.electronAPI.db.listReticulumRemoteAddresses).mockImplementation( + () => + new Promise((resolve) => { + resolveList = resolve; + }), + ); + + const hydratePromise = useReticulumRemoteAddressStore.getState().hydrate(); + // Let the chained run() start and invoke the list IPC (which assigns resolveList). + await new Promise((r) => setTimeout(r, 0)); + // clear() the store while the list IPC is still in flight. + useReticulumRemoteAddressStore.getState().clear(); + // The now-stale response must not repopulate the cleared store. + resolveList([ROW]); + await hydratePromise; + + const state = useReticulumRemoteAddressStore.getState(); + expect(state.addresses.size).toBe(0); + expect(state.hydrated).toBe(false); + }); + it('removes an address from local state after a successful delete', async () => { useReticulumRemoteAddressStore.setState({ addresses: new Map([[ROW.id, ROW]]), diff --git a/src/renderer/stores/reticulumRemoteAddressStore.ts b/src/renderer/stores/reticulumRemoteAddressStore.ts index 2773d5c7c..7331d0daa 100644 --- a/src/renderer/stores/reticulumRemoteAddressStore.ts +++ b/src/renderer/stores/reticulumRemoteAddressStore.ts @@ -7,6 +7,8 @@ interface ReticulumRemoteAddressStoreState { addresses: Map; hydrated: boolean; loading: boolean; + /** In-flight fetch, so concurrent callers chain instead of reading stale data. */ + loadingPromise: Promise | null; hydrate: () => Promise; upsert: (row: UpsertRemoteAddressRequest) => Promise; remove: (id: string) => Promise; @@ -18,26 +20,45 @@ interface ReticulumRemoteAddressStoreState { clear: () => void; } +// Bumped by clear() so an in-flight hydrate() cannot restore cleared state after its +// listReticulumRemoteAddresses() promise resolves late (module-level to avoid re-renders). +let clearGeneration = 0; + export const useReticulumRemoteAddressStore = create( (set, get) => ({ addresses: new Map(), hydrated: false, loading: false, + loadingPromise: null, hydrate: async () => { - if (get().loading) return; - set({ loading: true }); - try { - const rows = await window.electronAPI.db.listReticulumRemoteAddresses(); - const map = new Map(); - for (const row of rows) { - map.set(row.id, row); + // Chain onto any in-flight fetch so a hydrate requested after a write always runs + // once the current one settles. Early-returning stale data would drop a just-upserted + // row and surface to callers as upsert_failed (concurrent RNCP receive-dest shares). + const prior = get().loadingPromise ?? Promise.resolve(); + const run = async (): Promise => { + // Snapshot the clear-generation so a clear() during the awaited IPC drops this write. + const gen = clearGeneration; + set({ loading: true }); + try { + const rows = await window.electronAPI.db.listReticulumRemoteAddresses(); + if (gen !== clearGeneration) return; + const map = new Map(); + for (const row of rows) { + map.set(row.id, row); + } + set({ addresses: map, hydrated: true, loading: false }); + } catch (e) { + console.warn('[reticulumRemoteAddressStore] hydrate ' + errLikeToLogString(e)); + if (gen === clearGeneration) set({ loading: false }); } - set({ addresses: map, hydrated: true, loading: false }); - } catch (e) { - console.warn('[reticulumRemoteAddressStore] hydrate ' + errLikeToLogString(e)); - set({ loading: false }); - } + }; + const p = prior.then(run); + set({ loadingPromise: p }); + void p.finally(() => { + if (get().loadingPromise === p) set({ loadingPromise: null }); + }); + return p; }, upsert: async (row) => { @@ -89,7 +110,8 @@ export const useReticulumRemoteAddressStore = create { - set({ addresses: new Map(), hydrated: false, loading: false }); + clearGeneration += 1; + set({ addresses: new Map(), hydrated: false, loading: false, loadingPromise: null }); }, }), );