From ca3363025e35f8492abb76505c588e548f251c2c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 07:34:46 -0600 Subject: [PATCH 1/4] fix: resolve RNCP share race, silence heartbeat rejects, readable NODEINFO log - reticulumRemoteAddressStore: chain concurrent hydrate() calls so a write is not lost to a stale read (fixes spurious rncp receive-dest upsert_failed) - meshtastic heartbeat: drive it ourselves with an awaited/caught heartbeat() instead of the SDK's fire-and-forget setHeartbeatInterval, so a queue "Packet does not exist" teardown race no longer recurs as an unhandled rejection; narrow that swallow to the post-teardown window (armed at safeDisconnect) so genuine mid-session anomalies stay visible - NODEINFO request failed now logs via errLikeToLogString instead of printing [object Object] --- src/renderer/lib/connection.ts | 5 +++ .../meshtastic/meshtasticConfigureRetry.ts | 26 ------------ .../meshtasticRuntimeWireEffects.ts | 5 +-- .../meshtasticSdkRoutingErrorConsoleHook.ts | 26 +++--------- .../meshtasticSdkRoutingErrorLog.test.ts | 4 +- .../meshtasticTransportSideEffects.test.ts | 41 +++++++++++++++---- .../meshtasticTransportSideEffects.ts | 27 ++++++++---- .../lib/rendererUnhandledRejection.test.ts | 30 ++------------ .../lib/rendererUnhandledRejection.ts | 20 ++------- .../reticulumRemoteAddressStore.test.ts | 26 ++++++++++++ .../stores/reticulumRemoteAddressStore.ts | 40 ++++++++++++------ 11 files changed, 126 insertions(+), 124 deletions(-) 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/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..acfa1d2e6 100644 --- a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts @@ -1,6 +1,6 @@ // @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 { attachMeshtasticTransportLossWatch } from './meshtasticTransportLossDetection'; import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSideEffects'; @@ -15,13 +15,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 +41,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 +61,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 +84,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 +102,22 @@ 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('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/stores/reticulumRemoteAddressStore.test.ts b/src/renderer/stores/reticulumRemoteAddressStore.test.ts index 37dacc4ac..b3db3b049 100644 --- a/src/renderer/stores/reticulumRemoteAddressStore.test.ts +++ b/src/renderer/stores/reticulumRemoteAddressStore.test.ts @@ -46,6 +46,32 @@ 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('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..345d2e5cb 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; @@ -23,21 +25,33 @@ export const useReticulumRemoteAddressStore = create { - 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 => { + 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); + } + set({ addresses: map, hydrated: true, loading: false }); + } catch (e) { + console.warn('[reticulumRemoteAddressStore] hydrate ' + errLikeToLogString(e)); + 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 +103,7 @@ export const useReticulumRemoteAddressStore = create { - set({ addresses: new Map(), hydrated: false, loading: false }); + set({ addresses: new Map(), hydrated: false, loading: false, loadingPromise: null }); }, }), ); From 9ad818bce81888c3d8b9e90e5529a86b63b84a88 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 08:20:10 -0600 Subject: [PATCH 2/4] fix(meshcore): show pubkey short IDs, offline keys, and propagate deletes Contacts now display a pubkey-derived short id (! + first 8 hex) instead of the non-reversible XOR-fold hash, with the XOR id kept only as a fallback. The full public key is resolved offline from SQLite via a runtime-owned meshcorePubKeyHexByNodeId map (hydrated on mount/disconnect and kept in sync at every radio-contacts site), and is shown copyable in NodeDetailModal. Deleting a contact while offline (or when a radio removal fails) now retries conn.removeContact on the next sync (initConn / refreshContacts / path-updated rebuild) so the delete propagates; the radio remains authority, so a contact it still reports revives while a successful removal never reappears. --- src/renderer/App.tsx | 28 +------- .../components/NodeDetailModal.test.tsx | 23 ++++++ src/renderer/components/NodeDetailModal.tsx | 33 ++++++++- .../components/NodeListPanel.test.tsx | 41 +++++++++++ src/renderer/components/NodeListPanel.tsx | 7 +- .../meshcoreHookPreamble.retryRemove.test.ts | 64 +++++++++++++++++ .../hooks/meshcore/meshcoreHookPreamble.ts | 37 +++++++++- .../meshcore/meshcorePathUpdatedRuntime.ts | 10 ++- src/renderer/lib/meshcoreUtils.test.ts | 19 +++++ src/renderer/lib/meshcoreUtils.ts | 7 ++ src/renderer/locales/cs/translation.json | 4 +- src/renderer/locales/de/translation.json | 4 +- src/renderer/locales/en/translation.json | 2 + src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 4 +- src/renderer/locales/id/translation.json | 4 +- src/renderer/locales/it/translation.json | 4 +- src/renderer/locales/ja/translation.json | 4 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 4 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 4 +- src/renderer/locales/tr/translation.json | 4 +- src/renderer/locales/uk/translation.json | 4 +- src/renderer/locales/zh/translation.json | 4 +- src/renderer/runtime/useMeshcoreRuntime.ts | 72 +++++++++++++++++-- 27 files changed, 353 insertions(+), 50 deletions(-) create mode 100644 src/renderer/hooks/meshcore/meshcoreHookPreamble.retryRemove.test.ts 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..18e7a95dd 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,25 @@ 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(); + renderMeshcoreModal(); + + const pubkeyEl = await screen.findByText(pubkeyHex); + expect(pubkeyEl).toBeInTheDocument(); + + 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('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..d6ba187a9 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; @@ -778,6 +783,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..dfabbd381 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -620,6 +620,47 @@ describe('NodeListPanel import contacts', () => { ); expect(screen.getByText(hex)).toBeInTheDocument(); }); + + it('renders pubkey-derived short id in the MeshCore ID cell when the key is known', () => { + const nodeId = 0xdeadbeef; + const hex = 'aabbccdd' + '00'.repeat(28); + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer' })], + ]); + const pubkeyMap = new Map([[nodeId, hex]]); + render( + , + ); + expect(screen.getByText('!aabbccdd')).toBeInTheDocument(); + expect(screen.queryByText('!deadbeef')).not.toBeInTheDocument(); + }); + + it('falls back to the XOR node id in the MeshCore ID cell when the key is unknown', () => { + const nodeId = 0xdeadbeef; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer' })], + ]); + render( + , + ); + expect(screen.getByText('!deadbeef')).toBeInTheDocument(); + }); }); describe('NodeListPanel flood advert (MeshCore)', () => { diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index ae60be6e8..4414858eb 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -56,6 +56,7 @@ import { isMeshcoreDmExcludedHwModel, MESHCORE_CONTACTS_WARNING_THRESHOLD, MESHCORE_MAX_CONTACTS, + meshcorePubkeyShortId, } from '../lib/meshcoreUtils'; import { MESHTASTIC_BUILTIN_CONTACT_GROUP_FILTERS, @@ -1413,7 +1414,11 @@ export default function NodeListPanel({ )} - {formatMeshtasticNodeId(node.node_id)} + {mode === 'meshcore' + ? (meshcorePubkeyShortId( + meshcorePublicKeyHexByNodeId?.get(node.node_id), + ) ?? formatMeshtasticNodeId(node.node_id)) + : formatMeshtasticNodeId(node.node_id)} {mode === 'meshcore' && meshcorePublicKeyHexByNodeId?.has(node.node_id) && ( 🔑 )} 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..a786bbf4f 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -3,6 +3,7 @@ import { sanitizeLogMessage } from '@/main/sanitize-log-message'; import { isValidLatLon } from '../../../shared/geoCoords'; import { meshcoreContactDisplayName } from '../../../shared/meshcoreContactSanitize'; import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../../lib/chatInMemoryBuffer'; +import { errLikeToLogString } from '../../lib/errLikeToLogString'; import type { MeshCoreConnection, MeshcoreContactDbRow, @@ -18,7 +19,10 @@ import { meshcorePayloadIsTapbackEmojiOnly, normalizeMeshcoreIncomingText, } from '../../lib/meshcoreChannelText'; -import { shouldApplyMeshcoreContact } from '../../lib/meshcoreLocallyDeletedContacts'; +import { + isMeshcoreLocallyDeletedContact, + shouldApplyMeshcoreContact, +} from '../../lib/meshcoreLocallyDeletedContacts'; import { CONTACT_TYPE_LABELS, isMeshcoreTransportStatusChatLine, @@ -1128,3 +1132,34 @@ 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 conn.removeContact(c.publicKey); + 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/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/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 04eb70a95..9f6f42171 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -2888,7 +2888,9 @@ "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." }, "nodeInfoBody": { "longName": "Dlouhé jméno", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 765ed3efa..bb05746ea 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Langer Name", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index fec2e7238..9538bd600 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -2887,6 +2887,8 @@ "sensorTelemetryLpp": "Sensor telemetry LPP", "nodeIncomplete": "Node data incomplete - waiting for full NodeInfo packet", "hasPublicKey": "Has public key - can send DMs", + "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", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 754211852..9a33e2320 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Nombre largo", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index e93eb2c98..f76410fb5 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Nom long", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 93ff22a8f..75eb8ef9a 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Nama Panjang", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index d4af7a304..2f92f7ca9 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Nome lungo", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 92944ed8c..7c53cdad7 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -2886,7 +2886,9 @@ "currentRouteHeading": "現在のルート", "hopNameLabel": "{{name}}", "shareContactQr": "連絡先QRを共有", - "shareContactQrAria": "公式アプリインポートのためにMeshCore連絡先QRコードを表示する" + "shareContactQrAria": "公式アプリインポートのためにMeshCore連絡先QRコードを表示する", + "copyPublicKey": "公開鍵をコピー", + "publicKeyCopied": "公開鍵をクリップボードにコピーしました。" }, "nodeInfoBody": { "longName": "長い名前", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index d6c35e47f..6d9ee7bc9 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -2886,7 +2886,9 @@ "currentRouteHeading": "현재 경로", "hopNameLabel": "{{name}}", "shareContactQr": "연락처 공유", - "shareContactQrAria": "공식 앱 가져오기를 위한 MeshCore 연락처 QR 코드 표시" + "shareContactQrAria": "공식 앱 가져오기를 위한 MeshCore 연락처 QR 코드 표시", + "copyPublicKey": "공개 키", + "publicKeyCopied": "공개 키가 클립보드에 복사되었습니다." }, "nodeInfoBody": { "longName": "긴 이름", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index f90433d50..9cdefa210 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Lange naam", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index b101d7044..3a01d8d40 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -2890,7 +2890,9 @@ "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." }, "nodeInfoBody": { "longName": "Długie imię", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 8c4efc5ba..f48bb2479 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -2886,7 +2886,9 @@ "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." }, "nodeInfoBody": { "longName": "Nome longo", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 4cfc55747..3dbc03af4 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -2888,7 +2888,9 @@ "currentRouteHeading": "Текущий маршрут", "hopNameLabel": "{{name}}", "shareContactQr": "Обратная связь", - "shareContactQrAria": "Показать контактный QR-код MeshCore для официального импорта приложения" + "shareContactQrAria": "Показать контактный QR-код MeshCore для официального импорта приложения", + "copyPublicKey": "Копировать открытый ключ", + "publicKeyCopied": "Открытый ключ скопирован в буфер обмена." }, "nodeInfoBody": { "longName": "Длинное имя", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index ce572affe..e4737d137 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -2886,7 +2886,9 @@ "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ı." }, "nodeInfoBody": { "longName": "Uzun Ad", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index b21cc7946..1c87e9529 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -2888,7 +2888,9 @@ "currentRouteHeading": "Поточний маршрут", "hopNameLabel": "{{name}}", "shareContactQr": "Поділитися контактним QR-кодом", - "shareContactQrAria": "Показати контактний QR-код MeshCore для офіційного імпорту додатка" + "shareContactQrAria": "Показати контактний QR-код MeshCore для офіційного імпорту додатка", + "copyPublicKey": "Відкритий ключ", + "publicKeyCopied": "Відкритий ключ скопійовано в буфер обміну." }, "nodeInfoBody": { "longName": "Довге ім'я", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 24f1e5bdc..c2b77d734 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -2886,7 +2886,9 @@ "currentRouteHeading": "当前路线", "hopNameLabel": "{{name}}", "shareContactQr": "分享联系人", - "shareContactQrAria": "显示MeshCore联系人二维码以进行官方应用导入" + "shareContactQrAria": "显示MeshCore联系人二维码以进行官方应用导入", + "copyPublicKey": "公钥", + "publicKeyCopied": "公钥已复制到剪贴板。" }, "nodeInfoBody": { "longName": "长名称", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index d7d126f2f..1cc3dd624 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,27 @@ 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)); +} + +/** Node id → full public-key hex, derived from live radio contacts (for UI identity + copy). */ +function meshcorePubKeyHexMapFromContacts( + contacts: MeshCoreContactRaw[], + self?: MeshCoreSelfInfo | null, +): Map { + const m = new Map(); + if (self?.publicKey?.length === 32) { + const selfId = pubkeyToNodeId(self.publicKey); + if (selfId !== 0) m.set(selfId, bytesToHex(self.publicKey)); + } + for (const c of contacts) { + const id = pubkeyToNodeId(c.publicKey); + if (id !== 0) m.set(id, bytesToHex(c.publicKey)); + } + return m; } export function useMeshcoreRuntime() { @@ -535,6 +557,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 +1141,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 +1152,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 +1788,12 @@ export function useMeshcoreRuntime() { myNodeId: myNodeNumRef.current, previousNodes: meshcorePreviousNodesBaselineForBuild(), pendingPathUpdateNodeIds: pendingIds, - onContacts: setMeshcoreContactsForTelemetry, + onContacts: (contacts) => { + setMeshcoreContactsForTelemetry(contacts); + setMeshcorePubKeyHexByNodeId( + meshcorePubKeyHexMapFromContacts(contacts, selfInfoRef.current), + ); + }, onNodes: (newNodes) => { setNodes(meshcorePathUpdatedNodesMergeUpdater(newNodes)); }, @@ -2526,8 +2564,13 @@ export function useMeshcoreRuntime() { ); } assertInitConnStillLive(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + const contacts = await retryRadioRemoveDeletedContacts( + conn, + contactsRaw.map(meshcoreContactRawFromDevice), + ); + assertInitConnStillLive(); setMeshcoreContactsForTelemetry(contacts); + setMeshcorePubKeyHexByNodeId(meshcorePubKeyHexMapFromContacts(contacts, info)); previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); newNodes = await awaitUnlessMeshcoreSetupCancelled( setupGen, @@ -4335,8 +4378,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(meshcorePubKeyHexMapFromContacts(contacts, selfInfo)); const previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); const newNodes = await buildNodesFromContacts(contacts, { self: selfInfo, @@ -4578,6 +4625,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 +4668,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 +7970,7 @@ export function useMeshcoreRuntime() { telemetryDeviceUpdateInterval: undefined as number | undefined, setRadioParams, meshcoreContactsForTelemetry, + meshcorePubKeyHexByNodeId, meshcoreAutoadd, applyMeshcoreContactAutoAdd, refreshMeshcoreAutoaddFromDevice, @@ -8025,6 +8086,7 @@ export function useMeshcoreRuntime() { connectAutomatic, setRadioParams, meshcoreContactsForTelemetry, + meshcorePubKeyHexByNodeId, meshcoreAutoadd, applyMeshcoreContactAutoAdd, refreshMeshcoreAutoaddFromDevice, From 7c9bbc32f87df40109a6b5f97b32c35973cb33a2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 08:59:56 -0600 Subject: [PATCH 3/4] feat(meshcore): drop ID column, add pubkey key indicator, and Health header Remove the unhelpful MeshCore ID column and instead show an accessible public-key icon inline next to any contact whose key is known. Make the node-detail key badge/tooltip DM-aware (Chat/Sensor can DM; Repeater/Room show key only). Give the first status column a visible "Health" header on both the Meshtastic node list and MeshCore contacts panel, replacing the now-unused columnStatus key. --- .../components/NodeDetailModal.test.tsx | 31 +++++++++ src/renderer/components/NodeDetailModal.tsx | 8 ++- .../components/NodeListPanel.test.tsx | 67 ++++++++++++++++--- src/renderer/components/NodeListPanel.tsx | 61 +++++++++-------- src/renderer/locales/cs/translation.json | 8 ++- src/renderer/locales/de/translation.json | 8 ++- src/renderer/locales/en/translation.json | 4 +- src/renderer/locales/es/translation.json | 8 ++- src/renderer/locales/fr/translation.json | 8 ++- src/renderer/locales/id/translation.json | 8 ++- src/renderer/locales/it/translation.json | 8 ++- src/renderer/locales/ja/translation.json | 8 ++- src/renderer/locales/ko/translation.json | 8 ++- src/renderer/locales/nl/translation.json | 8 ++- src/renderer/locales/pl/translation.json | 8 ++- src/renderer/locales/pt-BR/translation.json | 8 ++- src/renderer/locales/ru/translation.json | 8 ++- src/renderer/locales/tr/translation.json | 8 ++- src/renderer/locales/uk/translation.json | 8 ++- src/renderer/locales/zh/translation.json | 8 ++- 20 files changed, 206 insertions(+), 85 deletions(-) diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 18e7a95dd..71f5a14ee 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -327,6 +327,37 @@ describe('NodeDetailModal MeshCore actions', () => { 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>); + 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(); + }, + ); + + 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>); + 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(); + }, + ); + 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 d6ba187a9..fc946e64e 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -729,9 +729,13 @@ export default function NodeDetailModal({ {protocol === 'meshcore' && contactPubkey && ( - 🔑 DM + {isMeshcoreDmExcludedHwModel(node.hw_model) ? '🔑' : '🔑 DM'} )} {protocol === 'meshcore' && diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index dfabbd381..6d8404c6c 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -621,13 +621,11 @@ describe('NodeListPanel import contacts', () => { expect(screen.getByText(hex)).toBeInTheDocument(); }); - it('renders pubkey-derived short id in the MeshCore ID cell when the key is known', () => { + it('drops the MeshCore ID column (no ID header, no !-prefixed id text)', () => { const nodeId = 0xdeadbeef; - const hex = 'aabbccdd' + '00'.repeat(28); const nodes = new Map([ - [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer' })], + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })], ]); - const pubkeyMap = new Map([[nodeId, hex]]); render( { locationFilter={defaultFilter} onToggleFavorite={vi.fn()} mode="meshcore" - meshcorePublicKeyHexByNodeId={pubkeyMap} + meshcorePublicKeyHexByNodeId={new Map([[nodeId, 'aa'.repeat(32)]])} />, ); - expect(screen.getByText('!aabbccdd')).toBeInTheDocument(); - expect(screen.queryByText('!deadbeef')).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: /^ID$/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/^!/)).not.toBeInTheDocument(); }); - it('falls back to the XOR node id in the MeshCore ID cell when the key is unknown', () => { + it.each(['Chat', 'Sensor', 'Repeater', 'Room'])( + 'shows the key icon for any MeshCore %s contact with a known public key', + (hwModel) => { + const nodeId = 0xdeadbeef; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: hwModel })], + ]); + render( + , + ); + expect(screen.getByLabelText('Has public key')).toBeInTheDocument(); + }, + ); + + 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' })], + [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })], ]); render( { meshcorePublicKeyHexByNodeId={new Map()} />, ); - expect(screen.getByText('!deadbeef')).toBeInTheDocument(); + 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(); }); }); diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index 4414858eb..4dcd91277 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -56,7 +56,6 @@ import { isMeshcoreDmExcludedHwModel, MESHCORE_CONTACTS_WARNING_THRESHOLD, MESHCORE_MAX_CONTACTS, - meshcorePubkeyShortId, } from '../lib/meshcoreUtils'; import { MESHTASTIC_BUILTIN_CONTACT_GROUP_FILTERS, @@ -644,7 +643,7 @@ export default function NodeListPanel({ ]); const nodeTableScrollRef = useRef(null); - const nodeTableColSpan = (mode === 'meshcore' ? 11 : 19) - (coordinateFormat === 'mgrs' ? 1 : 0); + const nodeTableColSpan = (mode === 'meshcore' ? 10 : 19) - (coordinateFormat === 'mgrs' ? 1 : 0); const shouldVirtualizeNodeRows = nodeList.length > 100; const nodeRowVirtualizer = useVirtualizer({ count: nodeList.length, @@ -1013,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')}{' '} + + + )} )} - - {mode === 'meshcore' - ? (meshcorePubkeyShortId( - meshcorePublicKeyHexByNodeId?.get(node.node_id), - ) ?? formatMeshtasticNodeId(node.node_id)) - : formatMeshtasticNodeId(node.node_id)} - {mode === 'meshcore' && meshcorePublicKeyHexByNodeId?.has(node.node_id) && ( - 🔑 - )} - + {mode !== 'meshcore' && ( + + {formatMeshtasticNodeId(node.node_id)} + + )} @@ -1438,6 +1434,17 @@ export default function NodeListPanel({ )} + {mode === 'meshcore' && + meshcorePublicKeyHexByNodeId?.has(node.node_id) && ( + + 🔑 + + )} {!isSelf && (() => { const routingRow = getRoutingRowForNode( diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 9f6f42171..b039fa693 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -2890,7 +2890,8 @@ "shareContactQr": "Sdílet kontakt", "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." + "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", @@ -3012,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", @@ -3060,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 bb05746ea..f850961e8 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Kontakt teilen", "shareContactQrAria": "MeshCore-Kontakt-QR-Code für offiziellen App-Import anzeigen", "copyPublicKey": "Öffentlichen Schlüssel kopieren", - "publicKeyCopied": "Öffentlicher Schlüssel in die Zwischenablage kopiert." + "publicKeyCopied": "Öffentlicher Schlüssel in die Zwischenablage kopiert.", + "hasPublicKeyNoDm": "Verfügt über einen öffentlichen Schlüssel (keine direkten Nachrichten)" }, "nodeInfoBody": { "longName": "Langer Name", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Verbundene Mesh-Knoten", - "columnStatus": "Status", "columnFavorite": "Favorit", "columnId": "ID", "columnLongName": "Langname", @@ -3058,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 9538bd600..75ed2d24b 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -2887,6 +2887,7 @@ "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)", @@ -3188,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 9a33e2320..17901ca54 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Compartir contacto", "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." + "publicKeyCopied": "Clave pública copiada al portapapeles.", + "hasPublicKeyNoDm": "Tiene clave pública (sin mensajes directos)" }, "nodeInfoBody": { "longName": "Nombre largo", @@ -3010,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", @@ -3058,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 f76410fb5..4486caf09 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Partager le contact via", "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." + "publicKeyCopied": "Clé publique copiée dans le presse-papiers.", + "hasPublicKeyNoDm": "Possède une clé publique (pas de messages directs)" }, "nodeInfoBody": { "longName": "Nom long", @@ -3010,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", @@ -3058,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 75eb8ef9a..5fb8fd1ae 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Bagikan QR kontak", "shareContactQrAria": "Tampilkan kode QR kontak MeshCore untuk impor aplikasi resmi", "copyPublicKey": "Kunci Publik", - "publicKeyCopied": "Kunci publik disalin ke clipboard." + "publicKeyCopied": "Kunci publik disalin ke clipboard.", + "hasPublicKeyNoDm": "Memiliki kunci publik (tidak ada pesan langsung)" }, "nodeInfoBody": { "longName": "Nama Panjang", @@ -3010,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", @@ -3058,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 2f92f7ca9..52492a518 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Condividi QR contatto", "shareContactQrAria": "Mostra il codice QR del contatto MeshCore per l'importazione ufficiale dell'app", "copyPublicKey": "Public key", - "publicKeyCopied": "Chiave pubblica copiata negli appunti." + "publicKeyCopied": "Chiave pubblica copiata negli appunti.", + "hasPublicKeyNoDm": "Ha una chiave pubblica (nessun messaggio diretto)" }, "nodeInfoBody": { "longName": "Nome lungo", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Nodi mesh connessi", - "columnStatus": "Stato", "columnFavorite": "Preferito", "columnId": "ID", "columnLongName": "Nome lungo", @@ -3058,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 7c53cdad7..96ec567fc 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "連絡先QRを共有", "shareContactQrAria": "公式アプリインポートのためにMeshCore連絡先QRコードを表示する", "copyPublicKey": "公開鍵をコピー", - "publicKeyCopied": "公開鍵をクリップボードにコピーしました。" + "publicKeyCopied": "公開鍵をクリップボードにコピーしました。", + "hasPublicKeyNoDm": "公開鍵があります(ダイレクトメッセージはありません)" }, "nodeInfoBody": { "longName": "長い名前", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}} オフライン", "summaryOffline_other": "{{count}} オフライン", "tableCaptionMeshNodes": "接続されたメッシュノード", - "columnStatus": "状態", "columnFavorite": "お気に入り", "columnId": "ID", "columnLongName": "長い名前", @@ -3058,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 6d9ee7bc9..dab5aade0 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "연락처 공유", "shareContactQrAria": "공식 앱 가져오기를 위한 MeshCore 연락처 QR 코드 표시", "copyPublicKey": "공개 키", - "publicKeyCopied": "공개 키가 클립보드에 복사되었습니다." + "publicKeyCopied": "공개 키가 클립보드에 복사되었습니다.", + "hasPublicKeyNoDm": "공개 키가 있습니다 (직접 메시지 없음)" }, "nodeInfoBody": { "longName": "긴 이름", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}} 오프라인", "summaryOffline_other": "{{count}} 오프라인", "tableCaptionMeshNodes": "연결된 메시 노드", - "columnStatus": "상태", "columnFavorite": "가장 좋아하는", "columnId": "ID", "columnLongName": "긴 이름", @@ -3058,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 9cdefa210..c20451e17 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Deel contact", "shareContactQrAria": "Toon MeshCore-contact QR-code voor officiële app-import", "copyPublicKey": "Openbare sleutel", - "publicKeyCopied": "Openbare sleutel gekopieerd naar klembord." + "publicKeyCopied": "Openbare sleutel gekopieerd naar klembord.", + "hasPublicKeyNoDm": "Heeft een openbare sleutel (geen directe berichten)" }, "nodeInfoBody": { "longName": "Lange naam", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}} offline", "summaryOffline_other": "{{count}} offline", "tableCaptionMeshNodes": "Verbonden mesh-knooppunten", - "columnStatus": "Status", "columnFavorite": "Favoriet", "columnId": "Identiteitskaart", "columnLongName": "Lange naam", @@ -3058,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 3a01d8d40..87041a131 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -2892,7 +2892,8 @@ "shareContactQr": "Udostępnij kontaktowy QR", "shareContactQrAria": "Pokaż kontaktowy kod QR MeshCore do oficjalnego importu aplikacji", "copyPublicKey": "Klucz publiczny", - "publicKeyCopied": "Klucz publiczny skopiowano do schowka." + "publicKeyCopied": "Klucz publiczny skopiowano do schowka.", + "hasPublicKeyNoDm": "Posiada klucz publiczny (bez bezpośrednich wiadomości)" }, "nodeInfoBody": { "longName": "Długie imię", @@ -3014,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ę", @@ -3062,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 f48bb2479..89156fab4 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Partilhar contacto", "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." + "publicKeyCopied": "Chave pública copiada para a área de transferência.", + "hasPublicKeyNoDm": "Tem chave pública (sem mensagens diretas)" }, "nodeInfoBody": { "longName": "Nome longo", @@ -3010,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", @@ -3058,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 3dbc03af4..85a673168 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -2890,7 +2890,8 @@ "shareContactQr": "Обратная связь", "shareContactQrAria": "Показать контактный QR-код MeshCore для официального импорта приложения", "copyPublicKey": "Копировать открытый ключ", - "publicKeyCopied": "Открытый ключ скопирован в буфер обмена." + "publicKeyCopied": "Открытый ключ скопирован в буфер обмена.", + "hasPublicKeyNoDm": "Имеет открытый ключ (нет прямых сообщений)" }, "nodeInfoBody": { "longName": "Длинное имя", @@ -3012,7 +3013,6 @@ "summaryOffline_one": "{{count}} оффлайн", "summaryOffline_other": "{{count}} оффлайн", "tableCaptionMeshNodes": "Связанные узлы сетки", - "columnStatus": "Статус", "columnFavorite": "Любимый", "columnId": "ИДЕНТИФИКАТОР", "columnLongName": "Длинное имя", @@ -3060,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 e4737d137..d0158dc8e 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "Kişi Paylaş", "shareContactQrAria": "Resmi uygulama içe aktarma için MeshCore iletişim QR kodunu göster", "copyPublicKey": "genel anahtarı kopyala", - "publicKeyCopied": "Genel anahtar panoya kopyalandı." + "publicKeyCopied": "Genel anahtar panoya kopyalandı.", + "hasPublicKeyNoDm": "Genel anahtarı var (doğrudan mesaj yok)" }, "nodeInfoBody": { "longName": "Uzun Ad", @@ -3010,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", @@ -3058,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 1c87e9529..811d98706 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -2890,7 +2890,8 @@ "shareContactQr": "Поділитися контактним QR-кодом", "shareContactQrAria": "Показати контактний QR-код MeshCore для офіційного імпорту додатка", "copyPublicKey": "Відкритий ключ", - "publicKeyCopied": "Відкритий ключ скопійовано в буфер обміну." + "publicKeyCopied": "Відкритий ключ скопійовано в буфер обміну.", + "hasPublicKeyNoDm": "Має відкритий ключ (немає прямих повідомлень)" }, "nodeInfoBody": { "longName": "Довге ім'я", @@ -3012,7 +3013,6 @@ "summaryOffline_one": "{{count}} в автономному режимі", "summaryOffline_other": "{{count}} в автономному режимі", "tableCaptionMeshNodes": "З'єднані вузли сіті", - "columnStatus": "Статус", "columnFavorite": "Обране", "columnId": "ID", "columnLongName": "Довга назва", @@ -3060,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 c2b77d734..240c512d1 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -2888,7 +2888,8 @@ "shareContactQr": "分享联系人", "shareContactQrAria": "显示MeshCore联系人二维码以进行官方应用导入", "copyPublicKey": "公钥", - "publicKeyCopied": "公钥已复制到剪贴板。" + "publicKeyCopied": "公钥已复制到剪贴板。", + "hasPublicKeyNoDm": "有公钥(无直接消息)" }, "nodeInfoBody": { "longName": "长名称", @@ -3010,7 +3011,6 @@ "summaryOffline_one": "{{count}}离线", "summaryOffline_other": "{{count}}离线", "tableCaptionMeshNodes": "已连接的网格节点", - "columnStatus": "状态", "columnFavorite": "我的最愛", "columnId": "CN", "columnLongName": "长名", @@ -3058,7 +3058,9 @@ "emptyHistory": "还没有私信—发送或接收 DM 以在此处查看对等节点。", "meshcoreTypeSensor": "测传", "meshcoreTypeNone": "无", - "meshcoreTypeUnknown": "未知" + "meshcoreTypeUnknown": "未知", + "columnHealth": "健康", + "hasPublicKeyTitle": "公钥" }, "nomadNetwork": { "title": "Nomad Network", From 79865f64ff55bdc135494f4e987b643606300cb4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 09:21:29 -0600 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?timeout=20removeContact=20retry,=20merge=20pubkey=20map,=20guar?= =?UTF-8?q?d=20stale=20hydrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meshcoreHookPreamble: wrap retryRadioRemoveDeletedContacts' conn.removeContact in the shared withTimeout helper (meshcore.js has no internal timeout) so a stalled radio removal can no longer block the awaiting init/refresh/path-updated sync callers - useMeshcoreRuntime: merge live radio-contact pubkeys into the existing meshcorePubKeyHexByNodeId map (offloadContactsFromRadio-style updater) instead of replacing it, preserving SQLite-hydrated off-radio entries while pruning locally-deleted ids - reticulumRemoteAddressStore: capture a clear-generation token in hydrate() and check it before every fetched-state write so a clear() during the in-flight listReticulumRemoteAddresses() cannot restore cleared state; add deferred-IPC test - NodeListPanel: MeshCore nodeTableColSpan base 11 (Health/Favorite/Name/Last Heard/Type/Hops/Lat/Lon/Signal/SNR/Battery), keeping the mgrs subtraction - tests: safeDisconnect arms the late-configure swallow window before device.disconnect() and it expires; heartbeat non-Error rejection logs a normalized debug line without an unhandled rejection; add vitest-axe coverage to MeshCore public-key copy/badge and key-icon UI --- .../components/NodeDetailModal.test.tsx | 17 ++++++-- .../components/NodeListPanel.test.tsx | 6 ++- src/renderer/components/NodeListPanel.tsx | 2 +- .../hooks/meshcore/meshcoreHookPreamble.ts | 9 ++++- .../lib/connection.serial-cleanup.test.ts | 36 +++++++++++++++++ .../meshtasticTransportSideEffects.test.ts | 31 ++++++++++++++ src/renderer/runtime/useMeshcoreRuntime.ts | 40 +++++++++++-------- .../reticulumRemoteAddressStore.test.ts | 23 +++++++++++ .../stores/reticulumRemoteAddressStore.ts | 10 ++++- 9 files changed, 150 insertions(+), 24 deletions(-) diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 71f5a14ee..d2f8b64ad 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -316,11 +316,14 @@ describe('NodeDetailModal MeshCore actions', () => { on_radio: 1, } as unknown as Awaited>); const user = userEvent.setup(); - renderMeshcoreModal(); + 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); @@ -334,11 +337,15 @@ describe('NodeDetailModal MeshCore actions', () => { public_key: 'ab'.repeat(32), on_radio: 1, } as unknown as Awaited>); - renderMeshcoreModal({ node: { ...meshcoreRepeaterNode, hw_model: hwModel } }); + 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(); }, ); @@ -349,12 +356,16 @@ describe('NodeDetailModal MeshCore actions', () => { public_key: 'ab'.repeat(32), on_radio: 1, } as unknown as Awaited>); - renderMeshcoreModal({ node: { ...meshcoreRepeaterNode, hw_model: hwModel } }); + 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(); }, ); diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 6d8404c6c..08892fad1 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -643,12 +643,12 @@ describe('NodeListPanel import contacts', () => { it.each(['Chat', 'Sensor', 'Repeater', 'Room'])( 'shows the key icon for any MeshCore %s contact with a known public key', - (hwModel) => { + async (hwModel) => { const nodeId = 0xdeadbeef; const nodes = new Map([ [nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: hwModel })], ]); - render( + const { container } = render( { />, ); expect(screen.getByLabelText('Has public key')).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); }, ); diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index 4dcd91277..3595f8ccb 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -643,7 +643,7 @@ export default function NodeListPanel({ ]); const nodeTableScrollRef = useRef(null); - const nodeTableColSpan = (mode === 'meshcore' ? 10 : 19) - (coordinateFormat === 'mgrs' ? 1 : 0); + const nodeTableColSpan = (mode === 'meshcore' ? 11 : 19) - (coordinateFormat === 'mgrs' ? 1 : 0); const shouldVirtualizeNodeRows = nodeList.length > 100; const nodeRowVirtualizer = useVirtualizer({ count: nodeList.length, diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index a786bbf4f..b78083641 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -2,6 +2,7 @@ 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 { @@ -177,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}. */ @@ -1148,7 +1151,11 @@ export async function retryRadioRemoveDeletedContacts( const id = pubkeyToNodeId(c.publicKey); if (id !== 0 && isMeshcoreLocallyDeletedContact(id)) { try { - await conn.removeContact(c.publicKey); + await withTimeout( + conn.removeContact(c.publicKey), + MESHCORE_REMOVE_CONTACT_TIMEOUT_MS, + 'removeContact', + ); console.debug( `[meshcore] retry removeContact: dropped tombstoned contact 0x${id.toString(16)}`, ); 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/meshtastic/meshtasticTransportSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts index acfa1d2e6..3b3b21946 100644 --- a/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts @@ -2,6 +2,7 @@ import type { MeshDevice } from '@meshtastic/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { errLikeToLogString } from '../errLikeToLogString'; import { attachMeshtasticTransportLossWatch } from './meshtasticTransportLossDetection'; import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSideEffects'; @@ -107,6 +108,36 @@ describe('pushMeshtasticTransportSideEffectUnsubs', () => { 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( diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 1cc3dd624..0837c4cb3 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -522,21 +522,29 @@ function meshcorePathUpdatedNodesMergeUpdater( filterOutMeshcoreLocallyDeletedContacts(mergeMeshcoreChatStubNodes(prev, newNodes)); } -/** Node id → full public-key hex, derived from live radio contacts (for UI identity + copy). */ -function meshcorePubKeyHexMapFromContacts( +/** + * 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, -): Map { - const m = new Map(); - if (self?.publicKey?.length === 32) { - const selfId = pubkeyToNodeId(self.publicKey); - if (selfId !== 0) m.set(selfId, bytesToHex(self.publicKey)); - } - for (const c of contacts) { - const id = pubkeyToNodeId(c.publicKey); - if (id !== 0) m.set(id, bytesToHex(c.publicKey)); - } - return m; +): (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() { @@ -1791,7 +1799,7 @@ export function useMeshcoreRuntime() { onContacts: (contacts) => { setMeshcoreContactsForTelemetry(contacts); setMeshcorePubKeyHexByNodeId( - meshcorePubKeyHexMapFromContacts(contacts, selfInfoRef.current), + mergeMeshcorePubKeyHexFromContacts(contacts, selfInfoRef.current), ); }, onNodes: (newNodes) => { @@ -2570,7 +2578,7 @@ export function useMeshcoreRuntime() { ); assertInitConnStillLive(); setMeshcoreContactsForTelemetry(contacts); - setMeshcorePubKeyHexByNodeId(meshcorePubKeyHexMapFromContacts(contacts, info)); + setMeshcorePubKeyHexByNodeId(mergeMeshcorePubKeyHexFromContacts(contacts, info)); previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); newNodes = await awaitUnlessMeshcoreSetupCancelled( setupGen, @@ -4383,7 +4391,7 @@ export function useMeshcoreRuntime() { contactsRaw.map(meshcoreContactRawFromDevice), ); setMeshcoreContactsForTelemetry(contacts); - setMeshcorePubKeyHexByNodeId(meshcorePubKeyHexMapFromContacts(contacts, selfInfo)); + setMeshcorePubKeyHexByNodeId(mergeMeshcorePubKeyHexFromContacts(contacts, selfInfo)); const previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); const newNodes = await buildNodesFromContacts(contacts, { self: selfInfo, diff --git a/src/renderer/stores/reticulumRemoteAddressStore.test.ts b/src/renderer/stores/reticulumRemoteAddressStore.test.ts index b3db3b049..0779eeb63 100644 --- a/src/renderer/stores/reticulumRemoteAddressStore.test.ts +++ b/src/renderer/stores/reticulumRemoteAddressStore.test.ts @@ -72,6 +72,29 @@ describe('reticulumRemoteAddressStore', () => { 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 345d2e5cb..7331d0daa 100644 --- a/src/renderer/stores/reticulumRemoteAddressStore.ts +++ b/src/renderer/stores/reticulumRemoteAddressStore.ts @@ -20,6 +20,10 @@ 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(), @@ -33,9 +37,12 @@ export const useReticulumRemoteAddressStore = create => { + // 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); @@ -43,7 +50,7 @@ export const useReticulumRemoteAddressStore = create { + clearGeneration += 1; set({ addresses: new Map(), hydrated: false, loading: false, loadingPromise: null }); }, }),