diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..0fd29174d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,44 @@ +# CodeRabbit Free-tier tuning for mesh-client. +# Docs: https://docs.coderabbit.ai/getting-started/yaml-configuration +# Autofix requires Pro — use “Prompt for AI Agents” on Free. + +language: en-US +reviews: + profile: quiet + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + enable_prompt_for_ai_agents: true + path_filters: + - '!src/renderer/locales/**' + - '!**/pnpm-lock.yaml' + - '!flatpak/generated-sources.json' + - '!patches/**' + - '!reticulum-sidecar/patches/**' + - '!**/coverage/**' + - '!**/dist/**' + - '!**/dist-electron/**' + - '!**/target/**' + path_instructions: + - path: '**/*' + instructions: | + Focus on correctness, security, races, resource leaks, and IPC/contract bugs. + Skip style/formatting (Prettier/ESLint/Clippy own that). + Do not request cognitive-complexity or Sonar-style refactors. + Prefer minimal diffs; do not suggest drive-by cleanups outside the PR scope. + This repo follows AGENTS.md multi-protocol and i18n rules. + - path: 'src/renderer/locales/**' + instructions: Skip — generated/translated locale JSON. + - path: '**/*.test.ts' + instructions: Prefer behavioral assertions; skip style-only test nits. + - path: '**/*.test.tsx' + instructions: Prefer behavioral/axe assertions; skip style-only test nits. + auto_review: + enabled: true + drafts: false + auto_pause_after_reviewed_commits: 2 + ignore_title_keywords: + - 'chore: bump' + - 'chore(deps)' + - 'dependabot' diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d8c5895cc..5c68c5557 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -116,6 +116,13 @@ else fi pnpm run typecheck || fail 'pnpm run typecheck' + +if staged_match '^(src/shared/|tsconfig\.strict\.json)'; then + pnpm run typecheck:strict-shared || fail 'pnpm run typecheck:strict-shared' +else + printf 'pre-commit: skip typecheck:strict-shared (no src/shared or tsconfig.strict.json staged)\n' >&2 +fi + pnpm run check:electron-security || fail 'pnpm run check:electron-security' if staged_match '^(flatpak/|org\.coloradomesh\.MeshClient\.yml|package\.json|scripts/check-flatpak\.mjs|scripts/sync-flatpak-electron)'; then diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..ab0a43c26 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# Branch-scoped Vitest before push: tests related to files changed since merge-base with origin/main. +# Full suite remains `pnpm run test:run` / `pnpm run check:pr`. Skip with git push --no-verify. + +REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +fail() { + printf 'pre-push: FAILED — %s\n' "$1" >&2 + exit 1 +} + +if ! git rev-parse --verify origin/main > /dev/null 2>&1; then + printf 'pre-push: skip Vitest --changed (origin/main not available; fetch or run pnpm run check:pr)\n' >&2 + exit 0 +fi + +MERGE_BASE=$(git merge-base HEAD origin/main) || fail 'git merge-base HEAD origin/main' +printf 'pre-push: vitest run --changed %s (merge-base with origin/main)\n' "$MERGE_BASE" >&2 + +cd "$REPO_ROOT" || fail 'cd repo root' +pnpm exec vitest run --changed "$MERGE_BASE" || fail 'vitest run --changed (pre-push)' + +printf 'pre-push: OK\n' >&2 +exit 0 diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index 97c03ea7d..a8839a27c 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -13,7 +13,7 @@ jobs: name: Reticulum sidecar (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} env: - RS_RETICULUM_REF: 6d2b28475321bc15c8f60796513d8878b47ed3ab + RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 permissions: contents: read strategy: diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml index 0150ec202..0cbf33e6a 100644 --- a/.github/workflows/reticulum-sidecar.yaml +++ b/.github/workflows/reticulum-sidecar.yaml @@ -98,7 +98,7 @@ jobs: build-rns-stack: env: - RS_RETICULUM_REF: 6d2b28475321bc15c8f60796513d8878b47ed3ab + RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 strategy: fail-fast: false matrix: @@ -167,7 +167,7 @@ jobs: build-windows-arm64-rns-stack: env: - RS_RETICULUM_REF: 6d2b28475321bc15c8f60796513d8878b47ed3ab + RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 permissions: contents: read runs-on: windows-latest diff --git a/.sonarcloud.properties b/.sonarcloud.properties deleted file mode 100644 index 0dba0934e..000000000 --- a/.sonarcloud.properties +++ /dev/null @@ -1,12 +0,0 @@ -# SonarCloud Automatic Analysis (Autoscan) settings. -# Multicriteria issue suppressions must be set in the SonarCloud UI -# (Administration → Analysis Scope → Ignore Issues on Multiple Criteria); -# Autoscan ignores multicriteria from property files. Keep entries in -# sonar-project.properties as the documented source of truth for that UI config. -# -# Supported Autoscan keys: https://docs.sonarsource.com/sonarqube-cloud/advanced-setup/automatic-analysis/ - -sonar.sources=src,reticulum-sidecar/src -sonar.tests=src -sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.test.mjs -sonar.exclusions=**/node_modules/**,**/dist/**,**/dist-electron/**,**/coverage/**,**/target/**,**/.vitest-reports/**,**/release/**,src/renderer/locales/**,**/*.d.ts,scripts/**,flatpak/**,.github/**,patches/**,reticulum-sidecar/patches/**,resources/** diff --git a/AGENTS.md b/AGENTS.md index 957f16248..7d78d54a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This file is self-contained. ARCHITECTURE.md and CONTRIBUTING.md are human refer - **Credits ↔ package.json:** When adding or renaming a person under **Authors** or **Contributors** in [`docs/credits.md`](docs/credits.md), also add/update the matching entry in root `package.json` `contributors` (same order as credits). Format: `"DisplayName https://github.com/handle"` when a GitHub URL exists, otherwise the credits display name/callsign only (e.g. `"megabear - KD5IHC"`). Do **not** put Colorado Mesh org thanks, Acknowledgements projects, or dependency/binary attribution tables into `contributors`. - **Testing:** Ship a passing test for behavioral changes; do not call the task done without it. - **Stateful/I/O code:** Preserve integrity on failure; document failure point, fallback, and logging where it matters. -- **Pre-commit patience:** Pre-commit runs staged-related Vitest (`pnpm run test:staged`), staged ESLint, full typecheck, and path-gated `check:*` scripts. Typical small commits are much faster than a full suite; vitest infra / lockfile changes still force a full Vitest run. Be patient — do not interrupt or force-skip. **PR CI** ([`tests.yaml`](.github/workflows/tests.yaml)) always runs the **full** Vitest suite (`pnpm run test:run`) — never `test:staged` / `test:changed` / `vitest related`. i18n is gated via `locale-quality.test.ts` (subprocess of `check:i18n`). **`pnpm run release`** (`scripts/release.sh`) runs full Vitest **plus ungated `check:*` scanners** (including a direct `check:i18n`). Green pre-commit ≠ green CI or release. +- **Pre-commit patience:** Pre-commit runs staged-related Vitest (`pnpm run test:staged`), staged ESLint, full typecheck, path-gated `typecheck:strict-shared` when `src/shared/` is staged, and path-gated `check:*` scripts. Typical small commits are much faster than a full suite; vitest infra / lockfile changes still force a full Vitest run. Be patient — do not interrupt or force-skip. **Pre-push** runs `vitest run --changed` against the merge-base with `origin/main` (branch-scoped; skip with `--no-verify`). **PR CI** ([`tests.yaml`](.github/workflows/tests.yaml)) always runs the **full** Vitest suite (`pnpm run test:run`) — never `test:staged` / `test:changed` / `vitest related`. i18n is gated via `locale-quality.test.ts` (subprocess of `check:i18n`). **`pnpm run check:pr`** (hand, before opening/updating a PR) runs full lint + typecheck + `typecheck:strict-shared` + `test:run` (+ full-feature sidecar check when the branch touches sidecar). **`pnpm run release`** (`scripts/release.sh`) runs full Vitest **plus ungated `check:*` scanners** (including a direct `check:i18n`). Green pre-commit ≠ green CI or release. - **Fresh clone:** Before other setup, run `node scripts/check-environment.mjs` (works before pnpm is installed). After `pnpm install`, re-run `pnpm run check:environment`. Fix required failures using printed hints and `setup:*` scripts; optional warnings can wait. Wrong/outdated pnpm is blocked by `scripts/check-package-manager.mjs` on `preinstall` and `pnpm run dev` (prints Corepack/`npm install -g pnpm@…` steps; Node 25+ needs Corepack installed separately). ### Platform parity @@ -89,7 +89,7 @@ Adding a cross-boundary feature: ## 5. Testing - Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`). -- **Reticulum sidecar (Rust):** Clippy + rustfmt via `pnpm run check:reticulum-sidecar` (stub build in pre-commit when `cargo` is on `PATH` **and** sidecar-related paths are staged) and full-feature lint in `reticulum-sidecar.yaml`. Coverage threshold (`cargo llvm-cov --fail-under-lines`) is enforced only in `tests.yaml` when sidecar paths change — not in pre-commit. +- **Reticulum sidecar (Rust):** Clippy + rustfmt via `pnpm run check:reticulum-sidecar` (full-feature fmt + Clippy + test when `cargo` is on `PATH` **and** sidecar-related paths are staged) and the same feature set in `reticulum-sidecar.yaml`. Coverage threshold (`cargo llvm-cov --fail-under-lines`) is enforced only in `tests.yaml` when sidecar paths change — not in pre-commit. - **Temp dirs in tests:** Use `mkdtempSync(path.join(os.tmpdir(), 'prefix-'))` — never write to a fixed name under `os.tmpdir()` (CodeQL + `check:insecure-temp-files`). - Vitest worker pool sizes and shared Vite dep inline lists live in `vitest.harness.ts` — update when adding deps that need inlining. - Prefer `mockConsoleWarn` / `withMockedConsoleWarn` from `src/renderer/lib/vitestConsoleMock.ts` over ad-hoc `vi.spyOn(console, 'warn')` in renderer tests. @@ -110,7 +110,9 @@ Adding a cross-boundary feature: ## 6. Commands & CI Checks -**Key commands:** `pnpm run dev`, `pnpm run lint`, `pnpm run typecheck`, `pnpm run test:run`, `pnpm run update`. Reticulum sidecar: `pnpm run check:reticulum-sidecar` (pre-commit stub), `pnpm run reticulum:sidecar:clippy:full`, `pnpm run reticulum:sidecar:test`. +**Key commands:** `pnpm run dev`, `pnpm run lint`, `pnpm run typecheck`, `pnpm run test:run`, `pnpm run check:pr`, `pnpm run update`. Reticulum sidecar: `pnpm run check:reticulum-sidecar` (full features), `pnpm run reticulum:sidecar:clippy:full`, `pnpm run reticulum:sidecar:test`. + +**ESLint type-aware scopes:** production `src/**` enables `no-unsafe-*`; `*.test.ts` / `*.test.tsx` keep those off. `@typescript-eslint/no-unnecessary-condition` is error only for `src/shared/**` and `src/renderer/lib/**` (not UI components/runtimes). **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. @@ -123,12 +125,14 @@ Adding a cross-boundary feature: 3. markdownlint on **staged** `.md` files only 4. When dependency manifests staged: `pnpm dedupe`, re-stage lockfile and originally staged paths 5. When `en/translation.json` is staged: `pnpm run i18n:auto-translate` and re-stage `src/renderer/locales/` -6. ESLint on **staged** JS/TS with `--cache` (CI still runs full `pnpm run lint`); full `typecheck` +6. ESLint on **staged** JS/TS with `--cache` (CI still runs full `pnpm run lint`); full `typecheck`; path-gated `typecheck:strict-shared` when `src/shared/` or `tsconfig.strict.json` staged 7. Always-on cheap `check:*` scanners; path-gated checks for flatpak / DB migrations / IPC / reticulum interface modes / decommissioned hubs / `check:reticulum-sidecar` (when `cargo` on `PATH` and sidecar paths staged); `check:i18n` when English locale staged else `check:i18n:branch`; `check:licenses` 8. `pnpm audit` only when dependency manifests staged; `actionlint` / `yamllint` when workflows / YAML staged 9. `pnpm run test:staged` → `scripts/precommit-tests.mjs` (staged-only `vitest related`; full suite for vitest config/setup/deps; skip when no source/test staged) -Before PR: `pnpm run lint`, `typecheck`, `test:run` (full suite), plus any relevant `check:*`. Release pre-flight (`pnpm run release`) always uses `test:run` + full `check:*` (no path-gating / soft-skips). +**Pre-push:** `.githooks/pre-push` runs `vitest run --changed ` when `origin/main` exists. + +Before PR: `pnpm run check:pr` (lint + typecheck + `typecheck:strict-shared` + full `test:run` + path-aware sidecar). Release pre-flight (`pnpm run release`) always uses `test:run` + full `check:*` (no path-gating / soft-skips). ## 7. Git & PR Workflow @@ -170,7 +174,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts` (includes MeshCore **High Companion TX Queue** when `queueLen > 200`), `RemediationEngine.ts`, `ReticulumDiagnosticEngine.ts`. - **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. -- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is Meshtastic-tab-only. +- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). - **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). - **Full reference:** [docs/diagnostics.md](docs/diagnostics.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d81d2abd1..dbd46d3e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). - **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts`, `RemediationEngine.ts`. - **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. -- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is Meshtastic-tab-only. +- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). - **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). - **Node health score:** `src/renderer/lib/nodeHealthScore.ts`; `nodeHealthScore(node)` → `NodeHealthBreakdown`; `nodeHealthTier(total)` → color tier. - **Watch/notify:** `src/renderer/stores/watchedNodesStore.ts` (persisted Set); `src/renderer/hooks/useNodeStatusNotifier.ts` (fires OS Notification on online/offline transitions). diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 4b970fede..1bf9edc54 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -52,7 +52,7 @@ Runs on every push and pull request to `main`: 5. Upload Cobertura coverage to GitHub Code Coverage (non-fork PRs / pushes) — Vitest merge job only 6. Upload merged test results artifact (retained 7 days) -SonarQube Cloud uses **Automatic Analysis (Autoscan)** — not GitHub Actions — because the Free plan Sonar Way quality gate includes cognitive-complexity thresholds we cannot customize, and CI scanning would fail PRs on that gate. Keep **Automatic Analysis enabled**. Scope and issue suppressions are configured in `sonar-project.properties` / `.sonarcloud.properties` and (for multicriteria under Autoscan) the SonarCloud project Analysis Scope UI. +Static analysis on PRs is **CodeQL** (security) plus ESLint, Clippy, and pre-commit `check:*` scanners. AI PR review is **CodeRabbit** (see [CodeRabbit](#coderabbit) below). SonarQube Cloud is not used. Test results are available as a downloadable artifact from the workflow run. @@ -63,7 +63,18 @@ Path-filtered on `reticulum-sidecar/**` and related scripts: 1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`) 2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs) -Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit stub). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). +Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). + +--- + +## CodeRabbit + +PR review comments come from [CodeRabbit](https://docs.coderabbit.ai/) via [`.coderabbit.yaml`](../.coderabbit.yaml) (quiet profile, path filters, auto-pause after two reviewed commits). + +- Prefer opening as a **draft** until the feature diff is ready, then mark ready for review. +- Free plan: about **1 PR review per developer per hour**; each auto-incremental push counts. After auto-pause, request another pass with `@coderabbitai review`. +- Batch actionable findings via the **Prompt for AI Agents** block into one local commit (Autofix requires Pro). +- Check remaining allowance with `@coderabbitai rate limit`. --- @@ -243,7 +254,9 @@ The pre-commit hook (`.githooks/pre-commit`) runs checks beyond what GitHub Acti - **Staged-file** Prettier + markdownlint (not a full-tree `pnpm run format` / `lint:md`) - `pnpm dedupe` when dependency manifests are staged - `pnpm run i18n:auto-translate` when `en/translation.json` is staged (fills new English keys vs `HEAD`) + re-stages locales -- Staged ESLint (`--cache`) + full `typecheck`; always-on cheap `check:*` scanners; path-gated flatpak / DB / IPC / reticulum catalog / sidecar stub checks (sidecar stub also requires `cargo` on `PATH` when sidecar paths are staged; `check:i18n` when English locale staged, else `check:i18n:branch`) +- Staged ESLint (`--cache`) + full `typecheck`; path-gated `typecheck:strict-shared` when shared paths staged; always-on cheap `check:*` scanners; path-gated flatpak / DB / IPC / reticulum catalog / full-feature sidecar checks (sidecar also requires `cargo` on `PATH` when sidecar paths are staged; `check:i18n` when English locale staged, else `check:i18n:branch`) +- Pre-push: `vitest run --changed` vs merge-base with `origin/main` when available +- Before PR: `pnpm run check:pr` (full lint + typecheck + strict-shared + `test:run` + path-aware sidecar) - `pnpm audit` only when dependency manifests staged; `actionlint` / `yamllint` only when relevant files are staged - `pnpm run test:staged` (`scripts/precommit-tests.mjs`: staged-only `vitest related`; full suite when vitest config/setup mocks or dependency manifests change; skip when no source/test staged) diff --git a/docs/development-environment.md b/docs/development-environment.md index 20114a5ac..c620bbfe7 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -107,7 +107,7 @@ If you use Homebrew only, `pnpm run update` will try `brew upgrade rust` when ru Linux and Windows: use rustup; on Windows you may also need [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload (same as native Node modules). -[`reticulum-sidecar/rust-toolchain.toml`](../reticulum-sidecar/rust-toolchain.toml) pins **`stable`** and installs `clippy`, `rustfmt`, and `llvm-tools-preview` on the first `cargo` command run inside `reticulum-sidecar/` (rustup auto-install). When `cargo` is on your `PATH` **and** sidecar-related paths are staged, pre-commit runs `pnpm run check:reticulum-sidecar` (stub fmt + Clippy + test — no coverage). +[`reticulum-sidecar/rust-toolchain.toml`](../reticulum-sidecar/rust-toolchain.toml) pins **`stable`** and installs `clippy`, `rustfmt`, and `llvm-tools-preview` on the first `cargo` command run inside `reticulum-sidecar/` (rustup auto-install). When `cargo` is on your `PATH` **and** sidecar-related paths are staged, pre-commit runs `pnpm run check:reticulum-sidecar` (full-feature fmt + Clippy + test with `rns-stack,rns-ble,rns-rnode-tcp` — no coverage). #### Build the sidecar @@ -162,11 +162,11 @@ Skip cleanup while iterating on sidecar Rust or Reticulum in Electron — the ne #### Lint and coverage (sidecar) -| Command | When | -| ---------------------------------------- | ------------------------------------------------------------ | -| `pnpm run check:reticulum-sidecar` | Pre-commit stub fmt + Clippy + test (when `cargo` on `PATH`) | -| `pnpm run reticulum:sidecar:clippy:full` | Before PR when editing `reticulum-sidecar/**` | -| `pnpm run reticulum:sidecar:coverage` | Optional local HTML report (`cargo install cargo-llvm-cov`) | +| Command | When | +| ---------------------------------------- | -------------------------------------------------------------------- | +| `pnpm run check:reticulum-sidecar` | Pre-commit full-feature fmt + Clippy + test (when `cargo` on `PATH`) | +| `pnpm run reticulum:sidecar:clippy:full` | Before PR when editing `reticulum-sidecar/**` | +| `pnpm run reticulum:sidecar:coverage` | Optional local HTML report (`cargo install cargo-llvm-cov`) | CI: full-feature lint in [`reticulum-sidecar.yaml`](../.github/workflows/reticulum-sidecar.yaml); line-coverage threshold in [`tests.yaml`](../.github/workflows/tests.yaml) when sidecar paths change (not pre-commit). @@ -383,10 +383,11 @@ flatpak run --command=flatpak-builder-lint org.freedesktop.Sdk \ #### Typecheck -| Script | Description | -| ------------------------- | -------------------------------------------------- | -| `typecheck` | TypeScript check: renderer + main process | -| `typecheck:strict-shared` | Strict TypeScript check for shared/renderer subset | +| Script | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------- | +| `typecheck` | TypeScript check: renderer + main process | +| `typecheck:strict-shared` | Strict TypeScript (`noUncheckedIndexedAccess` + `exactOptionalPropertyTypes`) for `src/shared` | +| `check:pr` | PR-parity local gate: lint + typecheck + strict-shared + full `test:run` (+ sidecar if branch touches it) | #### Quality checks @@ -410,7 +411,7 @@ flatpak run --command=flatpak-builder-lint org.freedesktop.Sdk \ | `check:protocol-string-gates` | Enforce protocol capability gates over string compares | | `check:reticulum-decommissioned-hubs` | Keep TS/Rust decommissioned hub lists aligned | | `check:reticulum-interface-modes` | Keep TS/Rust Reticulum interface-mode catalogs aligned | -| `check:reticulum-sidecar` | Stub `cargo fmt` + Clippy + test (skips when `cargo` missing) | +| `check:reticulum-sidecar` | Full-feature `cargo fmt` + Clippy + test (skips when `cargo` missing) | | `check:silent-catches` | Detect empty or unlogged catch blocks | | `check:url-hostname-sanitization` | Verify URL hostname sanitization helpers | | `check:xss-patterns` | Detect risky DOM/HTML sink patterns | @@ -557,6 +558,8 @@ Other useful commands: - `pnpm test` (watch mode — reruns only changed test files) - `pnpm run test:staged` (pre-commit helper: Vitest related to **staged** files only) - `pnpm run test:changed` (one-shot tests for working-tree edits vs `HEAD`, including unstaged WIP) +- `pnpm run check:pr` (before opening/updating a PR: full lint + typecheck + `typecheck:strict-shared` + full `test:run`) +- Pre-push hook: `vitest run --changed` vs merge-base with `origin/main` (branch-scoped; not full suite) - `pnpm run test:ui` / `test:logic` / `test:main` (single Vitest project) - `pnpm run test:coverage` (CI coverage report; used by `act:tests:native`) - `pnpm run test:coverage:merge` (merge sharded CI blob reports locally) @@ -612,9 +615,11 @@ This generates `dist-electron/main/meta.json`. Upload this file to [esbuild's on ### 6) Git hooks and pre-commit behavior -After `pnpm install`, repo hooks are enabled via `core.hooksPath` (see the `prepare` script in `package.json`). The pre-commit hook runs on every commit. Typical commits run **staged-related Vitest only** (`pnpm run test:staged` → `vitest related` on staged source/test files, optionally narrowed to matching Vitest projects). Unstaged WIP is ignored. Full typecheck still runs every commit; ESLint runs on staged JS/TS with `--cache` (CI still runs full-tree lint). Several expensive `check:*` steps and `pnpm audit` / sidecar stub builds are **path-gated**. +After `pnpm install`, repo hooks are enabled via `core.hooksPath` (see the `prepare` script in `package.json`). The pre-commit hook runs on every commit. Typical commits run **staged-related Vitest only** (`pnpm run test:staged` → `vitest related` on staged source/test files, optionally narrowed to matching Vitest projects). Unstaged WIP is ignored. Full typecheck still runs every commit; ESLint runs on staged JS/TS with `--cache` (CI still runs full-tree lint). Path-gated `typecheck:strict-shared` runs when `src/shared/` (or `tsconfig.strict.json`) is staged. Several expensive `check:*` steps and `pnpm audit` / full-feature sidecar builds are **path-gated**. -Green pre-commit does **not** replace PR CI: [`.github/workflows/tests.yaml`](../.github/workflows/tests.yaml) always runs the full Vitest suite with coverage. +ESLint: production `src/**` enforces `no-unsafe-*`; test files keep those off. `no-unnecessary-condition` is enforced for `src/shared/**` and `src/renderer/lib/**` only. + +Green pre-commit does **not** replace PR CI: [`.github/workflows/tests.yaml`](../.github/workflows/tests.yaml) always runs the full Vitest suite with coverage. Use `pnpm run check:pr` before opening a PR. Pre-push runs branch `--changed` Vitest when `origin/main` is available. Hook order (authoritative source: [`.githooks/pre-commit`](../.githooks/pre-commit)): @@ -624,13 +629,15 @@ Hook order (authoritative source: [`.githooks/pre-commit`](../.githooks/pre-comm 4. When `package.json` or `pnpm-lock.yaml` is staged: `pnpm dedupe`, re-stage `pnpm-lock.yaml`, then re-stage the originally staged paths 5. When `src/renderer/locales/en/translation.json` is staged: `pnpm run i18n:auto-translate` (incremental vs `HEAD` English, not `--all`) and re-stage `src/renderer/locales/` — see [Internationalization](#9-internationalization-i18n) 6. ESLint on **staged** JS/TS with `--cache` (skip when none staged) -7. `pnpm run typecheck` (full tree) +7. `pnpm run typecheck` (full tree); path-gated `typecheck:strict-shared` when `src/shared/` or `tsconfig.strict.json` staged 8. Always-on: `check:electron-security`, `check:log-injection`, `check:log-service-sinks`, `check:codeql-extensions`, `check:insecure-temp-files`, `check:console-log`, `check:silent-catches`, `check:url-hostname-sanitization`, `check:xss-patterns`, `check:protocol-string-gates`, `check:log-panel-filter`, `check:licenses`; `check:i18n` when English locale staged else `check:i18n:branch` 9. Path-gated: `check:flatpak`, `check:db-migrations`, `check:ipc-contract`, `check:reticulum-interface-modes`, `check:reticulum-decommissioned-hubs`, `check:reticulum-sidecar` (when `cargo` on `PATH` and sidecar paths staged) 10. `pnpm audit --audit-level=high` only when dependency manifests staged; `actionlint` when `.github/workflows/*` staged; `yamllint` when any `*.yaml` / `*.yml` staged 11. `pnpm run test:staged` (`scripts/precommit-tests.mjs`: staged-only `vitest related`; full suite when vitest config/setup mocks or dependency manifests change; skip when no source/test staged) -**Release / CI full suite:** `pnpm run release` (`scripts/release.sh`) and PR [`tests.yaml`](../.github/workflows/tests.yaml) always run `pnpm run test:run` (full Vitest) — never `test:staged`. Release also runs the ungated `check:*` set and requires actionlint + yamllint. +**Pre-push:** [`.githooks/pre-push`](../.githooks/pre-push) runs `vitest run --changed ` when `origin/main` exists. + +**Release / CI full suite:** `pnpm run release` (`scripts/release.sh`) and PR [`tests.yaml`](../.github/workflows/tests.yaml) always run `pnpm run test:run` (full Vitest) — never `test:staged`. Release also runs the ungated `check:*` set and requires actionlint + yamllint. Use `pnpm run check:pr` for the same Vitest/lint/typecheck surface locally before a PR. Install hook dependencies via [Helper scripts](#8-helper-scripts-auto-install-where-possible) (`setup:actionlint`, yamllint via pip/brew/apt). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 509b269f8..2b3b91fe1 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ All three protocols share one **Diagnostics** sidebar tab; sections differ by `P **LoRa routing/RF per protocol:** Switching tabs calls `clearDiagnostics({ preserveForeignLora: true })` and `runReanalysis` with that tab's `nodesForUi` and capabilities — Meshtastic anomalies are computed from Meshtastic nodes only; MeshCore from MeshCore contacts only. -**Foreign LoRa overhear UI:** The MeshCore-heard and other-foreign-LoRa tables render on the **Meshtastic** tab only (`protocol === 'meshtastic'`). MeshCore may record foreign traffic internally when raw RX bytes are available, but the Diagnostics panel does not show those tables on the MeshCore tab. Reticulum RNode foreign overhear is not wired yet (sidecar packet tap exposes RNS-parsed frames only). +**Foreign LoRa overhear UI:** The MeshCore-heard and other-foreign-LoRa tables render on the **Meshtastic** and **MeshCore** LoRa tabs when connected with a known self node id (Meshtastic detections keyed by Meshtastic self id; MeshCore foreign overhear keyed by MeshCore self id). Reticulum RNode foreign overhear is not wired yet (sidecar packet tap exposes RNS-parsed frames only). **Other surfaces:** `NodeListPanel`, `MapPanel`, and `NodeInfoBody` also call `filterDiagnosticRowsForProtocol` so inline badges and halos match the active tab. @@ -177,9 +177,9 @@ These findings use packet-stats data from a MeshCore device's Repeater Status re ## 4. Foreign LoRa Detection -Foreign LoRa detection identifies **non-Meshtastic** LoRa traffic observed by your connected device's radio (or, in dual-radio setups, by a MeshCore companion overheard on the Meshtastic frequency). The detection window is the **last 90 minutes**. +Foreign LoRa detection identifies **cross-protocol / unrecognized** LoRa traffic observed by your connected device's radio (Meshtastic hearing MeshCore or unknown LoRa; MeshCore hearing Meshtastic / unknown LoRa; dual-radio setups can also bridge MeshCore RX into the Meshtastic listener map). The detection window is the **last 90 minutes**. -**Diagnostics UI:** Foreign-LoRa tables appear on the **Meshtastic** protocol tab only (see **Multi-protocol tab scoping**). +**Diagnostics UI:** Foreign-LoRa tables appear on the **Meshtastic** and **MeshCore** protocol tabs (see **Multi-protocol tab scoping**). **Signal classes:** diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 3d4c6fa94..266c2542a 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -12,41 +12,41 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio ## Feature matrix -| Area | Meshtastic | MeshCore | Gap type | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403) | BLE, Web Serial, TCP bridge (5000) | Different stacks | -| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | -| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot only; meshcore.js limits) | **App** | -| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | -| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | -| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | -| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | -| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | -| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | -| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | May record foreign traffic internally; **no** foreign-LoRa tables on MeshCore tab | **App** (Meshtastic-tab UI) | -| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | -| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | -| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | -| Position | Full GPS protobuf + request position | Advert lat/lon + `setAdvertLatLong` | **Partial** | -| Waypoints | Supported | Not in protocol surface | **Blocked** | -| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | -| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | -| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | -| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | -| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App**; chat body renders tokens as inline labels (see below) | -| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (App toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | -| MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | -| Chat composer | `ChatComposer.tsx` in `ChatPanel` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | -| Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | -| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override; `app_settings` reapply on connect | **App** (MeshCore v8+ transport keys) | -| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | -| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | -| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | -| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App**; protocol-neutral with Meshtastic built-ins | -| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | -| Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | -| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | -| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress in the header indicator | **App** (MeshCore-only) | +| Area | Meshtastic | MeshCore | Gap type | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403) | BLE, Web Serial, TCP bridge (5000) | Different stacks | +| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | +| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot via companion; meshcore.js limits for shutdown/factory/OTA) | **App** (implemented; reboot wired; extended admin capability-limited) | +| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | +| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | +| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | +| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | +| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | +| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | +| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | Records foreign traffic; Diagnostics foreign-LoRa tables on MeshCore tab (keyed by MeshCore self id) and Meshtastic tab | **App** (implemented; tables on Meshtastic and MeshCore tabs) | +| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | +| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | +| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | +| Position | Full GPS protobuf + request position | Radio **Position / GPS**: advertised readout + lat/lon + `setAdvertLatLong` via Send Position; no GPS mode / broadcast intervals / altitude / request-position | **App** (implemented; advert lat/lon only — protocol) | +| Waypoints | Supported | Not in protocol surface | **Blocked** | +| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | +| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | +| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | +| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | +| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App** (implemented); chat body renders tokens as inline labels (see below) | +| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (App toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | +| MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | +| Chat composer | `ChatComposer.tsx` in `ChatPanel` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | +| Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | +| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override; `app_settings` reapply on connect | **App** (MeshCore v8+ transport keys) | +| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | +| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | +| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | +| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App** (implemented); protocol-neutral with Meshtastic built-ins | +| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | +| Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | +| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | +| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress in the header indicator | **App** (MeshCore-only) | ## MeshCore: Room servers diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a50ed2bc4..9908e1a99 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1499,7 +1499,7 @@ Legacy SQLite rows could cross-contaminate the shared `nodes` table before proto **Symptoms**: MeshCore-heard or Reticulum traffic tables missing on MeshCore or Reticulum tabs. -**Fix**: By design — foreign-LoRa overhear tables render on the **Meshtastic** Diagnostics tab only. MeshCore may still record overhear internally when raw RX bytes are available. Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). +**Fix**: Foreign-LoRa overhear tables render on the **Meshtastic** and **MeshCore** Diagnostics tabs (keyed by that protocol’s self node id). Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). ### No signal bars on some nodes diff --git a/eslint.config.mjs b/eslint.config.mjs index f8665476f..f5c57a834 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,11 +81,12 @@ export default tseslint.config( 'prefer-const': 'error', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-return': 'off', + // Prod src: no-unsafe-* at error (strictTypeChecked). Tests override back to off below. + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', '@typescript-eslint/no-unsafe-enum-comparison': 'error', '@typescript-eslint/restrict-template-expressions': 'off', '@typescript-eslint/unbound-method': 'off', @@ -94,8 +95,7 @@ export default tseslint.config( 'error', { checksVoidReturn: { attributes: false } }, ], - // strictTypeChecked enables this, but it flags many defensive DOM/runtime patterns where - // types are narrower than reality; keep other strict rules without churning the whole UI. + // Off globally: defensive UI ?. / ?? churn. Re-enabled for shared + renderer/lib below. '@typescript-eslint/no-unnecessary-condition': 'off', // Autofix removes generics that TypeScript still needs for inference (tsc errors after // eslint --fix). Prefer explicit types at those call sites over a blanket autofix. @@ -111,15 +111,26 @@ export default tseslint.config( '@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions'] }], }, }, - // Stricter typing for shared helpers (protocol parsers, IPC types, pure lib). + // Protocol / pure logic: flag always-truthy/falsy conditions and redundant ?. / ??. { - files: ['src/shared/**/*.ts'], + files: ['src/shared/**/*.{ts,tsx}', 'src/renderer/lib/**/*.{ts,tsx}'], rules: { - '@typescript-eslint/no-unsafe-argument': 'error', - '@typescript-eslint/no-unsafe-assignment': 'error', - '@typescript-eslint/no-unsafe-call': 'error', - '@typescript-eslint/no-unsafe-member-access': 'error', - '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-unnecessary-condition': [ + 'error', + { allowConstantLoopConditions: 'only-allowed-literals' }, + ], + }, + }, + // Tests: mock/any theater — keep no-unsafe-* off (prod stays error). + { + files: ['**/*.{test,spec}.{ts,tsx}'], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + // Shared/lib tests inherit no-unnecessary-condition; leave it on for logic tests. }, }, // Node scripts: no TS program — disable type-checked rules; keep security + Node globals diff --git a/package.json b/package.json index dda1c5d19..06248c608 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "check:log-panel-filter": "node scripts/check-log-panel-filter.mjs", "check:log-service-sinks": "node scripts/check-log-service-sinks.mjs", "check:pn-hosting-policy": "node scripts/check-pn-hosting-policy.mjs", + "check:pr": "node scripts/check-pr.mjs", "check:protocol-string-gates": "node scripts/check-protocol-string-gates.mjs", "check:reticulum-decommissioned-hubs": "node scripts/check-reticulum-decommissioned-hubs.mjs", "check:reticulum-interface-modes": "node scripts/check-reticulum-interface-modes.mjs", @@ -136,7 +137,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.13.0", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.7.26", - "@stoprocent/noble": "^2.5.10", + "@stoprocent/noble": "^2.6.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@zip.js/zip.js": "^2.8.34", @@ -205,7 +206,7 @@ "leaflet": "^1.9.4", "license-checker-rseidelsohn": "^4.4.2", "markdownlint-cli2": "^0.22.1", - "postcss": "^8.5.24", + "postcss": "^8.5.25", "prettier": "^3.9.6", "prettier-plugin-sh": "^0.18.1", "prettier-plugin-tailwindcss": "^0.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b45d1017f..54b47b78a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: npm:@jsr/meshtastic__protobufs@^2.7.26 version: '@jsr/meshtastic__protobufs@2.7.26' '@stoprocent/noble': - specifier: ^2.5.10 - version: 2.5.10(supports-color@8.1.1) + specifier: ^2.6.0 + version: 2.6.0(supports-color@8.1.1) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -245,7 +245,7 @@ importers: version: 0.22.1(supports-color@8.1.1) postcss: specifier: ^8.5.18 - version: 8.5.24 + version: 8.5.25 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -765,12 +765,12 @@ packages: resolution: {integrity: sha512-vdBbYEKw8mzE7y5br0OpfwjBAZIowsrPOIbh8a7KnL+eSQDH6jw4yy11JzAZZW0km6l4Ntmg8fvsZwEzAVTdHA==} hasBin: true - '@napi-rs/wasm-runtime@1.2.0': - resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + '@napi-rs/wasm-runtime@1.2.1': + resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^2.0.0-alpha.3 - '@emnapi/runtime': ^2.0.0-alpha.3 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} @@ -1106,8 +1106,8 @@ packages: resolution: {integrity: sha512-xTigfmWCBqNJyPibOCS8F4dTt0LdE5zHAlpOc919ladh1Q+PjQgSGMQZoXxpK9CzZgZCmPF5f90jC0IkogwQEg==} os: [linux, android, freebsd, win32, darwin] - '@stoprocent/noble@2.5.10': - resolution: {integrity: sha512-350BwxEE45x1jL9GkIeFTWeZe4EAUx5FZN9SVzEZce3g7FaVDdfyDhCmLC4cnqqAo6wSdigWRkbQk/xa54cvOw==} + '@stoprocent/noble@2.6.0': + resolution: {integrity: sha512-7z3b+UT+zZxjtjcrHBL3moY/VkN7kplvVkPFssDTSLT/SJd2yuBLQw6jkC858A617giAD6dJ0v/eWPZVrDs/Hw==} engines: {node: '>=14'} peerDependencies: dbus-next: ^0.10.0 @@ -1666,8 +1666,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.6: - resolution: {integrity: sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==} + baseline-browser-mapping@2.11.7: + resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} engines: {node: '>=6.0.0'} hasBin: true @@ -3669,8 +3669,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.24: - resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} postject@1.0.0-alpha.6: @@ -5266,7 +5266,7 @@ snapshots: commander: 12.1.0 crypto-js: 4.2.0 - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 @@ -5407,7 +5407,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -5547,7 +5547,7 @@ snapshots: - supports-color optional: true - '@stoprocent/noble@2.5.10(supports-color@8.1.1)': + '@stoprocent/noble@2.6.0(supports-color@8.1.1)': dependencies: debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) node-addon-api: 8.9.0 @@ -5628,7 +5628,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.24 + postcss: 8.5.25 tailwindcss: 4.3.3 '@tanstack/react-virtual@3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -6173,7 +6173,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.6: {} + baseline-browser-mapping@2.11.7: {} bidi-js@1.0.3: dependencies: @@ -6208,7 +6208,7 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.6 + baseline-browser-mapping: 2.11.7 caniuse-lite: 1.0.30001806 electron-to-chromium: 1.5.398 node-releases: 2.0.51 @@ -8474,7 +8474,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.24: + postcss@8.5.25: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 @@ -9380,7 +9380,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.24 + postcss: 8.5.25 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 4be369be1..d2c93dc40 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "rns-crypto" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aes", "cbc", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "rns-identity" -version = "1.0.1" +version = "1.1.0" dependencies = [ "hex", "rand 0.8.6", @@ -1919,14 +1919,16 @@ dependencies = [ "rns-crypto", "rns-wire", "serde", + "serde_bytes", "thiserror 2.0.18", "tracing", + "windows-sys 0.61.2", "zeroize", ] [[package]] name = "rns-interface" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bluer", "btleplug", @@ -1936,6 +1938,7 @@ dependencies = [ "if-addrs", "jni", "libc", + "md-5", "objc2", "objc2-core-bluetooth", "objc2-foundation", @@ -1957,7 +1960,7 @@ dependencies = [ [[package]] name = "rns-link" -version = "1.0.1" +version = "1.1.0" dependencies = [ "hex", "rand 0.8.6", @@ -1972,7 +1975,7 @@ dependencies = [ [[package]] name = "rns-protocol" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bzip2", "hex", @@ -1988,7 +1991,7 @@ dependencies = [ [[package]] name = "rns-ratkey" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aes", "bip39", @@ -2011,7 +2014,7 @@ dependencies = [ [[package]] name = "rns-runtime" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bytes", "hex", @@ -2037,7 +2040,7 @@ dependencies = [ [[package]] name = "rns-transport" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bytes", "hex", @@ -2056,7 +2059,7 @@ dependencies = [ [[package]] name = "rns-wire" -version = "1.0.1" +version = "1.1.0" dependencies = [ "rns-crypto", "sha2", diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index 38815d1bd..d9808ebdd 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -30,7 +30,6 @@ Apply overlays (required for `rns-stack` until upstream merges): ./scripts/apply-rsReticulum-packet-tap.sh ./scripts/apply-rsReticulum-auto-beacon-utun.sh ./scripts/apply-rsReticulum-link-client-nomad.sh -./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh ./scripts/apply-rsReticulum-discovery-announce-egress.sh ./scripts/apply-rsLXMF-propagation-sync-peering.sh diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 180373ec9..d5cd545ab 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -33,7 +33,7 @@ Wire packet tap API for the Reticulum Stats/Sniffer panel (`wire_packet` WebSock | Field | Value | | ----- | ----- | -| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/10 | **Adds (4 files):** @@ -56,13 +56,13 @@ From mesh-client repo root (sibling `../rsReticulum` required): ```bash cd ../rsReticulum git fetch origin -git diff 6d2b28475321bc15c8f60796513d8878b47ed3ab -- \ +git diff 9928abed269a83ec5a7ef165ff1142d938cad706 -- \ crates/rns-runtime/src/reticulum.rs \ crates/rns-transport/src/messages.rs \ crates/rns-transport/src/actor/mod.rs \ crates/rns-transport/src/actor/inbound.rs \ > ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch -git -C /tmp/rsReticulum-patch-test checkout 6d2b28475321bc15c8f60796513d8878b47ed3ab +git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch ``` @@ -76,7 +76,7 @@ Skip macOS/iOS VPN tunnel interfaces (`utun*`, `ipsec*`, `ppp*`) for AutoInterfa | Field | Value | | ----- | ----- | -| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/11 | **Modifies (1 file):** @@ -103,9 +103,9 @@ Apply after the packet-tap patch when both overlays are needed: ```bash cd ../rsReticulum # after implementing on top of RS_RETICULUM_REF -git diff 6d2b28475321bc15c8f60796513d8878b47ed3ab -- crates/rns-interface/src/auto.rs \ +git diff 9928abed269a83ec5a7ef165ff1142d938cad706 -- crates/rns-interface/src/auto.rs \ > ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch -git -C /tmp/rsReticulum-patch-test checkout 6d2b28475321bc15c8f60796513d8878b47ed3ab +git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch ``` @@ -119,7 +119,7 @@ Recall cached destination public keys in `LinkClient` before waiting on path-res | Field | Value | | ----- | ----- | -| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/14 | **Modifies (4 files):** @@ -149,7 +149,7 @@ Apply after packet-tap + auto-beacon when rebuilding a pinned checkout: ```bash # From a clean pin with the other overlays applied, then the upstream commit: -git -C /tmp/rsReticulum-patch-test checkout 6d2b28475321bc15c8f60796513d8878b47ed3ab +git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 git -C /tmp/rsReticulum-patch-test apply reticulum-sidecar/patches/rsReticulum-packet-tap.patch git -C /tmp/rsReticulum-patch-test apply reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch git -C /tmp/rsReticulum-linkclient-nomad format-patch -1 --stdout \ @@ -162,48 +162,9 @@ git -C /tmp/rsReticulum-patch-test diff \ When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) merges, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. -## rsReticulum-rnode-tcp-activity-keepalive.patch +## Removed: rsReticulum-rnode-tcp-activity-keepalive.patch -Port Python `RNodeInterface` / `TCPConnection.ACTIVITY_KEEPALIVE` (3.5s idle → `detect()`): Wi‑Fi/TCP RNodes otherwise close the socket at ~`ACTIVITY_TIMEOUT` (6s), causing mesh-client / rnsd-rs up/down flaps. - -| Field | Value | -| ----- | ----- | -| **Base commit** | `4095022` (`ratspeak/rsReticulum` `main` tip when generated; also applies on pin `6d2b28475321bc15c8f60796513d8878b47ed3ab`) | -| **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/15 | - -**Modifies (1 file):** - -- `crates/rns-interface/src/rnode.rs` — TCP activity keepalive constants + write-loop `detect()` on idle - -### Apply locally - -From mesh-client repo root (sibling `../rsReticulum` required): - -```bash -./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh -``` - -Apply after the other rsReticulum overlays when rebuilding a pinned checkout: - -```bash -./scripts/apply-rsReticulum-packet-tap.sh -./scripts/apply-rsReticulum-auto-beacon-utun.sh -./scripts/apply-rsReticulum-link-client-nomad.sh -./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh -``` - -### Regenerate - -```bash -cd ../rsReticulum -git fetch origin -git diff origin/main...HEAD -- crates/rns-interface/src/rnode.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsReticulum-rnode-tcp-activity-keepalive.patch -``` - -### Sunset - -When [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) merges, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +Sunset when upstream landed `RNodeIdleProbe` (`88d3d38` — *rnode: restore TCP application idle probes*). [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) was closed as superseded; mesh-client no longer carries that overlay (pin `9928abed269a83ec5a7ef165ff1142d938cad706` or later already includes idle probes). `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` still tracks `#15` so `pnpm run update` warns on closed-without-merge until the entry is dropped after sunset is confirmed. ## rsReticulum-ble-rnode-pairing-transition-debounce.patch @@ -211,8 +172,8 @@ Debounce BLE RNode reconnect after mid-SMP disconnect (`BLE pairing in progress` | Field | Value | | ----- | ----- | -| **Base commit** | applies on current `rsReticulum` tip used for mesh-client overlays (also intended for pin `6d2b28475321bc15c8f60796513d8878b47ed3ab`) | -| **Upstream PR** | none yet (mesh-client overlay) | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` (after prior overlays) | +| **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/20 | **Modifies (1 file):** @@ -232,7 +193,6 @@ Apply after the other rsReticulum overlays when rebuilding a pinned checkout: ./scripts/apply-rsReticulum-packet-tap.sh ./scripts/apply-rsReticulum-auto-beacon-utun.sh ./scripts/apply-rsReticulum-link-client-nomad.sh -./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh ./scripts/apply-rsReticulum-discovery-announce-egress.sh ``` @@ -247,7 +207,7 @@ git diff -- crates/rns-interface/src/ble_rnode.rs \ ### Sunset -When upstream ships an equivalent debounce (or a passkey-window pause), remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When [ratspeak/rsReticulum#20](https://github.com/ratspeak/rsReticulum/pull/20) merges and the clone pin includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsReticulum-discovery-announce-egress.patch @@ -255,7 +215,7 @@ Register `rnstransport.discovery.interface` as a local destination before announ | Field | Value | | ----- | ----- | -| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` (after prior overlays) | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` (after prior overlays) | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/19 | **Modifies (3 files):** @@ -278,7 +238,6 @@ Apply **after** the other rsReticulum overlays (packet-tap also touches `reticul ./scripts/apply-rsReticulum-packet-tap.sh ./scripts/apply-rsReticulum-auto-beacon-utun.sh ./scripts/apply-rsReticulum-link-client-nomad.sh -./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh ./scripts/apply-rsReticulum-discovery-announce-egress.sh ``` diff --git a/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch b/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch index 9725ad51a..8af1db82a 100644 --- a/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch +++ b/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch @@ -1,5 +1,5 @@ diff --git a/crates/rns-interface/src/auto.rs b/crates/rns-interface/src/auto.rs -index 3d0db69..479026e 100644 +index acbf303..d40ce2a 100644 --- a/crates/rns-interface/src/auto.rs +++ b/crates/rns-interface/src/auto.rs @@ -35,7 +35,7 @@ pub const MULTI_IF_DEQUE_TTL: f64 = 0.75; @@ -210,7 +210,7 @@ index 3d0db69..479026e 100644 } } } -@@ -2035,4 +2138,26 @@ mod tests { +@@ -2036,4 +2139,26 @@ mod tests { let expected = BEACON_INTERVAL * 3.25; assert!((REVERSE_PEERING_INTERVAL - expected).abs() < 0.001); } diff --git a/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch b/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch index e9ff37ba0..ac4758178 100644 --- a/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch +++ b/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch @@ -1,19 +1,19 @@ diff --git a/crates/rns-interface/src/ble_rnode.rs b/crates/rns-interface/src/ble_rnode.rs -index 3bbc7c5..e742172 100644 +index a0cf11a..9bd4780 100644 --- a/crates/rns-interface/src/ble_rnode.rs +++ b/crates/rns-interface/src/ble_rnode.rs -@@ -43,6 +43,10 @@ pub const NUS_TX_CHAR_UUID: Uuid = Uuid::from_u128(0x6E400003_B5A3_F393_E0A9_E50 +@@ -53,6 +53,10 @@ pub const NUS_TX_CHAR_UUID: Uuid = Uuid::from_u128(0x6E400003_B5A3_F393_E0A9_E50 const RECONNECT_WAIT: u64 = 5; /// Capped below TCP's 300s — a BLE radio is either in range or not. const RECONNECT_WAIT_MAX: u64 = 120; +/// After a mid-SMP disconnect (`BLE pairing in progress`), wait before +/// reconnecting so the OS passkey dialog is not re-fired every second while -+/// the user is typing the PIN (mesh-client macOS pairing UX). ++/// the user is typing the PIN (desktop BLE pairing UX). +const PAIRING_TRANSITION_RETRY_WAIT: u64 = 30; /// `None` retries forever; teardown goes via `stop_ble_rnode_interface`. const MAX_RECONNECT_TRIES: Option = None; const SCAN_TIMEOUT: u64 = 3; -@@ -1272,7 +1276,11 @@ pub async fn spawn_ble_rnode_interface( +@@ -2009,7 +2013,11 @@ pub async fn spawn_ble_rnode_interface_with_driver_and_options( Ok(c) => c, Err(e) => { let pairing_transition = is_pairing_transition_error(&e); @@ -23,6 +23,22 @@ index 3bbc7c5..e742172 100644 + } else { + backoff + }; + snapshot_publisher.connection_attempt_failed(); tracing::warn!(name = %log_name, error = %e, "BLE RNode connect failed"); ble_diag(format!( - "[ble] connect_rnode err: {e} — retrying in {retry_wait}s (attempt {})", +@@ -5447,4 +5455,15 @@ mod tests { + tokio::time::sleep(Duration::from_secs(2)).await; + handle.online.store(false, Ordering::SeqCst); + } ++ ++ #[test] ++ fn test_pairing_transition_uses_long_retry_wait() { ++ assert_eq!(PAIRING_TRANSITION_RETRY_WAIT, 30); ++ assert!(is_pairing_transition_error(&InterfaceError::SendFailed( ++ "BLE pairing in progress: Authentication required".into() ++ ))); ++ assert!(!is_pairing_transition_error(&InterfaceError::SendFailed( ++ "BLE device not found: RNode".into() ++ ))); ++ } + } diff --git a/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch b/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch index 6489780df..6af3cb7a5 100644 --- a/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch +++ b/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch @@ -1,8 +1,8 @@ diff --git a/crates/rns-runtime/src/reticulum.rs b/crates/rns-runtime/src/reticulum.rs -index 5e73442..f466dd7 100644 +index 766d6db..05c6c06 100644 --- a/crates/rns-runtime/src/reticulum.rs +++ b/crates/rns-runtime/src/reticulum.rs -@@ -104,10 +104,13 @@ impl Default for DiscoveryRuntime { +@@ -1028,10 +1028,13 @@ impl Default for DiscoveryRuntime { } } @@ -17,23 +17,34 @@ index 5e73442..f466dd7 100644 } impl ReticulumHandle { -@@ -1298,6 +1301,7 @@ pub async fn init( +@@ -3184,6 +3187,10 @@ pub async fn init_with_options_and_rnode_startup_options( Ok(iface_handles) => { - for iface_handle in iface_handles { - let registered_id = iface_handle.id; -+ let online = iface_handle.online.clone(); - register_interface_with_post_init( - &transport_tx, - iface_handle, -@@ -1311,6 +1315,7 @@ pub async fn init( - LocalDiscoveryInterface { - id: registered_id, - config: cfg.clone(), -+ online: Some(online), - }, - ); - } -@@ -2338,15 +2343,69 @@ async fn start_on_network_discovery(handle: ReticulumHandle) { + let pending_rnode = + pending_configured_rnode_runtime(iface_config, &iface_handles); ++ let online_by_spawned_id: Vec<(u64, Arc)> = iface_handles ++ .iter() ++ .map(|owned| (owned.interface.id, owned.interface.online.clone())) ++ .collect(); + match register_interfaces_with_post_init_batch( + &transport_tx, + iface_handles, +@@ -3204,10 +3211,15 @@ pub async fn init_with_options_and_rnode_startup_options( + } + for registered_id in registered_ids { + if let Some(ref cfg) = discovery_config { ++ let online = online_by_spawned_id ++ .iter() ++ .find(|(id, _)| *id == registered_id) ++ .map(|(_, flag)| flag.clone()); + discovery_runtime.local_interfaces.lock().await.push( + LocalDiscoveryInterface { + id: registered_id, + config: cfg.clone(), ++ online, + }, + ); + } +@@ -5509,15 +5521,69 @@ async fn start_on_network_discovery(handle: ReticulumHandle) { } } @@ -70,7 +81,7 @@ index 5e73442..f466dd7 100644 + +/// Hash + `RegisterDestination` that marks the discovery aspect as instance-local +/// so outbound announces pass `interface_allows_announce` (non-local + no path -+/// is blocked on Boundary / Roaming). ++/// is blocked on every interface). +fn discovery_local_destination_registration( + identity_hash: &[u8; 16], +) -> ([u8; 16], TransportMessage) { @@ -106,7 +117,7 @@ index 5e73442..f466dd7 100644 let announce_identity = handle .network_identity -@@ -2355,13 +2414,38 @@ async fn run_discovery_announcer( +@@ -5526,13 +5592,38 @@ async fn run_discovery_announcer( let encrypt_identity = handle.network_identity.clone(); let tick_interval = Duration::from_secs(rns_transport::discovery::ANNOUNCE_JOB_INTERVAL_SECS); @@ -146,7 +157,7 @@ index 5e73442..f466dd7 100644 for request in requests { match build_announce_packet( &announce_identity, -@@ -2369,15 +2453,16 @@ async fn run_discovery_announcer( +@@ -5540,15 +5631,16 @@ async fn run_discovery_announcer( Some(&request.app_data), ) { Ok(raw) => { @@ -168,7 +179,7 @@ index 5e73442..f466dd7 100644 })) .await; } -@@ -2389,7 +2474,14 @@ async fn run_discovery_announcer( +@@ -5560,7 +5652,14 @@ async fn run_discovery_announcer( tokio::select! { _ = handle.shutdown.wait() => break, @@ -184,7 +195,7 @@ index 5e73442..f466dd7 100644 } } } -@@ -4130,6 +4222,77 @@ loglevel = 7 +@@ -10217,6 +10316,77 @@ loglevel = 7 assert!(h.discovery_enabled().await); } @@ -227,7 +238,7 @@ index 5e73442..f466dd7 100644 + assert_eq!(pending.len(), 1); + assert!(registered.is_empty()); + -+ online.store(true, std::sync::atomic::Ordering::SeqCst); ++ online.store(true, Ordering::SeqCst); + let ready = take_online_discovery_interfaces(&mut pending, &mut registered); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].id, 7); @@ -263,20 +274,18 @@ index 5e73442..f466dd7 100644 async fn enable_overrides_previous_stamper_without_error() { let h = dummy_handle(); diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs -index 612cc04..710a657 100644 +index 13313f1..00465b1 100644 --- a/crates/rns-transport/src/actor/mod.rs +++ b/crates/rns-transport/src/actor/mod.rs -@@ -6425,6 +6425,87 @@ mod tests { - let _ = std::fs::remove_dir_all(&dir); +@@ -8434,6 +8434,93 @@ mod tests { + ); } + /// Local discovery announces use `on_outbound` → `broadcast_local_announce_on_interfaces`. -+ /// Without RegisterDestination (and with no path), Boundary is gated off — -+ /// RMAP hubs never see TX even though outbound routing logs ran. -+ /// (On this pin, Full/Gateway still allow non-local with no path; tip main -+ /// also gates those via a mode-independent local/path check.) ++ /// Without RegisterDestination (and with no path), every interface is gated off — ++ /// the wire never sees TX even though outbound routing logs ran. + #[test] -+ fn outbound_discovery_announce_silent_on_boundary_without_local_destination() { ++ fn outbound_discovery_announce_silent_without_local_destination() { + let (mut actor, _tx) = TransportActor::new(); + actor.is_transport_enabled = true; + @@ -284,6 +293,10 @@ index 612cc04..710a657 100644 + boundary.mode = InterfaceMode::Boundary; + actor.interfaces.insert(1, boundary); + ++ let (mut full, mut full_rx) = make_test_interface("full"); ++ full.mode = InterfaceMode::Full; ++ actor.interfaces.insert(2, full); ++ + let (mut ap, mut ap_rx) = make_test_interface("RNode"); + ap.mode = InterfaceMode::AccessPoint; + actor.interfaces.insert(3, ap); @@ -299,6 +312,10 @@ index 612cc04..710a657 100644 + "non-local discovery announce must not TX on Boundary" + ); + assert!( ++ full_rx.try_recv().is_err(), ++ "non-local discovery announce must not TX on Full" ++ ); ++ assert!( + ap_rx.try_recv().is_err(), + "AccessPoint must never receive announces" + ); @@ -351,14 +368,14 @@ index 612cc04..710a657 100644 + ); + } + + /// Python 1.3.8 Transport.py:1220-1236: internal-mode egress and + /// announces_from_internal origin gating. #[test] - fn outbound_announce_respects_access_point_mode() { - let (mut actor, _tx) = TransportActor::new(); diff --git a/crates/rns-transport/src/discovery/announcer.rs b/crates/rns-transport/src/discovery/announcer.rs -index 2062718..abdc204 100644 +index 45aa554..0d49377 100644 --- a/crates/rns-transport/src/discovery/announcer.rs +++ b/crates/rns-transport/src/discovery/announcer.rs -@@ -561,4 +561,35 @@ mod tests { +@@ -562,4 +562,35 @@ mod tests { assert_eq!(second[0].interface_id, 2); assert!(matches!(skips[0].1, SkipReason::RateLimited { .. })); } diff --git a/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch b/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch index 12daeee0a..ce61947ab 100644 --- a/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch +++ b/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch @@ -1,5 +1,5 @@ diff --git a/crates/rns-runtime/src/link_client.rs b/crates/rns-runtime/src/link_client.rs -index 8ea0c31..a3edf12 100644 +index fcfc3dc..c024504 100644 --- a/crates/rns-runtime/src/link_client.rs +++ b/crates/rns-runtime/src/link_client.rs @@ -16,8 +16,15 @@ use rns_identity::identity::Identity; @@ -141,7 +141,7 @@ index 8ea0c31..a3edf12 100644 async fn send_msg(&self, msg: TransportMessage) -> Result<(), LinkClientError> { self.transport_tx .send(msg) -@@ -677,4 +750,42 @@ mod tests { +@@ -697,4 +770,42 @@ mod tests { assert_eq!(header.context, rns_wire::context::PacketContext::LinkClose); assert!(responder.receive_teardown(&request.raw[offset..])); } @@ -185,10 +185,10 @@ index 8ea0c31..a3edf12 100644 + } } diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs -index 3973969..fce392e 100644 +index 0b5a20e..13313f1 100644 --- a/crates/rns-transport/src/actor/mod.rs +++ b/crates/rns-transport/src/actor/mod.rs -@@ -8430,6 +8430,60 @@ mod tests { +@@ -10809,6 +10809,60 @@ mod tests { )); } @@ -250,10 +250,10 @@ index 3973969..fce392e 100644 fn filter_blackholed_dests_returns_only_blackholed() { let (mut actor, _tx) = TransportActor::new(); diff --git a/crates/rns-transport/src/actor/rpc.rs b/crates/rns-transport/src/actor/rpc.rs -index 4eff64d..708472c 100644 +index 07f3b52..8113412 100644 --- a/crates/rns-transport/src/actor/rpc.rs +++ b/crates/rns-transport/src/actor/rpc.rs -@@ -545,6 +545,13 @@ impl TransportActor { +@@ -616,6 +616,13 @@ impl TransportActor { } TransportQueryResponse::HashResult(None) } @@ -268,10 +268,10 @@ index 4eff64d..708472c 100644 let mut hits = Vec::new(); for dest in &dests { diff --git a/crates/rns-transport/src/messages.rs b/crates/rns-transport/src/messages.rs -index 045aa0f..2179103 100644 +index 34b0443..23ae6c8 100644 --- a/crates/rns-transport/src/messages.rs +++ b/crates/rns-transport/src/messages.rs -@@ -515,6 +515,13 @@ pub enum TransportQuery { +@@ -768,6 +768,13 @@ pub enum TransportQuery { /// `recent_announces`. Returns `IntResult(count_purged)`. Use sparingly — /// this can drop legit-but-unseen entries. PurgeUnverifiedBlackholes, @@ -285,7 +285,7 @@ index 045aa0f..2179103 100644 } #[derive(Debug)] -@@ -527,6 +534,8 @@ pub enum TransportQueryResponse { +@@ -781,6 +788,8 @@ pub enum TransportQueryResponse { FloatResult(Option), StringResult(Option), HashResult(Option<[u8; 16]>), diff --git a/reticulum-sidecar/patches/rsReticulum-packet-tap.patch b/reticulum-sidecar/patches/rsReticulum-packet-tap.patch index 06db02fba..ec738d0a5 100644 --- a/reticulum-sidecar/patches/rsReticulum-packet-tap.patch +++ b/reticulum-sidecar/patches/rsReticulum-packet-tap.patch @@ -1,8 +1,8 @@ diff --git a/crates/rns-runtime/src/reticulum.rs b/crates/rns-runtime/src/reticulum.rs -index c0568e6..5e73442 100644 +index 8cb3495..766d6db 100644 --- a/crates/rns-runtime/src/reticulum.rs +++ b/crates/rns-runtime/src/reticulum.rs -@@ -172,6 +172,17 @@ impl ReticulumHandle { +@@ -1442,6 +1442,17 @@ impl ReticulumHandle { result } @@ -17,14 +17,14 @@ index c0568e6..5e73442 100644 + .await; + } + - /// Query the authoritative control plane. + /// Recall the identity and latest announce metadata for `destination_hash`. /// - /// In client mode, Python proxies Reticulum control methods to the local + /// This reads this process' live, validated replicated announce cache in diff --git a/crates/rns-transport/src/actor/inbound.rs b/crates/rns-transport/src/actor/inbound.rs -index 68e1fa3..232795f 100644 +index 651d49e..26b1768 100644 --- a/crates/rns-transport/src/actor/inbound.rs +++ b/crates/rns-transport/src/actor/inbound.rs -@@ -35,6 +35,15 @@ impl TransportActor { +@@ -41,6 +41,15 @@ impl TransportActor { packet.raw.clone() }; @@ -41,10 +41,10 @@ index 68e1fa3..232795f 100644 Ok((header, offset)) => (header, offset), Err(e) => { diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs -index 3973969..f888cae 100644 +index d47b524..0b5a20e 100644 --- a/crates/rns-transport/src/actor/mod.rs +++ b/crates/rns-transport/src/actor/mod.rs -@@ -145,6 +145,9 @@ pub struct TransportActor { +@@ -201,6 +201,9 @@ pub struct TransportActor { /// background switches the maintenance tick to the long interval so the /// actor stops burning CPU (and battery) while the app is suspended. pub is_foreground: Arc, @@ -54,15 +54,15 @@ index 3973969..f888cae 100644 } /// Cached announce metadata for diagnostics + CacheRequest replay. Raw -@@ -269,6 +272,7 @@ impl TransportActor { - channel_drops: 0, +@@ -355,6 +358,7 @@ impl TransportActor { announce_handlers: Vec::new(), + next_announce_handler_id: 0, is_foreground: Arc::new(AtomicBool::new(true)), + packet_tap: None, }; (actor, tx) -@@ -616,6 +620,9 @@ impl TransportActor { +@@ -833,6 +837,9 @@ impl TransportActor { debug!(dest = hex::encode(dest), "registered path waiter"); } } @@ -72,7 +72,7 @@ index 3973969..f888cae 100644 TransportMessage::Shutdown => unreachable!(), } } -@@ -955,6 +962,35 @@ impl TransportActor { +@@ -1265,6 +1272,35 @@ impl TransportActor { /// /// `try_send` failures: `Full` bumps `tx_drops`; `Closed` auto-deregisters /// (zombie interface — receiver dropped without DeregisterInterface). @@ -108,12 +108,12 @@ index 3973969..f888cae 100644 #[tracing::instrument( level = "trace", name = "actor.send_to_interface", -@@ -985,8 +1021,17 @@ impl TransportActor { +@@ -1295,8 +1331,18 @@ impl TransportActor { } else { Bytes::copy_from_slice(raw) }; - match entry.tx.try_send(data) { -- Ok(()) => {} +- Ok(()) => true, + match entry.tx.try_send(data.clone()) { + Ok(()) => { + self.emit_packet_tap( @@ -124,19 +124,19 @@ index 3973969..f888cae 100644 + None, + None, + ); ++ true + } Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { let tx_drops = entry .tx_drops diff --git a/crates/rns-transport/src/messages.rs b/crates/rns-transport/src/messages.rs -index 045aa0f..8756378 100644 +index 2d532be..34b0443 100644 --- a/crates/rns-transport/src/messages.rs +++ b/crates/rns-transport/src/messages.rs -@@ -212,7 +212,71 @@ pub struct AnnounceHandlerEvent { - pub name_hash: [u8; 10], +@@ -336,6 +336,71 @@ pub struct PathRequestOptions { + pub recursive: bool, } --/// Every mutation of transport state enters through this enum — the actor +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PacketTapDirection { + Rx, @@ -202,10 +202,10 @@ index 045aa0f..8756378 100644 + } +} + + /// Every mutation of transport state enters through this enum — the actor /// dispatches on the variant, so adding a new operation is a matter of adding /// a variant and a match arm rather than exposing a new lock or shared type. - // This is the transport actor's public message surface. Boxing individual -@@ -321,6 +385,10 @@ pub enum TransportMessage { +@@ -483,6 +548,10 @@ pub enum TransportMessage { dest: [u8; 16], reply: tokio::sync::oneshot::Sender, }, @@ -216,7 +216,7 @@ index 045aa0f..8756378 100644 Shutdown, } -@@ -353,6 +421,7 @@ pub fn msg_variant_name(msg: &TransportMessage) -> &'static str { +@@ -520,6 +589,7 @@ pub fn msg_variant_name(msg: &TransportMessage) -> &'static str { TransportMessage::RegisterLink { .. } => "RegisterLink", TransportMessage::ActivateLink { .. } => "ActivateLink", TransportMessage::AwaitPath { .. } => "AwaitPath", @@ -224,7 +224,7 @@ index 045aa0f..8756378 100644 TransportMessage::Shutdown => "Shutdown", } } -@@ -733,6 +802,7 @@ impl std::fmt::Debug for TransportMessage { +@@ -966,6 +1036,7 @@ impl std::fmt::Debug for TransportMessage { Self::AwaitPath { dest, .. } => { f.debug_struct("AwaitPath").field("dest", dest).finish() } diff --git a/reticulum-sidecar/patches/rsReticulum-rnode-tcp-activity-keepalive.patch b/reticulum-sidecar/patches/rsReticulum-rnode-tcp-activity-keepalive.patch deleted file mode 100644 index ca1cd7fea..000000000 --- a/reticulum-sidecar/patches/rsReticulum-rnode-tcp-activity-keepalive.patch +++ /dev/null @@ -1,180 +0,0 @@ -diff --git a/crates/rns-interface/src/rnode.rs b/crates/rns-interface/src/rnode.rs -index 41e232d..fe8a5b1 100644 ---- a/crates/rns-interface/src/rnode.rs -+++ b/crates/rns-interface/src/rnode.rs -@@ -124,6 +124,28 @@ const RNODE_TCP_KEEPCNT: u32 = 12; - const RNODE_TCP_USER_TIMEOUT_SECS: u64 = 24; - #[cfg(any(feature = "serial", feature = "rnode-tcp"))] - const RNODE_TCP_BUFFER_BYTES: usize = 131_072; -+/// Python `TCPConnection.ACTIVITY_TIMEOUT` — firmware closes idle Wi‑Fi/TCP -+/// sessions around this many seconds without host KISS writes. -+#[cfg(any(feature = "serial", feature = "rnode-tcp"))] -+pub const RNODE_TCP_ACTIVITY_TIMEOUT_SECS: u64 = 6; -+/// Python `TCPConnection.ACTIVITY_KEEPALIVE` (`ACTIVITY_TIMEOUT - 2.5`): -+/// idle longer than this → send `detect()` so the RNode keeps the TCP socket. -+#[cfg(any(feature = "serial", feature = "rnode-tcp"))] -+pub const RNODE_TCP_ACTIVITY_KEEPALIVE_MS: u64 = -+ (RNODE_TCP_ACTIVITY_TIMEOUT_SECS * 1_000).saturating_sub(2_500); -+ -+#[cfg(any(feature = "serial", feature = "rnode-tcp"))] -+fn tcp_activity_keepalive() -> Duration { -+ #[cfg(test)] -+ { -+ // Keep unit tests fast; production uses the Python-parity interval. -+ Duration::from_millis(100) -+ } -+ #[cfg(not(test))] -+ { -+ Duration::from_millis(RNODE_TCP_ACTIVITY_KEEPALIVE_MS) -+ } -+} - - #[cfg(any(feature = "serial", feature = "rnode-tcp"))] - type RNodeStopRegistry = Mutex>>; -@@ -935,22 +957,49 @@ pub async fn spawn_rnode_interface( - let ready_w = ready.clone(); - let txb_w = txb_r.clone(); - let beacon_w = beacon.clone(); -+ // Python RNodeInterface.py:1144-1147 — Wi‑Fi/TCP RNodes need -+ // periodic detect() writes or firmware closes the socket (~6s). -+ let tcp_activity = port_write.is_tcp(); - let write_handle = tokio::spawn(async move { - let mut port_w = port_write; - // Python first_tx semantics: armed by data TX, cleared when - // the callsign beacon goes out (RNodeInterface.py:712-718, 1142-1146). - let mut first_tx: Option = None; -+ // open_configured_rnode_stream already wrote detect+init. -+ let mut last_write = tokio::time::Instant::now(); -+ let activity_keepalive = tcp_activity_keepalive(); - loop { -- let request = if let Some((interval, ref callsign)) = beacon_w { -+ // Wake periodically for TCP activity keepalive and/or beacon. -+ let need_periodic = tcp_activity || beacon_w.is_some(); -+ let request = if need_periodic { - match tokio::time::timeout(Duration::from_secs(1), conn_rx.recv()).await { - Ok(Some(request)) => request, - Ok(None) => break, - Err(_) => { -- if first_tx.is_none_or(|t| t.elapsed() < interval) { -+ if let Some((interval, ref callsign)) = beacon_w { -+ if first_tx.is_some_and(|t| t.elapsed() >= interval) { -+ tracing::debug!("RNode transmitting station-ID beacon"); -+ RNodeWriteRequest::Packet(callsign.clone()) -+ } else if tcp_activity -+ && last_write.elapsed() >= activity_keepalive -+ { -+ tracing::debug!( -+ "RNode TCP activity keepalive (detect)" -+ ); -+ let (done_tx, _done_rx) = oneshot::channel(); -+ RNodeWriteRequest::Raw(build_detect_sequence(), done_tx) -+ } else { -+ continue; -+ } -+ } else if tcp_activity -+ && last_write.elapsed() >= activity_keepalive -+ { -+ tracing::debug!("RNode TCP activity keepalive (detect)"); -+ let (done_tx, _done_rx) = oneshot::channel(); -+ RNodeWriteRequest::Raw(build_detect_sequence(), done_tx) -+ } else { - continue; - } -- tracing::debug!("RNode transmitting station-ID beacon"); -- RNodeWriteRequest::Packet(callsign.clone()) - } - } - } else { -@@ -1001,6 +1050,8 @@ pub async fn spawn_rnode_interface( - } - match result { - Ok(p) => { -+ // Any successful write resets Python last_write. -+ last_write = tokio::time::Instant::now(); - if is_packet { - tracing::debug!(id, framed_len, "RNode packet write complete"); - } -@@ -1555,4 +1606,83 @@ mod tests { - drop(handle.tx); - server.join().unwrap(); - } -+ -+ #[cfg(any(feature = "serial", feature = "rnode-tcp"))] -+ #[test] -+ fn test_tcp_activity_keepalive_matches_python_parity() { -+ assert_eq!(RNODE_TCP_ACTIVITY_TIMEOUT_SECS, 6); -+ assert_eq!(RNODE_TCP_ACTIVITY_KEEPALIVE_MS, 3_500); -+ // Production interval (not the cfg(test) short value). -+ assert_eq!( -+ Duration::from_millis(RNODE_TCP_ACTIVITY_KEEPALIVE_MS), -+ Duration::from_millis(3_500) -+ ); -+ } -+ -+ #[cfg(any(feature = "serial", feature = "rnode-tcp"))] -+ #[tokio::test] -+ async fn test_rnode_tcp_sends_activity_keepalive_detect() { -+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); -+ let addr = listener.local_addr().unwrap(); -+ let config = RNodeConfig::new("rnode-tcp-keepalive", &format!("tcp://{addr}")); -+ let detect = build_detect_sequence(); -+ let init_len = detect.len() + build_init_sequence(&config).len(); -+ let (saw_keepalive_tx, mut saw_keepalive_rx) = tokio::sync::mpsc::unbounded_channel(); -+ -+ let server = std::thread::spawn(move || { -+ let (mut stream, _) = listener.accept().unwrap(); -+ stream -+ .set_read_timeout(Some(Duration::from_secs(5))) -+ .unwrap(); -+ -+ // Drain detect+init from open_configured_rnode_stream. -+ let mut buf = [0u8; 1024]; -+ let mut total = 0usize; -+ while total < init_len { -+ match std::io::Read::read(&mut stream, &mut buf) { -+ Ok(0) => return, -+ Ok(n) => total += n, -+ Err(_) => return, -+ } -+ } -+ -+ // Wait for activity keepalive detect() after idle (test keepalive = 100ms). -+ let mut extra = Vec::new(); -+ let deadline = std::time::Instant::now() + Duration::from_secs(3); -+ while std::time::Instant::now() < deadline { -+ match std::io::Read::read(&mut stream, &mut buf) { -+ Ok(0) => break, -+ Ok(n) => { -+ extra.extend_from_slice(&buf[..n]); -+ if extra.windows(detect.len()).any(|w| w == detect.as_slice()) { -+ let _ = saw_keepalive_tx.send(()); -+ return; -+ } -+ } -+ Err(e) if e.kind() == std::io::ErrorKind::WouldBlock -+ || e.kind() == std::io::ErrorKind::TimedOut => -+ { -+ continue; -+ } -+ Err(_) => break, -+ } -+ } -+ }); -+ -+ let (transport_tx, _transport_rx) = mpsc::channel::(8); -+ let handle = spawn_rnode_interface(config, 78, transport_tx) -+ .await -+ .unwrap(); -+ -+ tokio::time::timeout(Duration::from_secs(4), saw_keepalive_rx.recv()) -+ .await -+ .expect("timed out waiting for TCP activity keepalive detect") -+ .expect("keepalive channel closed"); -+ -+ assert!(handle.online.load(Ordering::SeqCst)); -+ -+ handle.read_task.abort(); -+ drop(handle.tx); -+ server.join().unwrap(); -+ } - } diff --git a/reticulum-sidecar/src/main.rs b/reticulum-sidecar/src/main.rs index fecf11ad8..1ac6b2443 100644 --- a/reticulum-sidecar/src/main.rs +++ b/reticulum-sidecar/src/main.rs @@ -160,7 +160,7 @@ async fn main() -> ExitCode { info!(config_dir = %config_dir.display(), storage_dir = %storage_dir.display(), "data dirs"); let (event_tx, _) = broadcast::channel::(256); - let stack = Arc::new(StackHandle::bootstrap(config_dir, storage_dir, event_tx).await); + let stack = Arc::new(Box::pin(StackHandle::bootstrap(config_dir, storage_dir, event_tx)).await); let app = api::router(stack); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index b0620b7db..2a6951b8e 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2715,7 +2715,12 @@ mod tests { async fn list_peers_stub_empty_after_clear_announces() { let (config_dir, storage_dir) = temp_stack_dirs(); let (tx, _) = broadcast::channel(8); - let handle = StackHandle::bootstrap(config_dir.clone(), storage_dir.clone(), tx).await; + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; handle.clear_announces().await.expect("clear announces"); assert!(handle.list_peers().await.is_empty()); let _ = std::fs::remove_dir_all(config_dir); @@ -2792,7 +2797,12 @@ mod tests { async fn clear_contacts_empties_persisted_lxmf_contacts() { let (config_dir, storage_dir) = temp_stack_dirs(); let (tx, _) = broadcast::channel(8); - let handle = StackHandle::bootstrap(config_dir.clone(), storage_dir.clone(), tx).await; + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; { let mut inner = handle.inner.write().await; inner.upsert_contact("aabbccddeeff00112233445566778899", Some("Announced".into())); diff --git a/scripts/apply-rsReticulum-auto-beacon-utun.sh b/scripts/apply-rsReticulum-auto-beacon-utun.sh index 05831d95e..575d44cd3 100755 --- a/scripts/apply-rsReticulum-auto-beacon-utun.sh +++ b/scripts/apply-rsReticulum-auto-beacon-utun.sh @@ -2,7 +2,7 @@ # Apply mesh-client rsReticulum AutoInterface beacon overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch" diff --git a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh index 53817ec1a..815ed2435 100755 --- a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +++ b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh @@ -4,7 +4,7 @@ # re-fired the OS passkey dialog while the user was typing; wait 30s instead. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch" diff --git a/scripts/apply-rsReticulum-discovery-announce-egress.sh b/scripts/apply-rsReticulum-discovery-announce-egress.sh index 6add80f8b..cade74d0a 100755 --- a/scripts/apply-rsReticulum-discovery-announce-egress.sh +++ b/scripts/apply-rsReticulum-discovery-announce-egress.sh @@ -5,7 +5,7 @@ # (BLE RNode late bring-up). Upstream: https://github.com/ratspeak/rsReticulum/pull/19 set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch" diff --git a/scripts/apply-rsReticulum-link-client-nomad.sh b/scripts/apply-rsReticulum-link-client-nomad.sh index 9ad7336a0..fa302481d 100755 --- a/scripts/apply-rsReticulum-link-client-nomad.sh +++ b/scripts/apply-rsReticulum-link-client-nomad.sh @@ -2,7 +2,7 @@ # Apply mesh-client rsReticulum LinkClient Nomad overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch" diff --git a/scripts/apply-rsReticulum-packet-tap.sh b/scripts/apply-rsReticulum-packet-tap.sh index e8044063f..09b3a8efc 100755 --- a/scripts/apply-rsReticulum-packet-tap.sh +++ b/scripts/apply-rsReticulum-packet-tap.sh @@ -2,7 +2,7 @@ # Apply mesh-client rsReticulum packet-tap overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-packet-tap.patch" diff --git a/scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh b/scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh deleted file mode 100755 index 85a750f87..000000000 --- a/scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Apply mesh-client rsReticulum RNode TCP activity-keepalive overlay for rns-stack local builds. -# Mirrors Python RNodeInterface ACTIVITY_KEEPALIVE (detect every 3.5s) so Wi‑Fi RNodes -# do not close the TCP socket at ~ACTIVITY_TIMEOUT (6s). -set -euo pipefail - -RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-rnode-tcp-activity-keepalive.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" -RNODE_RS="${RNS_DIR}/crates/rns-interface/src/rnode.rs" - -if [[ ! -d "${RNS_DIR}/.git" ]]; then - echo "error: rsReticulum not found at ${RNS_DIR}" >&2 - echo "Clone: git clone https://github.com/ratspeak/rsReticulum.git ${RNS_DIR}" >&2 - exit 1 -fi - -if [[ ! -f "${PATCH_FILE}" ]]; then - echo "error: patch not found at ${PATCH_FILE}" >&2 - exit 1 -fi - -# Upstream PR: https://github.com/ratspeak/rsReticulum/pull/15 -overlay_already_present() { - [[ -f "${RNODE_RS}" ]] || return 1 - # Require the pub const and its Duration::from_millis use (not a bare identifier mention). - grep -qE '^[[:space:]]*pub const RNODE_TCP_ACTIVITY_KEEPALIVE_MS: u64 =' "${RNODE_RS}" \ - && grep -qE 'from_millis\(RNODE_TCP_ACTIVITY_KEEPALIVE_MS\)' "${RNODE_RS}" -} - -if overlay_already_present; then - echo "rnode TCP activity-keepalive overlay already present on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" - exit 0 -fi - -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" - exit 0 -fi - -echo "rnode TCP activity-keepalive patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -# Upstream PR: https://github.com/ratspeak/rsReticulum/pull/15 -if overlay_already_present; then - echo "rnode TCP activity-keepalive overlay already present on rsReticulum @ ${RS_RETICULUM_REF:0:12}" - exit 0 -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" diff --git a/scripts/check-pr.mjs b/scripts/check-pr.mjs new file mode 100755 index 000000000..709c76abd --- /dev/null +++ b/scripts/check-pr.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * PR-parity local gate: full lint, typecheck, strict-shared, full Vitest, + * and full-feature sidecar check when the branch touches sidecar paths. + * + * Usage: pnpm run check:pr + * + * Merge-base: origin/main when available; otherwise skips sidecar path detection + * (still runs sidecar check only if forced via env — not used by default). + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +const SIDECAR_PATH_RE = + /^(reticulum-sidecar\/|scripts\/check-reticulum-sidecar\.sh|scripts\/clone-ratspeak-stack\.sh|scripts\/check-rsnomad\.sh)/; + +/** + * @param {string} cmd + * @param {string[]} args + * @returns {number} + */ +function run(cmd, args) { + console.error(`check:pr: ${cmd} ${args.join(' ')}`); + const result = spawnSync(cmd, args, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: false, + }); + if (result.error) { + console.error(`check:pr: failed to spawn ${cmd}:`, result.error.message); + return 1; + } + return typeof result.status === 'number' ? result.status : 1; +} + +/** + * @param {string} gitArgs + * @returns {string} + */ +function gitStdout(gitArgs) { + const result = spawnSync('git', gitArgs.split(' '), { + cwd: ROOT, + encoding: 'utf8', + shell: false, + }); + if (result.status !== 0) return ''; + return (result.stdout ?? '').trim(); +} + +/** + * @returns {string | null} merge-base SHA, or null if unavailable + */ +export function resolveOriginMainMergeBase() { + const hasOriginMain = spawnSync('git', ['rev-parse', '--verify', 'origin/main'], { + cwd: ROOT, + stdio: 'ignore', + shell: false, + }); + if (hasOriginMain.status !== 0) return null; + const mb = gitStdout('merge-base HEAD origin/main'); + return mb || null; +} + +/** + * @param {string} mergeBase + * @returns {string[]} + */ +export function listChangedPathsVsMergeBase(mergeBase) { + const out = gitStdout(`diff --name-only ${mergeBase}...HEAD`); + if (!out) return []; + return out + .split('\n') + .map((p) => p.replace(/\\/g, '/')) + .filter(Boolean); +} + +/** + * @param {Iterable} paths + * @returns {boolean} + */ +export function branchTouchesSidecar(paths) { + for (const p of paths) { + if (SIDECAR_PATH_RE.test(p)) return true; + } + return false; +} + +/** + * @returns {number} + */ +export function main() { + const steps = [ + ['pnpm', ['run', 'lint']], + ['pnpm', ['run', 'typecheck']], + ['pnpm', ['run', 'typecheck:strict-shared']], + ['pnpm', ['run', 'test:run']], + ]; + + for (const [cmd, args] of steps) { + const code = run(cmd, args); + if (code !== 0) return code; + } + + const mergeBase = resolveOriginMainMergeBase(); + if (!mergeBase) { + console.error( + 'check:pr: skip sidecar path check (origin/main unavailable); run pnpm run check:reticulum-sidecar manually if needed', + ); + return 0; + } + + const changed = listChangedPathsVsMergeBase(mergeBase); + if (branchTouchesSidecar(changed)) { + const code = run('pnpm', ['run', 'check:reticulum-sidecar']); + if (code !== 0) return code; + } else { + console.error( + 'check:pr: skip check:reticulum-sidecar (no sidecar paths in branch vs origin/main)', + ); + } + + console.error('check:pr: OK'); + return 0; +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + process.exit(main()); +} diff --git a/scripts/check-pr.test.mjs b/scripts/check-pr.test.mjs new file mode 100644 index 000000000..06e8edcc6 --- /dev/null +++ b/scripts/check-pr.test.mjs @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { branchTouchesSidecar } from './check-pr.mjs'; + +describe('check-pr branchTouchesSidecar', () => { + it('detects reticulum-sidecar and related scripts', () => { + expect(branchTouchesSidecar(['src/renderer/App.tsx'])).toBe(false); + expect(branchTouchesSidecar(['reticulum-sidecar/src/main.rs'])).toBe(true); + expect(branchTouchesSidecar(['scripts/check-reticulum-sidecar.sh'])).toBe(true); + expect(branchTouchesSidecar(['docs/reticulum.md', 'scripts/clone-ratspeak-stack.sh'])).toBe( + true, + ); + }); +}); diff --git a/scripts/check-reticulum-sidecar.sh b/scripts/check-reticulum-sidecar.sh index e6f0d3f14..20ed79407 100755 --- a/scripts/check-reticulum-sidecar.sh +++ b/scripts/check-reticulum-sidecar.sh @@ -1,17 +1,18 @@ #!/usr/bin/env bash -# Stub-build fmt + clippy + test for reticulum-sidecar (pre-commit). -# Full-feature lint lives in reticulum-sidecar.yaml; coverage threshold in tests.yaml. +# Full-feature fmt + clippy + test for reticulum-sidecar (pre-commit when sidecar paths staged). +# Coverage threshold still lives only in tests.yaml. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SIDECAR_DIR="${REPO_ROOT}/reticulum-sidecar" +RNS_FEATURES='rns-stack,rns-ble,rns-rnode-tcp' if ! command -v cargo > /dev/null 2>&1; then echo "check:reticulum-sidecar: cargo not on PATH — skip" >&2 exit 0 fi -# Optional path deps must exist on disk even for the stub build. +# Optional path deps must exist on disk even for the feature build. bash "${REPO_ROOT}/scripts/clone-ratspeak-stack.sh" # Lint sibling rsNomad (path dep); Clippy on the sidecar does not analyze path-dep sources. @@ -19,5 +20,5 @@ bash "${REPO_ROOT}/scripts/check-rsnomad.sh" cd "${SIDECAR_DIR}" cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo test +cargo clippy --all-targets --features "${RNS_FEATURES}" -- -D warnings +cargo test --features "${RNS_FEATURES}" diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index c13458d75..39e21eebc 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -39,12 +39,11 @@ ensure_repo() { } ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ - '6d2b28475321bc15c8f60796513d8878b47ed3ab' 'rsReticulum' + '9928abed269a83ec5a7ef165ff1142d938cad706' 'rsReticulum' "${SCRIPT_DIR}/apply-rsReticulum-packet-tap.sh" "${SCRIPT_DIR}/apply-rsReticulum-auto-beacon-utun.sh" "${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" -"${SCRIPT_DIR}/apply-rsReticulum-rnode-tcp-activity-keepalive.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" "${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" diff --git a/scripts/ensure-rsReticulum-patches.sh b/scripts/ensure-rsReticulum-patches.sh index 5edf66552..c74347639 100755 --- a/scripts/ensure-rsReticulum-patches.sh +++ b/scripts/ensure-rsReticulum-patches.sh @@ -15,7 +15,6 @@ fi "${SCRIPT_DIR}/apply-rsReticulum-packet-tap.sh" "${SCRIPT_DIR}/apply-rsReticulum-auto-beacon-utun.sh" "${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" -"${SCRIPT_DIR}/apply-rsReticulum-rnode-tcp-activity-keepalive.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" "${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" diff --git a/scripts/update.sh b/scripts/update.sh index 7fbcfe778..11dfeaa33 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -211,7 +211,7 @@ check_ratspeak_patches() { 'rsReticulum-auto-beacon-utun.patch|ratspeak/rsReticulum|11|rsReticulum auto-beacon utun|https://github.com/ratspeak/rsReticulum/pull/11' 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' 'rsReticulum-rnode-tcp-activity-keepalive.patch|ratspeak/rsReticulum|15|rsReticulum RNode TCP activity keepalive|https://github.com/ratspeak/rsReticulum/pull/15' - 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum||rsReticulum BLE RNode pairing-transition debounce|' + 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum|20|rsReticulum BLE RNode pairing-transition debounce|https://github.com/ratspeak/rsReticulum/pull/20' 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' 'rsLXMF-propagation-node-policy-setters.patch|ratspeak/rsLXMF|6|rsLXMF PropagationNode policy setters|https://github.com/ratspeak/rsLXMF/pull/6' @@ -258,12 +258,16 @@ check_ratspeak_patches() { for entry in "${RATSPEAK_PATCH_ENTRIES[@]}"; do IFS='|' read -r patch_base repo pr label url <<< "${entry}" local file="${patches_dir}/${patch_base}" - if [ ! -f "${file}" ]; then + local patch_present=0 + if [ -f "${file}" ]; then + patch_present=1 + fi + if [ "${patch_present}" -eq 0 ] && [ -z "${pr}" ]; then echo " ${label}: patch file absent (${patch_base}) — already removed?" continue fi - if [ -z "${pr}" ]; then - echo " ${label}: local overlay present; no tracked PR — review ${url}" + if [ "${patch_present}" -eq 1 ] && [ -z "${pr}" ]; then + echo " ${label}: local overlay present; no tracked PR — review sunset" echo " See reticulum-sidecar/patches/README.md (sunset when upstream lands)." continue fi @@ -271,18 +275,36 @@ check_ratspeak_patches() { state="$(github_pr_state "${repo}" "${pr}")" case "${state}" in merged) - warn_box "${label} (Ratspeak overlay)" "local patch" "upstream MERGED" "${url}" - echo " Reason tracked: ${repo}#${pr} merged — remove ${file} and drop apply steps" - echo " (clone-ratspeak-stack.sh / ensure-rsReticulum-patches.sh / apply-*.sh)." - has_ratspeak_warning=1 - HAS_WARNING=1 + if [ "${patch_present}" -eq 1 ]; then + warn_box "${label} (Ratspeak overlay)" "local patch" "upstream MERGED" "${url}" + echo " Reason tracked: ${repo}#${pr} merged — remove ${file} and drop apply steps" + echo " (clone-ratspeak-stack.sh / ensure-rsReticulum-patches.sh / apply-*.sh)." + has_ratspeak_warning=1 + HAS_WARNING=1 + else + echo " ${label}: patch absent and ${repo}#${pr} merged — drop entry from RATSPEAK_PATCH_ENTRIES." + fi ;; open) - echo " ${label}: upstream PR still open — ${url}" + if [ "${patch_present}" -eq 1 ]; then + echo " ${label}: upstream PR still open — ${url}" + else + warn_box "${label} (Ratspeak overlay)" "patch absent" "PR still open" "${url}" + echo " Reason tracked: ${repo}#${pr} open but ${patch_base} missing — restore overlay or drop entry." + has_ratspeak_warning=1 + HAS_WARNING=1 + fi ;; closed) + # Still warn when the .patch is already gone so closed-without-merge stays visible + # (e.g. #15 superseded by upstream RNodeIdleProbe — confirm sunset, then drop entry). warn_box "${label} (Ratspeak overlay)" "local patch" "PR closed (not merged?)" "${url}" - echo " Reason tracked: ${repo}#${pr} closed without merge — verify overlay still needed." + if [ "${patch_present}" -eq 1 ]; then + echo " Reason tracked: ${repo}#${pr} closed without merge — verify overlay still needed." + else + echo " Reason tracked: ${repo}#${pr} closed without merge; ${patch_base} already absent —" + echo " confirm sunset (or restore overlay), then drop entry from RATSPEAK_PATCH_ENTRIES." + fi has_ratspeak_warning=1 HAS_WARNING=1 ;; diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 2828615ba..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,315 +0,0 @@ -sonar.organization=colorado-mesh -sonar.projectKey=Colorado-Mesh_mesh-client -# Project lives on SonarQube Cloud (sonarcloud.io), not the US regional host. -sonar.host.url=https://sonarcloud.io - -# First-party product sources only (Electron app + Reticulum sidecar). -# Do not analyze build tooling, CI, packaging, patches, or generated locales. -sonar.sources=src,reticulum-sidecar/src - -# Noise / generated / third-party / non-product trees -sonar.exclusions=**/node_modules/**,**/dist/**,**/dist-electron/**,**/coverage/**,**/target/**,**/.vitest-reports/**,**/release/**,src/renderer/locales/**,**/*.d.ts,scripts/**,flatpak/**,.github/**,patches/**,reticulum-sidecar/patches/**,resources/** - -# Tests (Vitest) — first-party only -sonar.tests=src -sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.test.mjs - -# Coverage from Vitest merge job + optional Rust sidecar LCOV (when artifact present) -sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.rust.lcov.reportPaths=reticulum-sidecar/lcov.info - -# TypeScript accuracy -sonar.sourceEncoding=UTF-8 -sonar.typescript.tsconfigPaths=tsconfig.json - -# Large Electron codebase — avoid OOM/timeouts -sonar.javascript.node.maxspace=8192 - -# Analysis is via SonarCloud dashboard Autoscan (not GitHub Actions). -# Free-plan Sonar Way includes cognitive complexity we cannot customize in CI. -# Autoscan does not apply multicriteria from this file — set Ignore Issues on -# Multiple Criteria in the SonarCloud project Analysis Scope UI to match below. - -# --------------------------------------------------------------------------- -# Issue suppressions (Sonar Way profile is read-only on Free; suppress noise -# here). Keep BUG rules and real security findings visible; suppress style and -# accepted platform/dev-tooling risk. -# --------------------------------------------------------------------------- -sonar.issue.ignore.multicriteria=void_ts,void_js,ternary_ts,ternary_js,complexity_ts,complexity_js,complexity_rust,props_readonly,node_protocol_ts,node_protocol_js,number_static,assertion_specific,nested_fn,aria_role,replace_all,shell_double_bracket,ctor_readonly,modern_js_style,typeerror_style,array_from_ts,array_from_js,path_scripts_js,path_scripts_ts,path_main_index,path_sidecar_path,csp_html,tmpdir_vitest,tmpdir_dev_stub,mqtt_demo_password,firmware_eval,firmware_llm,firmware_tmpdir,char_code,react_index_keys,nested_templates,param_tests,too_many_params,regex_backtrack_ts,regex_backtrack_js,promise_resolve,object_assign_style,optional_undefined,unused_type_alias,prefer_at,multi_push,redundant_act,use_state_pair,typeof_undefined,union_alias,unused_prop,string_raw,includes_some,cond_default,object_has_own,prefer_set,hardcoded_ip,bool_param,regex_complexity,concise_class,catch_name,outer_fn,error_msg,object_default_param,indexof_findindex,prefer_some,blob_text,default_param,math_min_max,math_hypot,string_ctor,neg_index,group_length,ambiguous_jsx,presence_query,codeql_newline_alt - -# Intentional void promise() for floating promises in React runtimes -sonar.issue.ignore.multicriteria.void_ts.ruleKey=typescript:S3735 -sonar.issue.ignore.multicriteria.void_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.void_js.ruleKey=javascript:S3735 -sonar.issue.ignore.multicriteria.void_js.resourceKey=**/* - -# Nested ternaries — JSX-heavy UI; Prettier already formats these -sonar.issue.ignore.multicriteria.ternary_ts.ruleKey=typescript:S3358 -sonar.issue.ignore.multicriteria.ternary_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.ternary_js.ruleKey=javascript:S3358 -sonar.issue.ignore.multicriteria.ternary_js.resourceKey=**/* - -# Cognitive complexity — TS/JS runtimes and Rust sidecar exceed threshold 15 by design -sonar.issue.ignore.multicriteria.complexity_ts.ruleKey=typescript:S3776 -sonar.issue.ignore.multicriteria.complexity_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.complexity_js.ruleKey=javascript:S3776 -sonar.issue.ignore.multicriteria.complexity_js.resourceKey=**/* -sonar.issue.ignore.multicriteria.complexity_rust.ruleKey=rust:S3776 -sonar.issue.ignore.multicriteria.complexity_rust.resourceKey=**/* - -# React props readonly — false positives with Omit/Pick utility types -sonar.issue.ignore.multicriteria.props_readonly.ruleKey=typescript:S6759 -sonar.issue.ignore.multicriteria.props_readonly.resourceKey=**/* - -# node: import prefix preference — not a correctness issue -sonar.issue.ignore.multicriteria.node_protocol_ts.ruleKey=typescript:S7772 -sonar.issue.ignore.multicriteria.node_protocol_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.node_protocol_js.ruleKey=javascript:S7772 -sonar.issue.ignore.multicriteria.node_protocol_js.resourceKey=**/* - -# Number.parseInt vs parseInt style -sonar.issue.ignore.multicriteria.number_static.ruleKey=typescript:S7773 -sonar.issue.ignore.multicriteria.number_static.resourceKey=**/* - -# Vitest assertion specificity preference -sonar.issue.ignore.multicriteria.assertion_specific.ruleKey=typescript:S5906 -sonar.issue.ignore.multicriteria.assertion_specific.resourceKey=**/* - -# Nested function depth in IPC handlers and event callbacks -sonar.issue.ignore.multicriteria.nested_fn.ruleKey=typescript:S2004 -sonar.issue.ignore.multicriteria.nested_fn.resourceKey=**/* - -# Prefer native tag over ARIA role — conflicts with intentional a11y patterns -sonar.issue.ignore.multicriteria.aria_role.ruleKey=typescript:S6819 -sonar.issue.ignore.multicriteria.aria_role.resourceKey=**/* - -# Minor style (e.g. replaceAll preference) -sonar.issue.ignore.multicriteria.replace_all.ruleKey=typescript:S7781 -sonar.issue.ignore.multicriteria.replace_all.resourceKey=**/* - -# Bash [[ vs [ in scripts -sonar.issue.ignore.multicriteria.shell_double_bracket.ruleKey=shelldre:S7688 -sonar.issue.ignore.multicriteria.shell_double_bracket.resourceKey=**/* - -# Constructor-only fields should be readonly — style -sonar.issue.ignore.multicriteria.ctor_readonly.ruleKey=typescript:S2933 -sonar.issue.ignore.multicriteria.ctor_readonly.resourceKey=**/* - -# Minor modern-JS style preferences -sonar.issue.ignore.multicriteria.modern_js_style.ruleKey=typescript:S7763 -sonar.issue.ignore.multicriteria.modern_js_style.resourceKey=**/* - -# Minor style (TypeError vs Error) -sonar.issue.ignore.multicriteria.typeerror_style.ruleKey=typescript:S7786 -sonar.issue.ignore.multicriteria.typeerror_style.resourceKey=**/* - -# Unnecessary Array.from before for…of — style/noise -sonar.issue.ignore.multicriteria.array_from_ts.ruleKey=typescript:S7747 -sonar.issue.ignore.multicriteria.array_from_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.array_from_js.ruleKey=javascript:S7747 -sonar.issue.ignore.multicriteria.array_from_js.resourceKey=**/* - -# Build/dev scripts intentionally inherit or prepend PATH for cargo/bash tooling. -# Prefer absolute executable paths in new code; keep these for legacy spawn sites. -sonar.issue.ignore.multicriteria.path_scripts_js.ruleKey=javascript:S4036 -sonar.issue.ignore.multicriteria.path_scripts_js.resourceKey=scripts/**/* -sonar.issue.ignore.multicriteria.path_scripts_ts.ruleKey=typescript:S4036 -sonar.issue.ignore.multicriteria.path_scripts_ts.resourceKey=scripts/**/* - -# Sidecar subprocess PATH wiring (documented in code) -sonar.issue.ignore.multicriteria.path_main_index.ruleKey=typescript:S4036 -sonar.issue.ignore.multicriteria.path_main_index.resourceKey=src/main/index.ts -sonar.issue.ignore.multicriteria.path_sidecar_path.ruleKey=typescript:S4036 -sonar.issue.ignore.multicriteria.path_sidecar_path.resourceKey=src/main/reticulum-sidecar-path.ts - -# Electron + Vite require style-src 'unsafe-inline' and broad connect-src -sonar.issue.ignore.multicriteria.csp_html.ruleKey=Web:S7039 -sonar.issue.ignore.multicriteria.csp_html.resourceKey=src/renderer/index.html - -# Test/dev stubs using tmp paths -sonar.issue.ignore.multicriteria.tmpdir_vitest.ruleKey=typescript:S5443 -sonar.issue.ignore.multicriteria.tmpdir_vitest.resourceKey=**/vitest.electronApiMock.ts -sonar.issue.ignore.multicriteria.tmpdir_dev_stub.ruleKey=typescript:S5443 -sonar.issue.ignore.multicriteria.tmpdir_dev_stub.resourceKey=**/devElectronApiStub.ts - -# Public Meshtastic demo broker password (meshdev / large4cats), not a secret -sonar.issue.ignore.multicriteria.mqtt_demo_password.ruleKey=typescript:S2068 -sonar.issue.ignore.multicriteria.mqtt_demo_password.resourceKey=src/renderer/lib/meshtasticMqttTlsMigration.ts - -# Dev-only firmware config generator — accepted risk -sonar.issue.ignore.multicriteria.firmware_eval.ruleKey=javascript:S1523 -sonar.issue.ignore.multicriteria.firmware_eval.resourceKey=scripts/gen-firmware-configs.mjs -sonar.issue.ignore.multicriteria.firmware_llm.ruleKey=jssecurity:S8707 -sonar.issue.ignore.multicriteria.firmware_llm.resourceKey=scripts/gen-firmware-configs.mjs -sonar.issue.ignore.multicriteria.firmware_tmpdir.ruleKey=javascript:S5443 -sonar.issue.ignore.multicriteria.firmware_tmpdir.resourceKey=scripts/gen-firmware-configs.mjs - -# Byte-oriented charCodeAt/fromCharCode (keys, hashes, binary) — codePointAt is wrong here -sonar.issue.ignore.multicriteria.char_code.ruleKey=typescript:S7758 -sonar.issue.ignore.multicriteria.char_code.resourceKey=**/* - -# Stable React keys for chat/segment lists often need array index when no stable id exists -sonar.issue.ignore.multicriteria.react_index_keys.ruleKey=typescript:S6479 -sonar.issue.ignore.multicriteria.react_index_keys.resourceKey=**/* - -# Nested template literals — readability preference, not correctness -sonar.issue.ignore.multicriteria.nested_templates.ruleKey=typescript:S4624 -sonar.issue.ignore.multicriteria.nested_templates.resourceKey=**/* - -# Prefer parameterized Vitest — style; explicit cases are clearer for protocol fixtures -sonar.issue.ignore.multicriteria.param_tests.ruleKey=typescript:S5976 -sonar.issue.ignore.multicriteria.param_tests.resourceKey=**/* - -# Too many parameters — panel/admin action helpers are intentionally flat -sonar.issue.ignore.multicriteria.too_many_params.ruleKey=typescript:S107 -sonar.issue.ignore.multicriteria.too_many_params.resourceKey=**/* - -# Regex ReDoS on trusted protocol/sidecar log lines — bounded inputs; not user-controlled -sonar.issue.ignore.multicriteria.regex_backtrack_ts.ruleKey=typescript:S8786 -sonar.issue.ignore.multicriteria.regex_backtrack_ts.resourceKey=**/* -sonar.issue.ignore.multicriteria.regex_backtrack_js.ruleKey=javascript:S8786 -sonar.issue.ignore.multicriteria.regex_backtrack_js.resourceKey=**/* - -# return Promise.resolve(value) vs return value in async Protocol stubs -sonar.issue.ignore.multicriteria.promise_resolve.ruleKey=typescript:S7746 -sonar.issue.ignore.multicriteria.promise_resolve.resourceKey=**/* - -# Object.assign vs spread — style -sonar.issue.ignore.multicriteria.object_assign_style.ruleKey=typescript:S6661 -sonar.issue.ignore.multicriteria.object_assign_style.resourceKey=**/* - -# Redundant optional + undefined union — style noise on IPC/option types -sonar.issue.ignore.multicriteria.optional_undefined.ruleKey=typescript:S4782 -sonar.issue.ignore.multicriteria.optional_undefined.resourceKey=**/* - -# Type alias of primitive — domain aliases (e.g. NodeNum) are intentional -sonar.issue.ignore.multicriteria.unused_type_alias.ruleKey=typescript:S6564 -sonar.issue.ignore.multicriteria.unused_type_alias.resourceKey=**/* - -# Prefer .at() over [length - n] — style noise across binary/protocol code -sonar.issue.ignore.multicriteria.prefer_at.ruleKey=typescript:S7755 -sonar.issue.ignore.multicriteria.prefer_at.resourceKey=**/* - -# Multiple Array#push — hot-path byte builders prefer explicit pushes -sonar.issue.ignore.multicriteria.multi_push.ruleKey=typescript:S7778 -sonar.issue.ignore.multicriteria.multi_push.resourceKey=**/* - -# Redundant act() in tests — Testing Library helpers vary by call site -sonar.issue.ignore.multicriteria.redundant_act.ruleKey=typescript:S8980 -sonar.issue.ignore.multicriteria.redundant_act.resourceKey=**/* - -# useState not destructured as [v, setV] — tuple rename / single-element patterns -sonar.issue.ignore.multicriteria.use_state_pair.ruleKey=typescript:S6754 -sonar.issue.ignore.multicriteria.use_state_pair.resourceKey=**/* - -# typeof x === 'undefined' — guards for optional globals / SSR-safe checks -sonar.issue.ignore.multicriteria.typeof_undefined.ruleKey=typescript:S7741 -sonar.issue.ignore.multicriteria.typeof_undefined.resourceKey=**/* - -# Inline union vs type alias — style -sonar.issue.ignore.multicriteria.union_alias.ruleKey=typescript:S4323 -sonar.issue.ignore.multicriteria.union_alias.resourceKey=**/* - -# Unused props — intentional memo comparator keys / API-stable optional callbacks -sonar.issue.ignore.multicriteria.unused_prop.ruleKey=typescript:S6767 -sonar.issue.ignore.multicriteria.unused_prop.resourceKey=**/* - -# String.raw preference — style -sonar.issue.ignore.multicriteria.string_raw.ruleKey=typescript:S7780 -sonar.issue.ignore.multicriteria.string_raw.resourceKey=**/* - -# .includes vs .some — style -sonar.issue.ignore.multicriteria.includes_some.ruleKey=typescript:S7765 -sonar.issue.ignore.multicriteria.includes_some.resourceKey=**/* - -# Unnecessary conditional for default — style -sonar.issue.ignore.multicriteria.cond_default.ruleKey=typescript:S6644 -sonar.issue.ignore.multicriteria.cond_default.resourceKey=**/* - -# Object.hasOwn vs hasOwnProperty.call — keep call for older Electron targets -sonar.issue.ignore.multicriteria.object_has_own.ruleKey=typescript:S6653 -sonar.issue.ignore.multicriteria.object_has_own.resourceKey=**/* - -# Prefer Set for membership — arrays kept for small fixed lists / iteration order -sonar.issue.ignore.multicriteria.prefer_set.ruleKey=typescript:S7776 -sonar.issue.ignore.multicriteria.prefer_set.resourceKey=**/* - -# Hardcoded hub / transport IPs — Reticulum presets and RNode defaults -sonar.issue.ignore.multicriteria.hardcoded_ip.ruleKey=typescript:S1313 -sonar.issue.ignore.multicriteria.hardcoded_ip.resourceKey=**/* - -# Boolean param selecting action — established API shapes -sonar.issue.ignore.multicriteria.bool_param.ruleKey=typescript:S2301 -sonar.issue.ignore.multicriteria.bool_param.resourceKey=**/* - -# Complex regex on bounded protocol inputs -sonar.issue.ignore.multicriteria.regex_complexity.ruleKey=typescript:S5843 -sonar.issue.ignore.multicriteria.regex_complexity.resourceKey=**/* - -# Concise character class preference (\d / \w) — style -sonar.issue.ignore.multicriteria.concise_class.ruleKey=typescript:S6353 -sonar.issue.ignore.multicriteria.concise_class.resourceKey=**/* - -# Catch param naming convention — style -sonar.issue.ignore.multicriteria.catch_name.ruleKey=typescript:S7718 -sonar.issue.ignore.multicriteria.catch_name.resourceKey=**/* - -# Move nested function to outer scope — style; closures capture intentionally -sonar.issue.ignore.multicriteria.outer_fn.ruleKey=typescript:S7721 -sonar.issue.ignore.multicriteria.outer_fn.resourceKey=**/* - -# Error constructor message required — some rethrows omit message by design -sonar.issue.ignore.multicriteria.error_msg.ruleKey=typescript:S7722 -sonar.issue.ignore.multicriteria.error_msg.resourceKey=**/* - -# Object literal as default parameter — intentional option bags -sonar.issue.ignore.multicriteria.object_default_param.ruleKey=typescript:S7737 -sonar.issue.ignore.multicriteria.object_default_param.resourceKey=**/* - -# indexOf vs findIndex — style -sonar.issue.ignore.multicriteria.indexof_findindex.ruleKey=typescript:S7753 -sonar.issue.ignore.multicriteria.indexof_findindex.resourceKey=**/* - -# Prefer some over find/filter length — style -sonar.issue.ignore.multicriteria.prefer_some.ruleKey=typescript:S7754 -sonar.issue.ignore.multicriteria.prefer_some.resourceKey=**/* - -# Blob#text vs FileReader — FileReader kept for broader Electron coverage -sonar.issue.ignore.multicriteria.blob_text.ruleKey=typescript:S7756 -sonar.issue.ignore.multicriteria.blob_text.resourceKey=**/* - -# Default params over reassignment — style -sonar.issue.ignore.multicriteria.default_param.ruleKey=typescript:S7760 -sonar.issue.ignore.multicriteria.default_param.resourceKey=**/* - -# Math.min/max ternary simplify — style -sonar.issue.ignore.multicriteria.math_min_max.ruleKey=typescript:S7766 -sonar.issue.ignore.multicriteria.math_min_max.resourceKey=**/* - -# Math.hypot preference — style -sonar.issue.ignore.multicriteria.math_hypot.ruleKey=typescript:S7769 -sonar.issue.ignore.multicriteria.math_hypot.resourceKey=**/* - -# Use String directly vs wrapper arrow — style -sonar.issue.ignore.multicriteria.string_ctor.ruleKey=typescript:S7770 -sonar.issue.ignore.multicriteria.string_ctor.resourceKey=**/* - -# Negative index for subarray — style -sonar.issue.ignore.multicriteria.neg_index.ruleKey=typescript:S7771 -sonar.issue.ignore.multicriteria.neg_index.resourceKey=**/* - -# Numeric separator group length — style -sonar.issue.ignore.multicriteria.group_length.ruleKey=typescript:S7749 -sonar.issue.ignore.multicriteria.group_length.resourceKey=**/* - -# Ambiguous JSX spacing — style -sonar.issue.ignore.multicriteria.ambiguous_jsx.ruleKey=typescript:S6772 -sonar.issue.ignore.multicriteria.ambiguous_jsx.resourceKey=**/* - -# Testing Library getBy* presence — queryBy used intentionally in some tests -sonar.issue.ignore.multicriteria.presence_query.ruleKey=typescript:S9027 -sonar.issue.ignore.multicriteria.presence_query.resourceKey=**/* - -# CodeQL log-injection barrier requires /\n|\r/g (not character class) in sanitizeForLogSink -sonar.issue.ignore.multicriteria.codeql_newline_alt.ruleKey=typescript:S6035 -sonar.issue.ignore.multicriteria.codeql_newline_alt.resourceKey=**/sanitize-log-message.ts diff --git a/src/main/db-compat.ts b/src/main/db-compat.ts index 2e0e7671a..30b45b2ba 100644 --- a/src/main/db-compat.ts +++ b/src/main/db-compat.ts @@ -24,18 +24,19 @@ import fs from 'fs'; // it does not pollute dev console output. (The module is stable in Node.js 24+.) // We must load sqlite via require() here (not import) so the suppression override // is in place before the first require call fires the warning. -const _warnSave = process.emitWarning; +type EmitWarningCompat = (warning: string | Error, ...args: unknown[]) => void; +const warnSave = process.emitWarning.bind(process) as EmitWarningCompat; -(process as any).emitWarning = (warning: string | Error, ...args: unknown[]) => { +process.emitWarning = (warning: string | Error, ...args: unknown[]) => { const msg = typeof warning === 'string' ? warning : (warning.message ?? ''); if (msg.includes('SQLite is an experimental feature')) return; - return (_warnSave as any).call(process, warning, ...args); + warnSave(warning, ...args); }; // eslint-disable-next-line @typescript-eslint/no-require-imports const { DatabaseSync } = require('node:sqlite') as { DatabaseSync: typeof DatabaseSyncType }; -process.emitWarning = _warnSave; // restore after sqlite is loaded +process.emitWarning = warnSave; // restore after sqlite is loaded // ─── Parameter-filtering statement wrapper ──────────────────────────────────── diff --git a/src/main/db-schema-sync.test.ts b/src/main/db-schema-sync.test.ts index 2469bb5e1..7f26e6452 100644 --- a/src/main/db-schema-sync.test.ts +++ b/src/main/db-schema-sync.test.ts @@ -85,6 +85,40 @@ describe('runSchemaUpgrade', { timeout: 30_000 }, () => { db.close(); }); + it('repairs NULL messages.status including rows with null received_via and packet_id', () => { + dir = mkdtempSync(join(tmpdir(), 'mesh-schema-null-status-')); + const db = new NodeSqliteDB(join(dir, 'test.db')); + db.pragma('journal_mode = WAL'); + runSchemaUpgrade(db); + + const ts = Date.now(); + db.prepareOnce( + `INSERT INTO messages (sender_id, payload, channel, timestamp, packet_id, status, received_via) + VALUES (1, 'with packet', 0, ?, 42, NULL, 'rf')`, + ).run(ts); + db.prepareOnce( + `INSERT INTO messages (sender_id, payload, channel, timestamp, packet_id, status, received_via) + VALUES (2, 'emoji only', 0, ?, NULL, NULL, NULL)`, + ).run(ts + 1); + + runSchemaUpgrade(db); + + const nullCount = ( + db.prepareOnce('SELECT COUNT(*) as c FROM messages WHERE status IS NULL').get() as { + c: number; + } + ).c; + expect(nullCount).toBe(0); + const statuses = db + .prepareOnce('SELECT payload, status FROM messages ORDER BY timestamp') + .all() as { payload: string; status: string }[]; + expect(statuses).toEqual([ + { payload: 'with packet', status: 'acked' }, + { payload: 'emoji only', status: 'acked' }, + ]); + db.close(); + }); + it('converts millisecond nodes.last_heard to Unix seconds (v36)', () => { dir = mkdtempSync(join(tmpdir(), 'mesh-schema-last-heard-')); const db = new NodeSqliteDB(join(dir, 'test.db')); diff --git a/src/main/db-schema-sync.ts b/src/main/db-schema-sync.ts index 8e3e6334e..d71fe012d 100644 --- a/src/main/db-schema-sync.ts +++ b/src/main/db-schema-sync.ts @@ -829,8 +829,7 @@ function repairMeshtasticInboundNullStatus(db: NodeSqliteDB): void { if (!tableExists(db, 'messages')) return; db.prepare( `UPDATE messages SET status = 'acked' - WHERE status IS NULL - AND (received_via IS NOT NULL OR packet_id IS NOT NULL)`, + WHERE status IS NULL`, ).run(); } diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 60899f628..a0edcd1f7 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -57,9 +57,9 @@ describe('Meshtastic MQTT waypoint IPC (source contract)', () => { describe('MQTT forwarder dropped-event logs (source contract)', () => { it('sanitizes dynamic MQTT fields when mainWindow is not ready', () => { - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(String\(s\)\)/g) ?? []).length).toBe(2); - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(String\(msg\)\)/g) ?? []).length).toBe(3); - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(String\(id\)\)/g) ?? []).length).toBe(2); + expect((INDEX_SOURCE.match(/sanitizeLogMessage\(s\)/g) ?? []).length).toBe(2); + expect((INDEX_SOURCE.match(/sanitizeLogMessage\(msg\)/g) ?? []).length).toBe(3); + expect((INDEX_SOURCE.match(/sanitizeLogMessage\(id\)/g) ?? []).length).toBe(2); }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 53524d180..314ef4d62 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -123,7 +123,7 @@ import { MeshcoreMqttAdapter } from './meshcore-mqtt-adapter'; import { decodePathPayload, isPathPacket } from './meshcore-path-decoder'; import { ensureMicrophoneAccess, isAllowedMicrophonePrivacySettingsUrl } from './microphoneAccess'; import { resolveMqttBrokerClientId } from './mqtt-broker-client-id'; -import { MQTTManager, parsePsk } from './mqtt-manager'; +import { type CachedNode, MQTTManager, parsePsk } from './mqtt-manager'; import { handleNobleBleToRadioWrite } from './noble-ble-ipc'; import { NobleBleManager, type NobleSessionId } from './noble-ble-manager'; import { readFileUpTo } from './readFileUpTo'; @@ -2796,36 +2796,24 @@ ipcMain.handle('noble-ble-to-radio', async (event, sessionId: unknown, bytes: un }); // ─── MQTT: Forward manager events to renderer ─────────────────────── -mqttManager.on('status', (s) => { +mqttManager.on('status', (s: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:status', { status: s, protocol: 'meshtastic' }); - else - console.debug( - '[main] mqtt:status dropped (mainWindow not ready)', - sanitizeLogMessage(String(s)), - ); + else console.debug('[main] mqtt:status dropped (mainWindow not ready)', sanitizeLogMessage(s)); }); -mqttManager.on('error', (msg) => { +mqttManager.on('error', (msg: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:error', { error: msg, protocol: 'meshtastic' }); - else - console.debug( - '[main] mqtt:error dropped (mainWindow not ready)', - sanitizeLogMessage(String(msg)), - ); + else console.debug('[main] mqtt:error dropped (mainWindow not ready)', sanitizeLogMessage(msg)); }); -mqttManager.on('clientId', (id) => { +mqttManager.on('clientId', (id: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:clientId', { clientId: id, protocol: 'meshtastic' }); - else - console.debug( - '[main] mqtt:clientId dropped (mainWindow not ready)', - sanitizeLogMessage(String(id)), - ); + else console.debug('[main] mqtt:clientId dropped (mainWindow not ready)', sanitizeLogMessage(id)); }); -mqttManager.on('nodeUpdate', (n) => { +mqttManager.on('nodeUpdate', (n: CachedNode) => { if (mainWindow) mainWindow.webContents.send('mqtt:node-update', { ...n, protocol: 'meshtastic' as const }); else console.debug('[main] mqtt:node-update dropped (mainWindow not ready)'); - takServerManager?.onNodeUpdate(n); + takServerManager?.onNodeUpdate({ ...n, altitude: n.altitude ?? undefined }); }); mqttManager.on( 'traceRouteReply', @@ -2854,38 +2842,38 @@ mqttManager.on('brokerRaw', (payload: { topic: string; payload: Buffer; retained } }); -meshcoreMqttAdapter.on('status', (s) => { +meshcoreMqttAdapter.on('status', (s: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:status', { status: s, protocol: 'meshcore' }); else console.debug( '[main] mqtt:status (meshcore) dropped (mainWindow not ready)', - sanitizeLogMessage(String(s)), + sanitizeLogMessage(s), ); }); -meshcoreMqttAdapter.on('error', (msg) => { +meshcoreMqttAdapter.on('error', (msg: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:error', { error: msg, protocol: 'meshcore' }); else console.debug( '[main] mqtt:error (meshcore) dropped (mainWindow not ready)', - sanitizeLogMessage(String(msg)), + sanitizeLogMessage(msg), ); }); -meshcoreMqttAdapter.on('clientId', (id) => { +meshcoreMqttAdapter.on('clientId', (id: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:clientId', { clientId: id, protocol: 'meshcore' }); else console.debug( '[main] mqtt:clientId (meshcore) dropped (mainWindow not ready)', - sanitizeLogMessage(String(id)), + sanitizeLogMessage(id), ); }); -meshcoreMqttAdapter.on('subscribeWarning', (msg) => { +meshcoreMqttAdapter.on('subscribeWarning', (msg: string) => { if (mainWindow) mainWindow.webContents.send('mqtt:warning', { warning: msg, protocol: 'meshcore' }); else console.debug( '[main] mqtt:warning (meshcore) dropped (mainWindow not ready)', - sanitizeLogMessage(String(msg)), + sanitizeLogMessage(msg), ); }); meshcoreMqttAdapter.on('chatMessage', (m) => { @@ -3747,22 +3735,22 @@ ipcMain.handle('db:getMessages', (event, channel?: number, limit = 200) => { mqtt_status AS mqttStatus, received_via AS receivedVia, reply_preview_text AS replyPreviewText, reply_preview_sender AS replyPreviewSender, rx_hops AS rxHops, via_store_forward AS viaStoreForward`; - let rows: any[]; + let rows: Record[]; if (channel != null) { const ch = safeNonNegativeInt(channel); rows = db .prepareOnce( `SELECT ${columns} FROM messages WHERE channel = ? ORDER BY timestamp DESC LIMIT ?`, ) - .all(ch, safeLimit); + .all(ch, safeLimit) as Record[]; } else { rows = db .prepareOnce(`SELECT ${columns} FROM messages ORDER BY timestamp DESC LIMIT ?`) - .all(safeLimit); + .all(safeLimit) as Record[]; } // Map to_node back to `to` for the renderer; drop invalid reaction scalars from legacy rows - return rows.map((r: any) => { + return rows.map((r) => { const { to_node, emoji: emojiRaw, viaStoreForward: viaSfRaw, ...rest } = r; const emoji = emojiRaw != null ? (sanitizeUnicodeReactionScalar(emojiRaw) ?? undefined) : undefined; @@ -6447,16 +6435,16 @@ void app.whenReady().then(() => { const takSettingsPath = path.join(app.getPath('userData'), 'tak-settings.json'); try { if (fs.existsSync(takSettingsPath)) { - const raw = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); + const raw: unknown = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); // Backfill autoStart for settings files saved before the field was added. if ( - raw && + raw != null && typeof raw === 'object' && typeof (raw as Record).autoStart !== 'boolean' ) { (raw as Record).autoStart = false; } - const saved = raw as unknown; + const saved = raw; validateTakSettings(saved); if (saved.autoStart) { void ensureTakServerManager() diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 007e5ca65..0bc667654 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -1,7 +1,10 @@ import type { BrowserWindow } from 'electron'; import { ipcMain, shell } from 'electron'; -import type { ReticulumSidecarStatus } from '../../shared/reticulum-types'; +import type { + ReticulumSidecarStartOptions, + ReticulumSidecarStatus, +} from '../../shared/reticulum-types'; import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash'; import { sanitizeLogMessage } from '../log-service'; import { @@ -44,6 +47,18 @@ function isExpectedReticulumProxyError(message: string): boolean { ); } +function parseReticulumStartOptions(opts: unknown): ReticulumSidecarStartOptions { + if (opts == null) return {}; + if (typeof opts !== 'object' || Array.isArray(opts)) { + throw new Error('reticulum:start options must be an object'); + } + const reuseIfRunning = (opts as Record).reuseIfRunning; + if (reuseIfRunning != null && typeof reuseIfRunning !== 'boolean') { + throw new Error('reticulum:start reuseIfRunning must be boolean'); + } + return reuseIfRunning == null ? {} : { reuseIfRunning }; +} + function logReticulumProxyFailure(method: string, err: unknown, apiPath?: string): void { const message = err instanceof Error ? err.message : String(err); const log = isExpectedReticulumProxyError(message) ? console.debug : console.error; @@ -100,12 +115,12 @@ function validateRncpListenerDirs(opts: { export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { const { idleStatus, ensureManager, getManager } = deps; - ipcMain.handle('reticulum:start', async (event, opts) => { + ipcMain.handle('reticulum:start', async (event, opts: unknown) => { assertIpcSender(event, 'reticulum:start'); try { console.debug('[ReticulumIPC] start'); const m = ensureManager(); - return await m.start(opts ?? {}); + return await m.start(parseReticulumStartOptions(opts)); } catch (err) { console.error( '[ReticulumIPC] start failed:', diff --git a/src/main/ipc/tak-handlers.ts b/src/main/ipc/tak-handlers.ts index 1a9c1dcc8..2b4b493c9 100644 --- a/src/main/ipc/tak-handlers.ts +++ b/src/main/ipc/tak-handlers.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; -import type { TAKServerStatus } from '../../shared/tak-types'; +import type { TAKServerStatus, TAKSettings } from '../../shared/tak-types'; import { sanitizeLogMessage } from '../log-service'; import type { TakServerManager } from '../tak-server-manager'; import { assertIpcSender } from '../validate-ipc-sender'; @@ -9,14 +9,16 @@ export interface TakIpcDeps { idleTakStatus: TAKServerStatus; ensureTakServerManager: () => Promise; getTakServerManager: () => TakServerManager | null; - validateTakSettings: (settings: unknown) => void; + validateTakSettings: (settings: unknown) => asserts settings is TAKSettings; } /** Register TAK server IPC handlers (`tak:*`). */ export function registerTakIpcHandlers(deps: TakIpcDeps): void { - const { idleTakStatus, ensureTakServerManager, getTakServerManager, validateTakSettings } = deps; + const { idleTakStatus, ensureTakServerManager, getTakServerManager } = deps; + const validateTakSettings: (settings: unknown) => asserts settings is TAKSettings = + deps.validateTakSettings; - ipcMain.handle('tak:start', async (event, settings) => { + ipcMain.handle('tak:start', async (event, settings: unknown) => { assertIpcSender(event, 'tak:start'); try { console.debug('[IPC] tak:start'); diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index 599b47341..cf77374a2 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -1340,7 +1340,7 @@ export class MQTTManager extends EventEmitter { if (bytes[0] === 0x7b) { try { - const parsed = JSON.parse(new TextDecoder().decode(bytes)); + const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); this.handleJsonMessage(parsed, topic); } catch { // catch-no-log-ok non-JSON on topic; failure sampled via logSampledDebug (not parse error object) diff --git a/src/main/noble-ble-manager.ts b/src/main/noble-ble-manager.ts index 7c83171f8..fa4645123 100644 --- a/src/main/noble-ble-manager.ts +++ b/src/main/noble-ble-manager.ts @@ -5,9 +5,74 @@ import { withTimeout } from '../shared/withTimeout'; import { bleCoexistenceCoordinator, type BlePeripheralOwner } from './ble-coexistence-coordinator'; import { logDeviceConnection, sanitizeLogMessage } from './log-service'; +interface NobleAdvertisement { + localName?: string; + serviceUuids?: string[]; +} + +interface NobleCharacteristic { + uuid: string; + properties?: string[]; + on(event: 'data', listener: (data: Buffer, isNotification: boolean) => void): this; + on(event: 'notify', listener: (state: boolean) => void): this; + off(event: 'data', listener: (data: Buffer, isNotification: boolean) => void): this; + removeListener(event: 'data', listener: (data: Buffer, isNotification: boolean) => void): this; + removeAllListeners(event?: 'data'): this; + readAsync(): Promise; + writeAsync(data: Buffer, withoutResponse: boolean): Promise; + subscribeAsync(): Promise; + unsubscribeAsync(): Promise; +} + +interface NobleDiscoveryResult { + characteristics: NobleCharacteristic[]; +} + +interface NoblePeripheral { + id: string; + address?: string; + addressType?: string; + advertisement?: NobleAdvertisement; + mtu?: number | null; + rssi?: number; + state: string; + on(event: 'mtu', listener: (mtu: number) => void): this; + once(event: 'disconnect', listener: (reason?: unknown) => void): this; + removeListener(event: 'mtu', listener: (mtu: number) => void): this; + removeListener(event: 'disconnect', listener: (reason?: unknown) => void): this; + removeAllListeners(event?: 'mtu'): this; + connectAsync(): Promise; + disconnectAsync(): Promise; + discoverAllServicesAndCharacteristicsAsync(): Promise; + discoverSomeServicesAndCharacteristicsAsync( + serviceUuids: string[], + characteristicUuids: string[], + ): Promise; +} + +interface NobleApi { + state: string; + on(event: 'stateChange', listener: (state: string) => void): this; + on(event: 'discover', listener: (peripheral: NoblePeripheral) => void): this; + on(event: 'scanStop', listener: () => void): this; + removeListener(event: 'scanStop', listener: () => void): this; + removeAllListeners(event?: 'stateChange' | 'discover'): this; + startScanning( + serviceUuids: string[], + allowDuplicates: boolean, + callback: (error: Error | null) => void, + ): void; + stopScanning(): void; + stop(): void; +} + // Only load noble on Mac/Windows — Linux uses Web Bluetooth in renderer instead -// eslint-disable-next-line @typescript-eslint/no-require-imports -const noble = process.platform === 'linux' ? null : require('@stoprocent/noble'); +const noble = ( + process.platform === 'linux' + ? null + : // eslint-disable-next-line @typescript-eslint/no-require-imports + (require('@stoprocent/noble') as NobleApi) +) satisfies NobleApi | null; // Meshtastic BLE GATT UUIDs (from @meshtastic/transport-web-bluetooth) const SERVICE_UUID = '6ba1b21815a8461f9fa85dcae273eafd'; @@ -64,7 +129,9 @@ function normalizeUuid(uuid: string): string { } function normalizedGattProps(char: { properties?: unknown }): string[] { - return Array.isArray(char.properties) ? char.properties : []; + return Array.isArray(char.properties) + ? char.properties.filter((property): property is string => typeof property === 'string') + : []; } /** Score NUS TX candidates so we pick a real char over WinRT stubs (duplicate UUIDs, empty props). */ @@ -84,7 +151,10 @@ function meshcoreNusRxScore(char: { properties?: unknown }): number { return s; } -function meshcorePickBestChar(candidates: any[], score: (c: any) => number): any { +function meshcorePickBestChar( + candidates: NobleCharacteristic[], + score: (c: NobleCharacteristic) => number, +): NobleCharacteristic | null { if (candidates.length === 0) return null; return candidates.reduce((best, c) => (score(c) > score(best) ? c : best), candidates[0]); } @@ -112,12 +182,11 @@ import type { MeshProtocol } from '../shared/meshProtocol'; export type NobleSessionId = MeshProtocol; interface NobleBleSession { - // Noble GATT objects from @stoprocent/noble — typed as any (no stable TS surface); avoid `any | null` (redundant union). - connectedPeripheral: any; + connectedPeripheral: NoblePeripheral | null; connectedPeripheralDisconnectHandler: (() => void) | null; - toRadioChar: any; - fromRadioChar: any; - fromNumChar: any; + toRadioChar: NobleCharacteristic | null; + fromRadioChar: NobleCharacteristic | null; + fromNumChar: NobleCharacteristic | null; fromRadioDataHandler: ((data: Buffer, isNotification: boolean) => void) | null; fromNumDataHandler: ((data: Buffer) => void) | null; readPumpActive: boolean; @@ -180,7 +249,7 @@ export class NobleBleManager extends EventEmitter { private readonly sessions = new Map(); /** Serializes connect() calls across all sessions to prevent native CBCentralManager races. */ private connectQueue: Promise = Promise.resolve(); - private readonly knownPeripherals = new Map(); + private readonly knownPeripherals = new Map(); /** * Tracks which sessions have an active scan interest. * meshtastic → filtered scan (Meshtastic service UUID only) @@ -193,12 +262,12 @@ export class NobleBleManager extends EventEmitter { private scanningActive = false; /** Deduplicates concurrent doStartScanning calls until the native start callback completes or times out. */ private scanStartInFlight: Promise | null = null; - private lastAdapterState = String(noble?.state ?? 'unknown'); + private lastAdapterState = noble?.state ?? 'unknown'; private releaseHandlesCallCount = 0; constructor() { super(); - if (process.platform === 'linux') { + if (!noble) { console.debug('[NobleBleManager] skipping init on Linux (using Web Bluetooth in renderer)'); return; } @@ -218,7 +287,7 @@ export class NobleBleManager extends EventEmitter { } }); - noble.on('discover', (peripheral: any) => { + noble.on('discover', (peripheral: NoblePeripheral) => { // Client-side filter: noble's server-side UUID filter is unreliable on macOS. // When only the meshtastic session is scanning, only pass devices that advertise // the meshtastic service UUID. Devices that advertise zero service UUIDs are passed @@ -366,7 +435,7 @@ export class NobleBleManager extends EventEmitter { private attachNoblePeripheralMtuListener( sessionId: NobleSessionId, session: NobleBleSession, - peripheral: any, + peripheral: NoblePeripheral, ): void { if (session.peripheralMtuHandler) { try { @@ -381,19 +450,19 @@ export class NobleBleManager extends EventEmitter { session.peripheralMtuHandler = handler; peripheral.on('mtu', handler); if (peripheral.mtu != null) { - this.updateSessionAttMtuFromRaw(sessionId, session, peripheral.mtu as number, 'poll'); + this.updateSessionAttMtuFromRaw(sessionId, session, peripheral.mtu, 'poll'); } } private async waitForNoblePeripheralMtuSettled( sessionId: NobleSessionId, session: NobleBleSession, - peripheral: any, + peripheral: NoblePeripheral, ): Promise { const deadline = Date.now() + BLE_MTU_POST_GATT_WAIT_MS; while (Date.now() < deadline) { if (peripheral.mtu != null) { - this.updateSessionAttMtuFromRaw(sessionId, session, peripheral.mtu as number, 'poll'); + this.updateSessionAttMtuFromRaw(sessionId, session, peripheral.mtu, 'poll'); return; } await new Promise((r) => setTimeout(r, BLE_MTU_POLL_MS)); @@ -591,7 +660,7 @@ export class NobleBleManager extends EventEmitter { // so the picker stays empty on second and subsequent scan attempts. // Preserve peripherals already connected in noble — they won't re-advertise during a // scan, so keep them available for connect() and re-emit for auto-connect / picker. - const stillConnected: [string, any][] = []; + const stillConnected: [string, NoblePeripheral][] = []; for (const [id, peripheral] of this.knownPeripherals.entries()) { if (peripheral.state === 'connected') stillConnected.push([id, peripheral]); } @@ -658,7 +727,7 @@ export class NobleBleManager extends EventEmitter { sessionId: NobleSessionId, peripheralId: string, timeoutMs: number, - ): Promise { + ): Promise { const cached = this.knownPeripherals.get(peripheralId); if (cached) return Promise.resolve(cached); @@ -742,7 +811,8 @@ export class NobleBleManager extends EventEmitter { * avoiding a race where a late native callback could set scanningActive after we time out. */ private runDoStartScanningWithTimeout(): Promise { - if (!noble) return Promise.resolve(); + const nobleApi = noble; + if (!nobleApi) return Promise.resolve(); const filter = this.computeScanFilter(); let abandoned = false; @@ -760,7 +830,7 @@ export class NobleBleManager extends EventEmitter { clearTimer(); if (!this.scanningActive) { try { - noble!.stopScanning(); + nobleApi.stopScanning(); } catch (stopErr) { console.debug('[NobleBleManager] stopScanning after start timeout (ignored):', stopErr); // log-injection-ok noble internal error } @@ -768,11 +838,11 @@ export class NobleBleManager extends EventEmitter { reject(new Error(`noble.startScanning timed out after ${BLE_START_SCAN_TIMEOUT_MS}ms`)); }, BLE_START_SCAN_TIMEOUT_MS); - noble!.startScanning(filter, false, (err: Error | null) => { + nobleApi.startScanning(filter, false, (err: Error | null) => { if (abandoned) { if (!err) { try { - noble!.stopScanning(); + nobleApi.stopScanning(); } catch (stopErr) { console.debug( '[NobleBleManager] stopScanning after abandoned start callback (ignored):', @@ -827,7 +897,7 @@ export class NobleBleManager extends EventEmitter { } private doStopScanning(): Promise { - if (!this.scanningActive) return Promise.resolve(); + if (!noble || !this.scanningActive) return Promise.resolve(); // Mark stopped immediately — noble's stopScanning callback is unreliable on some platforms // (may never fire on Windows; can hang on macOS if CBCentralManager state is inconsistent). // CoreBluetooth receives the stop command regardless; we don't need to await confirmation. @@ -854,7 +924,7 @@ export class NobleBleManager extends EventEmitter { } // Clear scan requesters to prevent any deferred scan restart during teardown. this.scanRequesters.clear(); - if (process.platform === 'linux') { + if (!noble) { return; } // Only call noble.stopScanning() if scanning is actually active. @@ -893,6 +963,9 @@ export class NobleBleManager extends EventEmitter { } async connect(sessionId: NobleSessionId, peripheralId: string): Promise { + // Do not reject solely because `noble` is null: Linux production skips the native + // binding, and Linux CI behavior tests seed knownPeripherals + sessions without it. + // Scan paths still require noble (checked where startScanning/scanStop is used). // Serialize across all sessions — noble's native CBCentralManager crashes (SIGSEGV/SIGBUS) // if a second peripheral's discoverServices/subscribe races with the first. const prevQueue = this.connectQueue; @@ -903,7 +976,7 @@ export class NobleBleManager extends EventEmitter { await withTimeout(prevQueue, BLE_CONNECT_QUEUE_WAIT_MS, 'BLE connect queue wait'); const session = this.getSession(sessionId); - let peripheral: any = null; + let peripheral: NoblePeripheral | null = null; let connected = false; const peripheralOwner: BlePeripheralOwner = sessionId === 'meshcore' ? 'noble:meshcore' : 'noble:meshtastic'; @@ -968,7 +1041,7 @@ export class NobleBleManager extends EventEmitter { // Re-open a fresh session (disconnect sets closing=true; reset it for the new connection). session.closing = false; - peripheral = this.knownPeripherals.get(peripheralId); + peripheral = this.knownPeripherals.get(peripheralId) ?? null; if (!peripheral) { console.debug( `[BLE:${sessionId}] peripheral ${peripheralId} not in cache — scanning up to ${NOBLE_PERIPHERAL_SCAN_WAIT_MS}ms`, @@ -984,7 +1057,7 @@ export class NobleBleManager extends EventEmitter { ); bleCoexistenceCoordinator.assertCanConnect( peripheralOwner, - String(peripheral.address ?? peripheralId), + peripheral.address ?? peripheralId, ); session.connectStartedAtMs = Date.now(); session.firstPacketLogged = false; @@ -1036,7 +1109,8 @@ export class NobleBleManager extends EventEmitter { } } this.knownPeripherals.delete(peripheralId); - if (peripheral.state !== 'disconnected') { + const stateAfterDisconnect: string = peripheral.state; + if (stateAfterDisconnect !== 'disconnected') { peripheral = await this.waitForPeripheralDuringScan( sessionId, peripheralId, @@ -1083,13 +1157,17 @@ export class NobleBleManager extends EventEmitter { // Stop scanning before connecting — many Linux/BlueZ drivers abort connections while scanning. if (this.scanningActive) { + const nobleApi = noble; + if (!nobleApi) { + throw new Error('Noble BLE is unavailable on this platform'); + } console.debug(`[BLE:${sessionId}] stopping scan before connect`); await new Promise((resolve) => { const onScanStop = () => { - noble.removeListener('scanStop', onScanStop); + nobleApi.removeListener('scanStop', onScanStop); resolve(); }; - noble.on('scanStop', onScanStop); + nobleApi.on('scanStop', onScanStop); void this.doStopScanning(); }); } @@ -1145,16 +1223,16 @@ export class NobleBleManager extends EventEmitter { const tDiscover = Date.now(); const meshcoreWinFullDiscovery = isMeshcore && IS_WIN32; - let characteristics: any[]; + let characteristics: NobleCharacteristic[]; if (meshcoreWinFullDiscovery) { - const all = await withTimeout<{ characteristics: any[] }>( + const all = await withTimeout( peripheral.discoverAllServicesAndCharacteristicsAsync(), BLE_DISCOVERY_TIMEOUT_MS, 'BLE full GATT discovery (meshcore Win32)', ); characteristics = all.characteristics; } else { - const discovered = await withTimeout<{ characteristics: any[] }>( + const discovered = await withTimeout( peripheral.discoverSomeServicesAndCharacteristicsAsync( discoverServiceUuids, discoverCharUuids, @@ -1165,8 +1243,8 @@ export class NobleBleManager extends EventEmitter { characteristics = discovered.characteristics; } if (isMeshcore) { - const rxCandidates: any[] = []; - const txCandidates: any[] = []; + const rxCandidates: NobleCharacteristic[] = []; + const txCandidates: NobleCharacteristic[] = []; for (const char of characteristics) { const uuid = normalizeUuid(char.uuid); if (uuid === MESHCORE_RX_UUID) rxCandidates.push(char); @@ -1205,7 +1283,7 @@ export class NobleBleManager extends EventEmitter { if (isMeshcore) { console.debug( `[BLE:${sessionId}] ALL discovered characteristics for meshcore: ${characteristics - .map((c: any) => `${normalizeUuid(c.uuid)}[${(c.properties ?? []).join(',')}]`) + .map((c) => `${normalizeUuid(c.uuid)}[${(c.properties ?? []).join(',')}]`) .join(', ')}`, ); } @@ -1213,7 +1291,7 @@ export class NobleBleManager extends EventEmitter { // FROMNUM is optional for notification-based flow; require only TX/RX characteristics. if (!session.toRadioChar || !session.fromRadioChar) { console.warn( - `[BLE:${sessionId}] missing required chars — toRadio=${Boolean(session.toRadioChar)} fromRadio=${Boolean(session.fromRadioChar)} discoveredUuids=${characteristics.map((c: any) => c.uuid).join(',')}`, // log-injection-ok noble internal characteristic UUIDs + `[BLE:${sessionId}] missing required chars — toRadio=${Boolean(session.toRadioChar)} fromRadio=${Boolean(session.fromRadioChar)} discoveredUuids=${characteristics.map((c) => c.uuid).join(',')}`, // log-injection-ok noble internal characteristic UUIDs ); throw new Error('Failed to find required BLE characteristics'); } @@ -1323,9 +1401,9 @@ export class NobleBleManager extends EventEmitter { session.meshcoreGattInflight = null; } logDeviceConnection( - `transport=ble stack=${sessionId} peripheralId=${sanitizeLogMessage(peripheralId)} mac=${sanitizeLogMessage(String(peripheral.address ?? 'unknown'))}`, + `transport=ble stack=${sessionId} peripheralId=${sanitizeLogMessage(peripheralId)} mac=${sanitizeLogMessage(peripheral.address ?? 'unknown')}`, ); - const registeredMac = String(peripheral.address ?? peripheralId); + const registeredMac = peripheral.address ?? peripheralId; bleCoexistenceCoordinator.register(registeredMac, peripheralOwner); session.registeredMac = registeredMac; session.lastConnectedPeripheralId = peripheralId; @@ -1370,7 +1448,7 @@ export class NobleBleManager extends EventEmitter { } } this.clearSessionState(session); - if (connected) { + if (connected && peripheral) { await peripheral.disconnectAsync().catch((e: unknown) => { console.debug( '[noble-ble] connect error cleanup disconnect ' + @@ -1413,7 +1491,7 @@ export class NobleBleManager extends EventEmitter { const peripheral = session.connectedPeripheral; const rawMtu = peripheral != null && typeof peripheral.mtu === 'number' && Number.isFinite(peripheral.mtu) - ? (peripheral.mtu as number) + ? peripheral.mtu : session.attMtuSanitized; const limit = maxWriteRequestPayloadBytes(rawMtu); for (let offset = 0; offset < data.length; offset += limit) { diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 64a750eb3..4078aef5b 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -241,6 +241,8 @@ describe('ReticulumSidecarManager', () => { expect(first.running).toBe(true); expect(first.port).toBeGreaterThan(0); expect(first.pid).toBe(4242); + const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv | undefined; + expect(spawnEnv?.RUST_LOG).toBe('warn'); await manager.stop(); @@ -248,6 +250,38 @@ describe('ReticulumSidecarManager', () => { mkdirSpy.mockRestore(); }); + it('filters mixed stdout chunks line by line and flushes trailing text', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + const proc = mockSidecarProc(); + proc.kill.mockImplementation(() => { + proc.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc); + + const manager = new ReticulumSidecarManager(); + await manager.start(); + const stdout = (proc as unknown as { stdout: EventEmitter }).stdout; + stdout.emit( + 'data', + Buffer.from('INFO packet route mentions ERROR\nWARN actual warning\nERROR'), + ); + stdout.emit('end'); + + expect(debugSpy).toHaveBeenCalledWith('[ReticulumSidecar]', 'WARN actual warning'); + expect(debugSpy).toHaveBeenCalledWith('[ReticulumSidecar]', 'ERROR'); + expect(debugSpy).not.toHaveBeenCalledWith( + '[ReticulumSidecar]', + 'INFO packet route mentions ERROR', + ); + + await manager.stop(); + debugSpy.mockRestore(); + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + function getIssueTracker(manager: ReticulumSidecarManager): { recordLine: (line: string, nowMs?: number) => void; } { diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 9277e3e5e..121d09646 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -31,7 +31,9 @@ import { ReticulumSidecarAutoBeaconTracker } from './reticulumSidecarAutoBeaconT import { ReticulumSidecarInterfaceIssueTracker } from './reticulumSidecarIssueTracker'; import { logReticulumSidecarStderrLine, + resolveSidecarRustLog, ReticulumSidecarStderrDedupe, + shouldForwardReticulumSidecarStdout, } from './reticulumSidecarStderrLog'; import { startSidecarWatchdog } from './reticulumSidecarWatchdog'; @@ -50,6 +52,7 @@ export function sidecarChildEnv(): NodeJS.ProcessEnv { TMPDIR: process.env.TMPDIR, // NOSONAR passthrough of existing env var only; no temp file write here LANG: process.env.LANG, LC_ALL: process.env.LC_ALL, + RUST_LOG: resolveSidecarRustLog(), }; if (process.platform === 'win32') { env.APPDATA = process.env.APPDATA; @@ -334,10 +337,23 @@ export class ReticulumSidecarManager extends EventEmitter { }); this.proc = proc; - proc.stdout?.on('data', (chunk: Buffer) => { - const text = sanitizeLogMessage(chunk.toString('utf8').trim()); + let stdoutBuffer = ''; + const processStdoutLine = (line: string): void => { + const text = sanitizeLogMessage(line.trim()); + if (!text) return; this.recordSidecarOutputLine(text); + if (!shouldForwardReticulumSidecarStdout(text)) return; console.debug('[ReticulumSidecar]', text); + }; + proc.stdout?.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8'); + const lines = stdoutBuffer.split(/\r?\n/); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) processStdoutLine(line); + }); + proc.stdout?.on('end', () => { + if (stdoutBuffer) processStdoutLine(stdoutBuffer); + stdoutBuffer = ''; }); proc.stderr?.on('data', (chunk: Buffer) => { const text = sanitizeLogMessage(chunk.toString('utf8').trim()); diff --git a/src/main/reticulumSidecarStderrLog.test.ts b/src/main/reticulumSidecarStderrLog.test.ts index a31b0dc87..0b273bea0 100644 --- a/src/main/reticulumSidecarStderrLog.test.ts +++ b/src/main/reticulumSidecarStderrLog.test.ts @@ -2,9 +2,54 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { logReticulumSidecarStderrLine, + resolveSidecarRustLog, ReticulumSidecarStderrDedupe, + shouldForwardReticulumSidecarStdout, + SIDECAR_DEFAULT_RUST_LOG, } from './reticulumSidecarStderrLog'; +describe('shouldForwardReticulumSidecarStdout', () => { + it('forwards WARN and ERROR tracing lines', () => { + expect( + shouldForwardReticulumSidecarStdout( + '2026-07-30T11:23:18Z \u001b[33m WARN \u001b[0m auto: failed to select multicast', + ), + ).toBe(true); + expect(shouldForwardReticulumSidecarStdout('ERROR panic in link_manager')).toBe(true); + }); + + it('drops INFO and DEBUG packet-routing spam', () => { + expect( + shouldForwardReticulumSidecarStdout( + '\u001b[32m INFO \u001b[0m rns_transport::actor::inbound : data packet routing', + ), + ).toBe(false); + expect(shouldForwardReticulumSidecarStdout('DEBUG resource part received')).toBe(false); + expect( + shouldForwardReticulumSidecarStdout('INFO parser received WARN and ERROR payload tokens'), + ).toBe(false); + }); +}); + +describe('resolveSidecarRustLog', () => { + it('defaults to warn', () => { + expect(resolveSidecarRustLog({})).toBe(SIDECAR_DEFAULT_RUST_LOG); + }); + + it('honors MESH_CLIENT_RUST_LOG over RUST_LOG', () => { + expect( + resolveSidecarRustLog({ + MESH_CLIENT_RUST_LOG: 'info', + RUST_LOG: 'debug', + }), + ).toBe('info'); + }); + + it('honors RUST_LOG when mesh override unset', () => { + expect(resolveSidecarRustLog({ RUST_LOG: 'reticulum=debug' })).toBe('reticulum=debug'); + }); +}); + describe('ReticulumSidecarStderrDedupe', () => { let dedupe: ReticulumSidecarStderrDedupe; diff --git a/src/main/reticulumSidecarStderrLog.ts b/src/main/reticulumSidecarStderrLog.ts index d9349a348..5b32f725b 100644 --- a/src/main/reticulumSidecarStderrLog.ts +++ b/src/main/reticulumSidecarStderrLog.ts @@ -6,6 +6,46 @@ const AUTO_BEACON_TX_FAILED_MARKER = 'auto: beacon TX failed'; const BEACON_FAIL_WARN_INTERVAL_MS = 60 * MS_PER_SECOND; +/** Default tracing filter for sidecar child processes (overridable via env). */ +export const SIDECAR_DEFAULT_RUST_LOG = 'warn'; + +/** + * Whether a sidecar stdout line should be written to the app log. + * Tracing INFO/DEBUG packet routing floods the rotating log; keep WARN/ERROR only. + */ +export function shouldForwardReticulumSidecarStdout(text: string): boolean { + const fields = text.trimStart().split(/\s+/); + let index = fields[0] && Number.isFinite(Date.parse(fields[0])) ? 1 : 0; + let severity = fields[index] ?? ''; + while (severity.startsWith('\u001b[')) { + const end = severity.indexOf('m', 2); + if (end < 0) return false; + severity = severity.slice(end + 1); + if (!severity) { + index += 1; + severity = fields[index] ?? ''; + } + } + return ( + severity === 'WARN' || + severity.startsWith('WARN\u001b[') || + severity === 'ERROR' || + severity.startsWith('ERROR\u001b[') + ); +} + +/** + * Resolve RUST_LOG for sidecar spawn. Honors MESH_CLIENT_RUST_LOG, then RUST_LOG, + * else defaults to warn so INFO packet spam does not fill mesh-client.log. + */ +export function resolveSidecarRustLog(env: NodeJS.ProcessEnv = process.env): string { + const fromMesh = env.MESH_CLIENT_RUST_LOG?.trim(); + if (fromMesh) return fromMesh; + const fromRust = env.RUST_LOG?.trim(); + if (fromRust) return fromRust; + return SIDECAR_DEFAULT_RUST_LOG; +} + export type ReticulumSidecarStderrSink = (message: string) => void; export interface ReticulumSidecarStderrLogDecision { diff --git a/src/main/updater.ts b/src/main/updater.ts index 2d7e496ae..953784176 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -118,7 +118,7 @@ function registerElectronUpdaterHandlers(send: SendFn): boolean { let updater: AppUpdater; try { // eslint-disable-next-line @typescript-eslint/no-require-imports - updater = require('electron-updater').autoUpdater as AppUpdater; + updater = (require('electron-updater') as { autoUpdater: AppUpdater }).autoUpdater; } catch (e) { console.error( '[updater] electron-updater not available:', diff --git a/src/main/verify-backup-repairs.test.ts b/src/main/verify-backup-repairs.test.ts index d1dc46195..54b50c0f2 100644 --- a/src/main/verify-backup-repairs.test.ts +++ b/src/main/verify-backup-repairs.test.ts @@ -41,12 +41,9 @@ describe('user backup repairs (local dumps)', () => { expect(mcNodes).toBe(0); const nullStatus = ( - db - .prepare( - `SELECT COUNT(*) as c FROM messages - WHERE status IS NULL AND received_via IS NOT NULL`, - ) - .get() as { c: number } + db.prepare(`SELECT COUNT(*) as c FROM messages WHERE status IS NULL`).get() as { + c: number; + } ).c; expect(nullStatus).toBe(0); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 270978fa3..fd5056576 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -143,6 +143,10 @@ import { TakServerPanel, TelemetryPanel, } from './lazyTabPanels'; +import { + resolvePanelPositionSendHandler, + resolvePanelRebootHandler, +} from './lib/appPanelHandlerSelection'; import { protocolRecord, selectByProtocol } from './lib/appProtocolSelect'; import { getAppSettingsRaw } from './lib/appSettingsStorage'; import { @@ -3398,11 +3402,11 @@ function AppContent() { isConnected={isOperational} deviceFixedPosition={effectiveDeviceFixedPosition} ourPosition={activeRuntime.ourPosition} - onSendPositionToDevice={ - capabilities.hasFullPositionConfig - ? meshtasticPanelActions.sendPositionToDevice - : undefined - } + onSendPositionToDevice={resolvePanelPositionSendHandler( + capabilities, + meshtasticPanelActions.sendPositionToDevice, + meshcorePanelActions.sendPositionToDevice, + )} deviceOwner={effectiveDeviceOwner} onSetOwner={ capabilities.hasChannelConfig @@ -3680,11 +3684,12 @@ function AppContent() { configTarget={configTarget} capabilities={capabilities} isConnected={isOperational} - onReboot={ - capabilities.hasShutdown - ? meshtasticPanelActions.reboot - : async () => {} - } + onReboot={resolvePanelRebootHandler( + capabilities, + meshtasticPanelActions.reboot, + meshcorePanelActions.reboot, + async () => {}, + )} onShutdown={ capabilities.hasShutdown ? meshtasticPanelActions.shutdown diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index ba5efa987..411a8964e 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -40,6 +40,20 @@ import { HelpTooltip } from './HelpTooltip'; import MentionAutocomplete, { buildMentionCandidates } from './MentionAutocomplete'; import { useToast } from './Toast'; +function emojiUnicodeFromEvent(event: Event): string | null { + if ( + !(event instanceof CustomEvent) || + typeof event.detail !== 'object' || + event.detail === null + ) { + return null; + } + const detail = event.detail as Record; + if (typeof detail.emoji !== 'object' || detail.emoji === null) return null; + const emoji = detail.emoji as Record; + return typeof emoji.unicode === 'string' ? emoji.unicode : null; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { @@ -702,7 +716,8 @@ export function ChatComposer({ const el = emojiPickerRef.current; if (!el) return; const handler = (e: Event) => { - const unicode: string = (e as CustomEvent).detail.emoji.unicode; + const unicode = emojiUnicodeFromEvent(e); + if (!unicode) return; const textarea = inputRef.current; const currentValue = textarea?.value ?? ''; const start = textarea?.selectionStart ?? currentValue.length; diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 8790c98f0..a11fa8242 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -191,6 +191,20 @@ function ChatToolbarTooltipButton({ ); } +function emojiUnicodeFromEvent(event: Event): string | null { + if ( + !(event instanceof CustomEvent) || + typeof event.detail !== 'object' || + event.detail === null + ) { + return null; + } + const detail = event.detail as Record; + if (typeof detail.emoji !== 'object' || detail.emoji === null) return null; + const emoji = detail.emoji as Record; + return typeof emoji.unicode === 'string' ? emoji.unicode : null; +} + declare module 'react' { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { @@ -1656,7 +1670,8 @@ function ChatPanel({ if (!reactionCapturePendingRef.current) return; const target = reactionPickerTarget.current; if (!target) return; - const unicode = (e as CustomEvent).detail.emoji.unicode as string; + const unicode = emojiUnicodeFromEvent(e); + if (!unicode) return; const parsed = reactionGlyphFromPicker(unicode); if (parsed) { void handleReactRef.current?.(parsed.glyph, target.id, target.channel); @@ -2227,6 +2242,17 @@ function ChatPanel({ onProbeSettled={reticulumDmPathProbe.applyProbeResult} /> ) : null; + const rncpShareCandidates = + protocol === 'reticulum' && isDmMode + ? viewMessages + .filter((m) => !isOwnNode(m.sender_id)) + .map((m) => ({ + payload: m.payload, + senderHash: m.reticulum_sender_hash ?? null, + senderName: m.sender_name ?? null, + timestamp: m.timestamp, + })) + : []; const rncpControl = protocol === 'reticulum' && hasRncpTransfer && reticulumDmDestinationHash != null ? ( ) : null; if (!pathBadge && !dmNode && !rncpControl) return null; diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index 7d4193142..312023c80 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -5,7 +5,11 @@ import { axe } from 'vitest-axe'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { setMeshtasticConnectedMyNodeNum } from '../lib/meshtasticConnectedNodeRef'; -import { MESHTASTIC_CAPABILITIES, RETICULUM_CAPABILITIES } from '../lib/radio/BaseRadioProvider'; +import { + MESHCORE_CAPABILITIES, + MESHTASTIC_CAPABILITIES, + RETICULUM_CAPABILITIES, +} from '../lib/radio/BaseRadioProvider'; import type { DiagnosticRow, MeshNode, RoutingDiagnosticRow } from '../lib/types'; import type { ForeignLoraDetection } from '../stores/diagnosticsStore'; import DiagnosticsPanel from './DiagnosticsPanel'; @@ -418,7 +422,7 @@ describe('DiagnosticsPanel cross-protocol RF', () => { expect( screen.getByRole('heading', { - name: /other foreign lora on your meshtastic frequency \(2\)/i, + name: /other foreign lora overheard \(2\)/i, }), ).toBeInTheDocument(); expect(screen.getByText('Meshtastic Traffic')).toBeInTheDocument(); @@ -446,11 +450,11 @@ describe('DiagnosticsPanel cross-protocol RF', () => { screen.queryByRole('heading', { name: /meshcore nodes heard by your meshtastic radio/i }), ).not.toBeInTheDocument(); expect( - screen.queryByRole('heading', { name: /other foreign lora on your meshtastic frequency/i }), + screen.queryByRole('heading', { name: /other foreign lora overheard/i }), ).not.toBeInTheDocument(); }); - it('hides MeshCore heard-by-Meshtastic section on MeshCore diagnostics protocol', () => { + it('does not show Meshtastic-keyed MeshCore-heard rows on the MeshCore tab', () => { const myId = 0xface; const foreignId = 0xabc12345; diagnosticsStoreState.foreignLoraDetections = new Map([ @@ -490,6 +494,95 @@ describe('DiagnosticsPanel cross-protocol RF', () => { screen.queryByRole('heading', { name: /meshcore nodes heard by your meshtastic radio/i }), ).not.toBeInTheDocument(); }); + + it('shows other foreign LoRa on the MeshCore tab keyed by MeshCore self id', () => { + const myMcId = 0xbeef; + diagnosticsStoreState.foreignLoraDetections = new Map([ + [ + myMcId, + new Map([ + [ + 'meshtastic:0x111', + { + detectedAt: Date.now(), + packetClass: 'meshtastic', + proximity: 'nearby', + count: 3, + lastSenderId: 0x111, + source: 'meshcore-radio-rf', + }, + ], + ]), + ], + ]); + + render( + , + ); + + expect( + screen.getByRole('heading', { name: /other foreign lora overheard \(1\)/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Meshtastic Traffic')).toBeInTheDocument(); + }); + + it('matches MeshCore repeater conflicts to the active foreign-LoRa listener', () => { + const myMcId = 0xbeef; + diagnosticsStoreState.foreignLoraDetections = new Map([ + [ + myMcId, + new Map([ + [ + 'meshcore:nearby', + { + detectedAt: Date.now(), + packetClass: 'meshcore', + proximity: 'nearby', + count: 1, + lastSenderId: 0x111, + source: 'meshcore-radio-rf', + }, + ], + ]), + ], + ]); + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'rf', + id: 'rf:meshcore-conflict', + nodeId: myMcId, + condition: 'Potential MeshCore Repeater Conflict', + cause: 'Nearby repeater conflict', + severity: 'warning', + detectedAt: Date.now(), + }, + ]; + + render( + , + ); + + expect(screen.getByText(/nearby repeater may be causing collisions/i)).toBeInTheDocument(); + }); }); describe('DiagnosticsPanel reticulum scope', () => { diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 6d07bed63..3d6e5e168 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -146,7 +146,7 @@ interface Props { capabilities?: ProtocolCapabilities; /** Active radio protocol — auto-traceroute preference is stored per protocol. */ protocol: MeshProtocol; - /** Meshtastic node id used to look up foreign-LoRa detections (stable across panel remounts). */ + /** Meshtastic node id used to look up foreign-LoRa detections when on the Meshtastic tab. */ meshtasticListenerNodeId?: number; /** MeshCore contacts only — used for heard-by-Meshtastic links (not merged Meshtastic nodes). */ meshcoreNodes?: Map; @@ -193,6 +193,7 @@ export default function DiagnosticsPanel({ ); const showMqttControls = capabilities?.hasMqttHybrid !== false; const showLoRaMeshDiagnostics = capabilities?.hasHopCount !== false; + const showForeignLoraDiagnostics = capabilities?.hasDiagnosticsPanel !== false; const diagnosticRows = useDiagnosticsStore((s) => s.diagnosticRows); const diagnosticRowsRestoredAt = useDiagnosticsStore((s) => s.diagnosticRowsRestoredAt); const clearDiagnosticRowsSnapshot = useDiagnosticsStore((s) => s.clearDiagnosticRowsSnapshot); @@ -230,9 +231,16 @@ export default function DiagnosticsPanel({ const distanceOffsetKm = useDiagnosticsStore((s) => s.distanceOffsetKm); const setDistanceOffsetKm = useDiagnosticsStore((s) => s.setDistanceOffsetKm); const foreignLoraDetections = useDiagnosticsStore((s) => s.foreignLoraDetections); + /** Map key for foreign-LoRa detections: Meshtastic self id on MT tab, MeshCore self id on MC tab. */ + const foreignLoraListenerNodeId = + protocol === 'meshcore' && myNodeNum > 0 + ? myNodeNum + : protocol === 'meshtastic' && meshtasticListenerNodeId > 0 + ? meshtasticListenerNodeId + : 0; const foreignLoraBySender = useMemo( - () => foreignLoraDetections.get(meshtasticListenerNodeId), - [foreignLoraDetections, meshtasticListenerNodeId], + () => foreignLoraDetections.get(foreignLoraListenerNodeId), + [foreignLoraDetections, foreignLoraListenerNodeId], ); const meshcoreHeardList = useMemo( () => @@ -250,12 +258,12 @@ export default function DiagnosticsPanel({ s.diagnosticRows.some( (r) => r.kind === 'rf' && - r.nodeId === meshtasticListenerNodeId && + r.nodeId === foreignLoraListenerNodeId && r.condition === 'Potential MeshCore Repeater Conflict', ), ); - const showMeshtasticForeignLora = - protocol === 'meshtastic' && meshtasticListenerNodeId > 0 && isConnected; + const showForeignLoraTables = + showForeignLoraDiagnostics && foreignLoraListenerNodeId > 0 && isConnected; const [search, setSearch] = useState(''); const [tracePendingNodes, setTracePendingNodes] = useState>(() => new Set()); @@ -477,9 +485,7 @@ export default function DiagnosticsPanel({ const selfRows = anomalyList.filter((r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r)); const foreignLoraListenerId = - protocol === 'meshtastic' && meshtasticListenerNodeId > 0 - ? meshtasticListenerNodeId - : myNodeNum; + foreignLoraListenerNodeId > 0 ? foreignLoraListenerNodeId : myNodeNum; const otherCrossProtocolRows = anomalyList.filter( (r) => r.nodeId === foreignLoraListenerId && isForeignLoraRfRow(r) && !isMeshCoreInterferenceRow(r), @@ -993,7 +999,7 @@ export default function DiagnosticsPanel({ )} {/* MeshCore nodes heard by Meshtastic radio (per transmitter) */} - {showMeshtasticForeignLora && meshcoreHeardList.length > 0 && ( + {showForeignLoraTables && meshcoreHeardList.length > 0 && (

@@ -1075,7 +1081,7 @@ export default function DiagnosticsPanel({ )} {/* Meshtastic + unknown-lora foreign traffic on Meshtastic frequency */} - {showMeshtasticForeignLora && otherForeignList.length > 0 && ( + {showForeignLoraTables && otherForeignList.length > 0 && (

diff --git a/src/renderer/components/NomadMicronPageView.test.tsx b/src/renderer/components/NomadMicronPageView.test.tsx index b472ce840..e7b96801d 100644 --- a/src/renderer/components/NomadMicronPageView.test.tsx +++ b/src/renderer/components/NomadMicronPageView.test.tsx @@ -136,6 +136,25 @@ describe('NomadMicronPageView', () => { ); }); + it('keeps box padding spaces in the mounted DOM for fit-width and open-width', () => { + const markup = [ + ' │ This is the NomadNet page of the RMAP Project, a web interface │', + ' │ `F8f0•`f Visualize LoRa RNode Connection Info, │ │', + ].join('\n'); + + const { rerender } = render(); + const fitRoot = document.querySelector('.nomad-micron-page'); + expect(fitRoot).toHaveClass('nomad-micron-page--fit-width'); + expect(fitRoot?.textContent).toMatch(/web interface {2,}│/); + expect(fitRoot?.textContent).toMatch(/Connection Info, {2,}│ │/); + + rerender(); + const openRoot = document.querySelector('.nomad-micron-page'); + expect(openRoot).not.toHaveClass('nomad-micron-page--fit-width'); + expect(openRoot?.textContent).toMatch(/web interface {2,}│/); + expect(openRoot?.textContent).toMatch(/Connection Info, {2,}│ │/); + }); + it('fetches and mounts Micron partial content via onFetchPartial', async () => { const onFetchPartial = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/renderer/components/NomadNetworkPanel.test.tsx b/src/renderer/components/NomadNetworkPanel.test.tsx index 799605256..eb7210d3b 100644 --- a/src/renderer/components/NomadNetworkPanel.test.tsx +++ b/src/renderer/components/NomadNetworkPanel.test.tsx @@ -718,7 +718,7 @@ describe('NomadNetworkPanel', () => { ]), }); - render(); + render(); await openAnnouncesNode(user); await waitFor(() => { @@ -729,6 +729,35 @@ describe('NomadNetworkPanel', () => { const toggle = screen.getByLabelText('nomadNetwork.openWidth'); expect(toggle).toHaveAttribute('aria-pressed', 'true'); + expect(toggle).toHaveAttribute('title', 'nomadNetwork.openWidth'); + expect(screen.getByRole('button', { name: 'nomadNetwork.back' })).toHaveAttribute( + 'title', + 'nomadNetwork.back', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.reloadPage' })).toHaveAttribute( + 'title', + 'nomadNetwork.reloadPage', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.sendMessageAria' })).toHaveAttribute( + 'title', + 'nomadNetwork.sendMessageAria', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.forward' })).toHaveAttribute( + 'title', + 'nomadNetwork.forward', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.homePage' })).toHaveAttribute( + 'title', + 'nomadNetwork.homePage', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.showSource' })).toHaveAttribute( + 'title', + 'nomadNetwork.showSource', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.closeViewer' })).toHaveAttribute( + 'title', + 'nomadNetwork.closeViewer', + ); await user.click(toggle); expect(localStorage.getItem('mesh-client:nomadPageFitWidth')).toBe('false'); diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index ee8a0ebcd..4cffaa89a 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -968,6 +968,10 @@ export default function NomadNetworkPanel({ name: selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16), })} + title={t('nomadNetwork.sendMessageAria', { + name: + selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16), + })} onClick={() => { onOpenDm(selectedNode.destination_hash); }} @@ -980,6 +984,7 @@ export default function NomadNetworkPanel({ disabled={!canGoBack} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800 disabled:opacity-40" aria-label={t('nomadNetwork.back')} + title={t('nomadNetwork.back')} onClick={() => { navigateHistory(-1); }} @@ -991,6 +996,7 @@ export default function NomadNetworkPanel({ disabled={!canGoForward} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800 disabled:opacity-40" aria-label={t('nomadNetwork.forward')} + title={t('nomadNetwork.forward')} onClick={() => { navigateHistory(1); }} @@ -1001,6 +1007,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.homePage')} + title={t('nomadNetwork.homePage')} onClick={() => { void loadNodePage( selectedNode.destination_hash, @@ -1021,6 +1028,9 @@ export default function NomadNetworkPanel({ aria-label={ showPageSource ? t('nomadNetwork.hideSource') : t('nomadNetwork.showSource') } + title={ + showPageSource ? t('nomadNetwork.hideSource') : t('nomadNetwork.showSource') + } aria-pressed={showPageSource} onClick={() => { setShowPageSource((prev) => !prev); @@ -1040,6 +1050,9 @@ export default function NomadNetworkPanel({ aria-label={ pageFitWidth ? t('nomadNetwork.openWidth') : t('nomadNetwork.fitWidth') } + title={ + pageFitWidth ? t('nomadNetwork.openWidth') : t('nomadNetwork.fitWidth') + } aria-pressed={pageFitWidth} onClick={() => { setPageFitWidth((prev) => { @@ -1056,6 +1069,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.reloadPage')} + title={t('nomadNetwork.reloadPage')} onClick={() => { void loadNodePage(selectedNode.destination_hash, pagePath, { forceReload: true, @@ -1070,6 +1084,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.closeViewer')} + title={t('nomadNetwork.closeViewer')} onClick={closeViewer} > ✕ diff --git a/src/renderer/components/RadioPanel.test.tsx b/src/renderer/components/RadioPanel.test.tsx index 7f461a2f9..0b8d65164 100644 --- a/src/renderer/components/RadioPanel.test.tsx +++ b/src/renderer/components/RadioPanel.test.tsx @@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import type { MeshCoreSelfInfo } from '@/renderer/lib/meshcore/meshcoreHookTypes'; +import { MESHCORE_CAPABILITIES } from '@/renderer/lib/radio/BaseRadioProvider'; import { generateConfigUrl, MESHTASTIC_CHANNEL_ROLE } from '@/shared/meshtasticUrlEncoder'; import RadioPanel, { ConfigNumber } from './RadioPanel'; @@ -42,6 +44,24 @@ const defaultProps = { onResetNodeDb: vi.fn().mockResolvedValue(undefined), }; +function meshcoreSelfInfo(advLat: number, advLon: number): MeshCoreSelfInfo { + return { + name: 'Self', + publicKey: new Uint8Array(32), + type: 1, + txPower: 20, + advLat, + advLon, + manualAddContacts: false, + radioFreq: 915_000_000, + multiAcks: 0, + advertLocPolicy: 0, + telemetryModeBase: 0, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + }; +} + describe('RadioPanel accessibility', () => { it('has no axe violations with empty channel configs', async () => { const { container } = render( @@ -180,6 +200,55 @@ describe('RadioPanel remote target safeguards', () => { }); }); +describe('RadioPanel MeshCore advert position synchronization', () => { + it('hydrates, preserves dirty edits, and resumes syncing after a successful send', async () => { + const user = userEvent.setup(); + const onSendPositionToDevice = vi.fn().mockResolvedValue(undefined); + const renderPanel = (selfInfo: MeshCoreSelfInfo) => ( + + + + ); + const { rerender } = render(renderPanel(meshcoreSelfInfo(39_000_000, -105_000_000))); + const positionDetails = [...document.querySelectorAll('details')].find((details) => + details.textContent?.includes('Position / GPS'), + ); + expect(positionDetails).toBeDefined(); + await user.click(positionDetails!.querySelector('summary')!); + + const latitude = screen.getByLabelText('Latitude'); + const longitude = screen.getByLabelText('Longitude'); + await waitFor(() => { + expect(latitude).toHaveValue('39'); + expect(longitude).toHaveValue('-105'); + }); + + await user.clear(latitude); + await user.type(latitude, '40'); + await user.clear(longitude); + await user.type(longitude, '-104'); + rerender(renderPanel(meshcoreSelfInfo(41_000_000, -103_000_000))); + expect(latitude).toHaveValue('40'); + expect(longitude).toHaveValue('-104'); + + await user.click(screen.getByRole('button', { name: 'Send Position to Device' })); + await waitFor(() => { + expect(onSendPositionToDevice).toHaveBeenCalledWith(40, -104, 0); + }); + rerender(renderPanel(meshcoreSelfInfo(42_000_000, -102_000_000))); + await waitFor(() => { + expect(latitude).toHaveValue('42'); + expect(longitude).toHaveValue('-102'); + }); + }); +}); + describe('RadioPanel Bluetooth fixed PIN display', () => { it('shows leading zeros when syncing fixedPin from device config', async () => { const user = userEvent.setup(); diff --git a/src/renderer/components/RadioPanel.tsx b/src/renderer/components/RadioPanel.tsx index cd64203e5..11e4e8787 100644 --- a/src/renderer/components/RadioPanel.tsx +++ b/src/renderer/components/RadioPanel.tsx @@ -45,6 +45,7 @@ import { meshcoreOffloadAbortRemovedCount, } from '../lib/meshcoreOffload'; import { + formatMeshcoreAdvertisedPositionDegrees, MESHCORE_CHANNEL_INDEX_MAX, MESHCORE_CHANNEL_NAME_MAX_LEN, MESHCORE_CONTACTS_WARNING_THRESHOLD, @@ -81,6 +82,15 @@ interface ChannelConfig { positionPrecision: number; } +function isStringKeyedRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function numericArray(value: unknown): number[] | null { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'number')) return null; + return value; +} + interface Props { configTarget?: ConfigTargetContext; onSetConfig: (config: unknown) => Promise; @@ -760,6 +770,10 @@ export default function RadioPanel({ const a = ourPosition?.altitudeMeters; return a != null && Number.isFinite(a) ? String(a) : '0'; }); + /** True after the user edits lat/lon (or Use current GPS) until a successful send. */ + const meshcorePositionFormDirtyRef = useRef(false); + /** Last MeshCore advert lat/lon strings applied to the form (skip overwrite while dirty). */ + const syncedMeshcoreAdvertRef = useRef<{ lat: string; lon: string } | null>(null); const [gpsMode, setGpsMode] = useState(0); const [positionPrecision, setPositionPrecision] = useState(10); const [smartPositionEnabled, setSmartPositionEnabled] = useState(false); @@ -911,6 +925,32 @@ export default function RadioPanel({ setAltStr(String(a)); }, [ourPosition?.altitudeMeters]); + // MeshCore: sync lat/lon from companion advert when the form has not been user-edited. + useEffect(() => { + if (capabilities?.hasFullPositionConfig !== false) return; + if (!meshcoreSelfInfo) return; + const { lat, lon } = meshcoreScaledAdvLatLonToDeg( + meshcoreSelfInfo.advLat, + meshcoreSelfInfo.advLon, + ); + if (lat == null || lon == null) return; + const nextLat = String(lat); + const nextLon = String(lon); + const synced = syncedMeshcoreAdvertRef.current; + if (synced?.lat === nextLat && synced?.lon === nextLon) { + return; + } + if (meshcorePositionFormDirtyRef.current) return; + syncedMeshcoreAdvertRef.current = { lat: nextLat, lon: nextLon }; + setLatStr(nextLat); + setLonStr(nextLon); + }, [ + capabilities?.hasFullPositionConfig, + meshcoreSelfInfo, + meshcoreSelfInfo?.advLat, + meshcoreSelfInfo?.advLon, + ]); + // ─── Shared state ───────────────────────────────────────────── const [status, setStatus] = useState(null); const [applyingSection, setApplyingSection] = useState(null); @@ -1119,14 +1159,16 @@ export default function RadioPanel({ if (!file) return; void (async () => { try { - const cfg = JSON.parse(await file.text()); + const parsed: unknown = JSON.parse(await file.text()); + if (!isStringKeyedRecord(parsed)) throw new Error('Invalid config JSON'); + const cfg = parsed; console.debug('[RadioPanel] parsed config JSON:', cfg); console.debug( `[RadioPanel] current device state before import: radioFreqHz=${radioFreqHz} bandwidth=${bandwidth}`, ); // ── Extract values ─────────────────────────────────────────── - const importedName = cfg.name ? String(cfg.name) : null; + const importedName = typeof cfg.name === 'string' && cfg.name ? cfg.name : null; let importedFreqHz: number | null = null; let importedBwKhz: number | null = null; let importedSf: number | null = null; @@ -1135,7 +1177,7 @@ export default function RadioPanel({ if (importedName) setLongName(importedName); - if (cfg.radio_settings) { + if (isStringKeyedRecord(cfg.radio_settings)) { const rs = cfg.radio_settings; console.debug( `[RadioPanel] radio_settings from config: frequency=${rs.frequency} bandwidth=${rs.bandwidth} spreading_factor=${rs.spreading_factor} coding_rate=${rs.coding_rate} tx_power=${rs.tx_power}`, @@ -1173,20 +1215,14 @@ export default function RadioPanel({ if (cfg.public_key || cfg.private_key) { try { - const pubArr = Array.isArray(cfg.public_key) - ? Uint8Array.from(cfg.public_key as number[]) - : null; - const privArr = Array.isArray(cfg.private_key) - ? Uint8Array.from(cfg.private_key as number[]) - : null; + const publicKeyNumbers = numericArray(cfg.public_key); + const privateKeyNumbers = numericArray(cfg.private_key); + const pubArr = publicKeyNumbers ? Uint8Array.from(publicKeyNumbers) : null; + const privArr = privateKeyNumbers ? Uint8Array.from(privateKeyNumbers) : null; if (pubArr?.length === 32 && privArr && privArr.length >= 32) { void tryPersistMeshcoreIdentityFromRadioExport(pubArr, privArr); } else { - const publicKeyJson = Array.isArray(cfg.public_key) - ? cfg.public_key - : pubArr - ? Array.from(pubArr) - : cfg.public_key; + const publicKeyJson = publicKeyNumbers ?? cfg.public_key; const privateKeyJson = privArr ? Array.from(privArr) : cfg.private_key; localStorage.setItem( 'mesh-client:meshcoreIdentity', @@ -2094,12 +2130,29 @@ export default function RadioPanel({ {/* For MeshCore: lat/lon always shown (fixed position is the only option) */} {(fixedPosition || capabilities?.hasFullPositionConfig === false) && (
+ {capabilities?.hasFullPositionConfig === false && + (() => { + const advertised = formatMeshcoreAdvertisedPositionDegrees( + meshcoreSelfInfo?.advLat, + meshcoreSelfInfo?.advLon, + ); + if (!advertised) return null; + return ( +

+ {t('radioPanel.advertisedPositionLabel', { + lat: advertised.lat, + lon: advertised.lon, + })} +

+ ); + })()}

{t('radioPanel.setCoordinatesHint')} {ourPosition && (

-
- - { - setAltStr(e.target.value); - }} - disabled={disabled || applyingSection !== null} - placeholder="0" - className="bg-secondary-dark focus:border-brand-green w-36 rounded-lg border border-gray-600 px-3 py-2 text-gray-200 focus:outline-none disabled:opacity-50" - /> -
+ {capabilities?.hasFullPositionConfig !== false && ( +
+ + { + setAltStr(e.target.value); + }} + disabled={disabled || applyingSection !== null} + placeholder="0" + className="bg-secondary-dark focus:border-brand-green w-36 rounded-lg border border-gray-600 px-3 py-2 text-gray-200 focus:outline-none disabled:opacity-50" + /> +
+ )} {open && ( -
+
{relevantOffers.length > 0 && (

@@ -227,25 +376,103 @@ export function ChatDmRncpControl({

)} + {peerTransfers.length > 0 && ( +
+

+ {t('chatPanel.rncp.transfersTitle')} +

+ {peerTransfers.map((transfer) => ( +
+
+ {transfer.file_name ?? '—'} + + {transfer.status === 'active' + ? `${transfer.progress}%` + : t(`reticulumRemote.transfer.status.${transfer.status}`)} + + {transfer.status === 'active' && ( + + )} +
+ {transfer.status === 'active' && ( +
+
+
+ )} + {transfer.status === 'failed' && transfer.error && ( + + {transfer.error} + + )} +
+ ))} +
+ )} +

{t('chatPanel.rncp.destinationHelp')}

+ {otherSavedLabels.length > 0 && ( +

+ {t('chatPanel.rncp.savedForOtherPeers', { peers: otherSavedLabels.join(', ') })} +

+ )}
{ - setDestinationInput(e.target.value); + handleDestinationChange(e.target.value); + }} + onBlur={() => { + const parsed = parseReticulumDestinationInput(destinationInput); + if (parsed) setDestinationInput(parsed); }} aria-label={t('reticulumRemote.transfer.destinationAria')} className="bg-secondary-dark/80 min-w-0 flex-1 rounded border border-gray-600/50 px-2 py-1 text-xs text-gray-200 focus:border-blue-500/50 focus:outline-none" />
+ {pathConstrained && capability && ( +

+ {t('reticulumRemote.transfer.notAllowedHint', { + reason: capability.reason_key + ? t( + resolveRemoteReasonI18nKey(capability.reason_key) ?? + 'reticulumRemote.reasons.pathConstrained', + ) + : t('reticulumRemote.reasons.pathConstrained'), + })} +

+ )} {!savedAddress && (