From af7ad3745181ff0880d7959db8bc7812cc6b1fb4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 18:57:08 -0600 Subject: [PATCH 1/6] fix: harden IPC/logging gaps and clear audit findings Close floating promise and sender-validation holes, share must-match helpers that were drifting, localize overlooked UI strings, and correct factual docs mismatches from the obvious-wrong audit. --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 +- docs/accessibility-checklist.md | 2 +- docs/diagnostics.md | 26 ++-- docs/reticulum-sidecar-ipc.md | 4 +- scripts/check-insecure-temp-files.mjs | 3 + scripts/check-insecure-temp-files.test.mjs | 12 ++ scripts/check-log-injection.mjs | 4 +- .../test-linux-appimage-reticulum-sidecar.mjs | 22 +-- scripts/test-win-nsis-install.mjs | 21 ++- src/main/index.ipc-security.test.ts | 34 ++++- src/main/index.ts | 34 +++-- src/main/meshcore-mqtt-adapter.ts | 25 ++-- src/main/mqtt-manager.ts | 20 +-- src/main/tak-server-manager.ts | 7 +- src/main/updater.ts | 42 ++++-- src/renderer/components/ChatPanel.tsx | 5 +- src/renderer/components/NodeListPanel.tsx | 3 + src/renderer/components/RawPacketLogPanel.tsx | 32 +++-- .../hooks/openMeshCoreTransport.test.ts | 21 +++ src/renderer/hooks/useSendMessage.test.ts | 133 +++++++++++++++++- .../lib/drivers/ConnectionDriver.test.ts | 23 +++ src/renderer/lib/gpsSource.ts | 40 +++++- src/renderer/lib/lastConnectionStorage.ts | 3 +- .../lib/meshcoreRepeaterCredentialStorage.ts | 5 +- .../lib/meshcoreRoomCredentialStorage.ts | 5 +- src/renderer/lib/meshcoreRoomSyncStorage.ts | 9 +- src/renderer/lib/meshtasticBacklogUtils.ts | 15 +- src/renderer/lib/meshtasticMessageDedup.ts | 4 +- .../lib/meshtasticRemoteAdminKeyStorage.ts | 4 +- src/renderer/lib/mqttTls.ts | 6 +- src/renderer/lib/parseStoredJson.ts | 2 +- src/renderer/lib/timeConstants.ts | 10 +- src/renderer/locales/cs/translation.json | 5 +- src/renderer/locales/de/translation.json | 5 +- src/renderer/locales/en/translation.json | 3 + src/renderer/locales/es/translation.json | 5 +- src/renderer/locales/fr/translation.json | 5 +- src/renderer/locales/id/translation.json | 5 +- src/renderer/locales/it/translation.json | 5 +- src/renderer/locales/ja/translation.json | 5 +- src/renderer/locales/ko/translation.json | 5 +- src/renderer/locales/nl/translation.json | 5 +- src/renderer/locales/pl/translation.json | 5 +- src/renderer/locales/pt-BR/translation.json | 5 +- src/renderer/locales/ru/translation.json | 5 +- src/renderer/locales/tr/translation.json | 5 +- src/renderer/locales/uk/translation.json | 5 +- src/renderer/locales/zh/translation.json | 5 +- src/renderer/runtime/useMeshcoreRuntime.ts | 43 ++---- src/renderer/runtime/useMeshtasticRuntime.ts | 43 +++--- src/renderer/stores/nomadPageViewerStore.ts | 10 +- src/shared/appSettingsKeyPrefixes.ts | 13 ++ src/shared/meshtasticTextMessagePayload.ts | 3 +- src/shared/mqttTls.ts | 14 ++ 55 files changed, 564 insertions(+), 222 deletions(-) create mode 100644 src/shared/appSettingsKeyPrefixes.ts create mode 100644 src/shared/mqttTls.ts diff --git a/AGENTS.md b/AGENTS.md index 96f2b9734..34ba42276 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Retention:** App defaults Reticulum destination age/count pruning to 30 days / 10,000 destinations (favorites preserved; count max 50,000); Reticulum message retention independently enabled at 4,000. RRC room history retention independently enabled by default at **10,000** messages (30-day age prune) via `rrcMessageRetention*` settings and `db:pruneRrcMessagesByCount` / `db:pruneRrcMessagesByAge`. - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` -- **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + `reticulum:onVoiceAudio` (`/ws/voice`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). +- **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + push channel `reticulum:voiceAudio` (`/ws/voice`; preload `onVoiceAudio`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). - **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; companion `games_outbound.db` persists last envelope + `delivery_state` (LXMF outbound bridge → session chips / Resend). Dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [docs/reticulum-games-parity.md](docs/reticulum-games-parity.md). - **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` - **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eac557c15..511d28a0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,8 +21,8 @@ Thank you for your interest in contributing. See [Test harness setup and local quality checks](docs/development-environment.md#4-test-harness-setup-and-local-quality-checks) for Vitest projects, pre-PR commands, and browser dev stub behavior. -- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`). -- **Reticulum sidecar (Rust):** when editing `reticulum-sidecar/**`, run `pnpm run reticulum:sidecar:clippy:full` before PR; CI enforces line coverage in `tests.yaml` when sidecar paths change (see [development-environment.md](development-environment.md#lint-and-coverage-sidecar)). +- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main (node project): `src/main/**/*.test.ts`, plus `src/shared/**`, `src/preload/**`, `src/architecture/**`, `scripts/**/*.test.mjs`, and `vitest.harness.test.ts` (see `vitest.config.mts`). +- **Reticulum sidecar (Rust):** when editing `reticulum-sidecar/**`, run `pnpm run reticulum:sidecar:clippy:full` before PR; CI enforces line coverage in `tests.yaml` when sidecar paths change (see [docs/development-environment.md](docs/development-environment.md#lint-and-coverage-sidecar)). - Mock console before spying logged errors (e.g. `vi.spyOn(console, 'warn').mockImplementation(() => {})`; use `beforeEach` when shared). - Update `src/main/index.contract.test.ts` when CSP, build config, IPC limits, or log filters change. - Accessibility: vitest-axe in component tests; see **Accessibility / axe** in [AGENTS.md](AGENTS.md#5-testing). diff --git a/docs/accessibility-checklist.md b/docs/accessibility-checklist.md index b9e32572b..1ee39925a 100644 --- a/docs/accessibility-checklist.md +++ b/docs/accessibility-checklist.md @@ -7,7 +7,7 @@ This is a living document. Check items against VoiceOver (macOS), NVDA (Windows) ## Screen Reader Compatibility - [ ] App title announced on launch -- [ ] Tab labels (Chat, Nodes, Config…) read correctly +- [ ] Tab labels (Connection, Chat, Nodes, Radio, …) read correctly - [x] Connection status changes announced (`aria-live="polite"`) — device status in `App.tsx` header (`role="status" aria-live="polite"`); MQTT/TAK indicators still optional follow-up - [ ] Modal open/close announced as "dialog" - [x] Confirmation dialogs announced as "alert dialog" — `ConfirmModal` uses `role="alertdialog"` + `aria-describedby` diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f80ab47c0..4fe632882 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -468,19 +468,19 @@ Sidecar APIs: `GET /api/v1/config/audit`, `POST /api/v1/config/repair` (see [`re For contributors who want to modify or extend the diagnostics system: -| File | Purpose | -| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| [`src/renderer/stores/diagnosticsStore.ts`](src/renderer/stores/diagnosticsStore.ts) | Zustand store: anomaly state, persistence, MQTT ignore sets, foreign LoRa records | -| [`src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts) | Hop anomaly detection (hop_goblin, bad_route, impossible_hop, route_flapping) | -| [`src/renderer/lib/diagnostics/RFDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RFDiagnosticEngine.ts) | RF signal analysis (connected node + remote node findings) | -| [`src/renderer/lib/diagnostics/diagnosticRows.ts`](src/renderer/lib/diagnostics/diagnosticRows.ts) | Row merge/prune utilities, `filterDiagnosticRowsForProtocol`, default max-age values | -| [`src/renderer/lib/foreignLoraDetection.ts`](src/renderer/lib/foreignLoraDetection.ts) | Foreign LoRa packet classification, Reticulum overhear heuristic, proximity scoring | -| [`src/renderer/components/DiagnosticsPanel.tsx`](src/renderer/components/DiagnosticsPanel.tsx) | Diagnostics tab UI: health band + counts, anomaly table, foreign LoRa tables, settings | -| [`src/renderer/components/ReticulumDiagnosticsSection.tsx`](src/renderer/components/ReticulumDiagnosticsSection.tsx) | Reticulum config audit table + repair actions | -| [`src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts`](src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts) | Reticulum-native diagnostic rows (interfaces, audit merge) | -| [`src/renderer/lib/reticulum/reticulumConfigAudit.ts`](src/renderer/lib/reticulum/reticulumConfigAudit.ts) | Config audit/repair IPC client | -| [`src/renderer/components/NodeDetailModal.tsx`](src/renderer/components/NodeDetailModal.tsx) | Per-node detail overlay: routing health, MQTT ignore toggle | -| [`src/renderer/components/NodeInfoBody.tsx`](src/renderer/components/NodeInfoBody.tsx) | RF findings section, redundancy path history, congestion block | +| File | Purpose | +| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [`src/renderer/stores/diagnosticsStore.ts`](../src/renderer/stores/diagnosticsStore.ts) | Zustand store: anomaly state, persistence, MQTT ignore sets, foreign LoRa records | +| [`src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts`](../src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts) | Hop anomaly detection (hop_goblin, bad_route, impossible_hop, route_flapping) | +| [`src/renderer/lib/diagnostics/RFDiagnosticEngine.ts`](../src/renderer/lib/diagnostics/RFDiagnosticEngine.ts) | RF signal analysis (connected node + remote node findings) | +| [`src/renderer/lib/diagnostics/diagnosticRows.ts`](../src/renderer/lib/diagnostics/diagnosticRows.ts) | Row merge/prune utilities, `filterDiagnosticRowsForProtocol`, default max-age values | +| [`src/renderer/lib/foreignLoraDetection.ts`](../src/renderer/lib/foreignLoraDetection.ts) | Foreign LoRa packet classification, Reticulum overhear heuristic, proximity scoring | +| [`src/renderer/components/DiagnosticsPanel.tsx`](../src/renderer/components/DiagnosticsPanel.tsx) | Diagnostics tab UI: health band + counts, anomaly table, foreign LoRa tables, settings | +| [`src/renderer/components/ReticulumDiagnosticsSection.tsx`](../src/renderer/components/ReticulumDiagnosticsSection.tsx) | Reticulum config audit table + repair actions | +| [`src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts`](../src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts) | Reticulum-native diagnostic rows (interfaces, audit merge) | +| [`src/renderer/lib/reticulum/reticulumConfigAudit.ts`](../src/renderer/lib/reticulum/reticulumConfigAudit.ts) | Config audit/repair IPC client | +| [`src/renderer/components/NodeDetailModal.tsx`](../src/renderer/components/NodeDetailModal.tsx) | Per-node detail overlay: routing health, MQTT ignore toggle | +| [`src/renderer/components/NodeInfoBody.tsx`](../src/renderer/components/NodeInfoBody.tsx) | RF findings section, redundancy path history, congestion block | --- diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 78ddada71..ea34efef4 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -291,9 +291,9 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand | `reticulum:rncpSend` / `rncpFetch` / `setRncpListener` | Picker-gated rncp send/fetch/listener (path must match `reticulum-remote-paths` allowlist) | | `reticulum:showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` | Native pickers that seed the rncp send-file / save-dir+fetch-jail allowlists | | `reticulum:revealInFolder` | Reveal a path in the OS file manager when it matches an rncp picker allowlist | -| `reticulum:onEvent` / `onStatus` | Shared `/ws` events and sidecar status | +| `reticulum:event` / `reticulum:status` | Shared `/ws` events and sidecar status (preload: `onEvent` / `onStatus`) | | `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | -| `reticulum:onVoiceAudio` | Dedicated `/ws/voice` → `reticulum:voiceAudio` PCM frames (`voice.audio`) | +| `reticulum:voiceAudio` | Dedicated `/ws/voice` PCM frames (`voice.audio`; preload: `onVoiceAudio`) | | `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | | `reticulum:gamesStatus` / `gamesApps` / `gamesSessions` / … | Dedicated LRGP games IPC (~600/min); generic proxy rejects `/api/v1/games/*` | | `electronAPI.reticulum.games.*` | Preload: `getStatus` / `listApps` / `listSessions` / `getSession` / `sendAction` / `resend` / `markRead` / `deleteSession` | diff --git a/scripts/check-insecure-temp-files.mjs b/scripts/check-insecure-temp-files.mjs index 363732836..64b1f52d2 100644 --- a/scripts/check-insecure-temp-files.mjs +++ b/scripts/check-insecure-temp-files.mjs @@ -27,6 +27,9 @@ const WRITE_FNS = [ 'createWriteStream', 'copyFileSync', 'copyFile', + // mkdirSync on a predictable tmpdir path is the same class of issue (extract dirs, probes). + 'mkdirSync', + 'mkdir', ]; const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-electron', 'coverage', '.git']); diff --git a/scripts/check-insecure-temp-files.test.mjs b/scripts/check-insecure-temp-files.test.mjs index 8692e2245..f80934ff8 100644 --- a/scripts/check-insecure-temp-files.test.mjs +++ b/scripts/check-insecure-temp-files.test.mjs @@ -61,4 +61,16 @@ export const mock = () => path.join(os.tmpdir(), 'mesh-client-support-test-userd `); expect(result.status).toBe(0); }); + + it('fails on mkdirSync to predictable tmpdir path', () => { + const result = runCheckOnSnippet(` +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +const dir = path.join(os.tmpdir(), 'mesh-client-appimage-x64-1'); +fs.mkdirSync(dir, { recursive: true }); +`); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/insecure-temporary-file|predictable/); + }); }); diff --git a/scripts/check-log-injection.mjs b/scripts/check-log-injection.mjs index b388a12f9..815e1b0bb 100644 --- a/scripts/check-log-injection.mjs +++ b/scripts/check-log-injection.mjs @@ -5,7 +5,7 @@ * Flags console.log/warn/error in src/main that pass raw error-like variables * (err, e, error, reason) or error-derived content (e.message, String(e), etc.) * without sanitizeLogMessage() at the call site. - * See CONTRIBUTING.md § Log injection (CodeQL js/log-injection). + * See AGENTS.md §3 (Log injection / sanitizeLogMessage; CodeQL js/log-injection). * * To suppress a false positive, add // log-injection-ok with a short reason * on the same line as the console call. @@ -71,7 +71,7 @@ function main() { console.error(''); } console.error( - 'See CONTRIBUTING.md § Log injection (CodeQL js/log-injection). To suppress, add // log-injection-ok with a reason.', + 'See AGENTS.md §3 (Log injection / sanitizeLogMessage). To suppress, add // log-injection-ok with a reason.', ); process.exit(1); } diff --git a/scripts/test-linux-appimage-reticulum-sidecar.mjs b/scripts/test-linux-appimage-reticulum-sidecar.mjs index 47b363605..2edf3f961 100644 --- a/scripts/test-linux-appimage-reticulum-sidecar.mjs +++ b/scripts/test-linux-appimage-reticulum-sidecar.mjs @@ -11,6 +11,7 @@ import { existsSync, fstatSync, mkdirSync, + mkdtempSync, openSync, readSync, readdirSync, @@ -195,15 +196,18 @@ function extractAppImage(appImagePath, extractDir) { /** @param {'x64' | 'arm64'} arch @param {string} appImagePath */ function assertSidecarInAppImage(arch, appImagePath) { - const extractDir = path.join(tmpdir(), `mesh-client-appimage-${arch}-${process.pid}`); - const payloadRoot = extractAppImage(appImagePath, extractDir); - assertBundledReticulumSidecarInBundle({ - label: `${arch} AppImage Reticulum sidecar`, - platform: 'linux', - bundleRoot: payloadRoot, - fail, - }); - rmSync(extractDir, { recursive: true, force: true }); + const extractDir = mkdtempSync(path.join(tmpdir(), `mesh-client-appimage-${arch}-`)); + try { + const payloadRoot = extractAppImage(appImagePath, extractDir); + assertBundledReticulumSidecarInBundle({ + label: `${arch} AppImage Reticulum sidecar`, + platform: 'linux', + bundleRoot: payloadRoot, + fail, + }); + } finally { + rmSync(extractDir, { recursive: true, force: true }); + } console.debug( `[test-linux-appimage-reticulum-sidecar] OK — sidecar present in ${path.basename(appImagePath)}`, ); diff --git a/scripts/test-win-nsis-install.mjs b/scripts/test-win-nsis-install.mjs index 5b575841b..2af8b2b69 100644 --- a/scripts/test-win-nsis-install.mjs +++ b/scripts/test-win-nsis-install.mjs @@ -10,7 +10,15 @@ * node scripts/test-win-nsis-install.mjs --arch arm64 [--probe-7z] */ import { spawnSync } from 'child_process'; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -160,7 +168,12 @@ function main(arch, probe7z) { const installerPath = path.join(releaseDir, installer); if (probe7z) { - probe7zExtract(installerPath, path.join(tmpdir(), 'mesh-client-7z-probe'), arch); + const probeDir = mkdtempSync(path.join(tmpdir(), 'mesh-client-7z-probe-')); + try { + probe7zExtract(installerPath, probeDir, arch); + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } } const localAppData = process.env.LOCALAPPDATA; @@ -168,10 +181,10 @@ function main(arch, probe7z) { fail('LOCALAPPDATA is not set'); } const instDir = path.join(localAppData, 'Programs', 'Mesh-client'); - const logPath = path.join(tmpdir(), `mesh-client-install-${arch}.log`); + const workDir = mkdtempSync(path.join(tmpdir(), 'mesh-client-install-')); + const logPath = path.join(workDir, `mesh-client-install-${arch}.log`); rmSync(instDir, { recursive: true, force: true }); - rmSync(logPath, { force: true }); console.debug(`[test-win-nsis-install] Installing ${installer} → ${instDir}`); const installStatus = run(installerPath, ['/S', `/LOG=${logPath}`]); diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index 3a3dbb3c5..2ca9a28b4 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -7,6 +7,7 @@ import { formatHostForUrl, parseConnectHostPort } from '../shared/connectHost'; import { isValidHttpHostname } from './httpHostValidation'; const INDEX_SOURCE = readFileSync(join(__dirname, 'index.ts'), 'utf-8'); +const UPDATER_SOURCE = readFileSync(join(__dirname, 'updater.ts'), 'utf-8'); const SUPPORT_BUNDLE_SOURCE = readFileSync(join(__dirname, 'support-bundle.ts'), 'utf-8'); const TAK_IPC_SOURCE = readFileSync(join(__dirname, 'ipc/tak-handlers.ts'), 'utf-8'); const GPS_IPC_SOURCE = readFileSync(join(__dirname, 'ipc/gps-handlers.ts'), 'utf-8'); @@ -593,6 +594,8 @@ describe('privileged IPC sender validation (source contract)', () => { 'appSettings:set', 'app:rendererHeartbeat', 'app:getRendererLiveness', + 'app:getProcessUptimeSec', + 'meshcore:openJsonFile', 'db:saveNode', 'db:saveNodePath', 'db:getNodes', @@ -634,6 +637,34 @@ describe('privileged IPC sender validation (source contract)', () => { ).toBe(true); }); + it.each(['device-connected', 'device-disconnected'] as const)( + '%s validates the IPC sender', + (channel) => { + const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.on('${channel}'`); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 300); + expect(body).toContain('validateIpcSender(event)'); + }, + ); + + it.each(['update:check', 'update:download', 'update:install', 'update:open-releases'] as const)( + '%s calls assertIpcSender', + (channel) => { + const needle = `ipcMain.handle('${channel}'`; + let from = 0; + let found = 0; + while (from < UPDATER_SOURCE.length) { + const idx = UPDATER_SOURCE.indexOf(needle, from); + if (idx < 0) break; + found += 1; + const body = UPDATER_SOURCE.slice(idx, idx + 250); + expect(body).toContain(`assertIpcSender(event, '${channel}')`); + from = idx + needle.length; + } + expect(found).toBeGreaterThan(0); + }, + ); + it('http fromradio poll uses AbortSignal.timeout', () => { expect(INDEX_SOURCE).toContain('HTTP_FETCH_TIMEOUT_MS'); expect(INDEX_SOURCE).toMatch( @@ -705,7 +736,8 @@ describe('privileged IPC sender validation (source contract)', () => { }); it('appSettings allows meshcore repeater credential prefix', () => { - expect(INDEX_SOURCE).toContain('meshcoreRepeaterCredential:'); + expect(INDEX_SOURCE).toContain('MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain("from '../shared/appSettingsKeyPrefixes'"); expect(INDEX_SOURCE).toContain('appSettingsMaxValueLengthForKey'); }); diff --git a/src/main/index.ts b/src/main/index.ts index 34099bdb3..460890de0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,13 @@ import { pathToFileURL } from 'url'; import zlib from 'zlib'; import type { MQTTSettings } from '../renderer/lib/types'; +import { + MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX, + MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX, + MESHCORE_ROOM_LAST_POST_SETTING_PREFIX, + MESHCORE_ROOM_SYNC_SETTING_PREFIX, + MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX, +} from '../shared/appSettingsKeyPrefixes'; import { APP_ABOUT_TAGLINE } from '../shared/appTagline'; import { clampQueryLimit } from '../shared/clampQueryLimit'; import { formatHostForSocket, parseConnectHostPort } from '../shared/connectHost'; @@ -2620,12 +2627,20 @@ ipcMain.on('ble-reset-pairing-retry-count', (_event, sessionKind?: unknown) => { }); // ─── IPC: Connection status tracking (module-scope, not per-window) ─ -ipcMain.on('device-connected', () => { +ipcMain.on('device-connected', (event) => { + if (!validateIpcSender(event)) { + console.warn('[IPC] device-connected: unauthorized sender'); + return; + } console.debug('[main] device-connected: isConnected = true'); isConnected = true; startPowerSaveBlocker(); }); -ipcMain.on('device-disconnected', () => { +ipcMain.on('device-disconnected', (event) => { + if (!validateIpcSender(event)) { + console.warn('[IPC] device-disconnected: unauthorized sender'); + return; + } console.debug('[main] device-disconnected: isConnected = false'); isConnected = false; stopPowerSaveBlocker(); @@ -3503,7 +3518,10 @@ ipcMain.handle('storage:decrypt', (event, ciphertext: unknown) => { }); // ─── IPC: Login item (launch at startup) ─────────────────────────── -ipcMain.handle('app:getProcessUptimeSec', () => Math.floor(process.uptime())); +ipcMain.handle('app:getProcessUptimeSec', (event) => { + assertIpcSender(event, 'app:getProcessUptimeSec'); + return Math.floor(process.uptime()); +}); ipcMain.handle('app:getRendererLiveness', (event) => { if (!validateIpcSender(event)) { @@ -3580,13 +3598,6 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet = new Set([ 'meshtasticRemoteAdminKeyByNode', ]); const APP_SETTINGS_MAX_VALUE_LENGTH = 256; -const MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX = 'meshtasticRemoteAdminKey:'; -/** MeshCore Rooms tab — must match renderer meshcoreRoomSyncStorage / meshcoreRoomCredentialStorage. */ -const MESHCORE_ROOM_SYNC_SETTING_PREFIX = 'meshcoreRoomSync:'; -const MESHCORE_ROOM_LAST_POST_SETTING_PREFIX = 'meshcoreRoomLastPost:'; -const MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX = 'meshcoreRoomCredential:'; -/** MeshCore Repeaters tab — must match renderer meshcoreRepeaterCredentialStorage. */ -const MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX = 'meshcoreRepeaterCredential:'; function isAppSettingsKeyAllowed(key: string): boolean { return ( @@ -5394,7 +5405,8 @@ ipcMain.handle( }, ); -ipcMain.handle('meshcore:openJsonFile', async () => { +ipcMain.handle('meshcore:openJsonFile', async (event) => { + assertIpcSender(event, 'meshcore:openJsonFile'); try { if (!mainWindow) return null; const result = await dialog.showOpenDialog(mainWindow, { diff --git a/src/main/meshcore-mqtt-adapter.ts b/src/main/meshcore-mqtt-adapter.ts index bce7f4de7..9c0ab4251 100644 --- a/src/main/meshcore-mqtt-adapter.ts +++ b/src/main/meshcore-mqtt-adapter.ts @@ -11,6 +11,7 @@ import { MQTT_MAX_RECONNECT_ATTEMPTS, } from '../shared/meshtasticMqttReconnect'; import { computeMqttReconnectDelayMs } from '../shared/mqttReconnectSchedule'; +import { mqttUsesTls } from '../shared/mqttTls'; import { sanitizeLogMessage } from './log-service'; import { forceEndMqttClient } from './mqtt-client-teardown'; @@ -24,16 +25,13 @@ function normalizePrefix(prefix: string): string { /** For debug logs only — actual connect uses the same option-object shape as MQTTManager. */ function buildMeshcoreUrlForLog(settings: MQTTSettings): string { const host = settings.server.trim(); + const usesTls = mqttUsesTls(settings); if (settings.useWebSocket === true) { - const wsTlsEnabled = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 443); const wsPath = settings.wsPath ?? '/mqtt'; - const scheme = wsTlsEnabled ? 'wss' : 'ws'; + const scheme = usesTls ? 'wss' : 'ws'; return `${scheme}://${host}:${settings.port}${wsPath}`; } - const useTls = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 8883); - return useTls ? `mqtts://${host}:${settings.port}` : `mqtt://${host}:${settings.port}`; + return usesTls ? `mqtts://${host}:${settings.port}` : `mqtt://${host}:${settings.port}`; } /** Time allowed for TCP/TLS/WebSocket + MQTT CONNACK (slow networks, captive portals). */ @@ -254,9 +252,8 @@ export class MeshcoreMqttAdapter extends EventEmitter { const clientId = isV1Username ? settings.username : settings.clientId?.trim() || `meshcore-mqtt-${randomBytes(4).toString('hex')}`; - const useTls = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 8883); - const rejectUnauthorizedTls = useTls ? !settings.tlsInsecure : false; + const usesTls = mqttUsesTls(settings); + const rejectUnauthorizedTls = usesTls ? !settings.tlsInsecure : false; const logUrl = buildMeshcoreUrlForLog(settings); // Match MQTTManager: WebSocket uses mqtt.connect({ protocol, host, port, path, … }) — not @@ -266,10 +263,8 @@ export class MeshcoreMqttAdapter extends EventEmitter { // WebSocket-level pings (MESHCORE_MQTT_WSS_PING_MS) additionally keep LB/proxy paths alive. const keepaliveSec = settings.keepalive ?? 30; const wsEnabled = settings.useWebSocket === true; - const wsTlsEnabled = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 443); const wsPath = settings.wsPath ?? '/mqtt'; - const wsScheme = wsTlsEnabled ? 'wss' : 'ws'; + const wsScheme = usesTls ? 'wss' : 'ws'; let connectOpts: mqtt.IClientOptions = { clientId, username: settings.username || undefined, @@ -296,7 +291,7 @@ export class MeshcoreMqttAdapter extends EventEmitter { ...connectOpts, host: settings.server.trim(), port: settings.port, - protocol: useTls ? 'mqtts' : 'mqtt', + protocol: usesTls ? 'mqtts' : 'mqtt', rejectUnauthorized: rejectUnauthorizedTls, }; } @@ -306,8 +301,8 @@ export class MeshcoreMqttAdapter extends EventEmitter { sanitizeLogMessage(logUrl), 'ws:', settings.useWebSocket, - 'wsTlsEnabled:', - wsTlsEnabled, + 'usesTls:', + usesTls, 'wsPath:', wsPath, 'keepaliveSec:', diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index c395f5243..7ab9c4be8 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -23,6 +23,7 @@ import { resolveMeshtasticTextMessagePayload, } from '../shared/meshtasticTextMessagePayload'; import { computeMqttReconnectDelayMs } from '../shared/mqttReconnectSchedule'; +import { mqttUsesTls } from '../shared/mqttTls'; import { isTransientNetworkError } from '../shared/networkTransientErrors'; import { formatMeshtasticNodeId, @@ -576,24 +577,15 @@ export class MQTTManager extends EventEmitter { this.meshtasticConnectT0 = Date.now(); const hostTrim = settings.server.trim(); - const useTls = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 8883); const wsEnabled = settings.useWebSocket === true; - const wsTlsEnabled = - settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 443); - const rejectUnauthorized = wsEnabled - ? wsTlsEnabled - ? !settings.tlsInsecure - : false - : useTls - ? !settings.tlsInsecure - : false; + const usesTls = mqttUsesTls(settings); + const rejectUnauthorized = usesTls ? !settings.tlsInsecure : false; const wsPath = settings.wsPath ?? '/mqtt'; - const wsScheme = wsTlsEnabled ? 'wss' : 'ws'; + const wsScheme = usesTls ? 'wss' : 'ws'; const logUrl = wsEnabled ? `${wsScheme}://${hostTrim}:${settings.port}${wsPath}` - : useTls + : usesTls ? `mqtts://${hostTrim}:${settings.port}` : `mqtt://${hostTrim}:${settings.port}`; console.debug('[Meshtastic MQTT] connect start', sanitizeLogMessage(logUrl), 'ws:', wsEnabled); // log-filter-ok Meshtastic MQTT logs → App log panel @@ -622,7 +614,7 @@ export class MQTTManager extends EventEmitter { connectOpts = { host: hostTrim, port: settings.port, - protocol: useTls ? 'mqtts' : 'mqtt', + protocol: usesTls ? 'mqtts' : 'mqtt', protocolVersion: 4, // force MQTT 3.1.1; avoids v5 negotiation issues clientId, username: settings.username || undefined, diff --git a/src/main/tak-server-manager.ts b/src/main/tak-server-manager.ts index 549a28d6a..56f7f9600 100644 --- a/src/main/tak-server-manager.ts +++ b/src/main/tak-server-manager.ts @@ -76,10 +76,11 @@ export class TakServerManager extends EventEmitter { this.server.on('error', (err) => { const msg = `Server error: ${String(err)}`; - console.error('[TakServer]', msg); - this._status = { running: false, port: settings.port, clientCount: 0, error: msg }; + const safe = sanitizeLogMessage(msg); + console.error('[TakServer]', safe); + this._status = { running: false, port: settings.port, clientCount: 0, error: safe }; this.emit('status', this.getStatus()); - this.emit('error', msg); + this.emit('error', safe); }); await new Promise((resolve, reject) => { diff --git a/src/main/updater.ts b/src/main/updater.ts index 953784176..767240828 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,8 +1,9 @@ -import type { BrowserWindow } from 'electron'; +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import { app, ipcMain, shell } from 'electron'; import type { AppUpdater } from 'electron-updater'; import { sanitizeLogMessage } from './log-service'; +import { assertIpcSender } from './validate-ipc-sender'; // electron-updater is a runtime dependency only in the packaged app path // We do a dynamic require so the dev path still works without it installed @@ -44,8 +45,9 @@ async function openAppReleasePage(send: SendFn): Promise { await shell.openExternal(lastAppReleaseUrl ?? RELEASES_URL); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.warn('[updater] open release page failed:', sanitizeLogMessage(msg)); - send('update:error', { message: msg }); + const safe = sanitizeLogMessage(msg); + console.warn('[updater] open release page failed:', safe); + send('update:error', { message: safe }); } } @@ -99,17 +101,20 @@ function registerGithubReleaseApiHandlers(send: SendFn, uiReportsPackaged: boole void doCheck(); }; - ipcMain.handle('update:check', async () => { + ipcMain.handle('update:check', async (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:check'); send('update:checking', { notifyOnSettled: false }); await doCheck(); }); - ipcMain.handle('update:download', async () => { + ipcMain.handle('update:download', async (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:download'); if (!uiReportsPackaged) return; await openAppReleasePage(send); }); - ipcMain.handle('update:install', () => { + ipcMain.handle('update:install', (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:install'); /* no-op — no downloaded artifact in this path */ }); } @@ -154,8 +159,9 @@ function registerElectronUpdaterHandlers(send: SendFn): boolean { }); updater.on('error', (err: Error) => { + const safe = sanitizeLogMessage(err.message); console.error('[updater] error:', sanitizeLogMessage(err.message)); - send('update:error', { message: err.message }); + send('update:error', { message: safe }); }); const doCheck = async () => { @@ -163,8 +169,9 @@ function registerElectronUpdaterHandlers(send: SendFn): boolean { await updater.checkForUpdates(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.warn('[updater] checkForUpdates failed:', sanitizeLogMessage(msg)); - send('update:error', { message: msg }); + const safe = sanitizeLogMessage(msg); + console.warn('[updater] checkForUpdates failed:', safe); + send('update:error', { message: safe }); } }; @@ -176,12 +183,14 @@ function registerElectronUpdaterHandlers(send: SendFn): boolean { void doCheck(); }; - ipcMain.handle('update:check', async () => { + ipcMain.handle('update:check', async (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:check'); send('update:checking', { notifyOnSettled: false }); await doCheck(); }); - ipcMain.handle('update:download', async () => { + ipcMain.handle('update:download', async (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:download'); if (process.platform === 'darwin') { await openAppReleasePage(send); return; @@ -190,12 +199,14 @@ function registerElectronUpdaterHandlers(send: SendFn): boolean { await updater.downloadUpdate(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.warn('[updater] update:download failed:', sanitizeLogMessage(msg)); - send('update:error', { message: msg }); + const safe = sanitizeLogMessage(msg); + console.warn('[updater] update:download failed:', safe); + send('update:error', { message: safe }); } }); - ipcMain.handle('update:install', () => { + ipcMain.handle('update:install', (event: IpcMainInvokeEvent) => { + assertIpcSender(event, 'update:install'); if (process.platform === 'darwin') return; updater.quitAndInstall(false, true); }); @@ -222,7 +233,8 @@ export function initUpdater(win: BrowserWindow): void { const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; setInterval(() => checkNow?.(), CHECK_INTERVAL_MS).unref(); - ipcMain.handle('update:open-releases', async (_event, url?: string) => { + ipcMain.handle('update:open-releases', async (event: IpcMainInvokeEvent, url?: string) => { + assertIpcSender(event, 'update:open-releases'); try { console.debug('[IPC] update:open-releases'); let parsedUrl: URL | null = null; diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index b87bf4ed9..2a0a9c6f1 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -2527,10 +2527,13 @@ function ChatPanel({ const pickerOpensAbove = i >= filteredMessages.length - 3; const senderNode = nodes.get(msg.sender_id); - const displaySenderName = + const rawSenderName = nodeDisplayName(senderNode, protocol) || msg.sender_name.trim() || (msg.sender_id > 0 ? getDmLabel(msg.sender_id) : ''); + // MeshCore wire/ingest uses English "Unknown" as a sentinel; localize for display. + const displaySenderName = + rawSenderName === 'Unknown' ? t('common.unknown') : rawSenderName; // Day separator const daySeparator = daySeparatorIndices.has(i) ? ( diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index f6da8c116..3d1f64c91 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -142,6 +142,9 @@ function meshcoreContactTypeLabel( if (hw_model === 'Chat') return t('nodeListPanel.meshcoreTypeChat'); if (hw_model === 'Repeater') return t('nodeListPanel.meshcoreTypeRepeater'); if (hw_model === 'Room') return t('nodeListPanel.meshcoreTypeRoom'); + if (hw_model === 'Sensor') return t('nodeListPanel.meshcoreTypeSensor'); + if (hw_model === 'None') return t('nodeListPanel.meshcoreTypeNone'); + if (hw_model === 'Unknown') return t('nodeListPanel.meshcoreTypeUnknown'); return hw_model?.trim() || t('common.emDash'); } diff --git a/src/renderer/components/RawPacketLogPanel.tsx b/src/renderer/components/RawPacketLogPanel.tsx index 3bc7b7b12..ce95a0183 100644 --- a/src/renderer/components/RawPacketLogPanel.tsx +++ b/src/renderer/components/RawPacketLogPanel.tsx @@ -479,10 +479,12 @@ function MeshcoreExpandedDetails({ ); } -function meshtasticTransportSourceLabel(p: MeshtasticRawPacketEntry): 'LOCAL' | 'MQTT' | 'RF' { - if (p.isLocal) return 'LOCAL'; - if (p.viaMqtt) return 'MQTT'; - return 'RF'; +function meshtasticTransportSourceKey( + p: MeshtasticRawPacketEntry, +): 'rawPacketLog.filterChipLocal' | 'rawPacketLog.filterChipMqtt' | 'rawPacketLog.filterChipRf' { + if (p.isLocal) return 'rawPacketLog.filterChipLocal'; + if (p.viaMqtt) return 'rawPacketLog.filterChipMqtt'; + return 'rawPacketLog.filterChipRf'; } function MeshtasticExpandedDetails({ p }: { p: MeshtasticRawPacketEntry }) { @@ -490,21 +492,21 @@ function MeshtasticExpandedDetails({ p }: { p: MeshtasticRawPacketEntry }) { const parsed = parseMeshtasticRawPacketExpand(p.raw, { viaMqtt: p.viaMqtt }); if (!parsed.ok) return null; - const transport = meshtasticTransportSourceLabel(p); - const hopLine = - transport === 'MQTT' ? ( -

{t('rawPacketLog.hopsAbsentMqtt')}

- ) : parsed.hopsAway != null && parsed.hopStart != null && parsed.hopLimit != null ? ( -

{`hops=${parsed.hopsAway} (hopStart=${parsed.hopStart} hopLimit=${parsed.hopLimit})`}

- ) : parsed.hopStart != null || parsed.hopLimit != null ? ( -

{`hopStart=${parsed.hopStart ?? '?'} hopLimit=${parsed.hopLimit ?? '?'}`}

- ) : null; + const transportKey = meshtasticTransportSourceKey(p); + const hopLine = p.viaMqtt ? ( +

{t('rawPacketLog.hopsAbsentMqtt')}

+ ) : parsed.hopsAway != null && parsed.hopStart != null && parsed.hopLimit != null ? ( +

{`hops=${parsed.hopsAway} (hopStart=${parsed.hopStart} hopLimit=${parsed.hopLimit})`}

+ ) : parsed.hopStart != null || parsed.hopLimit != null ? ( +

{`hopStart=${parsed.hopStart ?? '?'} hopLimit=${parsed.hopLimit ?? '?'}`}

+ ) : null; return (

{t('rawPacketLog.portLabel')}: {p.portLabel}{' '} - {t('rawPacketLog.transportSourceLabel')}: {transport} + {t('rawPacketLog.transportSourceLabel')}:{' '} + {t(transportKey)}

{hopLine}

{formatMeshtasticRawPacketExpandDebugLine(parsed)}

@@ -1525,7 +1527,7 @@ function MeshtasticRow({ const label = p.fromNodeId != null ? getNodeLabel(p.fromNodeId) : null; const relativeTime = formatRawPacketRelativeTime(p.ts, t); const absoluteTime = formatTs(p.ts); - const transportLabel = p.isLocal ? 'LOCAL' : p.viaMqtt ? 'MQTT' : 'RF'; + const transportLabel = t(meshtasticTransportSourceKey(p)); const transportTooltip = p.isLocal ? t('rawPacketLog.transportBadgeLocalTooltip') : p.viaMqtt diff --git a/src/renderer/hooks/openMeshCoreTransport.test.ts b/src/renderer/hooks/openMeshCoreTransport.test.ts index 9c1726e1a..f8a1260c0 100644 --- a/src/renderer/hooks/openMeshCoreTransport.test.ts +++ b/src/renderer/hooks/openMeshCoreTransport.test.ts @@ -35,6 +35,27 @@ describe('openMeshCoreTransport', () => { await connectionDriver.disconnect(driverIdentityId); }); + it('forwards skipDiscoverSelf to ConnectionDriver.connect', async () => { + const fakeConn = { kind: 'meshcore-mock' } as unknown as Connection; + vi.spyOn(meshcoreProtocol, 'createDevice').mockResolvedValue(fakeConn); + vi.spyOn(meshcoreProtocol, 'subscribe').mockReturnValue(() => {}); + vi.spyOn(meshcoreProtocol, 'destroyDevice').mockResolvedValue(undefined); + const discoverSelf = vi.spyOn(meshcoreProtocol, 'discoverSelf').mockResolvedValue({ + publicKey: new Uint8Array(32).fill(7), + }); + const connectSpy = vi.spyOn(connectionDriver, 'connect'); + + await openMeshCoreTransport('tcp', { + host: '127.0.0.1:5000', + skipDiscoverSelf: true, + }); + + expect(connectSpy).toHaveBeenCalledWith('meshcore', expect.objectContaining({ type: 'tcp' }), { + skipDiscoverSelf: true, + }); + expect(discoverSelf).not.toHaveBeenCalled(); + }); + it('disconnects driver slot when connect succeeds but getHandle is null', async () => { const fakeConn = { kind: 'meshcore-mock' } as unknown as Connection; vi.spyOn(meshcoreProtocol, 'createDevice').mockResolvedValue(fakeConn); diff --git a/src/renderer/hooks/useSendMessage.test.ts b/src/renderer/hooks/useSendMessage.test.ts index 1f169b868..079301de4 100644 --- a/src/renderer/hooks/useSendMessage.test.ts +++ b/src/renderer/hooks/useSendMessage.test.ts @@ -8,7 +8,7 @@ import { meshcoreProtocol } from '../lib/protocols/MeshCoreProtocol'; import { meshtasticProtocol } from '../lib/protocols/MeshtasticProtocol'; import { reticulumProtocol } from '../lib/protocols/ReticulumProtocol'; import { registerReticulumDestinationHash } from '../lib/reticulum/destHash'; -import { registerMeshcoreSession } from '../lib/sessions/meshcoreSession'; +import { type MeshcoreSessionApi, registerMeshcoreSession } from '../lib/sessions/meshcoreSession'; import { type MeshtasticSessionApi, registerMeshtasticSession, @@ -47,6 +47,20 @@ function createMeshtasticSessionStub(): MeshtasticSessionApi { }; } +function createMeshcoreSessionStub( + overrides: Partial = {}, +): MeshcoreSessionApi { + return { + connect: vi.fn(), + prepareRfConnect: vi.fn(), + attachRfSession: vi.fn(), + handleRfConnectFailure: vi.fn(), + finalizeDriverDisconnect: vi.fn(), + connectAutomatic: vi.fn(), + ...overrides, + }; +} + describe('useSendMessage', () => { beforeEach(() => { vi.mocked(connectionDriver.getHandle).mockClear(); @@ -278,6 +292,123 @@ describe('useSendMessage', () => { sendSpy.mockRestore(); }); + it('SoftAP dead-accepted: sends via runMeshcoreUserTxWithLiveTcp without RF handle', async () => { + setMeshcoreTcpSoftApDeadAccepted(true); + const liveHandle = { kind: 'softap-live' }; + let runTxCalls = 0; + const runTx: NonNullable = async (op) => { + runTxCalls += 1; + vi.mocked(connectionDriver.getHandle).mockReturnValue(liveHandle); + return op(); + }; + registerMeshcoreSession( + createMeshcoreSessionStub({ + runMeshcoreUserTxWithLiveTcp: runTx, + }), + ); + const sendSpy = vi.spyOn(meshcoreProtocol, 'sendMessage').mockResolvedValue({ + packetId: 0xbeef01, + }); + vi.mocked(connectionDriver.getHandle).mockReturnValue(null); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'sig-mc', + transports: [], + createdAt: 1, + lastSeenAt: 1, + }); + setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); + + const { result } = renderHook(() => useSendMessage(ID_MC)); + result.current('softap hi', 1); + + await vi.waitFor(() => { + expect(runTxCalls).toBe(1); + expect(sendSpy).toHaveBeenCalledWith( + liveHandle, + expect.objectContaining({ text: 'softap hi', channelIndex: 1 }), + ); + const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('acked'); + expect(rows[0]?.id).toBe(String(0xbeef01)); + }); + sendSpy.mockRestore(); + }); + + it('SoftAP dead-accepted: falls back to ensureTcpLiveForUserTx when runTx missing', async () => { + setMeshcoreTcpSoftApDeadAccepted(true); + const liveHandle = { kind: 'softap-ensure' }; + const ensureTcpLiveForUserTx = vi.fn(() => { + vi.mocked(connectionDriver.getHandle).mockReturnValue(liveHandle); + return Promise.resolve(); + }); + registerMeshcoreSession( + createMeshcoreSessionStub({ + ensureTcpLiveForUserTx, + }), + ); + const sendSpy = vi.spyOn(meshcoreProtocol, 'sendMessage').mockResolvedValue({ + packetId: 0xbeef2, + }); + vi.mocked(connectionDriver.getHandle).mockReturnValue(null); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'sig-mc', + transports: [], + createdAt: 1, + lastSeenAt: 1, + }); + setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); + + const { result } = renderHook(() => useSendMessage(ID_MC)); + result.current('softap ensure', 2); + + await vi.waitFor(() => { + expect(ensureTcpLiveForUserTx).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + liveHandle, + expect.objectContaining({ text: 'softap ensure', channelIndex: 2 }), + ); + const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); + expect(rows[0]?.status).toBe('acked'); + }); + sendSpy.mockRestore(); + }); + + it('SoftAP dead-accepted: marks failed when live reopen yields no handle', async () => { + setMeshcoreTcpSoftApDeadAccepted(true); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + registerMeshcoreSession( + createMeshcoreSessionStub({ + runMeshcoreUserTxWithLiveTcp: vi.fn((op) => op()), + }), + ); + vi.mocked(connectionDriver.getHandle).mockReturnValue(null); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'sig-mc', + transports: [], + createdAt: 1, + lastSeenAt: 1, + }); + setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); + + const { result } = renderHook(() => useSendMessage(ID_MC)); + result.current('softap fail', 1); + + await vi.waitFor(() => { + const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('failed'); + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SoftAP live reopen failed')); + warn.mockRestore(); + }); + it('marks MeshCore DM acked when send resolves with packetId', async () => { const sendSpy = vi.spyOn(meshcoreProtocol, 'sendMessage').mockResolvedValue({ packetId: 0xabcd, diff --git a/src/renderer/lib/drivers/ConnectionDriver.test.ts b/src/renderer/lib/drivers/ConnectionDriver.test.ts index 96e402d8d..53eb3167b 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.test.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.test.ts @@ -1,8 +1,10 @@ +import type { Connection } from '@liamcottle/meshcore.js'; import type { MeshDevice } from '@meshtastic/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnectionStore } from '../../stores/connectionStore'; import { addIdentity, getIdentity, useIdentityStore } from '../../stores/identityStore'; +import { meshcoreProtocol } from '../protocols/MeshCoreProtocol'; import { meshtasticProtocol } from '../protocols/MeshtasticProtocol'; import type { TransportParams } from '../types'; import { connectionDriver } from './ConnectionDriver'; @@ -85,6 +87,27 @@ describe('ConnectionDriver', () => { expect(useConnectionStore.getState().connections[identityId].status).toBe('disconnected'); }); + it('connect with skipDiscoverSelf skips protocol.discoverSelf', async () => { + const host = `softap-skip-${Date.now()}`; + const params: TransportParams = { type: 'tcp', host }; + const fakeHandle = { kind: 'mock-meshcore-tcp' } as unknown as Connection; + + vi.spyOn(meshcoreProtocol, 'createDevice').mockResolvedValue(fakeHandle); + vi.spyOn(meshcoreProtocol, 'subscribe').mockReturnValue(() => {}); + vi.spyOn(meshcoreProtocol, 'destroyDevice').mockResolvedValue(undefined); + const discoverSelf = vi.spyOn(meshcoreProtocol, 'discoverSelf').mockResolvedValue({ + publicKey: new Uint8Array(32).fill(9), + }); + + const identityId = await connectionDriver.connect('meshcore', params, { + skipDiscoverSelf: true, + }); + expect(discoverSelf).not.toHaveBeenCalled(); + expect(connectionDriver.getHandle(identityId)).toBe(fakeHandle); + + await connectionDriver.disconnect(identityId); + }); + it('connect maps a tcp transport to a tcp connectionType, not null', async () => { const host = `tcp-host-${Date.now()}`; const params: TransportParams = { type: 'tcp', host }; diff --git a/src/renderer/lib/gpsSource.ts b/src/renderer/lib/gpsSource.ts index 26271358a..372245373 100644 --- a/src/renderer/lib/gpsSource.ts +++ b/src/renderer/lib/gpsSource.ts @@ -8,16 +8,44 @@ export type GpsSource = 'device' | 'browser' | 'ip' | 'static'; interface StoredGpsSettings { staticLat?: number; staticLon?: number; + refreshInterval?: number; } -/** User-configured static coordinates from App tab GPS settings. */ -export function readStoredStaticGps(): { lat: number; lon: number } | null { - if (typeof localStorage === 'undefined') return null; - const s = +function readStoredGpsSettings(): StoredGpsSettings { + if (typeof localStorage === 'undefined') return {}; + return ( parseStoredJson( localStorage.getItem(GPS_SETTINGS_STORAGE_KEY), - 'gpsSource readStoredStaticGps', - ) ?? {}; + 'gpsSource readStoredGpsSettings', + ) ?? {} + ); +} + +/** Host GPS poll interval in seconds (0 = disabled). */ +export function readGpsRefreshIntervalSecs(): number { + const interval = readStoredGpsSettings().refreshInterval; + return typeof interval === 'number' && Number.isFinite(interval) && interval > 0 ? interval : 0; +} + +/** Persist static coordinates while preserving other GPS settings keys. */ +export function persistStoredStaticGps(lat: number, lon: number): void { + if (typeof localStorage === 'undefined') return; + try { + const existing = readStoredGpsSettings(); + const refreshInterval = + typeof existing.refreshInterval === 'number' ? existing.refreshInterval : 0; + localStorage.setItem( + GPS_SETTINGS_STORAGE_KEY, + JSON.stringify({ ...existing, staticLat: lat, staticLon: lon, refreshInterval }), + ); + } catch { + // catch-no-log-ok localStorage quota or private mode + } +} + +/** User-configured static coordinates from App tab GPS settings. */ +export function readStoredStaticGps(): { lat: number; lon: number } | null { + const s = readStoredGpsSettings(); const { staticLat: lat, staticLon: lon } = s; if ( typeof lat === 'number' && diff --git a/src/renderer/lib/lastConnectionStorage.ts b/src/renderer/lib/lastConnectionStorage.ts index 80e6b6078..4d278ca94 100644 --- a/src/renderer/lib/lastConnectionStorage.ts +++ b/src/renderer/lib/lastConnectionStorage.ts @@ -50,7 +50,8 @@ export function resolveLastBlePeripheralId(protocol: MeshProtocol): string | und /** Meshtastic HTTP/TCP or MeshCore TCP host (stored as `http`/`tcp` connection type). */ export function resolveLastHttpAddress(protocol: MeshProtocol): string | undefined { const last = loadLastConnection(protocol); - const addr = last?.httpAddress?.trim(); + if (last?.type !== 'http' && last?.type !== 'tcp') return undefined; + const addr = last.httpAddress?.trim(); return addr || undefined; } diff --git a/src/renderer/lib/meshcoreRepeaterCredentialStorage.ts b/src/renderer/lib/meshcoreRepeaterCredentialStorage.ts index c05b9af58..8db4a0566 100644 --- a/src/renderer/lib/meshcoreRepeaterCredentialStorage.ts +++ b/src/renderer/lib/meshcoreRepeaterCredentialStorage.ts @@ -1,11 +1,12 @@ +import { MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX } from '@/shared/appSettingsKeyPrefixes'; + import { createMeshcorePerNodeCredentialStorage, type MeshcorePerNodeCredentialStorage, parseLegacyCredentialRaw, } from './meshcorePerNodeCredentialStorage'; -/** Per-repeater admin passwords in app_settings (local SQLite via IPC). */ -export const MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX = 'meshcoreRepeaterCredential:'; +export { MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX }; export interface MeshcoreRepeaterStoredCredential { password: string; diff --git a/src/renderer/lib/meshcoreRoomCredentialStorage.ts b/src/renderer/lib/meshcoreRoomCredentialStorage.ts index eae3b53de..6033ee686 100644 --- a/src/renderer/lib/meshcoreRoomCredentialStorage.ts +++ b/src/renderer/lib/meshcoreRoomCredentialStorage.ts @@ -1,11 +1,12 @@ +import { MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX } from '@/shared/appSettingsKeyPrefixes'; + import { createMeshcorePerNodeCredentialStorage, type MeshcorePerNodeCredentialStorage, parseLegacyCredentialRaw, } from './meshcorePerNodeCredentialStorage'; -/** Per-room guest/admin passwords in app_settings (local SQLite via IPC). */ -export const MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX = 'meshcoreRoomCredential:'; +export { MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX }; export interface MeshcoreRoomStoredCredential { guestPassword: string; diff --git a/src/renderer/lib/meshcoreRoomSyncStorage.ts b/src/renderer/lib/meshcoreRoomSyncStorage.ts index afa0f455b..eb778b5f5 100644 --- a/src/renderer/lib/meshcoreRoomSyncStorage.ts +++ b/src/renderer/lib/meshcoreRoomSyncStorage.ts @@ -1,3 +1,8 @@ +import { + MESHCORE_ROOM_LAST_POST_SETTING_PREFIX, + MESHCORE_ROOM_SYNC_SETTING_PREFIX, +} from '@/shared/appSettingsKeyPrefixes'; + import { getAppSettingsRaw, mergeAppSetting } from './appSettingsStorage'; import { errLikeToLogString } from './errLikeToLogString'; import { @@ -7,9 +12,7 @@ import { import { parseStoredJson } from './parseStoredJson'; import { MESHCORE_ROOM_SYNC_MIN_INTERVAL_MINUTES } from './timeConstants'; -/** Per-room background sync + last-post metadata in app_settings. */ -export const MESHCORE_ROOM_SYNC_SETTING_PREFIX = 'meshcoreRoomSync:'; -export const MESHCORE_ROOM_LAST_POST_SETTING_PREFIX = 'meshcoreRoomLastPost:'; +export { MESHCORE_ROOM_LAST_POST_SETTING_PREFIX, MESHCORE_ROOM_SYNC_SETTING_PREFIX }; /** Coerce MeshCore node id to unsigned 32-bit for stable setting keys. */ function toUnsignedNodeId(nodeId: number): number { diff --git a/src/renderer/lib/meshtasticBacklogUtils.ts b/src/renderer/lib/meshtasticBacklogUtils.ts index 31600d64a..74ce3e9e9 100644 --- a/src/renderer/lib/meshtasticBacklogUtils.ts +++ b/src/renderer/lib/meshtasticBacklogUtils.ts @@ -1,7 +1,8 @@ -import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { create, toBinary } from '@bufbuild/protobuf'; import type { MeshDevice } from '@meshtastic/core'; import { Mesh, Portnums, StoreForward } from '@meshtastic/protobufs'; +import { parseStoreForwardPacket } from '@/shared/meshtasticTextMessagePayload'; import { MS_PER_MINUTE } from '@/shared/timeConstants'; /** Duration after MQTT connect during which inbound messages are treated as backlog. */ @@ -134,23 +135,12 @@ export function buildStoreForwardHistoryRequestBytes( return toBinary(StoreForward.StoreAndForwardSchema, msg); } -function parseStoreForwardPacket(data: Uint8Array) { - if (!data.length) return null; - try { - return fromBinary(StoreForward.StoreAndForwardSchema, data); - } catch { - // catch-no-log-ok malformed StoreAndForward protobuf - return null; - } -} - /** Parse ROUTER_HEARTBEAT payload; null if not a heartbeat variant. */ export function parseStoreForwardHeartbeat(data: Uint8Array): StoreForwardHeartbeatInfo | null { const parsed = parseStoreForwardPacket(data); if (parsed?.rr !== StoreForward.StoreAndForward_RequestResponse.ROUTER_HEARTBEAT) { return null; } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (parsed.variant.case !== 'heartbeat' || !parsed.variant.value) return null; const hb = parsed.variant.value as { period?: number; secondary?: number }; return { @@ -165,7 +155,6 @@ export function parseStoreForwardHistory(data: Uint8Array): StoreForwardHistoryI if (parsed?.rr !== StoreForward.StoreAndForward_RequestResponse.ROUTER_HISTORY) { return null; } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (parsed.variant.case !== 'history' || !parsed.variant.value) return null; const hist = parsed.variant.value as { historyMessages?: number; diff --git a/src/renderer/lib/meshtasticMessageDedup.ts b/src/renderer/lib/meshtasticMessageDedup.ts index 45b2b8c25..756c38756 100644 --- a/src/renderer/lib/meshtasticMessageDedup.ts +++ b/src/renderer/lib/meshtasticMessageDedup.ts @@ -1,10 +1,10 @@ import { meshtasticWireUint32AllowZero } from '@/shared/reactionEmoji'; -import { MS_PER_MINUTE } from '@/shared/timeConstants'; +import { MESHTASTIC_DEDUP_WINDOW_MS } from './timeConstants'; import type { ChatMessage } from './types'; /** Align with seenPacketIds TTL in useMeshtasticRuntime. */ -export const MESHTASTIC_CROSS_TRANSPORT_DEDUP_WINDOW_MS = 10 * MS_PER_MINUTE; +export const MESHTASTIC_CROSS_TRANSPORT_DEDUP_WINDOW_MS = MESHTASTIC_DEDUP_WINDOW_MS; const CROSS_TRANSPORT_SCAN_LIMIT = 200; diff --git a/src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts b/src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts index f70bfd9c4..84cdc8f7b 100644 --- a/src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts +++ b/src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts @@ -1,11 +1,11 @@ +import { MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX } from '@/shared/appSettingsKeyPrefixes'; import { hexToBytesExact } from '@/shared/hexBytes'; import { getAppSettingsRaw, mergeAppSetting } from './appSettingsStorage'; import { errLikeToLogString } from './errLikeToLogString'; import { parseStoredJson } from './parseStoredJson'; -/** Per-node SQLite/localStorage key: meshtasticRemoteAdminKey: */ -export const MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX = 'meshtasticRemoteAdminKey:'; +export { MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX }; /** Legacy JSON blob key (read for migration only). */ export const MESHTASTIC_REMOTE_ADMIN_KEY_BY_NODE_SETTING = 'meshtasticRemoteAdminKeyByNode'; diff --git a/src/renderer/lib/mqttTls.ts b/src/renderer/lib/mqttTls.ts index 906efb62d..bb76a09f0 100644 --- a/src/renderer/lib/mqttTls.ts +++ b/src/renderer/lib/mqttTls.ts @@ -1,9 +1,7 @@ import type { MQTTSettings } from '@/renderer/lib/types'; +import { mqttUsesTls as mqttUsesTlsShared } from '@/shared/mqttTls'; /** Whether the desktop MQTT client uses TLS for the current settings (native mqtts or wss). */ export function mqttUsesTls(settings: MQTTSettings): boolean { - if (settings.useWebSocket === true) { - return settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 443); - } - return settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 8883); + return mqttUsesTlsShared(settings); } diff --git a/src/renderer/lib/parseStoredJson.ts b/src/renderer/lib/parseStoredJson.ts index 2298eab28..ebef5fbd3 100644 --- a/src/renderer/lib/parseStoredJson.ts +++ b/src/renderer/lib/parseStoredJson.ts @@ -1,7 +1,7 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; /** * Parse persisted JSON (e.g. localStorage); warn on parse failure. - * See CONTRIBUTING.md — Error boundaries and logging. + * See AGENTS.md §3 (Security & Error Handling) for logging expectations. */ // Generic is only for call-site inference (return is still `as T`); keep the ergonomic API. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is for caller inference only diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index ec77a2ec4..cd317cff8 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -135,12 +135,18 @@ export const MESHCORE_CHANNEL_RF_DEDUP_WINDOW_MS = 5 * MS_PER_MINUTE; /** Same DM body re-heard on RF (multi-path / repeater echo) within this window. */ export const MESHCORE_DM_RF_DEDUP_WINDOW_MS = 2 * MS_PER_MINUTE; +/** + * Canonical Meshtastic 10-minute dedup window (cross-transport merge, MQTT-only packet TTL, + * PacketRouter tapback optimistic match). Keep a single definition so these cannot drift. + */ +export const MESHTASTIC_DEDUP_WINDOW_MS = 10 * MS_PER_MINUTE; + /** PacketRouter tapback optimistic row match before Meshtastic RF echo re-key (temp packet_id → real id). * Wider than room post dedup (1 min) because client Date.now vs radio rxTime can skew several minutes. */ -export const MESHTASTIC_TAPBACK_OPTIMISTIC_DEDUP_WINDOW_MS = 10 * MS_PER_MINUTE; +export const MESHTASTIC_TAPBACK_OPTIMISTIC_DEDUP_WINDOW_MS = MESHTASTIC_DEDUP_WINDOW_MS; /** RF/MQTT packet-id dedup TTL (MQTT-only fallback map and ingest session). */ -export const MESHTASTIC_PACKET_DEDUP_TTL_MS = 10 * MS_PER_MINUTE; +export const MESHTASTIC_PACKET_DEDUP_TTL_MS = MESHTASTIC_DEDUP_WINDOW_MS; /** Hard cap for the MQTT-only packet dedup fallback map after TTL sweep. */ export const MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES = 5_000; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 04ac69321..3d20242d4 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -3029,7 +3029,10 @@ "echoesConnectionHealthTooltip": "{{score}}% zdraví připojení", "tabAll": "Vše", "tabHistory": "Historie", - "emptyHistory": "Zatím žádné přímé zprávy — odešlete nebo přijměte DM, abyste zde viděli peery." + "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" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index b26ec2c57..65ec13a93 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% Verbindungszustand", "tabAll": "Alle", "tabHistory": "Verlauf", - "emptyHistory": "Noch keine Direktnachrichten — senden oder empfangen Sie einen DM, um Peers hier zu sehen." + "emptyHistory": "Noch keine Direktnachrichten — senden oder empfangen Sie einen DM, um Peers hier zu sehen.", + "meshcoreTypeSensor": "Sensor", + "meshcoreTypeNone": "None", + "meshcoreTypeUnknown": "Unbekannt" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 8acf64410..0ed4c18d9 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -3188,6 +3188,9 @@ "meshcoreTypeChat": "Chat", "meshcoreTypeRepeater": "Repeater", "meshcoreTypeRoom": "Room", + "meshcoreTypeSensor": "Sensor", + "meshcoreTypeNone": "None", + "meshcoreTypeUnknown": "Unknown", "statusOnline": "Online", "statusStale": "Stale", "statusOffline": "Offline", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 7776262b0..a33777f47 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% estado de la conexión", "tabAll": "Todos", "tabHistory": "Historial", - "emptyHistory": "Aún no hay mensajes directos: envíe o reciba un DM para ver a los peers aquí." + "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" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 1cbaa267f..f98f10957 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% santé de la connexion", "tabAll": "Tous", "tabHistory": "Historique", - "emptyHistory": "Pas encore de messages directs — envoyez ou recevez un DM pour voir vos pairs ici." + "emptyHistory": "Pas encore de messages directs — envoyez ou recevez un DM pour voir vos pairs ici.", + "meshcoreTypeSensor": "Capteur", + "meshcoreTypeNone": "Aucune", + "meshcoreTypeUnknown": "Inconnu" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 3778d6bf2..200a718d0 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% kesehatan koneksi", "tabAll": "Semua", "tabHistory": "Riwayat", - "emptyHistory": "Belum ada pesan langsung — kirim atau terima DM untuk melihat peer di sini." + "emptyHistory": "Belum ada pesan langsung — kirim atau terima DM untuk melihat peer di sini.", + "meshcoreTypeSensor": "Sensor", + "meshcoreTypeNone": "Tidak Ada", + "meshcoreTypeUnknown": "Tidak Diketahui" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 6ced3c3b6..604763243 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% integrità della connessione", "tabAll": "Tutti", "tabHistory": "Cronologia", - "emptyHistory": "Ancora nessun messaggio diretto: invia o ricevi un DM per vedere i peer qui." + "emptyHistory": "Ancora nessun messaggio diretto: invia o ricevi un DM per vedere i peer qui.", + "meshcoreTypeSensor": "Sensore", + "meshcoreTypeNone": "Nessuna", + "meshcoreTypeUnknown": "Sconosciuto" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 3cbc15a85..81d4907cb 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}} %接続状態", "tabAll": "すべて", "tabHistory": "履歴", - "emptyHistory": "ダイレクトメッセージはまだありません—ここでピアを見るためにDMを送受信します。" + "emptyHistory": "ダイレクトメッセージはまだありません—ここでピアを見るためにDMを送受信します。", + "meshcoreTypeSensor": "センサー", + "meshcoreTypeNone": "なし", + "meshcoreTypeUnknown": "不明" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index b00ba2a45..f631afc77 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}} % 연결 상태", "tabAll": "전체", "tabHistory": "기록", - "emptyHistory": "아직 다이렉트 메시지가 없습니다. DM을 보내거나 받아 피어를 확인하세요." + "emptyHistory": "아직 다이렉트 메시지가 없습니다. DM을 보내거나 받아 피어를 확인하세요.", + "meshcoreTypeSensor": "검지기", + "meshcoreTypeNone": "없음", + "meshcoreTypeUnknown": "미상" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 3cea8f5ef..b97a1743a 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% verbindingsstatus", "tabAll": "Alles", "tabHistory": "Historie", - "emptyHistory": "Nog geen directe berichten — stuur of ontvang een DM om peers hier te zien." + "emptyHistory": "Nog geen directe berichten — stuur of ontvang een DM om peers hier te zien.", + "meshcoreTypeSensor": "Sensor", + "meshcoreTypeNone": "Geen", + "meshcoreTypeUnknown": "Onbekend" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 22f363f28..f8c1e8654 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -3031,7 +3031,10 @@ "echoesConnectionHealthTooltip": "{{score}}% stan połączenia", "tabAll": "Wszystko", "tabHistory": "Historia rozmów", - "emptyHistory": "Nie ma jeszcze bezpośrednich wiadomości — wyślij lub otrzymaj DM, aby zobaczyć peerów tutaj." + "emptyHistory": "Nie ma jeszcze bezpośrednich wiadomości — wyślij lub otrzymaj DM, aby zobaczyć peerów tutaj.", + "meshcoreTypeSensor": "Czujnik", + "meshcoreTypeNone": "Brak", + "meshcoreTypeUnknown": "Nieznany" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 900db034b..77de15305 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% integridade da ligação", "tabAll": "Todos", "tabHistory": "Histórico", - "emptyHistory": "Ainda não há mensagens diretas — envie ou receba um DM para ver os peers aqui." + "emptyHistory": "Ainda não há mensagens diretas — envie ou receba um DM para ver os peers aqui.", + "meshcoreTypeSensor": "Sensor", + "meshcoreTypeNone": "Nenhuma.", + "meshcoreTypeUnknown": "Desconhecida" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 59a0ee870..ea5702e02 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -3029,7 +3029,10 @@ "echoesConnectionHealthTooltip": "{{score}}% исправности соединения", "tabAll": "Все", "tabHistory": "История", - "emptyHistory": "Прямых сообщений пока нет — отправьте или получите DM, чтобы увидеть пиры здесь." + "emptyHistory": "Прямых сообщений пока нет — отправьте или получите DM, чтобы увидеть пиры здесь.", + "meshcoreTypeSensor": "Датчик", + "meshcoreTypeNone": "Нет", + "meshcoreTypeUnknown": "Неизвестно" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index cd1f9c6cc..441f3bcfa 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}}% bağlantı sağlığı", "tabAll": "Tümü", "tabHistory": "Geçmiş", - "emptyHistory": "Henüz doğrudan mesaj yok — buradaki eşleri görmek için bir DM gönderin veya alın." + "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" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 2c4c4aff5..32e7d26b3 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -3029,7 +3029,10 @@ "echoesConnectionHealthTooltip": "{{score}}% здоров'я з'єднання", "tabAll": "Всі", "tabHistory": "Історія", - "emptyHistory": "Поки немає прямих повідомлень — надішліть або отримайте DM, щоб побачити вузли тут." + "emptyHistory": "Поки немає прямих повідомлень — надішліть або отримайте DM, щоб побачити вузли тут.", + "meshcoreTypeSensor": "Прилад виявлення", + "meshcoreTypeNone": "None (Немає)", + "meshcoreTypeUnknown": "Невідомо" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 0c0dbdfc7..e6fcd0052 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -3027,7 +3027,10 @@ "echoesConnectionHealthTooltip": "{{score}} %连接健康", "tabAll": "全部", "tabHistory": "历史", - "emptyHistory": "还没有私信—发送或接收 DM 以在此处查看对等节点。" + "emptyHistory": "还没有私信—发送或接收 DM 以在此处查看对等节点。", + "meshcoreTypeSensor": "测传", + "meshcoreTypeNone": "无", + "meshcoreTypeUnknown": "未知" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 09f8bf14d..a956ac844 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -96,7 +96,12 @@ import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../lib/chatI import { setMeshcoreDiagnosticsNodes } from '../lib/diagnosticsNodesRef'; import { connectionDriver } from '../lib/drivers/ConnectionDriver'; import type { OurPosition } from '../lib/gpsSource'; -import { hasStoredStaticGps, readStoredStaticGps, resolveOurPosition } from '../lib/gpsSource'; +import { + hasStoredStaticGps, + persistStoredStaticGps, + readStoredStaticGps, + resolveOurPosition, +} from '../lib/gpsSource'; import { loadMeshcoreMessagesForHydration, loadMeshcoreSavedHopRowsForHydration, @@ -115,6 +120,7 @@ import { repairMeshcoreChannelSenderIdsInStore } from '../lib/ingest/meshcoreSen import { rehydrateMeshcoreConnectionParamsFromStorage, resolveLastBlePeripheralId, + resolveLastHttpAddress, } from '../lib/lastConnectionStorage'; import { meshcoreIdentityHasFullKeyPair, @@ -4085,24 +4091,7 @@ export function useMeshcoreRuntime() { throw err; } } else if (type === 'http') { - let addr = httpAddress; - if (!addr?.trim()) { - try { - const raw = localStorage.getItem('mesh-client:lastConnection:meshcore'); - const parsed = raw - ? (JSON.parse(raw) as { type?: string; httpAddress?: string }) - : null; - if ( - parsed?.type === 'http' && - typeof parsed.httpAddress === 'string' && - parsed.httpAddress.trim() - ) { - addr = parsed.httpAddress; - } - } catch { - // catch-no-log-ok corrupt lastConnection JSON - } - } + const addr = httpAddress?.trim() ? httpAddress : resolveLastHttpAddress('meshcore'); await connect('tcp', addr); } // BLE: requires user gesture — not supported for auto-connect @@ -4779,21 +4768,7 @@ export function useMeshcoreRuntime() { const selfNodeId = myNodeNumRef.current; const nowSec = Math.floor(Date.now() / 1000); setOurPosition({ lat, lon, source: 'static' }); - try { - const existing = - parseStoredJson>( - localStorage.getItem('mesh-client:gpsSettings'), - 'useMeshcoreRuntime sendPositionToDeviceMeshCore persist static', - ) ?? {}; - const refreshInterval = - typeof existing.refreshInterval === 'number' ? existing.refreshInterval : 0; - localStorage.setItem( - 'mesh-client:gpsSettings', - JSON.stringify({ ...existing, staticLat: lat, staticLon: lon, refreshInterval }), - ); - } catch { - // catch-no-log-ok localStorage quota or private mode - } + persistStoredStaticGps(lat, lon); if (selfNodeId > 0) { setNodes((prev) => { const next = new Map(prev); diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index a7e21773f..147592825 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -94,7 +94,11 @@ import { import { connectionDriver } from '../lib/drivers/ConnectionDriver'; import { matchForeignLoraFromMeshtasticLog } from '../lib/foreignLoraDetection'; import type { OurPosition } from '../lib/gpsSource'; -import { readStoredStaticGps, resolveOurPosition } from '../lib/gpsSource'; +import { + readGpsRefreshIntervalSecs, + readStoredStaticGps, + resolveOurPosition, +} from '../lib/gpsSource'; import { hydrateMeshtasticMessagesFromDb, syncNodesMapToIdentityStore, @@ -921,25 +925,17 @@ export function useMeshtasticRuntime() { const startGpsInterval = useCallback(() => { stopGpsInterval(); - try { - const gpsParsed = parseStoredJson<{ refreshInterval?: number }>( - localStorage.getItem('mesh-client:gpsSettings'), - 'useMeshtasticRuntime startGpsInterval', - ); - const intervalSecs = gpsParsed?.refreshInterval ?? 0; - if (intervalSecs > 0) { - gpsIntervalRef.current = setInterval(() => { - // Dual-protocol: avoid host IP/geo refresh churn while MeshCore is the active UI protocol. - if (getStoredMeshProtocol() !== 'meshtastic') return; - refreshOurPositionRef.current().catch((err: unknown) => { - console.error( - '[useMeshtasticRuntime] GPS interval refresh error: ' + errLikeToLogString(err), - ); - }); - }, intervalSecs * 1000); - } - } catch { - // catch-no-log-ok localStorage read for GPS interval setting — ignore parse errors + const intervalSecs = readGpsRefreshIntervalSecs(); + if (intervalSecs > 0) { + gpsIntervalRef.current = setInterval(() => { + // Dual-protocol: avoid host IP/geo refresh churn while MeshCore is the active UI protocol. + if (getStoredMeshProtocol() !== 'meshtastic') return; + refreshOurPositionRef.current().catch((err: unknown) => { + console.error( + '[useMeshtasticRuntime] GPS interval refresh error: ' + errLikeToLogString(err), + ); + }); + }, intervalSecs * 1000); } }, [stopGpsInterval]); @@ -4022,7 +4018,12 @@ export function useMeshtasticRuntime() { void hydrateMeshtasticMessagesFromDb( storeId, opts?.replaceFromDb ? 'replace' : 'upsert', - ); + ).catch((err: unknown) => { + console.warn( + '[useMeshtasticRuntime] refreshMessagesFromDb identity hydrate failed ' + + errLikeToLogString(err), + ); + }); } }) .catch((err: unknown) => { diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index ebe0cba44..9a8ba5a27 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -552,9 +552,13 @@ export const useNomadPageViewerStore = create((set, get) = if (!get().panelActive) { const label = node?.display_name?.trim() || hash.slice(0, 8); // Lazy import avoids pulling i18n into panel unit-test graphs. - void import('@/renderer/lib/i18n').then(({ default: i18n }) => { - pushAppToast(i18n.t('nomadNetwork.pageReadyToast', { name: label }), 'success', 6_000); - }); + void import('@/renderer/lib/i18n') + .then(({ default: i18n }) => { + pushAppToast(i18n.t('nomadNetwork.pageReadyToast', { name: label }), 'success', 6_000); + }) + .catch((err: unknown) => { + console.warn('[nomadPageViewerStore] pageReadyToast i18n import failed ' + String(err)); + }); } }, })); diff --git a/src/shared/appSettingsKeyPrefixes.ts b/src/shared/appSettingsKeyPrefixes.ts new file mode 100644 index 000000000..7bec1932e --- /dev/null +++ b/src/shared/appSettingsKeyPrefixes.ts @@ -0,0 +1,13 @@ +/** + * Prefixed `app_settings` keys allowed by main IPC (`isAppSettingsKeyAllowed`). + * Renderer storage modules must use these same prefix strings. + */ +export const MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX = 'meshtasticRemoteAdminKey:'; + +/** MeshCore Rooms tab — sync prefs / last-post stamps / credentials. */ +export const MESHCORE_ROOM_SYNC_SETTING_PREFIX = 'meshcoreRoomSync:'; +export const MESHCORE_ROOM_LAST_POST_SETTING_PREFIX = 'meshcoreRoomLastPost:'; +export const MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX = 'meshcoreRoomCredential:'; + +/** MeshCore Repeaters tab — per-node admin passwords. */ +export const MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX = 'meshcoreRepeaterCredential:'; diff --git a/src/shared/meshtasticTextMessagePayload.ts b/src/shared/meshtasticTextMessagePayload.ts index 2407a8702..a509017f1 100644 --- a/src/shared/meshtasticTextMessagePayload.ts +++ b/src/shared/meshtasticTextMessagePayload.ts @@ -10,7 +10,8 @@ export interface ResolvedMeshtasticTextPayload { viaStoreForward?: boolean; } -function parseStoreForwardPacket(data: Uint8Array): { +/** Decode StoreAndForward protobuf; null on empty/malformed bytes. */ +export function parseStoreForwardPacket(data: Uint8Array): { rr: number; variant: { case?: string; value?: unknown }; } | null { diff --git a/src/shared/mqttTls.ts b/src/shared/mqttTls.ts new file mode 100644 index 000000000..cd505e5ff --- /dev/null +++ b/src/shared/mqttTls.ts @@ -0,0 +1,14 @@ +/** Minimal MQTT settings shape for TLS inference (main + renderer). */ +export interface MqttTlsSettings { + port: number; + useWebSocket?: boolean; + tlsEnabled?: boolean | null; +} + +/** Whether the desktop MQTT client uses TLS for the current settings (native mqtts or wss). */ +export function mqttUsesTls(settings: MqttTlsSettings): boolean { + if (settings.useWebSocket === true) { + return settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 443); + } + return settings.tlsEnabled === true || (settings.tlsEnabled !== false && settings.port === 8883); +} From 129a3f7865175f5b8d5bbd189c550c2c835a1b38 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 19:04:47 -0600 Subject: [PATCH 2/6] fix(games): attribute draw offers to self vs opponent Gate Accept/Decline and banners on metadata.draw_offered_by so offering a draw no longer shows the opponent-offer UI. --- src/renderer/components/GamesPanel.test.tsx | 27 +++++++++- src/renderer/components/GamesPanel.tsx | 13 +++-- .../components/games/ChessBoard.test.tsx | 54 +++++++++++++++++++ src/renderer/components/games/ChessBoard.tsx | 10 +++- .../components/games/TicTacToeBoard.test.tsx | 28 +++++++++- .../components/games/TicTacToeBoard.tsx | 11 ++-- .../reticulum/reticulumGamesMetadata.test.ts | 54 +++++++++++++++++++ .../lib/reticulum/reticulumGamesMetadata.ts | 26 +++++++++ src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- src/shared/games-types.ts | 4 ++ 25 files changed, 246 insertions(+), 27 deletions(-) create mode 100644 src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts diff --git a/src/renderer/components/GamesPanel.test.tsx b/src/renderer/components/GamesPanel.test.tsx index 4c71a1791..d6a5ce493 100644 --- a/src/renderer/components/GamesPanel.test.tsx +++ b/src/renderer/components/GamesPanel.test.tsx @@ -193,7 +193,7 @@ describe('GamesPanel', () => { }); }); - it('sends draw accept and decline when draw_offered metadata is set', async () => { + it('sends draw accept and decline when opponent offered a draw', async () => { await renderAndSelectSession( makeSession({ metadata: { @@ -205,10 +205,12 @@ describe('GamesPanel', () => { winner: '', terminal: '', draw_offered: true, + draw_offered_by: peerHash, }, }), ); + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Accept draw offer' })); await waitFor(() => { expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( @@ -225,6 +227,29 @@ describe('GamesPanel', () => { }); }); + it('shows waiting banner and hides Accept when local player offered a draw', async () => { + await renderAndSelectSession( + makeSession({ + metadata: { + board: '_________', + turn: 'me', + first_turn: 'me', + my_marker: 'X', + move_count: 0, + winner: '', + terminal: '', + draw_offered: true, + draw_offered_by: 'me', + }, + }), + ); + + expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Accept draw offer' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Decline draw offer' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); + }); + it('shows resend after a failed action and triggers resend', async () => { vi.mocked(window.electronAPI.reticulum.games.sendAction).mockResolvedValue({ ok: false, diff --git a/src/renderer/components/GamesPanel.tsx b/src/renderer/components/GamesPanel.tsx index bda51687d..79568abfc 100644 --- a/src/renderer/components/GamesPanel.tsx +++ b/src/renderer/components/GamesPanel.tsx @@ -6,8 +6,9 @@ import { DeliveryStatusBadgeFrame } from '@/renderer/components/DeliveryStatusBa import { ChessBoard } from '@/renderer/components/games/ChessBoard'; import { TicTacToeBoard } from '@/renderer/components/games/TicTacToeBoard'; import { - gamesMetaBool, gamesMetaStr, + isGamesDrawOfferFromOpponent, + isGamesDrawOfferFromSelf, isGamesSessionInitiator, } from '@/renderer/lib/reticulum/reticulumGamesMetadata'; import { @@ -133,9 +134,11 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { (lastActionResult != null && !lastActionResult.ok && lastActionResult.session_id === selectedSession.session_id)); - const drawOffered = selectedSession - ? gamesMetaBool(selectedSession.metadata, 'draw_offered') + const drawOfferedByOpponent = selectedSession + ? isGamesDrawOfferFromOpponent(selectedSession) : false; + const drawOfferedBySelf = selectedSession ? isGamesDrawOfferFromSelf(selectedSession) : false; + const drawPending = drawOfferedByOpponent || drawOfferedBySelf; const drawClaimReason = selectedSession?.app_id === 'chess' ? gamesMetaStr(selectedSession.metadata, 'draw_offer_reason') @@ -350,7 +353,7 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { > {t('gamesPanel.resign')} - {drawOffered ? ( + {drawOfferedByOpponent ? ( <>
)} - {drawOffered && isActive && ( + {drawOfferedByOpponent && isActive && (
{t('gamesPanel.drawOfferedBanner')}
)} + {drawOfferedBySelf && isActive && ( +
{t('gamesPanel.drawOfferWaitingBanner')}
+ )} ); } diff --git a/src/renderer/components/games/TicTacToeBoard.test.tsx b/src/renderer/components/games/TicTacToeBoard.test.tsx index 60c35e27e..864102ef4 100644 --- a/src/renderer/components/games/TicTacToeBoard.test.tsx +++ b/src/renderer/components/games/TicTacToeBoard.test.tsx @@ -125,7 +125,7 @@ describe('TicTacToeBoard', () => { expect(screen.getByText('You won!')).toBeInTheDocument(); }); - it('shows the draw-offered banner when active and a draw was offered', () => { + it('shows the opponent draw-offered banner when peer offered', () => { render( { winner: '', terminal: '', draw_offered: true, + draw_offered_by: 'peer', }, })} onMove={vi.fn()} @@ -145,5 +146,30 @@ describe('TicTacToeBoard', () => { ); expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + expect(screen.queryByText('Draw offer sent. Waiting for opponent…')).not.toBeInTheDocument(); + }); + + it('shows the waiting banner when local player offered a draw', () => { + render( + , + ); + + expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); + expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); }); }); diff --git a/src/renderer/components/games/TicTacToeBoard.tsx b/src/renderer/components/games/TicTacToeBoard.tsx index 174ae08db..7b8e59a5a 100644 --- a/src/renderer/components/games/TicTacToeBoard.tsx +++ b/src/renderer/components/games/TicTacToeBoard.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next'; import { - gamesMetaBool, gamesMetaNum, gamesMetaStr, + isGamesDrawOfferFromOpponent, + isGamesDrawOfferFromSelf, } from '@/renderer/lib/reticulum/reticulumGamesMetadata'; import type { GameSession } from '@/shared/games-types'; @@ -24,7 +25,8 @@ export function TicTacToeBoard({ session, onMove, disabled = false }: TicTacToeB const turn = gamesMetaStr(metadata, 'turn'); const terminal = gamesMetaStr(metadata, 'terminal'); const winner = gamesMetaStr(metadata, 'winner'); - const drawOffered = gamesMetaBool(metadata, 'draw_offered'); + const drawOfferedByOpponent = isGamesDrawOfferFromOpponent(session); + const drawOfferedBySelf = isGamesDrawOfferFromSelf(session); const moveCount = gamesMetaNum(metadata, 'move_count'); const isActive = session.status === 'active'; @@ -84,9 +86,12 @@ export function TicTacToeBoard({ session, onMove, disabled = false }: TicTacToeB ); })} - {drawOffered && isActive && ( + {drawOfferedByOpponent && isActive && (
{t('gamesPanel.drawOfferedBanner')}
)} + {drawOfferedBySelf && isActive && ( +
{t('gamesPanel.drawOfferWaitingBanner')}
+ )} ); } diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts new file mode 100644 index 000000000..fbc84567a --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + gamesDrawOfferedBy, + isGamesDrawOfferFromOpponent, + isGamesDrawOfferFromSelf, +} from './reticulumGamesMetadata'; + +describe('gamesDrawOfferedBy', () => { + it('reads draw_offered_by from metadata', () => { + expect(gamesDrawOfferedBy({ draw_offered_by: 'abc' })).toBe('abc'); + }); + + it('returns empty when missing or non-string', () => { + expect(gamesDrawOfferedBy(undefined)).toBe(''); + expect(gamesDrawOfferedBy({})).toBe(''); + expect(gamesDrawOfferedBy({ draw_offered_by: 1 })).toBe(''); + }); +}); + +describe('isGamesDrawOfferFromSelf / isGamesDrawOfferFromOpponent', () => { + it('returns false for both when draw_offered is not set', () => { + const session = { identity_id: 'me', metadata: { draw_offered: false } }; + expect(isGamesDrawOfferFromSelf(session)).toBe(false); + expect(isGamesDrawOfferFromOpponent(session)).toBe(false); + }); + + it('treats self owner as self offer', () => { + const session = { + identity_id: 'me', + metadata: { draw_offered: true, draw_offered_by: 'me' }, + }; + expect(isGamesDrawOfferFromSelf(session)).toBe(true); + expect(isGamesDrawOfferFromOpponent(session)).toBe(false); + }); + + it('treats peer owner as opponent offer', () => { + const session = { + identity_id: 'me', + metadata: { draw_offered: true, draw_offered_by: 'peer' }, + }; + expect(isGamesDrawOfferFromSelf(session)).toBe(false); + expect(isGamesDrawOfferFromOpponent(session)).toBe(true); + }); + + it('treats missing draw_offered_by as opponent offer (legacy)', () => { + const session = { + identity_id: 'me', + metadata: { draw_offered: true }, + }; + expect(isGamesDrawOfferFromSelf(session)).toBe(false); + expect(isGamesDrawOfferFromOpponent(session)).toBe(true); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts index 31643244d..d0da2b04a 100644 --- a/src/renderer/lib/reticulum/reticulumGamesMetadata.ts +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts @@ -39,3 +39,29 @@ export function isGamesSessionInitiator(session: { session.initiator && session.identity_id && session.initiator === session.identity_id, ); } + +/** Hash of who offered the pending draw (`metadata.draw_offered_by`), or empty. */ +export function gamesDrawOfferedBy(metadata: Record | undefined): string { + return gamesMetaStr(metadata, 'draw_offered_by'); +} + +/** True when a draw is pending and the local player offered it. */ +export function isGamesDrawOfferFromSelf(session: { + identity_id: string; + metadata?: Record; +}): boolean { + if (!gamesMetaBool(session.metadata, 'draw_offered')) return false; + const owner = gamesDrawOfferedBy(session.metadata); + return Boolean(owner && session.identity_id && owner === session.identity_id); +} + +/** + * True when a draw is pending and Accept/Decline should be shown. + * Missing `draw_offered_by` (older clients) is treated as an opponent offer. + */ +export function isGamesDrawOfferFromOpponent(session: { + identity_id: string; + metadata?: Record; +}): boolean { + return gamesMetaBool(session.metadata, 'draw_offered') && !isGamesDrawOfferFromSelf(session); +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 3d20242d4..24ef7d8db 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5323,6 +5323,7 @@ "propagating": "Offline doručené zprávy...", "propagated": "Doručená na Vyzvedni.to a připravená k výdeji", "failed": "Je třeba to zkusit znovu" - } + }, + "drawOfferWaitingBanner": "Nabídka byla odeslána. Čeká se na soupeře..." } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 65ec13a93..4217cc377 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Offline-Posteingang...", "propagated": "Gespeichert", "failed": "Erneuter Versuch erforderlich" - } + }, + "drawOfferWaitingBanner": "Angebot ziehen gesendet. Warten auf Gegner..." } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 0ed4c18d9..60c06b1a6 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1970,6 +1970,7 @@ "deleteConfirmTitle": "Delete session?", "deleteConfirmMessage": "Remove this game session from your list? This cannot be undone.", "drawOfferedBanner": "Your opponent offered a draw.", + "drawOfferWaitingBanner": "Draw offer sent. Waiting for opponent…", "challenge": "Challenge", "challengeAria": "Challenge to a game", "challengeAppAria": "Challenge to {{app}}", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index a33777f47..513da7b75 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Bandeja de entrada sin conexión...", "propagated": "Almacenado", "failed": "Se necesita volver a intentarlo" - } + }, + "drawOfferWaitingBanner": "Oferta de sorteo enviada. Esperando al oponente..." } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index f98f10957..cce5d24c7 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Boîte de réception hors ligne…", "propagated": "emmagasiné, rangé, conservé, entreposé, stocké", "failed": "Réessayer nécessaire" - } + }, + "drawOfferWaitingBanner": "Offre de tirage envoyée. En attente de l'adversaire…" } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 200a718d0..db50cb743 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Kotak Masuk Offline…", "propagated": "Disimpan", "failed": "Coba lagi yang diperlukan" - } + }, + "drawOfferWaitingBanner": "Penawaran undian terkirim. Menunggu lawan…" } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 604763243..1bd13dcce 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Posta in arrivo offline...", "propagated": "Memorizzato", "failed": "Nuovo tentativo necessario" - } + }, + "drawOfferWaitingBanner": "Offerta di pareggio inviata. In attesa dell'avversario..." } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 81d4907cb..b2fdde6fc 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5321,6 +5321,7 @@ "propagating": "オフライン受信トレイ…", "propagated": "格納されています", "failed": "再試行が必要です" - } + }, + "drawOfferWaitingBanner": "抽選オファーが送信されました。対戦相手を待っています…" } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index f631afc77..c7a4f4cb0 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5321,6 +5321,7 @@ "propagating": "오프라인 수신함...", "propagated": "저장량", "failed": "다시 시도 필요" - } + }, + "drawOfferWaitingBanner": "무승부 오퍼가 전송되었습니다. 상대를 기다리는 중..." } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index b97a1743a..62d89dff8 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Offline inbox...", "propagated": "Opgeslagen", "failed": "Opnieuw proberen nodig" - } + }, + "drawOfferWaitingBanner": "Tekenaanbod verzonden. Wachten op tegenstander..." } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index f8c1e8654..3f5e99973 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5325,6 +5325,7 @@ "propagating": "Skrzynka odbiorcza offline...", "propagated": "Przechowywane", "failed": "Wymagana ponowna próba" - } + }, + "drawOfferWaitingBanner": "Oferta losowania wysłana. Oczekiwanie na przeciwnika…" } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 77de15305..2f7cd68cd 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Caixa de entrada offline...", "propagated": "Armazenada", "failed": "Tentativa necessária" - } + }, + "drawOfferWaitingBanner": "Oferta de empate enviada. Aguardando oponente..." } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index ea5702e02..986a3e12e 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5323,6 +5323,7 @@ "propagating": "Входящие в автономном режиме...", "propagated": "Хранится", "failed": "Требуется повторить попытку" - } + }, + "drawOfferWaitingBanner": "Предложение о розыгрыше отправлено. Ожидание оппонента…" } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 441f3bcfa..b84a71d3e 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5321,6 +5321,7 @@ "propagating": "Çevrimdışı Gelen Kutusu…", "propagated": "ve saklanmalı.", "failed": "Yeniden deneme gerekli" - } + }, + "drawOfferWaitingBanner": "Beraberlik teklifi gönderildi. Rakip bekleniyor…" } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 32e7d26b3..591d6f8cb 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5323,6 +5323,7 @@ "propagating": "Офлайн-скринька…", "propagated": "Збережено", "failed": "Потрібна повторна спроба" - } + }, + "drawOfferWaitingBanner": "Надіслано пропозицію розіграшу. Очікування опонента…" } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index e6fcd0052..e2e5be3ee 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5321,6 +5321,7 @@ "propagating": "离线收件箱…", "propagated": "已存储", "failed": "需要重试" - } + }, + "drawOfferWaitingBanner": "抽奖活动已发送。正在等待对手……" } } diff --git a/src/shared/games-types.ts b/src/shared/games-types.ts index 938c2c956..d7faec345 100644 --- a/src/shared/games-types.ts +++ b/src/shared/games-types.ts @@ -88,6 +88,8 @@ export interface GamesTttMetadata { winner: string; terminal: string; draw_offered: boolean; + /** Hash of who offered the pending draw (local `identity_id` or peer). */ + draw_offered_by?: string; } /** `lrgp-rs` ChessApp session metadata (see `default_metadata` in chess.rs). */ @@ -101,6 +103,8 @@ export interface GamesChessMetadata { winner: string; terminal: string; draw_offered: boolean; + /** Hash of who offered the pending draw (local `identity_id` or peer). */ + draw_offered_by?: string; draw_offer_reason?: string; in_check: boolean; legal_moves: string[]; From f04b5eef9ab60a65ad1b495742e92aebd2c18790 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 19:09:04 -0600 Subject: [PATCH 3/6] test(games): cover draw-offer owner edge cases and live updates Expand helper matrix, legacy/self/opponent GamesPanel regressions, board inactive/legacy banners, and games.update store preservation. --- src/renderer/components/GamesPanel.test.tsx | 118 ++++++++++++++++++ .../components/games/ChessBoard.test.tsx | 53 ++++++++ .../components/games/TicTacToeBoard.test.tsx | 47 +++++++ .../reticulum/reticulumGamesMetadata.test.ts | 118 +++++++++++++----- .../stores/reticulumGamesStore.test.ts | 17 +++ 5 files changed, 322 insertions(+), 31 deletions(-) diff --git a/src/renderer/components/GamesPanel.test.tsx b/src/renderer/components/GamesPanel.test.tsx index d6a5ce493..3e29b3146 100644 --- a/src/renderer/components/GamesPanel.test.tsx +++ b/src/renderer/components/GamesPanel.test.tsx @@ -245,11 +245,129 @@ describe('GamesPanel', () => { ); expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); + expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Accept draw offer' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Decline draw offer' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); }); + it('treats legacy draw_offered without draw_offered_by as an opponent offer', async () => { + await renderAndSelectSession( + makeSession({ + metadata: { + board: '_________', + turn: 'me', + first_turn: 'me', + my_marker: 'X', + move_count: 0, + winner: '', + terminal: '', + draw_offered: true, + }, + }), + ); + + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept draw offer' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline draw offer' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); + }); + + it('sends draw_offer when Offer draw is clicked', async () => { + await renderAndSelectSession(makeSession()); + await userEvent.click(screen.getByRole('button', { name: 'Offer draw' })); + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ command: 'draw_offer', session_id: 's1' }), + ); + }); + }); + + it('switches to waiting UI when games.update stamps local player as draw owner', async () => { + const session = makeSession(); + await renderAndSelectSession(session); + expect(screen.getByRole('button', { name: 'Offer draw' })).toBeInTheDocument(); + + act(() => { + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: session.app_id, + session_id: session.session_id, + direction: 'outbound', + session: { + ...session, + metadata: { + ...session.metadata, + draw_offered: true, + draw_offered_by: 'me', + }, + updated_at: 2, + }, + }); + }); + + expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); + expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Accept draw offer' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); + }); + + it('switches to Accept/Decline when games.update stamps opponent as draw owner', async () => { + const session = makeSession(); + await renderAndSelectSession(session); + + act(() => { + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: session.app_id, + session_id: session.session_id, + direction: 'inbound', + session: { + ...session, + metadata: { + ...session.metadata, + draw_offered: true, + draw_offered_by: peerHash, + }, + updated_at: 2, + }, + }); + }); + + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept draw offer' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline draw offer' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); + }); + + it('hides chess claim buttons while a self draw offer is pending', async () => { + await renderAndSelectSession( + makeSession({ + app_id: 'chess', + metadata: { + fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', + turn: 'me', + my_color: 'w', + first_turn: 'me', + move_count: 0, + winner: '', + terminal: '', + draw_offered: true, + draw_offered_by: 'me', + draw_offer_reason: '3fr', + in_check: false, + legal_moves: [], + moves: [], + }, + }), + ); + + expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Claim threefold repetition draw' }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer draw' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Accept draw offer' })).not.toBeInTheDocument(); + }); + it('shows resend after a failed action and triggers resend', async () => { vi.mocked(window.electronAPI.reticulum.games.sendAction).mockResolvedValue({ ok: false, diff --git a/src/renderer/components/games/ChessBoard.test.tsx b/src/renderer/components/games/ChessBoard.test.tsx index 52c5d0764..5bf5a01d6 100644 --- a/src/renderer/components/games/ChessBoard.test.tsx +++ b/src/renderer/components/games/ChessBoard.test.tsx @@ -358,4 +358,57 @@ describe('ChessBoard', () => { expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); }); + + it('shows the opponent banner for legacy draw_offered without owner', () => { + render( + , + ); + + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + }); + + it('hides draw banners when the session is not active', () => { + render( + , + ); + + expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); + expect(screen.queryByText('Draw offer sent. Waiting for opponent…')).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/games/TicTacToeBoard.test.tsx b/src/renderer/components/games/TicTacToeBoard.test.tsx index 864102ef4..5b983fed8 100644 --- a/src/renderer/components/games/TicTacToeBoard.test.tsx +++ b/src/renderer/components/games/TicTacToeBoard.test.tsx @@ -172,4 +172,51 @@ describe('TicTacToeBoard', () => { expect(screen.getByText('Draw offer sent. Waiting for opponent…')).toBeInTheDocument(); expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); }); + + it('shows the opponent banner for legacy draw_offered without owner', () => { + render( + , + ); + + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + }); + + it('hides draw banners when the session is not active', () => { + render( + , + ); + + expect(screen.queryByText('Your opponent offered a draw.')).not.toBeInTheDocument(); + expect(screen.queryByText('Draw offer sent. Waiting for opponent…')).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts index fbc84567a..3d09ce004 100644 --- a/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts @@ -15,40 +15,96 @@ describe('gamesDrawOfferedBy', () => { expect(gamesDrawOfferedBy(undefined)).toBe(''); expect(gamesDrawOfferedBy({})).toBe(''); expect(gamesDrawOfferedBy({ draw_offered_by: 1 })).toBe(''); + expect(gamesDrawOfferedBy({ draw_offered_by: null })).toBe(''); + expect(gamesDrawOfferedBy({ draw_offered_by: '' })).toBe(''); }); }); describe('isGamesDrawOfferFromSelf / isGamesDrawOfferFromOpponent', () => { - it('returns false for both when draw_offered is not set', () => { - const session = { identity_id: 'me', metadata: { draw_offered: false } }; - expect(isGamesDrawOfferFromSelf(session)).toBe(false); - expect(isGamesDrawOfferFromOpponent(session)).toBe(false); - }); - - it('treats self owner as self offer', () => { - const session = { - identity_id: 'me', - metadata: { draw_offered: true, draw_offered_by: 'me' }, - }; - expect(isGamesDrawOfferFromSelf(session)).toBe(true); - expect(isGamesDrawOfferFromOpponent(session)).toBe(false); - }); - - it('treats peer owner as opponent offer', () => { - const session = { - identity_id: 'me', - metadata: { draw_offered: true, draw_offered_by: 'peer' }, - }; - expect(isGamesDrawOfferFromSelf(session)).toBe(false); - expect(isGamesDrawOfferFromOpponent(session)).toBe(true); - }); - - it('treats missing draw_offered_by as opponent offer (legacy)', () => { - const session = { - identity_id: 'me', - metadata: { draw_offered: true }, - }; - expect(isGamesDrawOfferFromSelf(session)).toBe(false); - expect(isGamesDrawOfferFromOpponent(session)).toBe(true); + it.each([ + { + name: 'no pending draw', + session: { identity_id: 'me', metadata: { draw_offered: false } }, + self: false, + opponent: false, + }, + { + name: 'draw_offered absent', + session: { identity_id: 'me', metadata: {} }, + self: false, + opponent: false, + }, + { + name: 'metadata undefined', + session: { identity_id: 'me' }, + self: false, + opponent: false, + }, + { + name: 'stale owner without draw_offered flag', + session: { + identity_id: 'me', + metadata: { draw_offered: false, draw_offered_by: 'me' }, + }, + self: false, + opponent: false, + }, + { + name: 'string truthy draw_offered is ignored', + session: { + identity_id: 'me', + metadata: { draw_offered: 'true', draw_offered_by: 'peer' }, + }, + self: false, + opponent: false, + }, + { + name: 'self owner', + session: { + identity_id: 'me', + metadata: { draw_offered: true, draw_offered_by: 'me' }, + }, + self: true, + opponent: false, + }, + { + name: 'peer owner', + session: { + identity_id: 'me', + metadata: { draw_offered: true, draw_offered_by: 'peer' }, + }, + self: false, + opponent: true, + }, + { + name: 'missing draw_offered_by (legacy)', + session: { + identity_id: 'me', + metadata: { draw_offered: true }, + }, + self: false, + opponent: true, + }, + { + name: 'empty draw_offered_by (legacy)', + session: { + identity_id: 'me', + metadata: { draw_offered: true, draw_offered_by: '' }, + }, + self: false, + opponent: true, + }, + { + name: 'empty local identity_id cannot be self', + session: { + identity_id: '', + metadata: { draw_offered: true, draw_offered_by: 'me' }, + }, + self: false, + opponent: true, + }, + ])('$name', ({ session, self, opponent }) => { + expect(isGamesDrawOfferFromSelf(session)).toBe(self); + expect(isGamesDrawOfferFromOpponent(session)).toBe(opponent); }); }); diff --git a/src/renderer/stores/reticulumGamesStore.test.ts b/src/renderer/stores/reticulumGamesStore.test.ts index 7bda58418..1662ea4ba 100644 --- a/src/renderer/stores/reticulumGamesStore.test.ts +++ b/src/renderer/stores/reticulumGamesStore.test.ts @@ -92,6 +92,23 @@ describe('reticulumGamesStore', () => { expect(sessions[0].status).toBe('active'); }); + it('applyGamesUpdate preserves draw_offered_by on outbound self offers', () => { + useReticulumGamesStore.getState().setSessions([makeSession({ status: 'active' })]); + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: 'ttt', + session_id: 's1', + direction: 'outbound', + session: makeSession({ + status: 'active', + metadata: { draw_offered: true, draw_offered_by: 'me' }, + updated_at: 2, + }), + }); + const meta = useReticulumGamesStore.getState().sessions[0].metadata; + expect(meta.draw_offered).toBe(true); + expect(meta.draw_offered_by).toBe('me'); + }); + it('applyGamesUpdate ignores payloads with no session', () => { useReticulumGamesStore.getState().setSessions([makeSession()]); useReticulumGamesStore.getState().applyGamesUpdate({ From 929fb5ab7297bb7769297b59c12a332e6d7e1526 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 19:23:20 -0600 Subject: [PATCH 4/6] test: cover audit hardening helpers and IPC contracts Lock in shared mqttTls/appSettings prefixes, GPS/last-connection helpers, S&F parse, updater sender checks, and runtime source contracts from the audit fixes. --- src/main/database.test.ts | 6 +- src/main/index.contract.test.ts | 25 ++++-- src/main/updater.contract.test.ts | 23 +++++ src/renderer/lib/gpsSource.test.ts | 40 +++++++++ .../lib/lastConnectionStorage.test.ts | 14 +++ .../lib/meshtasticMessageDedup.test.ts | 8 ++ .../runtime/auditHardening.contract.test.ts | 87 +++++++++++++++++++ src/shared/appSettingsKeyPrefixes.test.ts | 36 ++++++++ .../meshtasticTextMessagePayload.test.ts | 9 ++ src/shared/mqttTls.test.ts | 20 +++++ 10 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 src/renderer/runtime/auditHardening.contract.test.ts create mode 100644 src/shared/appSettingsKeyPrefixes.test.ts create mode 100644 src/shared/mqttTls.test.ts diff --git a/src/main/database.test.ts b/src/main/database.test.ts index 2f936ca9f..b0e35cef4 100644 --- a/src/main/database.test.ts +++ b/src/main/database.test.ts @@ -442,9 +442,9 @@ describe('app_settings table + message retention defaults (schema sync)', () => expect(INDEX_SOURCE).toContain('meshcoreMessageRetentionCount'); expect(INDEX_SOURCE).toContain('reduceMotion'); expect(INDEX_SOURCE).toContain('use24HourTime'); - expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); - expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:'); - expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_SYNC_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_LAST_POST_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX'); expect(INDEX_SOURCE).toContain('reticulumLastSelfLxmfHash'); expect(INDEX_SOURCE).toContain('reticulumRmapAnnounceIntervalMin'); expect(INDEX_SOURCE).toContain('reticulumRmapReachableOn'); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index be4c278d4..cfb028694 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -152,11 +152,11 @@ describe('Persistent app settings IPC (source contract)', () => { expect(INDEX_SOURCE).toContain("'meshcoreLastSelfNodeId'"); expect(INDEX_SOURCE).toContain("'reticulumLastSelfLxmfHash'"); expect(INDEX_SOURCE).toContain("'use24HourTime'"); - expect(INDEX_SOURCE).toContain('meshtasticRemoteAdminKey:'); - expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); - expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:'); - expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:'); - expect(INDEX_SOURCE).toContain('meshcoreRepeaterCredential:'); + expect(INDEX_SOURCE).toContain('MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_SYNC_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_LAST_POST_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX'); expect(INDEX_SOURCE).toContain('isAppSettingsKeyAllowed'); }); @@ -558,4 +558,19 @@ describe('Native Electron call guards (source contract)', () => { /ipcMain\.handle\('meshcore:openJsonFile'[\s\S]*?fs\.promises\.readFile/, ); }); + + it('validates IPC sender for meshcore:openJsonFile and device-connected listeners', () => { + expect(INDEX_SOURCE).toMatch( + /ipcMain\.handle\('meshcore:openJsonFile'[\s\S]*?assertIpcSender\(event, 'meshcore:openJsonFile'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /ipcMain\.on\('device-connected'[\s\S]*?validateIpcSender\(event\)/, + ); + expect(INDEX_SOURCE).toMatch( + /ipcMain\.on\('device-disconnected'[\s\S]*?validateIpcSender\(event\)/, + ); + expect(INDEX_SOURCE).toMatch( + /ipcMain\.handle\('app:getProcessUptimeSec'[\s\S]*?assertIpcSender\(event, 'app:getProcessUptimeSec'\)/, + ); + }); }); diff --git a/src/main/updater.contract.test.ts b/src/main/updater.contract.test.ts index b13bb2749..3e915c409 100644 --- a/src/main/updater.contract.test.ts +++ b/src/main/updater.contract.test.ts @@ -21,6 +21,29 @@ describe('updater source contracts', () => { expect(UPDATER_SOURCE).toContain('getCheckNowFromMenu'); }); + it('validates IPC sender on update invoke channels', () => { + for (const channel of [ + 'update:check', + 'update:download', + 'update:install', + 'update:open-releases', + ] as const) { + const needle = `ipcMain.handle('${channel}'`; + expect(UPDATER_SOURCE).toContain(needle); + const idx = UPDATER_SOURCE.indexOf(needle); + expect(UPDATER_SOURCE.slice(idx, idx + 250)).toContain( + `assertIpcSender(event, '${channel}')`, + ); + } + }); + + it('sanitizes updater error payloads before logging and notifying the renderer', () => { + expect(UPDATER_SOURCE).toMatch(/send\('update:error',\s*\{\s*message:\s*safe\s*\}\)/); + expect(UPDATER_SOURCE).toContain( + "console.error('[updater] error:', sanitizeLogMessage(err.message))", + ); + }); + it('declares builder-util-runtime so electron-updater resolves in packaged Windows builds', () => { expect(PACKAGE_JSON.dependencies?.['builder-util-runtime']).toBeTruthy(); }); diff --git a/src/renderer/lib/gpsSource.test.ts b/src/renderer/lib/gpsSource.test.ts index 16c3cd054..470053221 100644 --- a/src/renderer/lib/gpsSource.test.ts +++ b/src/renderer/lib/gpsSource.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { GPS_SETTINGS_STORAGE_KEY, hasStoredStaticGps, + persistStoredStaticGps, + readGpsRefreshIntervalSecs, readStoredStaticGps, resolveOurPosition, shouldPreserveStaticGpsForSelfNode, @@ -29,6 +31,44 @@ describe('readStoredStaticGps', () => { }); }); +describe('readGpsRefreshIntervalSecs / persistStoredStaticGps', () => { + afterEach(() => { + localStorage.removeItem(GPS_SETTINGS_STORAGE_KEY); + }); + + it('returns 0 when refreshInterval is missing or non-positive', () => { + expect(readGpsRefreshIntervalSecs()).toBe(0); + localStorage.setItem(GPS_SETTINGS_STORAGE_KEY, JSON.stringify({ refreshInterval: 0 })); + expect(readGpsRefreshIntervalSecs()).toBe(0); + localStorage.setItem(GPS_SETTINGS_STORAGE_KEY, JSON.stringify({ refreshInterval: -5 })); + expect(readGpsRefreshIntervalSecs()).toBe(0); + }); + + it('returns a positive refresh interval when configured', () => { + localStorage.setItem(GPS_SETTINGS_STORAGE_KEY, JSON.stringify({ refreshInterval: 30 })); + expect(readGpsRefreshIntervalSecs()).toBe(30); + }); + + it('persists static coords while preserving refreshInterval', () => { + localStorage.setItem( + GPS_SETTINGS_STORAGE_KEY, + JSON.stringify({ refreshInterval: 45, extra: 'keep' }), + ); + persistStoredStaticGps(39.1, -104.2); + const stored = JSON.parse(localStorage.getItem(GPS_SETTINGS_STORAGE_KEY) ?? '{}') as Record< + string, + unknown + >; + expect(stored).toMatchObject({ + staticLat: 39.1, + staticLon: -104.2, + refreshInterval: 45, + extra: 'keep', + }); + expect(readStoredStaticGps()).toEqual({ lat: 39.1, lon: -104.2 }); + }); +}); + describe('shouldPreserveStaticGpsForSelfNode', () => { afterEach(() => { localStorage.removeItem(GPS_SETTINGS_STORAGE_KEY); diff --git a/src/renderer/lib/lastConnectionStorage.test.ts b/src/renderer/lib/lastConnectionStorage.test.ts index 7a392c7ce..b3a65d01f 100644 --- a/src/renderer/lib/lastConnectionStorage.test.ts +++ b/src/renderer/lib/lastConnectionStorage.test.ts @@ -174,4 +174,18 @@ describe('lastConnectionStorage reconnect rehydrate', () => { ); expect(resolveLastSerialPortId('meshtastic')).toBe('ttyUSB0'); }); + + it('resolveLastHttpAddress accepts tcp type and ignores ble leftovers', () => { + localStorage.setItem( + 'mesh-client:lastConnection:meshcore', + JSON.stringify({ type: 'tcp', httpAddress: '192.168.4.1:5000' }), + ); + expect(resolveLastHttpAddress('meshcore')).toBe('192.168.4.1:5000'); + + localStorage.setItem( + 'mesh-client:lastConnection:meshcore', + JSON.stringify({ type: 'ble', bleDeviceId: 'aa:bb', httpAddress: '10.0.0.9:5000' }), + ); + expect(resolveLastHttpAddress('meshcore')).toBeUndefined(); + }); }); diff --git a/src/renderer/lib/meshtasticMessageDedup.test.ts b/src/renderer/lib/meshtasticMessageDedup.test.ts index 380aac58d..4ccc6eefd 100644 --- a/src/renderer/lib/meshtasticMessageDedup.test.ts +++ b/src/renderer/lib/meshtasticMessageDedup.test.ts @@ -9,6 +9,7 @@ import { meshtasticStoreForwardContentMatch, normalizeMeshtasticDedupPayload, } from './meshtasticMessageDedup'; +import { MESHTASTIC_DEDUP_WINDOW_MS, MESHTASTIC_PACKET_DEDUP_TTL_MS } from './timeConstants'; import type { ChatMessage } from './types'; function baseMsg(overrides: Partial = {}): ChatMessage { @@ -29,6 +30,13 @@ describe('normalizeMeshtasticDedupPayload', () => { }); }); +describe('Meshtastic 10-minute dedup window aliases', () => { + it('keeps cross-transport and packet TTL aliases on the canonical constant', () => { + expect(MESHTASTIC_CROSS_TRANSPORT_DEDUP_WINDOW_MS).toBe(MESHTASTIC_DEDUP_WINDOW_MS); + expect(MESHTASTIC_PACKET_DEDUP_TTL_MS).toBe(MESHTASTIC_DEDUP_WINDOW_MS); + }); +}); + describe('meshtasticCrossTransportMatch', () => { it('matches MQTT then RF with different packetId and timestamp skew', () => { const mqtt = baseMsg({ diff --git a/src/renderer/runtime/auditHardening.contract.test.ts b/src/renderer/runtime/auditHardening.contract.test.ts new file mode 100644 index 000000000..841601d0e --- /dev/null +++ b/src/renderer/runtime/auditHardening.contract.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** + * Source contracts for audit hardening in LoRa runtimes / Nomad toast path. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { extractUseCallbackBody, loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const MESHTASTIC = loadRuntimeSource('useMeshtasticRuntime.ts'); +const MESHCORE = loadRuntimeSource('useMeshcoreRuntime.ts'); +const NOMAD_PAGE_VIEWER = readFileSync( + join(import.meta.dirname ?? __dirname, '../stores/nomadPageViewerStore.ts'), + 'utf-8', +); +const CHAT_PANEL = readFileSync( + join(import.meta.dirname ?? __dirname, '../components/ChatPanel.tsx'), + 'utf-8', +); +const NODE_LIST = readFileSync( + join(import.meta.dirname ?? __dirname, '../components/NodeListPanel.tsx'), + 'utf-8', +); +const RAW_PACKET_LOG = readFileSync( + join(import.meta.dirname ?? __dirname, '../components/RawPacketLogPanel.tsx'), + 'utf-8', +); +const TAK_SERVER = readFileSync( + join(import.meta.dirname ?? __dirname, '../../main/tak-server-manager.ts'), + 'utf-8', +); + +describe('audit hardening source contracts', () => { + it('refreshMessagesFromDb catches floating hydrateMeshtasticMessagesFromDb rejections', () => { + const body = extractUseCallbackBody(MESHTASTIC, 'refreshMessagesFromDb'); + expect(body).toContain('hydrateMeshtasticMessagesFromDb'); + expect(body).toMatch(/hydrateMeshtasticMessagesFromDb\([\s\S]*?\)\.catch\(\(err: unknown\) =>/); + expect(body).toContain('refreshMessagesFromDb identity hydrate failed'); + }); + + it('MeshCore auto-connect uses resolveLastHttpAddress instead of hand-parsed lastConnection JSON', () => { + expect(MESHCORE).toContain('resolveLastHttpAddress'); + expect(MESHCORE).not.toContain("localStorage.getItem('mesh-client:lastConnection:meshcore')"); + }); + + it('MeshCore static GPS persist uses gpsSource helper', () => { + expect(MESHCORE).toContain('persistStoredStaticGps'); + expect(MESHCORE).not.toContain("'mesh-client:gpsSettings'"); + }); + + it('Meshtastic GPS interval uses readGpsRefreshIntervalSecs', () => { + expect(MESHTASTIC).toContain('readGpsRefreshIntervalSecs'); + expect(MESHTASTIC).not.toContain("'mesh-client:gpsSettings'"); + }); + + it('nomad pageReady toast dynamic i18n import has a rejection handler', () => { + expect(NOMAD_PAGE_VIEWER).toMatch( + /import\('@\/renderer\/lib\/i18n'\)[\s\S]*?\.catch\(\(err: unknown\) =>/, + ); + expect(NOMAD_PAGE_VIEWER).toContain('pageReadyToast i18n import failed'); + }); + + it('ChatPanel localizes MeshCore Unknown sender sentinel', () => { + expect(CHAT_PANEL).toContain("rawSenderName === 'Unknown' ? t('common.unknown')"); + }); + + it('NodeListPanel maps Sensor/None/Unknown hw_model through i18n keys', () => { + expect(NODE_LIST).toContain("t('nodeListPanel.meshcoreTypeSensor')"); + expect(NODE_LIST).toContain("t('nodeListPanel.meshcoreTypeNone')"); + expect(NODE_LIST).toContain("t('nodeListPanel.meshcoreTypeUnknown')"); + }); + + it('RawPacketLog transport badges use translated filter-chip keys', () => { + expect(RAW_PACKET_LOG).toContain("'rawPacketLog.filterChipLocal'"); + expect(RAW_PACKET_LOG).toContain("'rawPacketLog.filterChipMqtt'"); + expect(RAW_PACKET_LOG).toContain("'rawPacketLog.filterChipRf'"); + expect(RAW_PACKET_LOG).not.toMatch(/const transportLabel = p\.isLocal \? 'LOCAL'/); + }); + + it('TAK server error path sanitizes before log/emit', () => { + expect(TAK_SERVER).toMatch( + /this\.server\.on\('error'[\s\S]*?sanitizeLogMessage\(msg\)[\s\S]*?console\.error\('\[TakServer\]', safe\)/, + ); + }); +}); diff --git a/src/shared/appSettingsKeyPrefixes.test.ts b/src/shared/appSettingsKeyPrefixes.test.ts new file mode 100644 index 000000000..ca57b5f02 --- /dev/null +++ b/src/shared/appSettingsKeyPrefixes.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +import { + MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX, + MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX, + MESHCORE_ROOM_LAST_POST_SETTING_PREFIX, + MESHCORE_ROOM_SYNC_SETTING_PREFIX, + MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX, +} from './appSettingsKeyPrefixes'; + +const INDEX_SOURCE = readFileSync(join(__dirname, '../main/index.ts'), 'utf-8'); + +describe('appSettingsKeyPrefixes', () => { + it('exports the canonical prefixes used by renderer storage modules', () => { + expect(MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX).toBe('meshtasticRemoteAdminKey:'); + expect(MESHCORE_ROOM_SYNC_SETTING_PREFIX).toBe('meshcoreRoomSync:'); + expect(MESHCORE_ROOM_LAST_POST_SETTING_PREFIX).toBe('meshcoreRoomLastPost:'); + expect(MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX).toBe('meshcoreRoomCredential:'); + expect(MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX).toBe('meshcoreRepeaterCredential:'); + }); + + it('is imported by main IPC allowlist (no duplicated string literals)', () => { + expect(INDEX_SOURCE).toContain("from '../shared/appSettingsKeyPrefixes'"); + expect(INDEX_SOURCE).toContain('MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_SYNC_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_LAST_POST_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX'); + expect(INDEX_SOURCE).toContain('MESHCORE_REPEATER_CREDENTIAL_SETTING_PREFIX'); + expect(INDEX_SOURCE).not.toMatch( + /const MESHTASTIC_REMOTE_ADMIN_KEY_SETTING_PREFIX = 'meshtasticRemoteAdminKey:'/, + ); + }); +}); diff --git a/src/shared/meshtasticTextMessagePayload.test.ts b/src/shared/meshtasticTextMessagePayload.test.ts index 666539f48..a77e1a5de 100644 --- a/src/shared/meshtasticTextMessagePayload.test.ts +++ b/src/shared/meshtasticTextMessagePayload.test.ts @@ -7,6 +7,7 @@ import { } from './meshtasticProtobufSchemas'; import { isLikelyReadableChatText, + parseStoreForwardPacket, resolveMeshtasticTextMessagePayload, } from './meshtasticTextMessagePayload'; @@ -34,4 +35,12 @@ describe('meshtasticTextMessagePayload', () => { expect(resolveMeshtasticTextMessagePayload(sfTextPacket(''))).toBeNull(); expect(resolveMeshtasticTextMessagePayload(sfTextPacket(' '))).toBeNull(); }); + + it('parseStoreForwardPacket returns null for empty/malformed bytes and decodes text variants', () => { + expect(parseStoreForwardPacket(new Uint8Array())).toBeNull(); + expect(parseStoreForwardPacket(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeNull(); + const parsed = parseStoreForwardPacket(sfTextPacket('sf hello')); + expect(parsed?.variant.case).toBe('text'); + expect(parsed?.rr).toBe(meshtasticStoreForwardRequestResponse.ROUTER_TEXT_BROADCAST); + }); }); diff --git a/src/shared/mqttTls.test.ts b/src/shared/mqttTls.test.ts new file mode 100644 index 000000000..bb3cefe9a --- /dev/null +++ b/src/shared/mqttTls.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { mqttUsesTls } from './mqttTls'; + +describe('shared mqttUsesTls', () => { + it('infers TLS from default ports and explicit tlsEnabled', () => { + expect(mqttUsesTls({ port: 8883 })).toBe(true); + expect(mqttUsesTls({ port: 1883 })).toBe(false); + expect(mqttUsesTls({ port: 1883, tlsEnabled: true })).toBe(true); + expect(mqttUsesTls({ port: 8883, tlsEnabled: false })).toBe(false); + }); + + it('infers WebSocket TLS from port 443 by default', () => { + expect(mqttUsesTls({ port: 443, useWebSocket: true })).toBe(true); + expect(mqttUsesTls({ port: 80, useWebSocket: true })).toBe(false); + expect(mqttUsesTls({ port: 80, useWebSocket: true, tlsEnabled: true })).toBe(true); + expect(mqttUsesTls({ port: 443, useWebSocket: true, tlsEnabled: false })).toBe(false); + }); +}); From 4707cd5eaa117db918f5bc64bc2d0a11eff58728 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 19:44:53 -0600 Subject: [PATCH 5/6] fix: address audit review on temp dirs, i18n, and logging Preserve mkdtemp extract dirs in packaging smokes, localize MeshCore Unknown only, and tighten TAK/nomad error logging tests. --- scripts/check-insecure-temp-files.test.mjs | 12 +++ .../test-linux-appimage-reticulum-sidecar.mjs | 6 +- ...-linux-appimage-reticulum-sidecar.test.mjs | 13 ++++ scripts/test-win-nsis-install.mjs | 74 ++++++++++--------- src/main/tak-server-manager.test.ts | 51 ++++++++++++- src/renderer/components/ChatPanel.test.tsx | 69 +++++++++++++++++ src/renderer/components/ChatPanel.tsx | 5 +- src/renderer/hooks/useSendMessage.test.ts | 58 ++++++++------- .../runtime/auditHardening.contract.test.ts | 6 +- src/renderer/stores/nomadPageViewerStore.ts | 4 +- 10 files changed, 228 insertions(+), 70 deletions(-) diff --git a/scripts/check-insecure-temp-files.test.mjs b/scripts/check-insecure-temp-files.test.mjs index f80934ff8..0a3d45f19 100644 --- a/scripts/check-insecure-temp-files.test.mjs +++ b/scripts/check-insecure-temp-files.test.mjs @@ -69,6 +69,18 @@ import os from 'os'; import path from 'path'; const dir = path.join(os.tmpdir(), 'mesh-client-appimage-x64-1'); fs.mkdirSync(dir, { recursive: true }); +`); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/insecure-temporary-file|predictable/); + }); + + it('fails on async fs.mkdir to predictable tmpdir path', () => { + const result = runCheckOnSnippet(` +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +const dir = path.join(os.tmpdir(), 'mesh-client-appimage-x64-async'); +await fs.promises.mkdir(dir, { recursive: true }); `); expect(result.status).toBe(1); expect(result.stderr).toMatch(/insecure-temporary-file|predictable/); diff --git a/scripts/test-linux-appimage-reticulum-sidecar.mjs b/scripts/test-linux-appimage-reticulum-sidecar.mjs index 2edf3f961..d1ec60f0e 100644 --- a/scripts/test-linux-appimage-reticulum-sidecar.mjs +++ b/scripts/test-linux-appimage-reticulum-sidecar.mjs @@ -135,9 +135,11 @@ export function findSquashfsOffset(appImagePath) { return null; } -/** Prepare a clean extract directory for AppImage --appimage-extract (spawnSync needs existing cwd). */ +/** + * Ensure extract cwd exists for AppImage --appimage-extract (spawnSync needs existing cwd). + * Does not delete/recreate `extractDir` — callers pass a unique mkdtemp path that must be preserved. + */ export function prepareAppImageExtractDir(extractDir) { - rmSync(extractDir, { recursive: true, force: true }); mkdirSync(extractDir, { recursive: true }); } diff --git a/scripts/test-linux-appimage-reticulum-sidecar.test.mjs b/scripts/test-linux-appimage-reticulum-sidecar.test.mjs index 91848c825..8c067b342 100644 --- a/scripts/test-linux-appimage-reticulum-sidecar.test.mjs +++ b/scripts/test-linux-appimage-reticulum-sidecar.test.mjs @@ -43,6 +43,19 @@ describe('test-linux-appimage-reticulum-sidecar', () => { } }); + it('prepareAppImageExtractDir preserves an existing unique extract directory', () => { + const extractDir = mkdtempSync(path.join(tmpdir(), 'mesh-appimage-unique-')); + const marker = path.join(extractDir, 'keep-me.txt'); + try { + writeFileSync(marker, 'ok'); + prepareAppImageExtractDir(extractDir); + expect(existsSync(extractDir)).toBe(true); + expect(existsSync(marker)).toBe(true); + } finally { + rmSync(extractDir, { recursive: true, force: true }); + } + }); + it('readElfMachineFromHeader reads e_machine from ELF header bytes', () => { expect(readElfMachineFromHeader(makeElfHeader(EM_X86_64))).toBe(EM_X86_64); expect(readElfMachineFromHeader(makeElfHeader(EM_AARCH64))).toBe(EM_AARCH64); diff --git a/scripts/test-win-nsis-install.mjs b/scripts/test-win-nsis-install.mjs index 2af8b2b69..b0d715a73 100644 --- a/scripts/test-win-nsis-install.mjs +++ b/scripts/test-win-nsis-install.mjs @@ -36,7 +36,10 @@ const MIN_EXE_BYTES = 50 * 1024 * 1024; /** @param {string} msg */ function fail(msg) { console.error(`[test-win-nsis-install] ${msg}`); - process.exit(1); + // Throw so try/finally cleanup around mkdtemp dirs still runs (process.exit skips finally). + const err = new Error(msg); + err.name = 'TestFail'; + throw err; } function readVersion() { @@ -110,9 +113,7 @@ function probe7zExtract(installerPath, outDir, arch) { fail(`--probe-7z requires 7-Zip at ${sevenZ}`); } - rmSync(outDir, { recursive: true, force: true }); - mkdirSync(outDir, { recursive: true }); - + // Caller supplies a unique mkdtemp directory — do not delete/recreate it. console.debug(`[test-win-nsis-install] Probing 7z extract from installer → ${outDir}`); const extractInstaller = run(sevenZ, ['x', `-o${outDir}`, installerPath, '-y']); if (extractInstaller !== 0) { @@ -182,41 +183,45 @@ function main(arch, probe7z) { } const instDir = path.join(localAppData, 'Programs', 'Mesh-client'); const workDir = mkdtempSync(path.join(tmpdir(), 'mesh-client-install-')); - const logPath = path.join(workDir, `mesh-client-install-${arch}.log`); + try { + const logPath = path.join(workDir, `mesh-client-install-${arch}.log`); - rmSync(instDir, { recursive: true, force: true }); + rmSync(instDir, { recursive: true, force: true }); - console.debug(`[test-win-nsis-install] Installing ${installer} → ${instDir}`); - const installStatus = run(installerPath, ['/S', `/LOG=${logPath}`]); - if (installStatus !== 0) { - if (existsSync(logPath)) { - console.error('[test-win-nsis-install] --- NSIS install log ---'); - console.error(readFileSync(logPath, 'utf-8')); + console.debug(`[test-win-nsis-install] Installing ${installer} → ${instDir}`); + const installStatus = run(installerPath, ['/S', `/LOG=${logPath}`]); + if (installStatus !== 0) { + if (existsSync(logPath)) { + console.error('[test-win-nsis-install] --- NSIS install log ---'); + console.error(readFileSync(logPath, 'utf-8')); + } + dumpDir('install dir after failed installer', instDir); + fail(`Installer exited ${installStatus}`); } - dumpDir('install dir after failed installer', instDir); - fail(`Installer exited ${installStatus}`); - } - const exePath = path.join(instDir, APP_EXE); - if (!existsSync(exePath)) { - if (existsSync(logPath)) { - console.error('[test-win-nsis-install] --- NSIS install log ---'); - console.error(readFileSync(logPath, 'utf-8')); + const exePath = path.join(instDir, APP_EXE); + if (!existsSync(exePath)) { + if (existsSync(logPath)) { + console.error('[test-win-nsis-install] --- NSIS install log ---'); + console.error(readFileSync(logPath, 'utf-8')); + } + dumpDir('install dir (exe missing)', instDir); + fail(`${APP_EXE} missing after silent install (log: ${logPath})`); } - dumpDir('install dir (exe missing)', instDir); - fail(`${APP_EXE} missing after silent install (log: ${logPath})`); - } - assertExe(`installed ${APP_EXE}`, exePath); - assertBundledReticulumSidecarInBundle({ - label: `installed ${arch} Reticulum sidecar`, - platform: 'win32', - bundleRoot: instDir, - fail, - }); - console.debug( - `[test-win-nsis-install] OK — ${arch} NSIS install left ${exePath} with bundled Reticulum sidecar`, - ); + assertExe(`installed ${APP_EXE}`, exePath); + assertBundledReticulumSidecarInBundle({ + label: `installed ${arch} Reticulum sidecar`, + platform: 'win32', + bundleRoot: instDir, + fail, + }); + console.debug( + `[test-win-nsis-install] OK — ${arch} NSIS install left ${exePath} with bundled Reticulum sidecar`, + ); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } } const args = process.argv.slice(2); @@ -232,6 +237,9 @@ if (archArg !== 'x64' && archArg !== 'arm64') { try { main(archArg, probe7z); } catch (e) { + if (e instanceof Error && e.name === 'TestFail') { + process.exit(1); + } console.error('[test-win-nsis-install] Unexpected error:', e); process.exit(1); } diff --git a/src/main/tak-server-manager.test.ts b/src/main/tak-server-manager.test.ts index 4b2ddbd37..d4027da18 100644 --- a/src/main/tak-server-manager.test.ts +++ b/src/main/tak-server-manager.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; -import type tls from 'tls'; +import fs from 'fs'; +import tls from 'tls'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('electron', () => ({ @@ -8,9 +9,10 @@ vi.mock('electron', () => ({ }, })); -vi.mock('./log-service', () => ({ - sanitizeLogMessage: (s: string) => s, -})); +vi.mock('./log-service', async () => { + const { sanitizeLogMessage } = await import('./sanitize-log-message'); + return { sanitizeLogMessage }; +}); vi.mock('./tak/certificate-manager', () => ({ loadOrGenerateCerts: vi.fn().mockResolvedValue({ @@ -113,6 +115,47 @@ describe('TakServerManager client limits', () => { }); }); +describe('TakServerManager server error sanitization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sanitizes CR/LF in server error before console, status, and error event', async () => { + const fakeServer = new EventEmitter() as EventEmitter & { + listen: (port: number, cb: () => void) => void; + close: () => void; + }; + fakeServer.listen = (_port, cb) => { + cb(); + }; + fakeServer.close = () => {}; + + vi.spyOn(tls, 'createServer').mockReturnValue(fakeServer as unknown as tls.Server); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + + const manager = new TakServerManager(); + await manager.start({ + enabled: true, + autoStart: false, + serverName: 'mesh-client-test', + port: 8089, + requireClientCert: false, + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const errorSpy = vi.fn(); + manager.on('error', errorSpy); + + fakeServer.emit('error', new Error('boom\r\ninjected')); + + const logged = consoleSpy.mock.calls.find((c) => c[0] === '[TakServer]')?.[1]; + expect(typeof logged).toBe('string'); + expect(logged).not.toMatch(/[\r\n]/); + expect(manager.getStatus().error).toBe(logged); + expect(errorSpy).toHaveBeenCalledWith(logged); + }); +}); + describe('TakServerManager.regenerateCertificates', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 84016732c..efaa391e4 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -7,6 +7,8 @@ import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import * as chatNotifications from '../lib/chatNotifications'; import { draftsStorageKey, lastReadStorageKey, saveDraft } from '../lib/chatPanelProtocolStorage'; import { getDistFromChatBottom, VIRTUALIZER_SCROLL_END_THRESHOLD } from '../lib/chatScrollUtils'; +import i18n from '../lib/i18n'; +import { ensureLocaleLoaded } from '../lib/localeResources'; import { messageRecordsToChatMessages } from '../lib/storeRecordAdapters'; import type { ChatMessage, MeshNode } from '../lib/types'; import type { MessageRecord } from '../stores/messageStore'; @@ -436,6 +438,73 @@ describe('ChatPanel accessibility', () => { expect(screen.getByRole('img', { name: 'Received via MQTT' })).toBeInTheDocument(); }); + it('localizes MeshCore Unknown sender sentinel via common.unknown', async () => { + await ensureLocaleLoaded(i18n, 'es'); + await i18n.changeLanguage('es'); + try { + expect(i18n.t('common.unknown')).toBe('Desconocido'); + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Desconocido' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument(); + } finally { + await i18n.changeLanguage('en'); + } + }); + + it.each(['meshtastic', 'reticulum'] as const)( + 'preserves literal Unknown sender name for %s', + async (protocol) => { + await ensureLocaleLoaded(i18n, 'es'); + await i18n.changeLanguage('es'); + try { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Unknown' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Desconocido' })).not.toBeInTheDocument(); + } finally { + await i18n.changeLanguage('en'); + } + }, + ); + it('shows Reticulum RF/TCP/network transport badges for incoming messages', async () => { const { rerender } = render( diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 2a0a9c6f1..33fb9db54 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -2532,8 +2532,11 @@ function ChatPanel({ msg.sender_name.trim() || (msg.sender_id > 0 ? getDmLabel(msg.sender_id) : ''); // MeshCore wire/ingest uses English "Unknown" as a sentinel; localize for display. + // Other protocols may use a legitimate node/display name "Unknown" — leave as-is. const displaySenderName = - rawSenderName === 'Unknown' ? t('common.unknown') : rawSenderName; + protocol === 'meshcore' && rawSenderName === 'Unknown' + ? t('common.unknown') + : rawSenderName; // Day separator const daySeparator = daySeparatorIndices.has(i) ? ( diff --git a/src/renderer/hooks/useSendMessage.test.ts b/src/renderer/hooks/useSendMessage.test.ts index 079301de4..33e53a50b 100644 --- a/src/renderer/hooks/useSendMessage.test.ts +++ b/src/renderer/hooks/useSendMessage.test.ts @@ -17,6 +17,7 @@ import { registerReticulumSession, type ReticulumSessionApi, } from '../lib/sessions/reticulumSession'; +import { mockConsoleWarn } from '../lib/vitestConsoleMock'; import { setConnection } from '../stores/connectionStore'; import { addIdentity, useIdentityStore } from '../stores/identityStore'; import { addMessage, useMessageStore } from '../stores/messageStore'; @@ -380,33 +381,36 @@ describe('useSendMessage', () => { it('SoftAP dead-accepted: marks failed when live reopen yields no handle', async () => { setMeshcoreTcpSoftApDeadAccepted(true); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - registerMeshcoreSession( - createMeshcoreSessionStub({ - runMeshcoreUserTxWithLiveTcp: vi.fn((op) => op()), - }), - ); - vi.mocked(connectionDriver.getHandle).mockReturnValue(null); - addIdentity({ - id: ID_MC, - protocol: meshcoreProtocol, - signature: 'sig-mc', - transports: [], - createdAt: 1, - lastSeenAt: 1, - }); - setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); - - const { result } = renderHook(() => useSendMessage(ID_MC)); - result.current('softap fail', 1); - - await vi.waitFor(() => { - const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); - expect(rows).toHaveLength(1); - expect(rows[0]?.status).toBe('failed'); - }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SoftAP live reopen failed')); - warn.mockRestore(); + const { spy: warn, restore } = mockConsoleWarn(); + try { + registerMeshcoreSession( + createMeshcoreSessionStub({ + runMeshcoreUserTxWithLiveTcp: vi.fn((op) => op()), + }), + ); + vi.mocked(connectionDriver.getHandle).mockReturnValue(null); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'sig-mc', + transports: [], + createdAt: 1, + lastSeenAt: 1, + }); + setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); + + const { result } = renderHook(() => useSendMessage(ID_MC)); + result.current('softap fail', 1); + + await vi.waitFor(() => { + const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('failed'); + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SoftAP live reopen failed')); + } finally { + restore(); + } }); it('marks MeshCore DM acked when send resolves with packetId', async () => { diff --git a/src/renderer/runtime/auditHardening.contract.test.ts b/src/renderer/runtime/auditHardening.contract.test.ts index 841601d0e..859913649 100644 --- a/src/renderer/runtime/auditHardening.contract.test.ts +++ b/src/renderer/runtime/auditHardening.contract.test.ts @@ -60,10 +60,12 @@ describe('audit hardening source contracts', () => { /import\('@\/renderer\/lib\/i18n'\)[\s\S]*?\.catch\(\(err: unknown\) =>/, ); expect(NOMAD_PAGE_VIEWER).toContain('pageReadyToast i18n import failed'); + expect(NOMAD_PAGE_VIEWER).toContain('errLikeToLogString(err)'); }); - it('ChatPanel localizes MeshCore Unknown sender sentinel', () => { - expect(CHAT_PANEL).toContain("rawSenderName === 'Unknown' ? t('common.unknown')"); + it('ChatPanel localizes MeshCore Unknown sender sentinel only', () => { + expect(CHAT_PANEL).toContain("protocol === 'meshcore' && rawSenderName === 'Unknown'"); + expect(CHAT_PANEL).toContain("t('common.unknown')"); }); it('NodeListPanel maps Sensor/None/Unknown hw_model through i18n keys', () => { diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 9a8ba5a27..e72e08e69 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -557,7 +557,9 @@ export const useNomadPageViewerStore = create((set, get) = pushAppToast(i18n.t('nomadNetwork.pageReadyToast', { name: label }), 'success', 6_000); }) .catch((err: unknown) => { - console.warn('[nomadPageViewerStore] pageReadyToast i18n import failed ' + String(err)); + console.warn( + '[nomadPageViewerStore] pageReadyToast i18n import failed ' + errLikeToLogString(err), + ); }); } }, From 836d0daa86200294979e03c24c3a40adf93f33c7 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 19:49:46 -0600 Subject: [PATCH 6/6] fix: hide MeshCore DM UI for Repeater contacts Mirror Room exclusion so Message, quick DM, unread, and History no longer treat repeaters as chat peers. Sensors stay DM-eligible. Fixes #809 --- src/renderer/App.tsx | 11 ++-- src/renderer/components/ChatPanel.tsx | 51 +++++++++++-------- .../components/NodeDetailModal.test.tsx | 19 +++---- src/renderer/components/NodeDetailModal.tsx | 38 +++++++------- .../components/NodeListPanel.test.tsx | 28 ++++++++++ src/renderer/components/NodeListPanel.tsx | 33 ++++++++---- src/renderer/components/RoomsPanel.tsx | 3 +- src/renderer/lib/meshcoreUtils.test.ts | 18 +++++++ src/renderer/lib/meshcoreUtils.ts | 5 ++ 9 files changed, 140 insertions(+), 66 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b72467b61..0f9585e34 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -215,7 +215,7 @@ import { saveMeshcoreFloodScopePresets, } from './lib/meshcoreFloodScopePresetsStorage'; import { syncMeshcoreDisplayReplyRepairs } from './lib/meshcoreStoreDedup'; -import { pubkeyToNodeId } from './lib/meshcoreUtils'; +import { isMeshcoreDmExcludedHwModel, pubkeyToNodeId } from './lib/meshcoreUtils'; import { meshNodeStubForDetailModal } from './lib/meshNodeStubForDetail'; import { shouldAutoLaunchMeshtasticMqtt, @@ -1280,7 +1280,8 @@ function AppContent() { const meshcoreChatUnreadDmOptions = useMemo( () => ({ - excludeDmPeer: (peer: number) => meshcoreUiNodes.get(peer)?.hw_model === 'Room', + excludeDmPeer: (peer: number) => + isMeshcoreDmExcludedHwModel(meshcoreUiNodes.get(peer)?.hw_model), }), [meshcoreUiNodes], ); @@ -4525,7 +4526,11 @@ function AppContent() { : undefined } onMessageNode={ - selectedNode?.node_id !== detailMyNodeNum && selectedNode?.hw_model !== 'Room' + selectedNode?.node_id !== detailMyNodeNum && + !( + detailModalProtocol === 'meshcore' && + isMeshcoreDmExcludedHwModel(selectedNode?.hw_model) + ) ? handleMessageNode : undefined } diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 33fb9db54..e9e997672 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -42,6 +42,7 @@ import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatShortRelativeAgo } from '@/renderer/lib/formatShortRelativeAgo'; import { useIconTrigger, useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { withMeshcoreFloodScopeOverride } from '@/renderer/lib/meshcoreFloodScopeSend'; +import { isMeshcoreDmExcludedHwModel } from '@/renderer/lib/meshcoreUtils'; import { MeshtasticHybridPathIcons, MeshtasticMqttPathIcon, @@ -614,7 +615,7 @@ function ChatPanel({ const meshcoreExcludeDmPeer = useMemo((): ChatUnreadDmOptions['excludeDmPeer'] | undefined => { if (protocol !== 'meshcore') return undefined; - return (peer: number) => nodes.get(peer)?.hw_model === 'Room'; + return (peer: number) => isMeshcoreDmExcludedHwModel(nodes.get(peer)?.hw_model); }, [nodes, protocol]); const chatUnreadDmOptions = useMemo( @@ -3026,27 +3027,33 @@ function ChatPanel({ /> {/* Quick DM */} - {!isOwn && ( - - )} + {!isOwn && + !( + protocol === 'meshcore' && + isMeshcoreDmExcludedHwModel(nodes.get(msg.sender_id)?.hw_model) + ) && ( + + )} {/* Star message */} {(() => { const starId = msgStarId(msg); diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 9e6db735f..8e5a4864f 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -305,19 +305,20 @@ describe('NodeDetailModal MeshCore actions', () => { }); 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); useNodeStore.setState({ nodes: { [OFFLINE_MESHCORE_IDENTITY_ID]: { - [meshcoreRepeaterNode.node_id]: { - nodeId: meshcoreRepeaterNode.node_id, + [chatNode.node_id]: { + nodeId: chatNode.node_id, publicKey: pubKey, }, }, }, }); - renderMeshcoreModal(); + renderMeshcoreModal({ node: chatNode }); expect(await screen.findByRole('button', { name: '💬 Message' })).not.toBeDisabled(); }); @@ -364,17 +365,13 @@ describe('NodeDetailModal MeshCore actions', () => { expect(onTraceRoute).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); }); - it('invokes message handler and closes modal when Message is clicked', async () => { + it('hides Message button for MeshCore Repeater nodes', () => { seedMeshcoreContactPubkey(); - const user = userEvent.setup(); const onMessageNode = vi.fn(); - const onClose = vi.fn(); - renderMeshcoreModal({ onMessageNode, onClose }); - - await user.click(await screen.findByRole('button', { name: '💬 Message' })); + renderMeshcoreModal({ onMessageNode }); - expect(onMessageNode).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); - expect(onClose).toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '💬 Message' })).not.toBeInTheDocument(); + expect(onMessageNode).not.toHaveBeenCalled(); }); it('invokes requestRepeaterStatus after repeater auth is skipped', async () => { diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 0b2e68d41..3738dd9c9 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -41,6 +41,7 @@ import { } from '../lib/meshcorePathChainDisplay'; import { meshcoreGetRoomSession, meshcoreIsRoomLoggedIn } from '../lib/meshcoreRoomSession'; import { + isMeshcoreDmExcludedHwModel, MESHCORE_CHAT_STUB_ID_MAX, MESHCORE_CHAT_STUB_ID_MIN, MESHCORE_CONTACTS_CRITICAL_THRESHOLD, @@ -2029,24 +2030,25 @@ export default function NodeDetailModal({ {t('nodeDetailModal.openRoomButton')} )} - {onMessageNode && !(protocol === 'meshcore' && node.hw_model === 'Room') && ( - - )} + {onMessageNode && + !(protocol === 'meshcore' && isMeshcoreDmExcludedHwModel(node.hw_model)) && ( + + )} {protocol === 'meshcore' && onExportContact && (