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..0a3d45f19 100644 --- a/scripts/check-insecure-temp-files.test.mjs +++ b/scripts/check-insecure-temp-files.test.mjs @@ -61,4 +61,28 @@ 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/); + }); + + 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/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..d1ec60f0e 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, @@ -134,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 }); } @@ -195,15 +198,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-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 5b575841b..b0d715a73 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'; @@ -28,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() { @@ -102,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) { @@ -160,7 +169,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,42 +182,46 @@ 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-')); + try { + const logPath = path.join(workDir, `mesh-client-install-${arch}.log`); - rmSync(instDir, { recursive: true, force: true }); - rmSync(logPath, { 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); @@ -219,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/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/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.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/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.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/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/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.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 b87bf4ed9..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( @@ -2527,10 +2528,16 @@ 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. + // Other protocols may use a legitimate node/display name "Unknown" — leave as-is. + const displaySenderName = + protocol === 'meshcore' && rawSenderName === 'Unknown' + ? t('common.unknown') + : rawSenderName; // Day separator const daySeparator = daySeparatorIndices.has(i) ? ( @@ -3020,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/GamesPanel.test.tsx b/src/renderer/components/GamesPanel.test.tsx index 4c71a1791..3e29b3146 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,147 @@ 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.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/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 ? ( <> )} - {onMessageNode && !(protocol === 'meshcore' && node.hw_model === 'Room') && ( - - )} + {onMessageNode && + !(protocol === 'meshcore' && isMeshcoreDmExcludedHwModel(node.hw_model)) && ( + + )} {protocol === 'meshcore' && onExportContact && (