diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml new file mode 100644 index 000000000..c702483c3 --- /dev/null +++ b/.github/workflows/reticulum-sidecar.yaml @@ -0,0 +1,157 @@ +name: Reticulum sidecar + +permissions: + contents: read + +on: + workflow_dispatch: + push: + paths: + - 'reticulum-sidecar/**' + - '.github/workflows/reticulum-sidecar.yaml' + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: mesh-client-reticulum-linux-x64 + - os: macos-latest + target: aarch64-apple-darwin + artifact: mesh-client-reticulum-macos-arm64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: mesh-client-reticulum-win-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + # Optional rns-stack path deps must exist on disk even for the default stub build. + # checkout@v6 only allows paths under GITHUB_WORKSPACE; Cargo expects Ratspeak siblings. + - name: Clone Ratspeak stack (rsReticulum, rsLXMF) + shell: bash + run: | + git clone --depth 1 https://github.com/ratspeak/rsReticulum.git "${GITHUB_WORKSPACE}/../rsReticulum" + git clone --depth 1 https://github.com/ratspeak/rsLXMF.git "${GITHUB_WORKSPACE}/../rsLXMF" + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: ${{ matrix.target }} + - name: Build sidecar (stub) + working-directory: reticulum-sidecar + run: | + cargo test + cargo build --release --target ${{ matrix.target }} + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: | + reticulum-sidecar/target/${{ matrix.target }}/release/mesh-client-reticulum* + retention-days: 14 + + build-rns-stack: + env: + RS_RETICULUM_REF: 6d2b28475321bc15c8f60796513d8878b47ed3ab + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: mesh-client-reticulum-rns-linux-x64 + - os: macos-latest + target: aarch64-apple-darwin + artifact: mesh-client-reticulum-rns-macos-arm64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: mesh-client-reticulum-rns-win-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - name: Clone Ratspeak stack (rsReticulum, rsLXMF) + shell: bash + run: | + RNS_DIR="${GITHUB_WORKSPACE}/../rsReticulum" + PATCH="${GITHUB_WORKSPACE}/reticulum-sidecar/patches/rsReticulum-packet-tap.patch" + git clone https://github.com/ratspeak/rsReticulum.git "${RNS_DIR}" + git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" + git -C "${RNS_DIR}" apply --check "${PATCH}" + git -C "${RNS_DIR}" apply "${PATCH}" + git clone --depth 1 https://github.com/ratspeak/rsLXMF.git "${GITHUB_WORKSPACE}/../rsLXMF" + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: ${{ matrix.target }} + - name: Install Linux BLE build deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config + - name: Build sidecar (rns-stack) + working-directory: reticulum-sidecar + run: | + cargo test --features rns-stack,rns-ble,rns-rnode-tcp + cargo build --release --target ${{ matrix.target }} --features rns-stack,rns-ble,rns-rnode-tcp + - name: Upload rns-stack artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: | + reticulum-sidecar/target/${{ matrix.target }}/release/mesh-client-reticulum* + retention-days: 14 + + build-windows-arm64: + permissions: + contents: read + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + # Optional rns-stack path deps must exist on disk even for the default stub build. + - name: Clone Ratspeak stack (rsReticulum, rsLXMF) + shell: bash + run: | + git clone --depth 1 https://github.com/ratspeak/rsReticulum.git "${GITHUB_WORKSPACE}/../rsReticulum" + git clone --depth 1 https://github.com/ratspeak/rsLXMF.git "${GITHUB_WORKSPACE}/../rsLXMF" + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: aarch64-pc-windows-msvc + - name: Build WoA sidecar (stub) + working-directory: reticulum-sidecar + run: | + cargo test + cargo build --release --target aarch64-pc-windows-msvc + - uses: actions/upload-artifact@v7 + with: + name: mesh-client-reticulum-win-arm64 + path: reticulum-sidecar/target/aarch64-pc-windows-msvc/release/mesh-client-reticulum.exe + retention-days: 14 + + build-windows-arm64-rns-stack: + env: + RS_RETICULUM_REF: 6d2b28475321bc15c8f60796513d8878b47ed3ab + permissions: + contents: read + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - name: Clone Ratspeak stack (rsReticulum, rsLXMF) + shell: bash + run: | + RNS_DIR="${GITHUB_WORKSPACE}/../rsReticulum" + PATCH="${GITHUB_WORKSPACE}/reticulum-sidecar/patches/rsReticulum-packet-tap.patch" + git clone https://github.com/ratspeak/rsReticulum.git "${RNS_DIR}" + git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" + git -C "${RNS_DIR}" apply --check "${PATCH}" + git -C "${RNS_DIR}" apply "${PATCH}" + git clone --depth 1 https://github.com/ratspeak/rsLXMF.git "${GITHUB_WORKSPACE}/../rsLXMF" + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: aarch64-pc-windows-msvc + - name: Build WoA sidecar (rns-stack) + working-directory: reticulum-sidecar + run: | + cargo test --features rns-stack,rns-ble,rns-rnode-tcp + cargo build --release --target aarch64-pc-windows-msvc --features rns-stack,rns-ble,rns-rnode-tcp + - uses: actions/upload-artifact@v7 + with: + name: mesh-client-reticulum-rns-win-arm64 + path: reticulum-sidecar/target/aarch64-pc-windows-msvc/release/mesh-client-reticulum.exe + retention-days: 14 diff --git a/.gitignore b/.gitignore index d4a75a8d1..93542ff18 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ coverage/ .context-os .rtk flatpak/generated-sources.json + +# Rust reticulum sidecar (cargo build output) +reticulum-sidecar/target/ diff --git a/.prettierignore b/.prettierignore index cc5821f4c..5e21ff351 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,6 +24,9 @@ resources/win/*.xml # CodeQL MaD — Prettier line-wrap breaks yamllint indentation for barrier tuples .github/codeql/extensions/**/*.yml +# Rust sidecar (no Prettier TOML parser) +reticulum-sidecar/ + # Images *.png *.jpg diff --git a/AGENTS.md b/AGENTS.md index 436c4cf94..a354df5ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ This file is self-contained. ARCHITECTURE.md and CONTRIBUTING.md are human refer - **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:** This repo has a very long pre-commit hook chain (lint, typecheck, thousands of tests, audit, actionlint, yamllint, many check:\* scripts). Commits can take 2+ minutes. Be patient and let them finish — do not interrupt or force-skip hooks. +- **Fresh clone:** Before other setup work, run `node scripts/check-environment.mjs` (works before pnpm is installed). After `pnpm install`, re-run `pnpm run check:environment`. Fix required failures using the printed hints and `setup:*` scripts; optional warnings can wait. ### Platform parity @@ -109,7 +110,7 @@ Adding a cross-boundary feature: **Key commands:** `pnpm run dev`, `pnpm run lint`, `pnpm run typecheck`, `pnpm run test:run`, `pnpm run update`. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `package.json:205-213`, keep `WATCH_ENTRIES` in `scripts/update.sh:59-69` in sync so the script warns on version changes to every patched dependency. +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `package.json:205-213`, keep `WATCH_ENTRIES` in `scripts/update.sh:59-69` in sync so the script warns on version changes to every patched dependency. `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH`. **Pre-commit hook order:** @@ -132,6 +133,17 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). ## 8. Subsystem Quick Reference +### Reticulum + +- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`); dev: `pnpm run reticulum:sidecar:dev` +- **IPC:** `reticulum:*` main handlers (`proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, config file read/import dialog); renderer uses `electronAPI.reticulum` proxy (no direct localhost from sandbox) +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces), `ReticulumNetworkPanel` (Network — identity, stack/announce settings, propagation, config import), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers), `NomadNetworkPanel` (Nomad Network) +- **Runtime:** `useReticulumRuntime`, `reticulumSession.ts`, `reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF +- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) +- **No Noble/MQTT** for Reticulum tab; gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` +- **Multi-protocol BLE:** Meshtastic, MeshCore, and Reticulum (BLE Peer + `ble://` RNode) may connect to **different** BLE devices at once on all platforms. Coexistence: `ble-coexistence-coordinator.ts` (peripheral MAC registry + scan-only mutex); Linux mesh uses Web Bluetooth + sidecar `btleplug`. Same MAC rejected; scans serialized—never disconnect unrelated GATT for scans. +- **Docs:** [docs/reticulum.md](docs/reticulum.md), [docs/reticulum-sidecar-ipc.md](docs/reticulum-sidecar-ipc.md) + ### Diagnostics - **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts`, `RemediationEngine.ts`. @@ -186,6 +198,20 @@ Meshtastic BLE: `connection.ts` / `TransportManager`. MeshCore BLE: `noble-ble-m **Linux Web Bluetooth (Meshtastic):** `webbluetooth-ble-manager.ts` subscribes to **fromNum** GATT notify for unsolicited mesh traffic, runs a **3 s background fromRadio poll** between write cycles, and uses **multi-shot read probes** instead of a single post-write safety read (LoRa latency). MeshCore BLE echo filtering: `meshcoreCompanionTxEchoFilter.ts` (Noble + Web Bluetooth). +**Dual-radio Noble BLE startup (macOS/Windows):** When both Meshtastic and MeshCore have **different** saved BLE peripherals, the renderer must serialize auto-connect and manual Noble connects. Coordinator: `src/renderer/lib/meshcoreDualNobleBleInit.ts`; UI wiring: `ConnectionPanel.tsx` (both panels stay mounted from `App.tsx`). + +| Rule | Detail | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Init timing | Call `initNobleBleDualRadioStartup()` from **`App.tsx` `useLayoutEffect`** (not `useEffect`). Child ConnectionPanel auto-connect `useEffect` runs after layout effects — initializing in parent `useEffect` races and leaves primary unset. | +| Primary order | `mesh-client:protocol` localStorage (`meshcore` / `meshtastic`; Reticulum or missing → Meshtastic). Single-radio installs skip peer deferral. | +| Primary notify | Primary calls `notifyNobleBlePrimaryRfLinkReady()` when GATT + handshake succeed (MeshCore transport + Meshtastic `createBleConnection`), or `notifyNobleBlePrimaryAutoConnectSettled()` on first attempt failure — **not** after full configure or scan fallback. | +| Secondary wait | Secondary waits only on `awaitNobleBlePrimaryAutoConnectSettled()` — do **not** add `awaitNobleBleProtocolSettle()` here (mutex + post-config defer handles configure overlap). | +| Mutex | All Noble IPC connects go through `withNobleBleConnectMutex()` (Meshtastic + MeshCore). | +| MeshCore reconnect | `useMeshcoreRuntime` must **not** start the reconnect loop on disconnect before the first successful configure — ConnectionPanel `reconnectBleWithScan` owns initial retries (`meshcoreEverConfiguredRef`). | +| Tests | Protocol-neutral unit tests: `meshcoreDualNobleBleInit.test.ts`. Meshtastic + MeshCore defer paths: `ConnectionPanel.test.tsx` (`active-protocol-first BLE auto-connect`). | + +Do **not** reintroduce Meshtastic-only startup gates, child-before-parent init, or runtime-side secondary auto-connect subscriptions — ConnectionPanel owns auto-connect for both protocols. + ### Meshtastic channel URLs & Store & Forward - **Config apply (Radio / Modules / Security):** Firmware `setConfig` / `setModuleConfig` replace full protobuf structs. UI must merge cached device slices with form edits via `meshtasticConfigApply.ts` (`mergeMeshtasticConfigApplyValue`, `buildMeshtasticModuleApplyValue`); slices live in `deviceStore.meshtasticConfigSlices` and `moduleConfigs` (PacketRouter + `meshtasticLegacyWireSubscriptions`). Module-specific validation: `meshtasticMqttModuleApply.ts`, `meshtasticSerialModuleApply.ts`. Apply failures surface `clientNotification` text within 8s (`meshtasticClientNotification.ts` → `formatMeshtasticModuleApplyError`); inline status via `ConfigApplyNotice.tsx`. Forms re-sync after reboot via `useSyncFormFromConfig`. @@ -212,9 +238,10 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel - **Locale files:** `src/renderer/locales/{en,es,uk,de,zh,pt-BR,fr,it,pl,cs,ja,ru,nl,ko,tr,id}/translation.json` — English is source of truth (`pnpm run check:i18n` reports key count). - **Locale persistence:** `locale` key in `app_settings` SQLite table (canonical) and `mesh-client:appSettings` localStorage (fast startup read); reconciled in `App.tsx` on mount. - **Reduce motion:** `reduceMotion` boolean in the same `app_settings` / localStorage bundle; toggled in **App → Appearance** ([`AppPanel.tsx`](src/renderer/components/AppPanel.tsx)). When true, non-essential UI motion (animated icons, decorative CSS pulses) is suppressed; loading spinners and connection status pulses remain. Does not auto-sync to OS `prefers-reduced-motion` after first-run init — see [`docs/accessibility-checklist.md`](docs/accessibility-checklist.md). -- **Adding strings:** add to `src/renderer/locales/en/translation.json`, use `t('your.key')` in components; `check:i18n` enforces all call sites resolve to English keys. +- **Adding strings:** add to `src/renderer/locales/en/translation.json`, use `t('your.key')` in components; `check:i18n` enforces all call sites resolve to English keys and **fails on unused English keys** (no static `t()`, registered dynamic prefix, quoted literal in `src/`, or `tabs.*` from `TAB_SLOT_IDS`). +- **Removing strings:** delete the key from `en/translation.json` and run `pnpm run i18n:prune-unused -- --write` to drop it from every locale (or remove manually). `check:i18n` blocks orphaned English keys. - **Auto-translate:** `pnpm run i18n:auto-translate` uses MyMemory (default) or LibreTranslate (`LIBRETRANSLATE_URL`). With git, the default run **only** fills keys that are **new in English vs `HEAD`** and still missing from each locale (pre-commit uses this). Use **`pnpm run i18n:auto-translate --all`** or **`I18N_TRANSLATE_ALL=1`** to backfill every key missing from a locale vs English. Use **`--audit`** (or `I18N_AUDIT=1`) to additionally retranslate any key whose locale value is still identical to English (i.e. never actually translated). Existing translated entries are never overwritten. MyMemory sends contact `info@coloradomesh.org` by default for the 50 k words/day quota; override with `MYMEMORY_EMAIL` if needed. -- **Key check:** `pnpm run check:i18n` — hard fails on missing English keys; warns (does not fail) on incomplete locale coverage so rate-limit gaps don't block commits. Also runs locale quality rules via `scripts/check-i18n-quality.mjs` (mojibake, `meshtastic://` spacing, false friends). +- **Key check:** `pnpm run check:i18n` — hard fails on missing English keys and unused English keys; warns (does not fail) on incomplete locale coverage so rate-limit gaps don't block commits. Also runs locale quality rules via `scripts/check-i18n-quality.mjs` (mojibake, `meshtastic://` spacing, false friends). Unused-key detection lives in `scripts/i18n-unused-keys.mjs`; `pnpm run check:i18n:branch` skips the unused pass and only runs quality rules on keys new/changed vs `HEAD`. - **Language selector:** `src/renderer/components/LanguageSelector.tsx` — globe-icon dropdown in the header; calls `i18n.changeLanguage()` + `mergeAppSetting('locale', ...)` + `electronAPI.appSettings.set('locale', ...)`. ### Chat Panel @@ -254,6 +281,8 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel | Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](docs/troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | | Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Copy Debug Snapshot** — [troubleshooting](docs/troubleshooting.md#reporting-bugs-copy-debug-snapshot-app-tab) | | BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | +| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](docs/troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | +| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](docs/troubleshooting.md#reticulum-interface-addeditdelete-fails) | | Serial missing | `serialPortSignature.ts` | | MQTT loop | `mqtt-manager.ts` | | DB errors | `database.ts` migrations | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2203edeb5..27112a729 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,12 +23,14 @@ Thank you for your interest in contributing. See [docs/development-environment.m ## Quick Commands ```bash +node scripts/check-environment.mjs # before pnpm install (fresh clone) pnpm install -pnpm run dev # Development mode -pnpm run build # Production build -pnpm run lint # ESLint -pnpm run test:run # Run tests -pnpm run update # Update all deps, with update warnings for @meshtastic/core and @liamcottle/meshcore.js +pnpm run check:environment # after install +pnpm run dev # Development mode +pnpm run build # Production build +pnpm run lint # ESLint +pnpm run test:run # Run tests +pnpm run update # Update all deps, with update warnings for @meshtastic/core and @liamcottle/meshcore.js ``` ## Pre-commit Hook @@ -42,7 +44,7 @@ Before each commit, the hook runs (order matters): 5. `pnpm run i18n:auto-translate` (incremental vs `HEAD` English, not `--all`) and re-stage `src/renderer/locales/` 6. `pnpm run lint` 7. `pnpm run typecheck` -8. `check:electron-security`, `check:flatpak`, `check:log-injection`, `check:log-service-sinks`, `check:codeql-extensions`, `check:db-migrations`, `check:ipc-contract`, `check:console-log`, `check:silent-catches`, `check:url-hostname-sanitization`, `check:xss-patterns`, `check:protocol-string-gates`, `check:log-panel-filter`, `check:i18n` (includes `scripts/check-i18n-quality.mjs` locale rules), `check:licenses` +8. `check:electron-security`, `check:flatpak`, `check:log-injection`, `check:log-service-sinks`, `check:codeql-extensions`, `check:db-migrations`, `check:ipc-contract`, `check:console-log`, `check:silent-catches`, `check:url-hostname-sanitization`, `check:xss-patterns`, `check:protocol-string-gates`, `check:log-panel-filter`, `check:i18n` (missing keys, **unused English keys**, and `scripts/check-i18n-quality.mjs` locale rules), `check:licenses` 9. `pnpm audit --audit-level=high` 10. `actionlint`, `yamllint` 11. `pnpm run test:run` diff --git a/README.md b/README.md index 6c21829da..3d940612a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mesh-Client -> Cross-platform **Electron** desktop client for **Meshtastic** and **MeshCore** on **macOS**, **Linux**, and **Windows** with **BLE**, **USB serial**, **Wi‑Fi/TCP**, **MQTT**, local **SQLite** history, **routing diagnostics**, and **16-language UI**. +> Cross-platform **Electron** desktop client for **Meshtastic**, **MeshCore**, and **Reticulum (LXMF)** on **macOS**, **Linux**, and **Windows** with **BLE**, **USB serial**, **Wi‑Fi/TCP**, **MQTT**, local **SQLite** history, **routing diagnostics**, and **16-language UI**. ![License](https://img.shields.io/badge/license-MIT-blue.svg) ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey) @@ -23,12 +23,12 @@ Reliable Desktop Power. Local Persistence. Total Insight. While official mobile apps cover the basics, desktop power users often face a fragmented ecosystem: limited app availability for MeshCore, inconsistent support across operating systems, and persistent sync issues on macOS. Mesh-Client fills those gaps with a high-performance desktop experience. -With a dedicated local SQLite database, Mesh-Client keeps message history and mesh logs durable across restarts and sync failures. It provides one reliable hub for both Meshtastic and MeshCore firmware, delivering a unified workflow regardless of protocol or hardware. +With a dedicated local SQLite database, Mesh-Client keeps message history and mesh logs durable across restarts and sync failures. It provides one reliable hub for Meshtastic, MeshCore, and Reticulum (via an AGPL Rust sidecar), delivering a unified workflow regardless of protocol or hardware. **Why Mesh-Client?** - **True message persistence:** Local SQLite storage for reliable long-term history, without lost chats or broken logs. -- **Universal protocol support:** One consistent interface for both Meshtastic and MeshCore devices. +- **Universal protocol support:** One consistent interface for Meshtastic, MeshCore, and Reticulum (amber protocol pill; LXMF DMs via sidecar). - **Advanced mesh visibility:** Routing diagnostics and mesh health insight that mobile apps often skip. - **Desktop-first workflow:** MQTT integration and a full-featured interface for power users. - **Cross-platform stability:** A feature-rich experience across macOS, Linux, and Windows. @@ -70,6 +70,8 @@ From real-time diagnostics to permanent message archives, Mesh-Client delivers t ## Key Features +See [docs/reticulum.md](docs/reticulum.md) for Reticulum/LXMF architecture (AGPL sidecar, amber protocol pill). + ### Meshtastic Features **Radio & Channel Configuration** diff --git a/docs/credits.md b/docs/credits.md index e627a3bf9..ad942eb60 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -26,8 +26,13 @@ We were inspired by features from these projects: - [meshtastic-cli](https://github.com/statico/meshtastic-cli): Terminal UI for monitoring Meshtastic mesh networks - [Mesh Monitor](https://meshmonitor.org/): Web-based mesh network monitoring dashboard - [CoreScope](https://github.com/Kpa-clawbot/CoreScope): Self-hosted MeshCore network analyzer with RF analytics, packet visualization, and topology tools +- [**Ratspeak**](https://github.com/ratspeak/Ratspeak): Primary reference for the Reticulum/rsReticulum/rsLXMF stack, sidecar IPC patterns, and peer interop ([rsReticulum](https://github.com/ratspeak/rsReticulum), [rsLXMF](https://github.com/ratspeak/rsLXMF)) -## Libraries & Tools +### Bundled binaries + +| Binary | License | Role | +| ----------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `mesh-client-reticulum` | AGPL-3.0 | Spawned Reticulum/LXMF sidecar (separate process; see [docs/reticulum.md](reticulum.md)) | Exact semver ranges live in [`package.json`](https://github.com/Colorado-Mesh/mesh-client/blob/main/package.json) at the repository root; the tables below mirror them for attribution. diff --git a/docs/development-environment.md b/docs/development-environment.md index f85395a02..8cdbdda08 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -21,6 +21,23 @@ node --version pnpm --version ``` +### Environment check + +Run the automated checklist after cloning (works before pnpm is installed): + +```bash +node scripts/check-environment.mjs # before pnpm install +pnpm install +pnpm run check:environment # re-check after install +pnpm run dev +``` + +**Required** checks (must pass): Git, Node.js, pnpm, `node_modules`, and platform-native build tools (Xcode CLT on macOS, `g++`/`make` on Linux, MSVC `cl` on Windows). + +**Optional** checks (warnings only): Python/pip, Rust, actionlint, yamllint, Docker, and Linux `dialout` group membership. Fix optional items when you need docs builds, pre-commit hooks, Reticulum sidecar work, `act`, or USB serial on Linux. + +Use the printed `→` hints and `setup:*` scripts (`setup:build-deps`, `setup:actionlint`, `setup:dialout`) to fix failures. See [Helper scripts (auto-install where possible)](#8-helper-scripts-auto-install-where-possible) below. + ### MkDocs (documentation) tooling Docs are built with MkDocs Material. @@ -47,7 +64,9 @@ If `pnpm run docs:install` fails with `externally-managed-environment`, activate ```bash git clone https://github.com/Colorado-Mesh/mesh-client cd mesh-client +node scripts/check-environment.mjs # optional but recommended on first clone pnpm install +pnpm run check:environment # re-check after install ``` If you are updating from an older clone, use a clean install when troubleshooting native module issues: @@ -62,6 +81,75 @@ pnpm install - Dev mode (hot reload): `pnpm run dev` - Production-like local start: `pnpm start` +### Reticulum sidecar (optional) + +Reticulum/LXMF runs in a separate Rust binary (`mesh-client-reticulum`) spawned by the Electron main process. The MIT TypeScript layers talk to it over localhost HTTP/WS only. You only need this when working on the **Reticulum** protocol tab. + +#### Installing Rust + +**Recommended: [rustup](https://rustup.rs/)** — matches [CI](.github/workflows/reticulum-sidecar.yaml) and `pnpm run update`: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" # or open a new terminal +rustc --version +cargo --version +``` + +**macOS Homebrew alternative:** `brew install rust` works for local `cargo build`, but CI uses rustup. **Do not install both** rustup and Homebrew `rust` on the same machine — they can fight over `rustc`/`cargo` on your `PATH`. Pick one: + +| Approach | Install | Update | +| ---------------------- | ------------------------------- | ----------------------------------------------- | +| **rustup (preferred)** | [rustup.rs](https://rustup.rs/) | `rustup update` (also run by `pnpm run update`) | +| **Homebrew** | `brew install rust` | `brew upgrade rust` | + +If you use Homebrew only, `pnpm run update` will try `brew upgrade rust` when rustup is absent. For cross-target builds (WoA, Linux glibc) and toolchain pins, prefer rustup. + +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). + +#### Build the sidecar + +From the repo root: + +```bash +pnpm run reticulum:sidecar:build +``` + +This writes `reticulum-sidecar/target/debug/mesh-client-reticulum` (macOS/Linux) or `.exe` on Windows. + +**First run in Electron dev:** **Reticulum** → **Connection** → **Start stack** will run `cargo build` automatically if that binary is missing (first compile can take a few minutes). Pre-build with the command above to avoid waiting on the first click. + +#### Run and verify + +```bash +# Optional: run sidecar standalone (port 19437) +pnpm run reticulum:sidecar:dev + +# Health check (standalone or after Start stack in the app) +curl -s http://127.0.0.1:19437/api/v1/status +# → {"status":"ok",...} +``` + +In Electron dev: open the **Reticulum** protocol pill (amber) → **Connection** → **Start stack**. Then use **Radio** to generate/import identity and manage interfaces (add, edit, delete). Dev builds resolve the binary from `reticulum-sidecar/target/debug/mesh-client-reticulum`. + +#### Keep Rust and the sidecar current + +`pnpm run update` updates Node dependencies **and**, when `cargo` is available: + +1. Runs `rustup update` (or `brew upgrade rust` if you use Homebrew rust without rustup) +2. Rebuilds the sidecar with `cargo build` in `reticulum-sidecar/` + +**Scope:** `pnpm update` / `pnpm-lock.yaml` changes are **repo-local** (commit the lockfile on your branch). The sidecar rebuild writes only to gitignored `reticulum-sidecar/target/`. **Rust toolchain updates are not repo-scoped** — `rustup update` refreshes the toolchain in your user profile (`~/.rustup`, `~/.cargo/bin`), shared by any Rust project on the machine. It does not modify committed files unless you later add a `rust-toolchain.toml` pin. + +#### Further reading + +- Optional full RNS stack: [`reticulum-sidecar/README.md`](../reticulum-sidecar/README.md) (`rns-stack` Cargo feature) +- Architecture: [docs/reticulum.md](reticulum.md) +- HTTP contract: [docs/reticulum-sidecar-ipc.md](reticulum-sidecar-ipc.md) +- Won't start / health timeout: [troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out](troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) + +Windows ARM64 release builds use a dedicated `aarch64-pc-windows-msvc` CI job (see `.github/workflows/reticulum-sidecar.yaml`). + ### Common pnpm commands Use these from the repository root: @@ -83,6 +171,10 @@ pnpm run lint pnpm run typecheck pnpm run format:check +# Reticulum sidecar (optional; requires Rust) +pnpm run reticulum:sidecar:build +pnpm run reticulum:sidecar:dev + # Docs pnpm run docs:install pnpm run docs:build @@ -162,6 +254,8 @@ flatpak-node-generator pnpm pnpm-lock.yaml -o flatpak/generated-sources.json `flatpak/generated-sources.json` is generated automatically in the `flatpak.yaml` CI workflow and does not need to be committed. For local builds you generate it manually as shown above; the file is only required locally and in a Flathub submission repo. +Offline install inside the Flatpak sandbox uses `scripts/flatpak-pnpm-install.mjs`, which retries transient `@jsr/_tmp_*` rename races (same root cause as Windows `dist:win` hoisted installs). + **4. Build and install locally** ```bash @@ -246,13 +340,17 @@ flatpak run --command=flatpak-builder-lint org.freedesktop.Sdk \ #### Setup / Helpers -| Script | Description | -| --------------------- | -------------------------------------------------------- | -| `setup:actionlint` | Install actionlint for GitHub workflow linting | -| `setup:build-deps` | Install native build dependencies | -| `setup:dialout` | Add user to dialout group for serial port access (Linux) | -| `i18n:auto-translate` | Machine-translate missing keys via MyMemory | -| `rebuild` | Rebuild native Node modules for Electron | +| Script | Description | +| ------------------------- | ---------------------------------------------------------- | +| `check:environment` | Verify local dev prerequisites (run after clone) | +| `setup:actionlint` | Install actionlint for GitHub workflow linting | +| `setup:build-deps` | Install native build dependencies | +| `setup:dialout` | Add user to dialout group for serial port access (Linux) | +| `i18n:auto-translate` | Machine-translate missing keys via MyMemory | +| `rebuild` | Rebuild native Node modules for Electron | +| `reticulum:sidecar:build` | Build debug `mesh-client-reticulum` (requires `cargo`) | +| `reticulum:sidecar:dev` | Run sidecar standalone on `127.0.0.1:19437` | +| `update` | Update pnpm deps, Rust toolchain (rustup), rebuild sidecar | #### Lifecycle (automatic) @@ -301,6 +399,7 @@ Not installed by pnpm (install separately when needed): - `actionlint` (recommended for workflow linting; run `pnpm run setup:actionlint` or install system-wide) - `yamllint` (required for YAML linting; install via `pip install yamllint` or `brew install yamllint` on macOS) +- **Rust / `cargo`** (optional; only for Reticulum sidecar — see [Reticulum sidecar](#reticulum-sidecar-optional); prefer [rustup](https://rustup.rs/), or `brew install rust` on macOS without rustup) - `docker` and `act` (only if you run GitHub Actions locally) - Python 3 + `venv` + MkDocs Python deps (for docs checks/builds) @@ -392,6 +491,9 @@ act --container-architecture linux/amd64 -P ubuntu-latest=ghcr.io/catthehacker/u These scripts try to install optional tooling automatically. If they fail (for example, missing `sudo`/admin rights), follow the manual steps in this doc instead. +0. Verify your environment (recommended after a fresh clone): + - `node scripts/check-environment.mjs` (before `pnpm install`) + - `pnpm run check:environment` (after `pnpm install`) 1. Install `actionlint` (used by the git pre-commit hook): - `pnpm run setup:actionlint` - This installs into `.githooks/bin` so the hook can find it. @@ -464,6 +566,10 @@ On first BLE connection, macOS prompts for Bluetooth access. If denied accidenta - Go to **System Settings > Privacy & Security > Bluetooth** - Enable access for Mesh-Client +### Reticulum sidecar (optional) + +If you work on the Reticulum protocol tab, install Rust and build the sidecar — see [Reticulum sidecar (optional)](#reticulum-sidecar-optional) above. On macOS, **rustup is preferred** over `brew install rust` (CI parity); do not install both. + ### macOS release-download note (not required for source development) If a downloaded app reports "Mesh-client is damaged and can't be opened", see [macOS: File is damaged and cannot be opened](troubleshooting.md#macos-file-is-damaged-and-cannot-be-opened). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index af4c24455..5ca4b43c7 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -390,24 +390,45 @@ SVG force-directed graph of nodes within direct reach (hops 0–1 from the conne --- -## 16. Key Source Files +## 16. Reticulum interface config diagnostics + +On the **Reticulum** protocol tab, the **Diagnostics** panel includes a **Reticulum interface config** section (below continuous ping). It audits the sidecar rnsd config against the live RNS interface list and surfaces actionable repairs. + +| Issue kind | Typical cause | In-app action | +| ------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | +| `tcp_enable_key` / `ghost_interface` | TCP blocks use `enabled = Yes` but RNS only loads `interface_enabled` | **Repair config** | +| `tcp_unreachable` | Hub host/port down or firewalled | **Disable** or **Edit interface** (jumps to Connection tab) | +| `rf_preset_deviation` / `rf_cross_mismatch` | RNode LoRa params differ from coordinated regional presets | **Apply preset** or edit RNode | +| `missing_auto_interface` | No enabled `AutoInterface` (LAN discovery off) | **Add Auto interface** | +| `missing_shared_instance` | `share_instance = Yes` but `SharedInstanceServer` not up | **Restart stack** | + +The Connection tab **Interfaces** list shows the same audit hints per row (amber/red subtext) with **Repair** / **Disable** shortcuts. **SharedInstanceServer** is runtime-only (created when **Share instance** is on) — it shows a **Runtime** badge and cannot be edited or deleted from config. + +Sidecar APIs: `GET /api/v1/config/audit`, `POST /api/v1/config/repair` (see [`reticulum-sidecar-ipc.md`](reticulum-sidecar-ipc.md)). + +--- + +## 17. Key Source Files For contributors who want to modify or extend the diagnostics system: -| File | Purpose | -| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [`src/renderer/stores/diagnosticsStore.ts`](src/renderer/stores/diagnosticsStore.ts) | Zustand store: anomaly state, persistence, MQTT ignore sets, foreign LoRa records | -| [`src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts) | Hop anomaly detection (hop_goblin, bad_route, impossible_hop, route_flapping) | -| [`src/renderer/lib/diagnostics/RFDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RFDiagnosticEngine.ts) | RF signal analysis (connected node + remote node findings) | -| [`src/renderer/lib/diagnostics/diagnosticRows.ts`](src/renderer/lib/diagnostics/diagnosticRows.ts) | Row merge/prune utilities, default max-age values | -| [`src/renderer/lib/foreignLoraDetection.ts`](src/renderer/lib/foreignLoraDetection.ts) | Foreign LoRa packet classification and proximity scoring | -| [`src/renderer/components/DiagnosticsPanel.tsx`](src/renderer/components/DiagnosticsPanel.tsx) | Tab 8 UI: health band + counts, anomaly table, settings | -| [`src/renderer/components/NodeDetailModal.tsx`](src/renderer/components/NodeDetailModal.tsx) | Per-node detail overlay: routing health, MQTT ignore toggle | -| [`src/renderer/components/NodeInfoBody.tsx`](src/renderer/components/NodeInfoBody.tsx) | RF findings section, redundancy path history, congestion block | +| File | Purpose | +| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| [`src/renderer/stores/diagnosticsStore.ts`](src/renderer/stores/diagnosticsStore.ts) | Zustand store: anomaly state, persistence, MQTT ignore sets, foreign LoRa records | +| [`src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RoutingDiagnosticEngine.ts) | Hop anomaly detection (hop_goblin, bad_route, impossible_hop, route_flapping) | +| [`src/renderer/lib/diagnostics/RFDiagnosticEngine.ts`](src/renderer/lib/diagnostics/RFDiagnosticEngine.ts) | RF signal analysis (connected node + remote node findings) | +| [`src/renderer/lib/diagnostics/diagnosticRows.ts`](src/renderer/lib/diagnostics/diagnosticRows.ts) | Row merge/prune utilities, default max-age values | +| [`src/renderer/lib/foreignLoraDetection.ts`](src/renderer/lib/foreignLoraDetection.ts) | Foreign LoRa packet classification and proximity scoring | +| [`src/renderer/components/DiagnosticsPanel.tsx`](src/renderer/components/DiagnosticsPanel.tsx) | Tab 8 UI: health band + counts, anomaly table, settings | +| [`src/renderer/components/ReticulumDiagnosticsSection.tsx`](src/renderer/components/ReticulumDiagnosticsSection.tsx) | Reticulum config audit table + repair actions | +| [`src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts`](src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts) | Reticulum-native diagnostic rows (interfaces, audit merge) | +| [`src/renderer/lib/reticulum/reticulumConfigAudit.ts`](src/renderer/lib/reticulum/reticulumConfigAudit.ts) | Config audit/repair IPC client | +| [`src/renderer/components/NodeDetailModal.tsx`](src/renderer/components/NodeDetailModal.tsx) | Per-node detail overlay: routing health, MQTT ignore toggle | +| [`src/renderer/components/NodeInfoBody.tsx`](src/renderer/components/NodeInfoBody.tsx) | RF findings section, redundancy path history, congestion block | --- -## 17. Technical Reference +## 18. Technical Reference This section documents the exact protocol and hardware mechanisms behind each diagnostic finding. Thresholds are quoted directly from the source code. diff --git a/docs/index.md b/docs/index.md index 6ec160aea..d434981ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,14 +12,14 @@ repository version, see [README on GitHub](https://github.com/Colorado-Mesh/mesh ## Why -Mesh-Client provides one desktop workflow for both Meshtastic and MeshCore +Mesh-Client provides one desktop workflow for Meshtastic, MeshCore, and Reticulum (LXMF via AGPL sidecar) with persistent local storage, keyboard-first UX, and protocol-specific diagnostic tooling. Key outcomes: - True message persistence with SQLite-backed history. -- Unified interface across Meshtastic and MeshCore. +- Unified interface across Meshtastic, MeshCore, and Reticulum. - Advanced mesh visibility via diagnostics, map overlays, and routing insights. - Multi-language support (16+ languages) with offline static bundles. - Cross-platform desktop support for macOS, Linux, and Windows. diff --git a/docs/license.md b/docs/license.md index 3d1fda236..cdf3cd07e 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,6 +1,18 @@ # License -Mesh-Client is released under the MIT License. +Mesh-Client application source (Electron main, preload, renderer, and shared TypeScript) is released under the **MIT License** below. + +## Bundled Reticulum sidecar (AGPL-3.0) + +Release builds may bundle `mesh-client-reticulum`, a separate executable built from [`reticulum-sidecar/`](../reticulum-sidecar/) that links AGPL-licensed Reticulum/LXMF crates. That binary is **not** MIT-licensed; it is **AGPL-3.0**. It runs as a child process only — no AGPL source is compiled into the MIT application layers. + +- Sidecar source: [`reticulum-sidecar/`](../reticulum-sidecar/) in this repository +- Architecture: [docs/reticulum.md](reticulum.md) +- Attribution: [docs/credits.md](credits.md) (Ratspeak / rsReticulum / rsLXMF) + +If you distribute builds that include the sidecar, comply with AGPL-3.0 source-offer requirements for that component. + +## MIT License (application code) ```text MIT License diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md new file mode 100644 index 000000000..5e1e72d67 --- /dev/null +++ b/docs/reticulum-sidecar-ipc.md @@ -0,0 +1,135 @@ +# Reticulum sidecar IPC contract + +HTTP + WebSocket on `127.0.0.1` (ephemeral port in production; default dev port **19437**). + +Aligned with [Ratspeak](https://github.com/ratspeak/Ratspeak) `ratspeak-tauri` commands — not meshchat aiohttp. + +Electron main validates proxy paths: must start with `/api/v1/` (no `..` segments). + +## REST + +### Status and app + +| Method | Path | Body / notes | Response | +| ------ | ------------------ | ------------ | -------------------------------------------------- | +| GET | `/api/v1/status` | | `{ status, version, rns_ready, lxmf_ready }` | +| GET | `/api/v1/app/info` | | `{ sidecar_version, rns_version?, lxmf_version? }` | + +### Identity + +| Method | Path | Body / notes | Response | +| ------ | ------------------------------- | ----------------------------- | --------------------------------------------------------- | +| GET | `/api/v1/identity/status` | | `{ configured, identity_hash, lxmf_hash, display_name? }` | +| POST | `/api/v1/identity/generate` | `{ display_name? }` | `{ ok, mnemonic?, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/import` | `{ mnemonic, display_name? }` | `{ ok, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/export` | `{ passphrase }` | `{ ok, backup? }` | +| POST | `/api/v1/identity/display-name` | `{ display_name }` | `{ ok }` | + +### Interfaces + +| Method | Path | Body / notes | Response | +| ------ | --------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| GET | `/api/v1/interfaces` | | `{ interfaces: [] }` | +| POST | `/api/v1/interfaces` | `{ type, name?, host?, port?, preset?, serial_port?, callsign? }` | `{ ok, interface? }` | +| PUT | `/api/v1/interfaces/{id}` | Partial patch (see below) | `{ ok, interface? }` | +| DELETE | `/api/v1/interfaces/{id}` | | `{ ok }` | +| POST | `/api/v1/interfaces/{id}/enable` | | `{ ok }` | +| POST | `/api/v1/interfaces/{id}/disable` | | `{ ok }` | +| GET | `/api/v1/rnode/presets` | | `{ presets: [] }` | +| GET | `/api/v1/serial/ports` | | `{ ports: [] }` | +| GET | `/api/v1/ble/availability` | | `{ available, missing, permissions_granted, probe_failed? }` | +| GET | `/api/v1/ble/scan` | `timeout_secs` (1–30, default 5), `mode` (`peer` \| `rnode` \| `all`) | `{ devices: [{ address, name?, rssi?, kind? }] }` or `{ ok: false, error }` | + +**`PUT /api/v1/interfaces/{id}` patch fields** (all optional): `name`, `type`, `enabled`, `host`, `port`, `preset`, `serial_port`, `frequency`, `bandwidth`, `txpower`, `spreading_factor`, `coding_rate`, `callsign`, `id_interval`, `mode`. + +The Connection tab UI edits a subset: **name** for all types; **host** / **port** for TCP; **serial_port**, **preset**, **callsign** for RNode. Enable/disable uses the dedicated POST routes. + +### Config and stack settings + +| Method | Path | Body / notes | Response | +| ------ | ------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/v1/config` | | `{ content }` | +| PUT | `/api/v1/config` | `{ content }` (full rnsd INI text) | `{ ok }` | +| GET | `/api/v1/config/export` | | `{ content }` | +| POST | `/api/v1/config/import` | `{ content, mode: merge\|replace }` | `{ ok, warnings? }` | +| GET | `/api/v1/config/audit` | | `{ issues: ConfigAuditIssue[] }` — config vs live interface audit | +| POST | `/api/v1/config/repair` | `{ repair_kinds?: string[] }` — `repair_config`, `apply_preset`, `add_auto` (empty = all) | `{ ok, repaired: string[], restart_required: bool }` | +| GET | `/api/v1/stack/settings` | | `{ enable_transport, share_instance, loglevel, announce_interval_sec }` | +| PUT | `/api/v1/stack/settings` | Full `StackSettings` JSON (all four fields recommended) | `{ ok }` — missing `announce_interval_sec` deserializes as **0** | +| POST | `/api/v1/stack/restart` | | `{ ok }` | +| DELETE | `/api/v1/announces` | | `{ ok }` — clears stub persisted peers; live path table may repopulate under `rns-stack` | + +### LXMF and contacts + +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id? }` | Live: `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | +| POST | `/api/v1/lxmf/resource` | `{ destination_hash, file_name, mime_type, data_base64, reply_to_hash? }` | Live: LXMF `FIELD_FILE_ATTACHMENTS` send. Stub: local persist only. `{ ok, message? }` | +| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | +| GET | `/api/v1/contacts` | | `{ contacts: [] }` | + +### Peers, topology, and propagation + +| Method | Path | Body / notes | Response | +| ------ | ------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| GET | `/api/v1/peers` | | `{ peers: [] }` — live path table when `rns-stack` enabled | +| POST | `/api/v1/peers/{hash}/path` | | `{ ok }` — emits `peers_updated` WS on success | +| POST | `/api/v1/peers/{hash}/probe` | | `{ ok, hops? }` live; `{ ok, mode, hash }` stub — emits `peers_updated` on success | +| POST | `/api/v1/ping` | `{ destination_hash }` | `{ ok, rtt_ms? }` | +| GET | `/api/v1/topology` | | `{ nodes, edges }` — `via_hash` is the immediate RNS next hop (transport id); sidecar infers `self → relay` when needed | +| GET | `/api/v1/packets` | `?limit=500` (1–2500) | `{ packets: [] }` — recent wire tap ring buffer | +| DELETE | `/api/v1/packets` | | `{ ok }` — clear wire tap buffer | +| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec }` — `local-prop` rows include `message_count`, `storage_bytes` when live | +| POST | `/api/v1/propagation/add` | `{ destination_hash, name? }` | `{ ok, node }` — add a remote propagation node by hash | +| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` | +| POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` | +| POST | `/api/v1/propagation/{id}/preferred` | | `{ ok }` | +| POST | `/api/v1/propagation/sync` | | `{ ok }` | +| POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` | + +### Nomad Network + +| Method | Path | Body / notes | Response | +| ------ | ----------------------------------------- | --------------------- | ------------------------------------- | +| GET | `/api/v1/nomadnetwork/nodes` | | `{ nodes: [] }` | +| POST | `/api/v1/nomadnetwork/nodes/favorite` | `{ hash, favorited }` | `{ ok }` | +| GET | `/api/v1/nomadnetwork/page/{hash}?path=…` | | page payload | +| GET | `/api/v1/nomadnetwork/file/{hash}?path=…` | | `{ ok, file_name?, content_base64? }` | + +### System + +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ----------------- | -------------------------------- | +| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot | +| POST | `/api/v1/system/factory-reset` | | `{ ok }` | +| GET | `/api/v1/voice/status` | | LXST stub status | +| GET | `/api/v1/games/status` | | LRGP stub status | +| GET | `/api/v1/identities` | | `{ identities: [] }` | +| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` | + +## WebSocket + +`GET /ws` — server push JSON text frames: + +```json +{ "type": "lxmf_message", "payload": { ... } } +``` + +Event types: `lxmf_message`, `lxmf_outbound_status`, `peers_updated`, `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `resource.received`, `wire_packet`. + +`lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), and transport markers `received_via` / `sent_via` (`rf`, `tcp`, or `network`). + +## Electron bridge + +Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). + +| IPC channel | Role | +| --------------------------------------------------------------- | ------------------------------------------- | +| `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | +| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | +| `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | +| `reticulum:showConfigImportDialog` | Native file picker for config import | +| `reticulum:onEvent` / `onStatus` | WS events and sidecar status | + +SQLite chat history uses separate `db:*` handlers (`getReticulumMessages`, `saveReticulumMessage`, `searchReticulumMessages`, `deleteReticulumMessage`, destination upserts). diff --git a/docs/reticulum.md b/docs/reticulum.md new file mode 100644 index 000000000..4f82cad87 --- /dev/null +++ b/docs/reticulum.md @@ -0,0 +1,223 @@ +# Reticulum in mesh-client + +Tracking: [#593](https://github.com/Colorado-Mesh/mesh-client/issues/593) + +mesh-client ships Reticulum as a **third protocol tab** (amber chrome). The stack is an **AGPL Rust sidecar** (`mesh-client-reticulum`) spawned by Electron main; the MIT renderer talks to it through `electronAPI.reticulum` (HTTP/WS proxy). Chat history and contacts persist in the main-process SQLite database. + +**Primary interop target:** [Ratspeak](https://github.com/ratspeak/Ratspeak) peers on rsReticulum/rsLXMF. + +## Architecture + +```mermaid +flowchart TB + subgraph ui [Renderer MIT] + App[App.tsx] + RT[useReticulumRuntime] + Stack[ReticulumStackPanel] + Network[ReticulumNetworkPanel] + Admin[ReticulumAdminPanel] + App --> RT + App --> Stack + App --> Network + App --> Admin + end + subgraph main [Electron main MIT] + IPC[reticulum:* IPC] + DB[(SQLite reticulum_* tables)] + IPC --> DB + end + subgraph rust [Sidecar AGPL] + Daemon[mesh-client-reticulum] + StackH[StackHandle + optional LiveBridge] + Daemon --> StackH + end + RT <-->|electronAPI| IPC + IPC <-->|localhost| Daemon +``` + +## User flow + +1. **Reticulum → Connection** (`ReticulumStackPanel`): click **Start stack** (or enable **Auto-start** for next visit). Add, edit, enable, or delete **Interfaces** here. **Stop stack** shuts down the sidecar without quitting the app; **Disconnect & quit** stops the sidecar (when running) and exits mesh-client. +2. **Reticulum → Network** (`ReticulumNetworkPanel`): create or import identity; import or export rnsd-style config; adjust stack settings and announce interval; manage propagation. +3. **Reticulum → Admin** (`ReticulumAdminPanel`): RNode firmware flasher and stack factory reset (danger zone). +4. **Chat:** DM-only LXMF text, reactions, file attachments, and voice clips (recorded as LXMF attachments). + +**Diagnostics tab** shows Reticulum-native interface/path/LXMF health (not Meshtastic Hop Goblins). **Topology tab** builds a best-effort graph from the RNS path table: each row supplies one immediate next-hop (`via_hash`, a transport relay id that may differ from a hub’s destination hash). The sidecar infers `self → relay` links when a relay is only referenced as `via`. Layout uses BFS over edges with a `hops` fallback when a node is not reachable from `self`. This is not a full multi-hop trace—RNS exposes only the next hop per destination. Sidebar **Peers** tab ([`ReticulumPeerListPanel`](src/renderer/components/ReticulumPeerListPanel.tsx)) lists network path-table peers and LXMF contacts in separate sub-tabs. + +## Panels + +| Tab (sidebar) | Component | Purpose | +| ------------- | ------------------------ | --------------------------------------------------------------------------------------------------- | +| Connection | `ReticulumStackPanel` | Stack start/stop, auto-start, interfaces CRUD, local interface health | +| Nomad Network | `NomadNetworkPanel` | Favourites / Announces list, search, favourite toggle (MeshChat-style) | +| Peers | `ReticulumPeerListPanel` | Network path-table peers and LXMF contacts (sub-tabs); path/probe; opens `ReticulumPeerDetailModal` | +| Network | `ReticulumNetworkPanel` | Identity, stack settings, announce controls, propagation, config import | +| Admin | `ReticulumAdminPanel` | RNode firmware flasher; stack factory reset (danger zone) | + +## Interface management (Connection tab) + +Interfaces are stored in the sidecar rnsd config under Electron `userData/reticulum/config/`. The Connection tab **Interfaces** section supports: + +| Action | UI | Sidecar API | +| ---------------- | ------------------------------------------------------------- | -------------------------------- | +| Add | Type selector (TCP / Auto / RNode) + form + **Add interface** | `POST /api/v1/interfaces` | +| Edit | **Edit** on a row → inline form | `PUT /api/v1/interfaces/{id}` | +| Enable / disable | Per-row toggle | `POST …/enable` or `…/disable` | +| Delete | **Delete** + confirmation modal | `DELETE /api/v1/interfaces/{id}` | + +**Edit fields by type:** + +- **All:** display name +- **TCP:** host, port +- **RNode:** USB serial, **Bluetooth** (`ble://…` URI), or **Wi-Fi** (`tcp://host[:7633]`, default port **7633**), LoRa preset, callsign — use **Pick device** for serial/BLE selection; Wi-Fi uses host + port fields +- **BLE Peer mesh:** optional seed peer addresses (sidecar spawns `BlePeerInterface` at runtime) +- **Auto:** name only (minimal discovery interface) + +**Bluetooth coexistence:** Meshtastic, MeshCore, and Reticulum may each use Bluetooth **simultaneously on different devices** (macOS, Windows, and Linux). The app tracks peripheral ownership by MAC address and serializes **active scans** only—connected GATT links are not torn down when another stack scans or connects elsewhere. Do not point two protocols at the same BLE MAC; the app rejects same-device conflicts. On Linux, mesh stacks use Web Bluetooth in the renderer while Reticulum uses the sidecar `btleplug` stack. + +For bulk changes or migrating from another rsReticulum install, use **Config import** (merge or replace) on the **Network** tab, or paste from a file picked via the system config paths below. + +### Config audit and repair + +The sidecar compares parsed config to the live interface list: + +- **Ghost TCP interfaces:** enabled in config but not loaded by RNS (often wrong `enabled` vs `interface_enabled` key). +- **Unreachable TCP hubs:** live interface down while enabled. +- **RF mismatches:** enabled RNodes on different coordinated/fallback profiles. + +Use **Diagnostics → Reticulum interface config** or inline hints on **Connection → Interfaces**. **Repair config** normalizes TCP blocks and legacy preset ids; **Apply preset** writes coordinated defaults from the selected preset id. + +### RNode RF presets (coordinated + fallback) + +Preset picker groups: + +| Tier | Examples | Notes | +| -------------------- | ------------------------------------------------------------------------------- | -------------------------------- | +| Coordinated regional | `rnode_us` (914.875 MHz), `rnode_uk`, `rnode_au`, … | Community-agreed offsets | +| Global fallback | `rnode_eu_fallback` (867.2 MHz), `rnode_eu_high_fallback`, `rnode_2g4_fallback` | Early Reticulum guide defaults | +| Legacy aliases | `rnode_us915` → `rnode_us`, `rnode_eu868` → `rnode_eu_fallback` | Migrated automatically on repair | + +Canonical data: [`src/shared/reticulumRnodeRfProfiles.json`](../src/shared/reticulumRnodeRfProfiles.json). + +## RNode over Wi-Fi + +RNode Wi-Fi is **not** a separate interface type. It stays **`RNodeInterface`** with `port = tcp://host[:7633]` (default TCP port **7633**). Do **not** use the **TCP Client** interface type (default mesh port **4242**) for RNode Wi-Fi. + +| Step | Action | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provision | USB → **Admin → Wi-Fi** (station SSID/PSK, or AP mode), **or** ~10 s button hold → join RNode AP → bootstrap console at `http://10.0.0.1`, **or** `rnodeconf` CLI | +| Interface | **Connection → Interfaces** → type **RNode** → transport **Wi-Fi** → host/IP + port (7633) + LoRa preset | +| Hardware | ESP32-S3 Wi-Fi-capable boards; plain ESP32 Wi-Fi is disabled in stock firmware | +| Pitfall | Wi-Fi is **off after flash** until provisioned; find the station IP on the OLED alternate page, router DHCP, or Admin **Read config** | + +The sidecar must be built with **`rns-rnode-tcp`** (included in mesh-client release builds) for `tcp://` RNode interfaces to connect at runtime. + +## Stack settings and announces (Network tab) + +**Stack settings** (`enable_transport`, `share_instance`, `loglevel`) are saved via `PUT /api/v1/stack/settings`. The UI merge-reads current settings so `announce_interval_sec` is preserved when saving transport/log options. + +**Announce controls** ([`ReticulumAnnounceControls`](src/renderer/components/ReticulumAnnounceControls.tsx)): set announce interval (`announce_interval_sec`, 0–86400) and **Clear announces** (`DELETE /api/v1/announces`). With the **stub** sidecar, clear announces empties the persisted peer cache. With **`rns-stack`**, the live path table may repopulate on the next refresh until RNS path-table clear is wired. + +## RNode firmware flasher (Admin tab) + +The **Reticulum → Admin** tab lists **RNode Firmware Flasher** as a collapsible section (visible before the stack starts). It uses the renderer **Web Serial API** to: + +1. Flash nRF52 devices (DFU touch + zip manifest) or ESP32 devices (`esptool-js`). +2. **Provision** EEPROM on new hardware (device info, MD5 checksum, lock byte). +3. **Set firmware hash** after each flash (reads hash from device). +4. Optional advanced tools: Bluetooth, **Wi-Fi** (station/AP provisioning), TNC mode, display read/rotation, EEPROM wipe. + +**Serial port contention:** stop the Reticulum stack (or disable the active RNode interface) before flashing—the sidecar holds the serial port when an RNode interface is enabled. Disconnect Meshtastic or MeshCore USB serial on the same device. + +Firmware `.zip` files are selected locally (in-app GitHub download is deferred). + +## Peers and sidecar storage + +- **`GET /api/v1/peers`**: with **`rns-stack`**, returns the live RNS path table (including empty); the sidecar updates its in-memory cache on each successful fetch. On fetch failure, the last cached peers are returned. With the **stub** stack, peers come from persisted state. +- **Your node is not listed as a peer:** the path table contains routes to **remote** destinations only. Your LXMF hash appears under **Network → Identity**; the topology graph uses a synthetic **You** center node. The `interface` column on a peer row means “path learned via this interface,” not “peers attached to this serial port.” +- **Stub storage file:** `userData/reticulum/storage/mesh_client_stack.json` — identity (including mnemonic in plaintext for backup UX), stub peers, and local LXMF message cache. Treat this file as sensitive. + +## LXMF outbound delivery (Chat DMs) + +With **`rns-stack`**, `POST /api/v1/lxmf/send` chooses delivery method from the path table: + +| Destination in path table? | Delivery method | UI | +| ----------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------- | +| Yes | **Direct** (LinkDeliveryManager) | RF/TCP/NET badge while sending; **Delivered** after link completion | +| No, preferred propagation node configured | **Propagated** (handoff to PN) | **PN** badge, “Queued at propagation node” until sidecar reports delivery | +| No, no propagation node | `{ ok: false, error: "no_propagation_node" }` | Toast prompts user to set a preferred propagation node on the Network tab | + +The chat UI keeps outbound messages in **Sending** until the sidecar emits `lxmf_outbound_status` (`delivered` / `failed`). This follows Reticulum’s async LXMF model—no TCP-style “connection refused” when a contact is offline; configure a propagation node for store-and-forward instead. + +## LXMF attachments and voice clips + +- **Send:** Chat composer paperclip (files) or mic button (voice clip, max ~60 s) on Reticulum DMs. Outbound uses `POST /api/v1/lxmf/resource` with `FIELD_FILE_ATTACHMENTS` on the live stack. +- **Receive:** Inbound attachments are cached under `userData/reticulum/attachments/`; chat shows playback for audio and **Save attachment** / **Show in folder** actions. Paths are jailed to that directory in the main process — arbitrary `attachment_path` values in SQLite are rejected at save time and **Show in folder** only opens jailed paths. +- **Realtime voice calls (LXST/Codec2):** not in scope; the Network tab no longer shows a voice-call stub. + +## Propagation nodes + +- **Preferred node:** offline DMs route to the preferred propagation node when the destination is not in the path table. +- **Sync:** Per-node **Sync messages** on the Network tab; progress via `propagation_sync` WebSocket events (also surfaced in Chat while syncing). +- **Local inbox:** Enable **Local propagation (offline inbox)** on the Network tab to serve as a propagation node (`rns-stack`); stats show queued count and storage used. +- **Add remote node:** Paste a 32-character destination hash in the propagation section to add a known MeshChat/Ratspeak propagation node. + +## Building the sidecar + +### Stub (CI / no siblings) + +```bash +cd reticulum-sidecar && cargo test && cargo build +``` + +Uses a file-backed local stack (full API surface for dev/UI). + +### Full rsReticulum stack (dev) + +Sibling layout (same as Ratspeak): + +``` +parent/ + rsReticulum/ # git clone https://github.com/ratspeak/rsReticulum + rsLXMF/ # git clone https://github.com/ratspeak/rsLXMF + mesh-client/ + reticulum-sidecar/ +``` + +```bash +pnpm run reticulum:sidecar:build -- --features rns-stack +# or: cd reticulum-sidecar && cargo build --features rns-stack +``` + +Optional: `rns-serial`, `rns-ble`, `rns-rnode-tcp` features for RNode USB serial, BLE, and Wi-Fi (`tcp://`) transports. + +CI builds both **stub** and **`rns-stack`** matrix jobs on linux x64, macOS arm64, and Windows x64/arm64 (see `.github/workflows/reticulum-sidecar.yaml`). Each job runs `cargo test` before release build. + +## IPC contract + +See [reticulum-sidecar-ipc.md](reticulum-sidecar-ipc.md). Renderer must not call localhost directly (sandbox). Main-process proxy paths must start with `/api/v1/`. + +## SQLite + +- `reticulum_destinations` — contact rows (hash, display name, favorited). +- `reticulum_messages` — LXMF chat history (`message_hash`, `reply_to_hash` for threads/reactions). + +## Config import + +Default system paths (main process reads; renderer imports via sidecar): + +| Platform | Paths | +| ------------- | ------------------------------------------------------------------------------ | +| macOS / Linux | `~/.reticulum/config`, `~/.config/rsReticulum/config`, `~/.rsReticulum/config` | +| Windows | `%APPDATA%\Reticulum\config`, `%APPDATA%\rsReticulum\config` | + +The sidecar stores the active config under Electron `userData/reticulum/config/` (rnsd INI format). + +## Out of scope / in progress + +- **LXST voice** and **LRGP games**: API status endpoints exist; full rsLXST/lrgp-rs integration is tracked separately. +- **Hardware identity (YubiKey/PIV)**: not yet wired. +- **Interface hot-reload** under `rns-stack`: CRUD updates config on disk; **restart the stack** after add, edit, or **delete** so live RNS drops stale transports. +- **RNode Wi-Fi (`tcp://`)**: use bracketed IPv6 literals (`tcp://[2001:db8::1]:7633`); unbracketed IPv6 with an explicit port is rejected by the UI parser. +- **Identity vault**: minimum 8-character passcode; unlock is rate-limited in the main process. Sidecar stub mode may return a one-time mnemonic on generate — it is **not** written to `mesh_client_stack.json` on disk. +- **Meshtastic/MeshCore RF paths**: ConnectionDriver, MQTT hybrid, channel config, Rooms BBS, Hop Goblins diagnostics. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4fc2ea9d7..3a8e16a8b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -803,6 +803,102 @@ With **Wi‑Fi off** or **airplane mode** on, using a **packaged** build if poss **Fix**: When possible, exchange contact adds so the remote node lists you as a contact. If you cannot add them (or they never add you), treat the timeout as expected, not a Mesh-Client defect when the radio never returns a result. +### Reticulum sidecar won't start or health poll times out + +**Symptoms**: Connection tab **Start stack** fails; logs show `[ReticulumSidecar]` health poll timeout; `reticulum:getStatus` reports `lastError`. Identity **Generate** / **Import** errors with `Reticulum sidecar is not running`. + +**Checks**: + +0. **Identity wizard**: click **Start stack** at the top of the Reticulum Connection panel before generating or importing a mnemonic. The sidecar must be running for `reticulum:proxyGet` / `proxyPost` identity routes. +1. **Dev — binary missing**: build once from repo root: `pnpm run reticulum:sidecar:build` (requires [Rust](https://rustup.rs/); see [development-environment.md](development-environment.md#reticulum-sidecar-optional)). Electron **Start stack** can auto-run `cargo build` on first click, but you need `cargo` on `PATH`. Error text `sidecar binary not found` means `reticulum-sidecar/target/debug/mesh-client-reticulum` does not exist yet. +2. **Dev — run / health**: `pnpm run reticulum:sidecar:dev` or confirm `curl http://127.0.0.1:19437/api/v1/status` after **Start stack**. +3. **Packaged app**: confirm `mesh-client-reticulum` exists under the app resources (`reticulum-sidecar/` beside the executable). WoA needs the ARM64 sidecar artifact, not x64. +4. **macOS Gatekeeper**: unsigned local sidecar builds may need `xattr -cr` on the binary or ad-hoc signing for dev. +5. **Port conflict**: sidecar picks an ephemeral port; stale processes under `~/Library/Application Support/mesh-client/reticulum/` are rare — quit the app fully and retry. + +Keep Rust current with `pnpm run update` (runs `rustup update` and rebuilds the sidecar when `cargo` is available). + +### Reticulum Nomad Network or topology API returns 404 + +**Symptoms**: Device log shows `sidecar GET /api/v1/nomadnetwork/nodes failed: 404` or `/api/v1/topology` **404** while the sidecar process is running. Nomad Network tab may show **API unavailable**. + +**Cause**: The running `mesh-client-reticulum` binary is **older than** the Rust sources in `reticulum-sidecar/` (routes were added after the binary was built). Dev auto-build only ran when the binary was missing, or you have not rebuilt since pulling. + +**Fix**: + +1. From repo root: `pnpm run reticulum:sidecar:build` +2. Quit mesh-client fully, reopen, **Connection → Start stack** +3. Confirm with `curl` against the sidecar port from logs: `/api/v1/nomadnetwork/nodes` and `/api/v1/topology` return JSON 200 + +In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the debug binary. + +### Reticulum sidecar stops during dev (Vite HMR) + +**Symptoms**: After saving a file in `pnpm run dev`, many `[ReticulumIPC] proxyGet failed: Reticulum sidecar is not running` lines appear; Nomad/Propagation/Radio panels fail until you restart the stack. + +**Cause**: Hot module reload remounted the Reticulum runtime, which previously called `reticulum:stop` on every unmount. + +**Fix**: Current dev builds **preserve** the sidecar across HMR remounts. If you still see this on an older build, click **Start stack** again on Connection. Explicit **Disconnect** / app quit still stops the sidecar. + +### Reticulum `proxyGet` fetch failed / many `[ReticulumIPC] start` lines + +**Symptoms**: Device log or devtools shows `Error occurred in handler for 'reticulum:proxyGet': TypeError: fetch failed`, often in bursts of three or more at once. The app log may also show dozens of `[ReticulumIPC] start` entries within a few seconds while Nomad/Radio/Peers panels stay empty or stale. + +**Cause**: Overlapping sidecar start attempts restart the process before its HTTP server is ready (start/reconnect storm). Panels keep calling `proxyGet` against a dead or stale localhost port during the churn. + +**Fix**: Current builds serialize sidecar start in the main process and suppress autostart/reconnect feedback loops during an in-flight start. If you still see this: disable **Autostart stack** on Connection, click **Start stack** once, wait up to ~30s for the health poll, then reopen other Reticulum tabs. + +### Reticulum announce interval resets after saving stack settings + +**Symptoms**: You set an announce interval on the Network tab, then saved **Stack settings** (transport / log level) and the interval returned to **0**. + +**Cause**: `PUT /api/v1/stack/settings` replaces all four fields (`enable_transport`, `share_instance`, `loglevel`, `announce_interval_sec`). A partial JSON body omits `announce_interval_sec`, which deserializes as **0**. + +**Fix**: Current Network UI merge-reads settings before PUT. If you hit this on an older build, re-save the announce interval after stack settings changes. + +### Clear announces does not empty the Peers tab under rns-stack + +**Symptoms**: **Clear announces** on Network succeeds but peers reappear after refresh. + +**Cause**: With the full **`rns-stack`** build, `DELETE /api/v1/announces` clears the stub cache only; the live RNS path table repopulates on the next `GET /api/v1/peers`. + +**Workaround**: Expect peers to return while connected to a live network; use the stub sidecar for offline UI testing of an empty peer list. + +### Reticulum identity hash mismatch (stub vs live stack) + +**Symptoms**: UI shows a configured identity hash that does not match Ratspeak/rsReticulum on disk, or LXMF peers cannot reach you. + +**Cause**: The stub stack can mark identity configured in `mesh_client_stack.json` before `config/identity` exists; the live bridge may spawn a fresh RNS identity until the identity file is written. + +**Fix**: Generate or import identity with the stack running; restart the stack after identity changes. Compare `GET /api/v1/identity/status` with your Ratspeak identity file. + +### Reticulum interface add/edit/delete fails + +**Symptoms**: Connection tab **Add interface**, **Edit**, or **Delete** shows an inline error; interface list does not refresh. + +**Checks**: + +1. **Stack running**: start the sidecar from **Connection → Start stack** before editing interfaces. Identity routes on the Network tab also require a live sidecar (`reticulum:proxyGet` / `proxyPut` / `proxyDelete`). +2. **Edit validation**: name is required; TCP needs a reachable host and valid port; RNode needs a serial port path when adding (edit can update preset/callsign without re-plugging). +3. **Delete**: confirm in the modal; if the interface id changed after config import, refresh by stopping and restarting the stack. +4. **Logs**: filter Device logs for `[ReticulumIPC]` or `[ReticulumSidecar]`; sidecar returns `{ ok: false, error }` for parse or unknown-interface failures. + +For bulk fixes, use Network **Config import** (merge) instead of hand-editing individual rows. See [reticulum.md — Interface management](reticulum.md#interface-management-connection-tab). + +### RNode Wi-Fi interface offline or won't connect + +**Symptoms**: Connection tab shows a Wi-Fi RNode interface as **down**; logs may show TCP connect failures to the configured host. + +**Checks**: + +1. **Interface type**: use **RNode** with transport **Wi-Fi** (`tcp://192.168.x.x:7633`), not **TCP Client** (mesh upstream on port **4242**). +2. **Provisioning**: Wi-Fi is disabled after flashing until you configure station or AP mode (**Admin → Wi-Fi**, AP + `http://10.0.0.1`, or `rnodeconf`). +3. **IP address**: DHCP may change the RNode IP — update the host on Connection → Interfaces, or set a static IP in Admin → Wi-Fi advanced. +4. **LAN reachability**: the computer running mesh-client must be on the same network as the RNode; check firewall rules for outbound TCP to port **7633**. +5. **Sidecar build**: packaged builds include `rns-rnode-tcp`; dev builds need `pnpm run reticulum:sidecar:build` with `rns-stack,rns-ble,rns-rnode-tcp` features. + +See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). + ### Can't see RF packets on custom MQTT broker **Cause**: The packet logger publishes to `{prefix}/{pubKey}/packets`, but you're viewing the packets somewhere that doesn't receive published MQTT messages. diff --git a/electron-builder.yml b/electron-builder.yml index f09d5e5e9..800c4dc5f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -20,6 +20,10 @@ publish: extraResources: - from: resources/icons/win/colorado-mesh.ico to: colorado-mesh.ico + - from: resources/reticulum-sidecar + to: reticulum-sidecar + filter: + - '**/*' - from: resources/icons/linux/256x256.png to: 256x256.png - from: resources/icons/mac/macos-menubar-icon-Template/macos-menubar-icon-Template.png diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index 1904b38fe..1bae6d10f 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -40,13 +40,11 @@ modules: build-commands: # Install pnpm standalone binary (no network needed) - install -Dm755 pnpm-standalone /run/build/mesh-client/.pnpm-bin/pnpm - # Offline install using generated-sources.json. + # Offline install using generated-sources.json (retries @jsr temp-dir races). # --store-dir must use the sandbox-absolute path because the type:shell # source that writes .npmrc runs outside the sandbox, so $PWD there is # the host cache path, not the in-sandbox /run/build/mesh-client path. - - >- - pnpm install --frozen-lockfile --offline --ignore-scripts - --store-dir /run/build/mesh-client/flatpak-node/pnpm-store + - node scripts/flatpak-pnpm-install.mjs # Build renderer, main, and preload - pnpm run build # Rebuild native addons (noble BLE, sqlite) against the bundled Electron ABI @@ -88,12 +86,12 @@ modules: dest-filename: pnpm-standalone only-arches: [aarch64] - type: archive - url: https://github.com/electron/electron/releases/download/v41.9.0/electron-v41.9.0-linux-x64.zip - sha256: 3d9d4eed2e0177d8dc3d9b3567775b234eb3c81d67967447ebefbf040cbc3604 + url: https://github.com/electron/electron/releases/download/v41.9.2/electron-v41.9.2-linux-x64.zip + sha256: 33299b6ee4d41204b4709caae1965fc783fd0d8e76359649f9066f0ccfa7b610 dest: electron-prebuilt only-arches: [x86_64] - type: archive - url: https://github.com/electron/electron/releases/download/v41.9.0/electron-v41.9.0-linux-arm64.zip - sha256: cb4994c389e5b2f5f55a892faa45ece972512d4b09cb3cc8c9ad18a4896cb393 + url: https://github.com/electron/electron/releases/download/v41.9.2/electron-v41.9.2-linux-arm64.zip + sha256: b3048344b209315a2496928948a96d7e7d668eac630ba7a317c6a7c43db79d80 dest: electron-prebuilt only-arches: [aarch64] diff --git a/package.json b/package.json index 900005ff3..68ce28cee 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "check:console-log": "node scripts/check-console-log.mjs", "check:db-migrations": "node scripts/check-db-migrations.mjs", "check:electron-security": "node scripts/check-electron-security.mjs", + "check:environment": "node scripts/check-environment.mjs", "check:flatpak": "node scripts/check-flatpak.mjs", "check:i18n": "node scripts/check-i18n.mjs", "check:i18n:branch": "node scripts/check-i18n.mjs --branch", @@ -68,6 +69,7 @@ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md,sh}\" && sort-package-json --ignore \"**/*.md\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md,sh}\" && sort-package-json --check --ignore \"**/*.md\"", "i18n:auto-translate": "node scripts/i18n-auto-translate.mjs", + "i18n:prune-unused": "node scripts/prune-i18n-unused.mjs", "preinstall": "pnpm dlx only-allow pnpm", "postinstall": "node scripts/rebuild-native.mjs", "lint": "eslint . --max-warnings 0", @@ -76,6 +78,8 @@ "prepare": "git config core.hooksPath .githooks", "rebuild": "node scripts/rebuild-native.mjs", "release": "bash scripts/release.sh", + "reticulum:sidecar:build": "cd reticulum-sidecar && cargo build --features rns-stack,rns-ble", + "reticulum:sidecar:dev": "cd reticulum-sidecar && cargo run --features rns-stack,rns-ble -- --headless --port 19437", "setup:actionlint": "node scripts/install-actionlint.mjs", "setup:build-deps": "node scripts/setup-build-deps.mjs", "setup:dialout": "node scripts/setup-dialout.mjs", @@ -93,15 +97,20 @@ "@bufbuild/protobuf": "^2.12.1", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.7.23", "@stoprocent/noble": "^2.5.5", + "@zip.js/zip.js": "^2.8.26", "builder-util-runtime": "9.7.0", + "dompurify": "^3.4.11", "electron-updater": "^6.8.9", "emoji-picker-element": "^1.29.1", - "i18next": "^26.3.3", + "esptool-js": "0.4.5", + "i18next": "^26.3.4", + "js-md5": "^0.8.3", "jszip": "^3.10.1", "leaflet.markercluster": "^1.5.3", "lucide-react-motion": "^0.4.0", "mgrs": "^2.1.0", - "motion": "^12.42.0", + "micron-parser": "^1.0.3", + "motion": "^12.42.2", "mqtt": "^5.15.1", "node-forge": "^1.4.0", "react-i18next": "^17.0.8", @@ -118,22 +127,23 @@ "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http@^0.2.1", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial@^0.2.5", "@michaelhart/meshcore-decoder": "^0.3.0", - "@tailwindcss/postcss": "^4.3.1", - "@tanstack/react-virtual": "^3.14.4", + "@tailwindcss/postcss": "^4.3.2", + "@tanstack/react-virtual": "^3.14.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/js-md5": "^0.8.0", "@types/leaflet": "^1.9.21", "@types/node": "^25.9.4", "@types/node-forge": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.62.0", - "@typescript-eslint/parser": "^8.62.0", + "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/parser": "^8.62.1", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.9", "concurrently": "^9.2.3", - "electron": "^41.9.0", + "electron": "^41.9.2", "electron-builder": "^26.15.6", "esbuild": "^0.28.1", "eslint": "^10.6.0", @@ -152,18 +162,18 @@ "license-checker-rseidelsohn": "^4.4.2", "markdownlint-cli2": "^0.22.1", "postcss": "^8.5.16", - "prettier": "^3.9.1", + "prettier": "^3.9.4", "prettier-plugin-sh": "^0.18.1", "prettier-plugin-tailwindcss": "^0.7.4", "react": "^19.2.7", "react-dom": "^19.2.7", "react-leaflet": "^5.0.0", - "recharts": "^3.9.0", + "recharts": "^3.9.1", "sort-package-json": "^3.7.1", - "tailwindcss": "^4.3.1", + "tailwindcss": "^4.3.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0", - "vite": "^8.1.0", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.3", "vitest": "^4.1.9", "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0de8faacc..1b9892f88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,18 +55,30 @@ importers: '@stoprocent/noble': specifier: ^2.5.5 version: 2.5.5(patch_hash=df0ff44bd608a879ac16e7fc8965eb3ba8cade13f8d4b64c330d475bd722dcca) + '@zip.js/zip.js': + specifier: ^2.8.26 + version: 2.8.26 builder-util-runtime: specifier: 9.7.0 version: 9.7.0 + dompurify: + specifier: ^3.4.11 + version: 3.4.11 electron-updater: specifier: ^6.8.9 version: 6.8.9 emoji-picker-element: specifier: ^1.29.1 version: 1.29.1 + esptool-js: + specifier: 0.4.5 + version: 0.4.5 i18next: - specifier: ^26.3.3 - version: 26.3.3(typescript@6.0.3) + specifier: ^26.3.4 + version: 26.3.4(typescript@6.0.3) + js-md5: + specifier: ^0.8.3 + version: 0.8.3 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -75,13 +87,16 @@ importers: version: 1.5.3(leaflet@1.9.4) lucide-react-motion: specifier: ^0.4.0 - version: 0.4.0(motion@12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.4.0(motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) mgrs: specifier: ^2.1.0 version: 2.1.0 + micron-parser: + specifier: ^1.0.3 + version: 1.0.3 motion: - specifier: ^12.42.0 - version: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^12.42.2 + version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) mqtt: specifier: ^5.15.1 version: 5.15.1 @@ -90,7 +105,7 @@ importers: version: 1.4.0 react-i18next: specifier: ^17.0.8 - version: 17.0.8(i18next@26.3.3(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) react-leaflet-cluster: specifier: ^4.1.3 version: 4.1.3(@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) @@ -126,11 +141,11 @@ importers: specifier: ^0.3.0 version: 0.3.0 '@tailwindcss/postcss': - specifier: ^4.3.1 - version: 4.3.1 + specifier: ^4.3.2 + version: 4.3.2 '@tanstack/react-virtual': - specifier: ^3.14.4 - version: 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -140,6 +155,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/js-md5': + specifier: ^0.8.0 + version: 0.8.0 '@types/leaflet': specifier: ^1.9.21 version: 1.9.21 @@ -156,14 +174,14 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': - specifier: ^8.62.0 - version: 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.62.1 + version: 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': - specifier: ^8.62.0 - version: 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.62.1 + version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 6.0.3(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) @@ -171,8 +189,8 @@ importers: specifier: ^9.2.3 version: 9.2.3 electron: - specifier: ^41.9.0 - version: 41.9.0 + specifier: ^41.9.2 + version: 41.9.2 electron-builder: specifier: ^26.15.6 version: 26.15.6(electron-builder-squirrel-windows@26.15.6) @@ -190,7 +208,7 @@ importers: version: 7.0.0(eslint@10.6.0(jiti@2.7.0)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) + version: 2.32.0(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@10.6.0(jiti@2.7.0)) @@ -199,7 +217,7 @@ importers: version: 2.3.3(eslint@10.6.0(jiti@2.7.0)) eslint-plugin-prettier: specifier: ^5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.1) + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@10.6.0(jiti@2.7.0)) @@ -228,14 +246,14 @@ importers: specifier: ^8.5.16 version: 8.5.16 prettier: - specifier: ^3.9.1 - version: 3.9.1 + specifier: ^3.9.4 + version: 3.9.4 prettier-plugin-sh: specifier: ^0.18.1 - version: 0.18.1(prettier@3.9.1) + version: 0.18.1(prettier@3.9.4) prettier-plugin-tailwindcss: specifier: ^0.7.4 - version: 0.7.4(prettier@3.9.1) + version: 0.7.4(prettier@3.9.4) react: specifier: ^19.2.7 version: 19.2.7 @@ -246,32 +264,32 @@ importers: specifier: ^5.0.0 version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: - specifier: ^3.9.0 - version: 3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) + specifier: ^3.9.1 + version: 3.9.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) sort-package-json: specifier: ^3.7.1 version: 3.7.1 tailwindcss: - specifier: ^4.3.1 - version: 4.3.1 + specifier: ^4.3.2 + version: 4.3.2 typescript: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.62.0 - version: 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.62.1 + version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) vite: - specifier: ^8.1.0 - version: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + specifier: ^8.1.3 + version: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) vitest-axe: specifier: 1.0.0-pre.5 version: 1.0.0-pre.5(vitest@4.1.9) zustand: specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + version: 5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) packages: @@ -448,8 +466,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.6': - resolution: {integrity: sha512-323ygp5Lz4DP2nVu54NLCx4n0TKsQ3OMDG3PPeOFjOTMDtSxGbCi7mQfWBc5C80pp+9awEsiHx9HR9DfAM/IEQ==} + '@electron/rebuild@4.1.0': + resolution: {integrity: sha512-GFky+1JeO8fpSqtZCh+2wFRN/MVbAhm7tXI2M7BA1Os79OR8LEoxRtAbRPyxvmKWhEMF/p10rNJkkgP1klI13g==} engines: {node: '>=22.12.0'} hasBin: true @@ -787,8 +805,8 @@ packages: resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -855,97 +873,97 @@ packages: engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} hasBin: true - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1099,69 +1117,69 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tailwindcss/node@4.3.1': - resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} - '@tailwindcss/oxide-android-arm64@4.3.1': - resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.1': - resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.1': - resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.1': - resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': - resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': - resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': - resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': - resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.3.1': - resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.3.1': - resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1172,33 +1190,33 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': - resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': - resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.1': - resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.3.1': - resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@tailwindcss/postcss@4.3.2': + resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==} - '@tanstack/react-virtual@3.14.4': - resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==} + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.17.2': - resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -1289,6 +1307,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/js-md5@0.8.0': + resolution: {integrity: sha512-gQkc1Felhyj+aB9jmz/ICLm1fDPQx7l/60JIBSSEC+j09JeaINlzd0Wj9LZlQkHnV5rJYkroOHE5wdbDgADJrw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1324,12 +1345,15 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@types/readable-stream@4.0.23': - resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/readable-stream@4.0.24': + resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1342,63 +1366,63 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.62.0': - resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} + '@typescript-eslint/eslint-plugin@8.62.1': + resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.62.0 + '@typescript-eslint/parser': ^8.62.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.62.0': - resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.62.0': - resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.62.0': - resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.62.0': - resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.62.0': - resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + '@typescript-eslint/type-utils@8.62.1': + resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.0': - resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.62.0': - resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.62.0': - resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.0': - resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@6.0.3': @@ -1462,6 +1486,10 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + '@zip.js/zip.js@2.8.26': + resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1599,6 +1627,9 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atob-lite@2.0.0: + resolution: {integrity: sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1702,8 +1733,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2002,6 +2033,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -2036,8 +2070,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.380: - resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==} + electron-to-chromium@1.5.384: + resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -2046,8 +2080,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.9.0: - resolution: {integrity: sha512-i1tQNY3Zbc8KXvsXYrada3G5F0v87ZKCeWy5M+hlSGECOCtMWGcAjMZePl5j2deqHEA5Int3qzCgF3lcFKK1vQ==} + electron@41.9.2: + resolution: {integrity: sha512-j8a6s8HDIeKMdsU8mLFl13ALuzORPjtoK/d9oKpmg8Qp/iTmKjPO6TRuFpHJzYXyTwG7ytCU/qjMbfhRkHVEfw==} engines: {node: '>= 22.12.0'} hasBin: true @@ -2103,8 +2137,8 @@ packages: resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} engines: {node: '>= 0.4'} - es-module-lexer@2.2.0: - resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -2260,6 +2294,9 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esptool-js@0.4.5: + resolution: {integrity: sha512-ueydZt9LIsVCYtPt5q+y0ATnJJA3OflKMPDLUrIG95O5Vi3ZBt80lPh2hghGgSpMv+Whd79zcu4Ty67xFdzAyQ==} + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2317,8 +2354,8 @@ packages: resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==} engines: {node: '>=18.2.0'} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2369,8 +2406,8 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - framer-motion@12.42.0: - resolution: {integrity: sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==} + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2391,8 +2428,8 @@ packages: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} fs-extra@7.0.1: @@ -2573,8 +2610,8 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - i18next@26.3.3: - resolution: {integrity: sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==} + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -2595,11 +2632,8 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@10.2.0: - resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} - - immer@11.1.8: - resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + immer@11.1.9: + resolution: {integrity: sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -2821,6 +2855,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-sdsl@4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} @@ -3017,8 +3054,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - linkify-it@5.0.1: - resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -3092,8 +3129,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.2.0: - resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true markdownlint-cli2-formatter-default@0.0.6: @@ -3210,6 +3247,9 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + micron-parser@1.0.3: + resolution: {integrity: sha512-6BKZa6eoS2AeFkjfQ5pZp7qoFNhI/N/3cZAiD9gp7bVkwjRi2+Wc7SV7np6DL9hkOmy+BGBcysbX86ygYRYhKg==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3270,14 +3310,14 @@ packages: engines: {node: '>=10'} hasBin: true - motion-dom@12.42.0: - resolution: {integrity: sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==} + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} motion-utils@12.39.0: resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - motion@12.42.0: - resolution: {integrity: sha512-Qhwvu9sVl5/URSq5CNzwMCpSKK8Uhnrwb6VO977kZyj/wOCS7mWebJUnBoHx5cZU1Zv8a9BD5CSICWKAlrLJgA==} + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3312,8 +3352,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-abi@4.31.0: - resolution: {integrity: sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==} + node-abi@4.32.0: + resolution: {integrity: sha512-2cvOMwK7aQ8eQhHTG9DBK/akKfNaJDwtbxgUaA1u3BHGpd6cbUvWCbA0u3aKan+s7Nl60tMGx4tn11+VS1fiUA==} engines: {node: '>=22.12.0'} node-addon-api@7.0.0: @@ -3452,6 +3492,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3516,10 +3559,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -3598,8 +3637,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.9.1: - resolution: {integrity: sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==} + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} engines: {node: '>=14'} hasBin: true @@ -3737,8 +3776,8 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - recharts@3.9.0: - resolution: {integrity: sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==} + recharts@3.9.1: + resolution: {integrity: sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==} engines: {node: '>=18'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3826,8 +3865,8 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4139,8 +4178,8 @@ packages: os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true - tailwindcss@4.3.1: - resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -4265,8 +4304,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.62.0: - resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} + typescript-eslint@8.62.1: + resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4336,8 +4375,8 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.1.0: - resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4821,11 +4860,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.6': + '@electron/rebuild@4.1.0': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) - node-abi: 4.31.0 + node-abi: 4.32.0 node-api-version: 0.2.1 node-gyp: 12.4.0 read-binary-file-arch: 1.0.6 @@ -4838,7 +4877,7 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) dir-compare: 4.2.0 - fs-extra: 11.3.5 + fs-extra: 11.3.6 minimatch: 9.0.9 plist: 3.1.0 transitivePeerDependencies: @@ -4848,7 +4887,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) - fs-extra: 11.3.5 + fs-extra: 11.3.6 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -5123,7 +5162,7 @@ snapshots: dependencies: semver: 7.7.4 - '@oxc-project/types@0.137.0': {} + '@oxc-project/types@0.138.0': {} '@peculiar/asn1-schema@2.8.0': dependencies: @@ -5162,7 +5201,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 - immer: 11.1.8 + immer: 11.1.9 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.2.0 @@ -5189,53 +5228,53 @@ snapshots: '@reteps/dockerfmt-linux-arm64': 0.5.4 '@reteps/dockerfmt-linux-x64': 0.5.4 - '@rolldown/binding-android-arm64@1.1.3': + '@rolldown/binding-android-arm64@1.1.4': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': + '@rolldown/binding-darwin-arm64@1.1.4': optional: true - '@rolldown/binding-darwin-x64@1.1.3': + '@rolldown/binding-darwin-x64@1.1.4': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': + '@rolldown/binding-freebsd-x64@1.1.4': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': + '@rolldown/binding-linux-arm64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': + '@rolldown/binding-linux-arm64-musl@1.1.4': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': + '@rolldown/binding-linux-ppc64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': + '@rolldown/binding-linux-s390x-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': + '@rolldown/binding-linux-x64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': + '@rolldown/binding-linux-x64-musl@1.1.4': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': + '@rolldown/binding-openharmony-arm64@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': + '@rolldown/binding-wasm32-wasi@1.1.4': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': + '@rolldown/binding-win32-arm64-msvc@1.1.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@rolldown/binding-win32-x64-msvc@1.1.4': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -5384,7 +5423,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/node@4.3.1': + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.21.6 @@ -5392,74 +5431,74 @@ snapshots: lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 - '@tailwindcss/oxide-android-arm64@4.3.1': + '@tailwindcss/oxide-android-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.1': + '@tailwindcss/oxide-darwin-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.1': + '@tailwindcss/oxide-darwin-x64@4.3.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.1': + '@tailwindcss/oxide-freebsd-x64@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.1': + '@tailwindcss/oxide-linux-x64-musl@4.3.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.1': + '@tailwindcss/oxide-wasm32-wasi@4.3.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': optional: true - '@tailwindcss/oxide@4.3.1': + '@tailwindcss/oxide@4.3.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-x64': 4.3.1 - '@tailwindcss/oxide-freebsd-x64': 4.3.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-x64-musl': 4.3.1 - '@tailwindcss/oxide-wasm32-wasi': 4.3.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - - '@tailwindcss/postcss@4.3.1': + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/postcss@4.3.2': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.3.1 - '@tailwindcss/oxide': 4.3.1 - postcss: 8.5.15 - tailwindcss: 4.3.1 + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + postcss: 8.5.16 + tailwindcss: 4.3.2 - '@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/virtual-core': 3.17.2 + '@tanstack/virtual-core': 3.17.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/virtual-core@3.17.2': {} + '@tanstack/virtual-core@3.17.3': {} '@testing-library/dom@10.4.1': dependencies: @@ -5556,6 +5595,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/js-md5@0.8.0': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -5592,7 +5633,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/readable-stream@4.0.23': + '@types/readable-stream@4.0.24': dependencies: '@types/node': 25.9.4 @@ -5600,6 +5641,9 @@ snapshots: dependencies: '@types/node': 25.9.4 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/use-sync-external-store@0.0.6': {} @@ -5611,14 +5655,14 @@ snapshots: dependencies: '@types/node': 25.9.4 - '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/type-utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 eslint: 10.6.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 @@ -5627,41 +5671,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.62.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.62.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) - '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.62.0': + '@typescript-eslint/scope-manager@8.62.1': dependencies: - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 - '@typescript-eslint/tsconfig-utils@8.62.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) eslint: 10.6.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5669,14 +5713,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.62.0': {} + '@typescript-eslint/types@8.62.1': {} - '@typescript-eslint/typescript-estree@8.62.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.62.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/project-service': 8.62.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37) minimatch: 10.2.5 semver: 7.7.4 @@ -5686,26 +5730,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.0': + '@typescript-eslint/visitor-keys@8.62.1': dependencies: - '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/types': 8.62.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': dependencies: @@ -5719,7 +5763,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -5730,13 +5774,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.6': dependencies: @@ -5770,6 +5814,8 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} + '@zip.js/zip.js@2.8.26': {} + abbrev@2.0.0: {} abbrev@4.0.0: {} @@ -5796,7 +5842,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5819,7 +5865,7 @@ snapshots: '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.6 + '@electron/rebuild': 4.1.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@noble/hashes': 2.2.0 @@ -5963,6 +6009,8 @@ snapshots: at-least-node@1.0.0: {} + atob-lite@2.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -5987,7 +6035,7 @@ snapshots: bl@6.1.6: dependencies: - '@types/readable-stream': 4.0.23 + '@types/readable-stream': 4.0.24 buffer: 6.0.3 inherits: 2.0.4 readable-stream: 4.7.0(patch_hash=96023d9278085d7490d08bce1a5079dd202f7782a0daa66fa81c6b1424ef8ab1) @@ -6024,8 +6072,8 @@ snapshots: browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.380 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.384 node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) @@ -6093,7 +6141,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001800: {} chai@6.2.2: {} @@ -6347,6 +6395,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -6408,7 +6460,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.380: {} + electron-to-chromium@1.5.384: {} electron-updater@6.8.9: dependencies: @@ -6435,7 +6487,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.9.0: + electron@41.9.2: dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0 @@ -6551,7 +6603,7 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - es-module-lexer@2.2.0: {} + es-module-lexer@2.3.0: {} es-object-atoms@1.1.2: dependencies: @@ -6627,11 +6679,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.6.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: @@ -6642,7 +6694,7 @@ snapshots: eslint: 10.6.0(jiti@2.7.0) requireindex: 1.1.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6653,7 +6705,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.6.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6665,7 +6717,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6694,10 +6746,10 @@ snapshots: dependencies: eslint: 10.6.0(jiti@2.7.0) - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.1): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4): dependencies: eslint: 10.6.0(jiti@2.7.0) - prettier: 3.9.1 + prettier: 3.9.4 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: @@ -6798,6 +6850,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 5.0.1 + esptool-js@0.4.5: + dependencies: + atob-lite: 2.0.0 + pako: 2.2.0 + tslib: 2.8.1 + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -6845,7 +6903,7 @@ snapshots: '@babel/runtime': 7.29.7 tslib: 2.8.1 - fast-uri@3.1.2: {} + fast-uri@3.1.3: {} fastq@1.20.1: dependencies: @@ -6900,9 +6958,9 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - framer-motion@12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - motion-dom: 12.42.0 + motion-dom: 12.42.2 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: @@ -6921,7 +6979,7 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.5: + fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 @@ -7148,7 +7206,7 @@ snapshots: transitivePeerDependencies: - supports-color - i18next@26.3.3(typescript@6.0.3): + i18next@26.3.4(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -7160,9 +7218,7 @@ snapshots: immediate@3.0.6: {} - immer@10.2.0: {} - - immer@11.1.8: {} + immer@11.1.9: {} imurmurhash@0.1.4: {} @@ -7372,6 +7428,8 @@ snapshots: jiti@2.7.0: {} + js-md5@0.8.3: {} + js-sdsl@4.3.0: {} js-tokens@10.0.0: {} @@ -7567,7 +7625,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - linkify-it@5.0.1: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -7607,9 +7665,9 @@ snapshots: lru-cache@7.18.3: {} - lucide-react-motion@0.4.0(motion@12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + lucide-react-motion@0.4.0(motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - motion: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -7629,11 +7687,11 @@ snapshots: dependencies: semver: 7.7.4 - markdown-it@14.2.0: + markdown-it@14.3.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.1 + linkify-it: 5.0.2 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -7648,7 +7706,7 @@ snapshots: js-yaml: 4.3.0 jsonc-parser: 3.3.1 jsonpointer: 5.0.1 - markdown-it: 14.2.0 + markdown-it: 14.3.0 markdownlint: 0.40.0 markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.22.1) micromatch: 4.0.8 @@ -7862,6 +7920,10 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + micron-parser@1.0.3: + dependencies: + dompurify: 3.4.11 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -7906,15 +7968,15 @@ snapshots: mkdirp@1.0.4: {} - motion-dom@12.42.0: + motion-dom@12.42.2: dependencies: motion-utils: 12.39.0 motion-utils@12.39.0: {} - motion@12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - framer-motion: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tslib: 2.8.1 optionalDependencies: react: 19.2.7 @@ -7930,7 +7992,7 @@ snapshots: mqtt@5.15.1: dependencies: - '@types/readable-stream': 4.0.23 + '@types/readable-stream': 4.0.24 '@types/ws': 8.18.1 commist: 3.2.0 concat-stream: 2.0.0 @@ -7960,7 +8022,7 @@ snapshots: natural-compare@1.4.0: {} - node-abi@4.31.0: + node-abi@4.32.0: dependencies: semver: 7.7.4 @@ -8114,6 +8176,8 @@ snapshots: pako@1.0.11: {} + pako@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -8190,12 +8254,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.15: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -8213,17 +8271,17 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-sh@0.18.1(prettier@3.9.1): + prettier-plugin-sh@0.18.1(prettier@3.9.4): dependencies: '@reteps/dockerfmt': 0.5.4 - prettier: 3.9.1 + prettier: 3.9.4 sh-syntax: 0.5.8 - prettier-plugin-tailwindcss@0.7.4(prettier@3.9.1): + prettier-plugin-tailwindcss@0.7.4(prettier@3.9.4): dependencies: - prettier: 3.9.1 + prettier: 3.9.4 - prettier@3.9.1: {} + prettier@3.9.4: {} pretty-format@27.5.1: dependencies: @@ -8275,11 +8333,11 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 - react-i18next@17.0.8(i18next@26.3.3(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 3.0.1 - i18next: 26.3.3(typescript@6.0.3) + i18next: 26.3.4(typescript@6.0.3) react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: @@ -8366,14 +8424,14 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + recharts@3.9.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.49.0 eventemitter3: 5.0.4 - immer: 10.2.0 + immer: 11.1.9 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-is: 17.0.2 @@ -8472,26 +8530,26 @@ snapshots: sprintf-js: 1.1.3 optional: true - rolldown@1.1.3: + rolldown@1.1.4: dependencies: - '@oxc-project/types': 0.137.0 + '@oxc-project/types': 0.138.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 run-parallel@1.2.0: dependencies: @@ -8867,7 +8925,7 @@ snapshots: systeminformation@5.31.11: {} - tailwindcss@4.3.1: {} + tailwindcss@4.3.2: {} tapable@2.3.3: {} @@ -8999,12 +9057,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -9086,12 +9144,12 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.16 - rolldown: 1.1.3 + rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.4 @@ -9106,18 +9164,18 @@ snapshots: axe-core: 4.12.1 chalk: 5.6.2 lodash-es: 4.18.1 - vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - vitest@4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - es-module-lexer: 2.2.0 + es-module-lexer: 2.3.0 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 @@ -9128,7 +9186,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.4 @@ -9312,9 +9370,9 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 - immer: 11.1.8 + immer: 11.1.9 react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/resources/reticulum-sidecar/README.md b/resources/reticulum-sidecar/README.md new file mode 100644 index 000000000..c71b8d054 --- /dev/null +++ b/resources/reticulum-sidecar/README.md @@ -0,0 +1,3 @@ +# Placeholder for packaged `mesh-client-reticulum` binaries + +CI uploads per-arch artifacts into release builds. Dev builds use `reticulum-sidecar/target/debug/mesh-client-reticulum`. diff --git a/reticulum-sidecar/.gitignore b/reticulum-sidecar/.gitignore new file mode 100644 index 000000000..f5adeae11 --- /dev/null +++ b/reticulum-sidecar/.gitignore @@ -0,0 +1,8 @@ +# Cargo build output +/target/ + +# Backup files created by rustfmt +**/*.rs.bk + +# MSVC Windows builds +*.pdb diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock new file mode 100644 index 000000000..0fb5bb3ac --- /dev/null +++ b/reticulum-sidecar/Cargo.lock @@ -0,0 +1,2716 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.29.0", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bluez-async" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +dependencies = [ + "bitflags 2.13.0", + "bluez-generated", + "dbus", + "dbus-tokio", + "futures", + "itertools", + "log", + "serde", + "serde-xml-rs", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "bluez-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062" +dependencies = [ + "dbus", +] + +[[package]] +name = "btleplug" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a11621cb2c8c024e444734292482b1ad86fb50ded066cf46252e46643c8748" +dependencies = [ + "async-trait", + "bitflags 2.13.0", + "bluez-async", + "dashmap 6.2.1", + "dbus", + "futures", + "jni", + "jni-utils", + "log", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "once_cell", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "uuid", + "windows 0.61.3", + "windows-future", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jni-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" +dependencies = [ + "dashmap 5.5.3", + "futures", + "jni", + "log", + "once_cell", + "static_assertions", + "uuid", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lxmf-core" +version = "1.0.1" +dependencies = [ + "base64", + "bytes", + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-link", + "rns-protocol", + "rns-transport", + "rns-wire", + "serde", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mesh-client-reticulum-sidecar" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "bytes", + "clap", + "futures-util", + "hex", + "http", + "lxmf-core", + "rmpv", + "rns-identity", + "rns-interface", + "rns-runtime", + "rns-transport", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-core-bluetooth" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rns-crypto" +version = "1.0.1" +dependencies = [ + "aes", + "cbc", + "ed25519-dalek", + "hkdf", + "hmac", + "rand 0.8.6", + "rand_core 0.6.4", + "sha2", + "subtle", + "thiserror 2.0.18", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "rns-identity" +version = "1.0.1" +dependencies = [ + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-wire", + "serde", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-interface" +version = "1.0.1" +dependencies = [ + "bluer", + "btleplug", + "bytes", + "futures", + "hex", + "if-addrs", + "jni", + "libc", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "rand 0.8.6", + "rns-crypto", + "rns-transport", + "rns-wire", + "serde", + "serde_json", + "serialport", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "windows 0.58.0", +] + +[[package]] +name = "rns-link" +version = "1.0.1" +dependencies = [ + "hex", + "rand 0.8.6", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-protocol" +version = "1.0.1" +dependencies = [ + "bzip2", + "hex", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-link", + "rns-wire", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-runtime" +version = "1.0.1" +dependencies = [ + "bytes", + "hex", + "hmac", + "md-5", + "nix 0.29.0", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-interface", + "rns-link", + "rns-protocol", + "rns-transport", + "rns-wire", + "serde", + "sha2", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-transport" +version = "1.0.1" +dependencies = [ + "bytes", + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "serde", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-wire" +version = "1.0.1" +dependencies = [ + "rns-crypto", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-xml-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" +dependencies = [ + "log", + "serde", + "thiserror 2.0.18", + "xml", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xml" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml new file mode 100644 index 000000000..79f42ee99 --- /dev/null +++ b/reticulum-sidecar/Cargo.toml @@ -0,0 +1,71 @@ +[package] +name = "mesh-client-reticulum-sidecar" +version = "0.1.0" +edition = "2024" +description = "Headless Reticulum/LXMF daemon for mesh-client" +license = "AGPL-3.0-or-later" +rust-version = "1.85" + +[[bin]] +name = "mesh-client-reticulum" +path = "src/main.rs" + +[features] +default = [] +# Full stack: requires sibling checkouts at ../../rsReticulum and ../../rsLXMF (Ratspeak layout). +rns-stack = [ + "dep:rns-runtime", + "dep:lxmf-core", + "dep:rns-identity", + "dep:rns-transport", + "dep:hex", + "dep:rmpv", +] +rns-serial = ["rns-runtime/serial"] +rns-ble = ["rns-runtime/ble", "dep:rns-interface", "rns-interface/ble"] +rns-rnode-tcp = ["rns-runtime/rnode-tcp"] + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +clap = { version = "4", features = ["derive"] } +futures-util = "0.3" +http = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["cors"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +hex = { version = "0.4", optional = true } +rmpv = { version = "1", optional = true } + +base64 = "0.22" +bytes = "1" + +[dependencies.rns-runtime] +package = "rns-runtime" +path = "../../rsReticulum/crates/rns-runtime" +optional = true +features = ["serial"] + +[dependencies.rns-identity] +package = "rns-identity" +path = "../../rsReticulum/crates/rns-identity" +optional = true + +[dependencies.rns-transport] +package = "rns-transport" +path = "../../rsReticulum/crates/rns-transport" +optional = true + +[dependencies.lxmf-core] +package = "lxmf-core" +path = "../../rsLXMF/crates/lxmf-core" +optional = true + +[dependencies.rns-interface] +package = "rns-interface" +path = "../../rsReticulum/crates/rns-interface" +optional = true +features = ["ble"] diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md new file mode 100644 index 000000000..db31af181 --- /dev/null +++ b/reticulum-sidecar/README.md @@ -0,0 +1,56 @@ +# mesh-client-reticulum sidecar + +Headless Reticulum/LXMF daemon spawned by mesh-client Electron main process. + +## Prerequisites + +Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See [docs/development-environment.md](../docs/development-environment.md#reticulum-sidecar-optional). + +## Build + +**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum` and `rsLXMF` directories on disk (CI checkouts them automatically; locally clone both next to `mesh-client`): + +```bash +pnpm run reticulum:sidecar:build +``` + +**Full rsReticulum + rsLXMF** — sibling checkout (Ratspeak layout): + +``` +parent/ + rsReticulum/ + rsLXMF/ + mesh-client/reticulum-sidecar/ +``` + +Apply the packet-tap overlay (required for wire sniffer / `rns-stack` until upstream merges): + +```bash +./scripts/apply-rsReticulum-packet-tap.sh +``` + +See [patches/README.md](patches/README.md) for base SHA and regen steps. + +```bash +cd reticulum-sidecar +cargo build --release --features rns-stack +``` + +Optional: `--features rns-stack,rns-serial,rns-ble,rns-rnode-tcp` + +## Dev + +```bash +pnpm run reticulum:sidecar:dev +curl -s http://127.0.0.1:19437/api/v1/status +``` + +Or **Reticulum tab → Connection → Start stack** (sidecar must be running before identity or Network configuration). + +## API + +[docs/reticulum-sidecar-ipc.md](../docs/reticulum-sidecar-ipc.md) + +## License + +AGPL-3.0-or-later (separate process from MIT mesh-client app). diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md new file mode 100644 index 000000000..d8c3348d9 --- /dev/null +++ b/reticulum-sidecar/patches/README.md @@ -0,0 +1,46 @@ +# rsReticulum overlays + +Patches applied on top of pinned [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) checkouts for mesh-client `rns-stack` builds. + +## rsReticulum-packet-tap.patch + +Wire packet tap API for the Reticulum Stats/Sniffer panel (`wire_packet` WebSocket events, `GET /api/v1/packets`). + +| Field | Value | +| ----- | ----- | +| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` | +| **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/10 | + +**Adds (4 files):** + +- `crates/rns-transport/src/messages.rs` — `PacketTapDirection`, `PacketTapEvent`, `SetPacketTap` +- `crates/rns-transport/src/actor/mod.rs` — tap storage, RX/TX emit +- `crates/rns-transport/src/actor/inbound.rs` — RX tap on inbound +- `crates/rns-runtime/src/reticulum.rs` — `ReticulumHandle::register_packet_tap` + +### Apply locally + +From mesh-client repo root (sibling `../rsReticulum` required): + +```bash +./scripts/apply-rsReticulum-packet-tap.sh +``` + +### Regenerate + +```bash +cd ../rsReticulum +git fetch origin +git diff 6d2b28475321bc15c8f60796513d8878b47ed3ab -- \ + 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 apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch +``` + +### Sunset + +When the upstream PR merges, remove this patch, drop the CI apply step, and clone `ratspeak/rsReticulum` `main` directly in `build-rns-stack` jobs. diff --git a/reticulum-sidecar/patches/rsReticulum-packet-tap.patch b/reticulum-sidecar/patches/rsReticulum-packet-tap.patch new file mode 100644 index 000000000..06db02fba --- /dev/null +++ b/reticulum-sidecar/patches/rsReticulum-packet-tap.patch @@ -0,0 +1,234 @@ +diff --git a/crates/rns-runtime/src/reticulum.rs b/crates/rns-runtime/src/reticulum.rs +index c0568e6..5e73442 100644 +--- a/crates/rns-runtime/src/reticulum.rs ++++ b/crates/rns-runtime/src/reticulum.rs +@@ -172,6 +172,17 @@ impl ReticulumHandle { + result + } + ++ /// Register a wire packet tap on the local transport actor (sniffer/debug). ++ pub async fn register_packet_tap( ++ &self, ++ tap_tx: tokio::sync::broadcast::Sender, ++ ) { ++ let _ = self ++ .transport_tx ++ .send(TransportMessage::SetPacketTap { tap_tx }) ++ .await; ++ } ++ + /// Query the authoritative control plane. + /// + /// In client mode, Python proxies Reticulum control methods to the local +diff --git a/crates/rns-transport/src/actor/inbound.rs b/crates/rns-transport/src/actor/inbound.rs +index 68e1fa3..232795f 100644 +--- a/crates/rns-transport/src/actor/inbound.rs ++++ b/crates/rns-transport/src/actor/inbound.rs +@@ -35,6 +35,15 @@ impl TransportActor { + packet.raw.clone() + }; + ++ self.emit_packet_tap( ++ crate::messages::PacketTapDirection::Rx, ++ packet.interface_id, ++ &raw, ++ packet.rssi, ++ packet.snr, ++ packet.q, ++ ); ++ + let (mut parsed, data_offset) = match rns_wire::header::PacketHeader::unpack(&raw) { + 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 +--- a/crates/rns-transport/src/actor/mod.rs ++++ b/crates/rns-transport/src/actor/mod.rs +@@ -145,6 +145,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, ++ ++ /// Optional wire packet tap — emits RX/TX frames for sniffer UIs. ++ packet_tap: Option>, + } + + /// Cached announce metadata for diagnostics + CacheRequest replay. Raw +@@ -269,6 +272,7 @@ impl TransportActor { + channel_drops: 0, + announce_handlers: Vec::new(), + is_foreground: Arc::new(AtomicBool::new(true)), ++ packet_tap: None, + }; + + (actor, tx) +@@ -616,6 +620,9 @@ impl TransportActor { + debug!(dest = hex::encode(dest), "registered path waiter"); + } + } ++ TransportMessage::SetPacketTap { tap_tx } => { ++ self.packet_tap = Some(tap_tx); ++ } + TransportMessage::Shutdown => unreachable!(), + } + } +@@ -955,6 +962,35 @@ impl TransportActor { + /// + /// `try_send` failures: `Full` bumps `tx_drops`; `Closed` auto-deregisters + /// (zombie interface — receiver dropped without DeregisterInterface). ++ fn emit_packet_tap( ++ &self, ++ direction: crate::messages::PacketTapDirection, ++ interface_id: InterfaceId, ++ raw: &[u8], ++ rssi: Option, ++ snr: Option, ++ q: Option, ++ ) { ++ let Some(tap) = self.packet_tap.as_ref() else { ++ return; ++ }; ++ let interface_name = self ++ .interfaces ++ .get(&interface_id) ++ .map(|e| e.name.clone()) ++ .unwrap_or_else(|| format!("interface_{interface_id}")); ++ let event = crate::messages::PacketTapEvent::from_wire( ++ direction, ++ interface_id, ++ interface_name, ++ raw, ++ rssi, ++ snr, ++ q, ++ ); ++ let _ = tap.send(event); ++ } ++ + #[tracing::instrument( + level = "trace", + name = "actor.send_to_interface", +@@ -985,8 +1021,17 @@ impl TransportActor { + } else { + Bytes::copy_from_slice(raw) + }; +- match entry.tx.try_send(data) { +- Ok(()) => {} ++ match entry.tx.try_send(data.clone()) { ++ Ok(()) => { ++ self.emit_packet_tap( ++ crate::messages::PacketTapDirection::Tx, ++ id, ++ &data, ++ None, ++ None, ++ None, ++ ); ++ } + 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 +--- 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], + } + +-/// Every mutation of transport state enters through this enum — the actor ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum PacketTapDirection { ++ Rx, ++ Tx, ++} ++ ++/// Wire-level packet observation event for debug/sniffer UIs. ++#[derive(Debug, Clone)] ++pub struct PacketTapEvent { ++ pub direction: PacketTapDirection, ++ pub interface_id: InterfaceId, ++ pub interface_name: String, ++ pub raw: Vec, ++ pub rssi: Option, ++ pub snr: Option, ++ pub q: Option, ++ pub packet_type: Option, ++ pub header_type: Option, ++ pub destination_hash: Option<[u8; 16]>, ++ pub transport_type: Option, ++ pub context: Option, ++} ++ ++const PACKET_TAP_RAW_CAP: usize = 4096; ++ ++impl PacketTapEvent { ++ pub fn from_wire( ++ direction: PacketTapDirection, ++ interface_id: InterfaceId, ++ interface_name: String, ++ raw: &[u8], ++ rssi: Option, ++ snr: Option, ++ q: Option, ++ ) -> Self { ++ let capped = if raw.len() > PACKET_TAP_RAW_CAP { ++ raw[..PACKET_TAP_RAW_CAP].to_vec() ++ } else { ++ raw.to_vec() ++ }; ++ let mut event = Self { ++ direction, ++ interface_id, ++ interface_name, ++ raw: capped, ++ rssi, ++ snr, ++ q, ++ packet_type: None, ++ header_type: None, ++ destination_hash: None, ++ transport_type: None, ++ context: None, ++ }; ++ if let Ok((header, _)) = rns_wire::header::PacketHeader::unpack(&event.raw) { ++ event.packet_type = Some(format!("{:?}", header.flags.packet_type)); ++ event.header_type = Some(format!("{:?}", header.flags.header_type)); ++ event.destination_hash = Some(header.destination_hash); ++ event.transport_type = Some(format!("{:?}", header.flags.transport_type)); ++ event.context = Some(format!("{:?}", header.context)); ++ } ++ event ++ } ++} ++ + /// 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 { + dest: [u8; 16], + reply: tokio::sync::oneshot::Sender, + }, ++ /// Optional wire packet tap for debug/sniffer consumers. ++ SetPacketTap { ++ tap_tx: tokio::sync::broadcast::Sender, ++ }, + Shutdown, + } + +@@ -353,6 +421,7 @@ pub fn msg_variant_name(msg: &TransportMessage) -> &'static str { + TransportMessage::RegisterLink { .. } => "RegisterLink", + TransportMessage::ActivateLink { .. } => "ActivateLink", + TransportMessage::AwaitPath { .. } => "AwaitPath", ++ TransportMessage::SetPacketTap { .. } => "SetPacketTap", + TransportMessage::Shutdown => "Shutdown", + } + } +@@ -733,6 +802,7 @@ impl std::fmt::Debug for TransportMessage { + Self::AwaitPath { dest, .. } => { + f.debug_struct("AwaitPath").field("dest", dest).finish() + } ++ Self::SetPacketTap { .. } => f.debug_struct("SetPacketTap").finish(), + Self::Shutdown => write!(f, "Shutdown"), + } + } diff --git a/reticulum-sidecar/src/api/config.rs b/reticulum-sidecar/src/api/config.rs new file mode 100644 index 000000000..bb0ffcec5 --- /dev/null +++ b/reticulum-sidecar/src/api/config.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; + +use crate::stack::config; +use crate::stack::{ImportMode, StackHandle, StackSettings, UpdateInterfacePatch}; + +#[derive(Debug, Deserialize)] +pub struct ConfigBody { + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct ConfigImportBody { + pub content: String, + pub mode: String, +} + +pub async fn get_config(State(stack): State>) -> Json { + match config::read_config(&stack.config_dir) { + Ok(content) => Json(serde_json::json!({ "content": content })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn put_config( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.put_config_content(&body.content).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn export_config(State(stack): State>) -> Json { + match config::read_config(&stack.config_dir) { + Ok(content) => Json(serde_json::json!({ "content": content })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn import_config( + State(stack): State>, + Json(body): Json, +) -> Json { + let Some(mode) = ImportMode::parse(&body.mode) else { + return Json(serde_json::json!({ + "ok": false, + "error": "mode must be merge or replace" + })); + }; + match stack.import_config(&body.content, mode).await { + Ok(result) => Json(serde_json::json!({ + "ok": true, + "warnings": result.warnings + })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn get_stack_settings(State(stack): State>) -> Json { + match config::get_stack_settings(&stack.config_dir) { + Ok(settings) => Json(serde_json::to_value(settings).unwrap_or_default()), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn put_stack_settings( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.set_stack_settings(&body).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn update_interface( + State(stack): State>, + Path(id): Path, + Json(body): Json, +) -> Json { + match stack.update_interface(&id, body).await { + Ok(row) => Json(serde_json::json!({ "ok": true, "interface": row })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn delete_interface( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.delete_interface(&id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn config_audit(State(stack): State>) -> Json { + match stack.config_audit().await { + Ok(issues) => Json(serde_json::json!({ "issues": issues })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +#[derive(Debug, Deserialize)] +pub struct ConfigRepairBody { + #[serde(default)] + pub repair_kinds: Vec, +} + +pub async fn config_repair( + State(stack): State>, + Json(body): Json, +) -> Json { + let request = crate::stack::config_audit::ConfigRepairRequest { + repair_kinds: body.repair_kinds, + }; + match stack.config_repair(request).await { + Ok((repaired, restart_required)) => Json(serde_json::json!({ + "ok": true, + "repaired": repaired, + "restart_required": restart_required, + })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} diff --git a/reticulum-sidecar/src/api/identity.rs b/reticulum-sidecar/src/api/identity.rs new file mode 100644 index 000000000..e49242784 --- /dev/null +++ b/reticulum-sidecar/src/api/identity.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; + +use crate::stack::StackHandle; + +#[derive(Deserialize)] +pub struct GenerateBody { + pub display_name: Option, +} + +#[derive(Deserialize)] +pub struct ImportBody { + pub mnemonic: String, + pub display_name: Option, +} + +#[derive(Deserialize)] +pub struct ExportBody { + pub passphrase: String, +} + +#[derive(Deserialize)] +pub struct DisplayNameBody { + pub display_name: String, +} + +pub async fn identity_status(State(stack): State>) -> Json { + let id = stack.identity_status().await; + Json(serde_json::json!({ + "configured": id.configured, + "identity_hash": id.identity_hash, + "lxmf_hash": id.lxmf_hash, + "display_name": id.display_name, + })) +} + +/// Generate a new identity. The response includes the mnemonic **once** so the +/// UI can show it for backup; it is not written to disk (`mesh_client_stack.json` +/// strips `mnemonic` on save). +pub async fn identity_generate( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.identity_generate(body.display_name).await { + Ok(id) => Json(serde_json::json!({ + "ok": true, + "identity_hash": id.identity_hash, + "lxmf_hash": id.lxmf_hash, + "display_name": id.display_name, + "mnemonic": id.mnemonic, + })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn identity_import( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack + .identity_import(&body.mnemonic, body.display_name) + .await + { + Ok(id) => Json(serde_json::json!({ + "ok": true, + "identity_hash": id.identity_hash, + "lxmf_hash": id.lxmf_hash, + "display_name": id.display_name, + })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn identity_export( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.identity_export_backup(&body.passphrase).await { + Ok(backup) => Json(serde_json::json!({ "ok": true, "backup": backup })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn identity_set_display_name( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.set_display_name(&body.display_name).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} diff --git a/reticulum-sidecar/src/api/interfaces.rs b/reticulum-sidecar/src/api/interfaces.rs new file mode 100644 index 000000000..b4bb8a69b --- /dev/null +++ b/reticulum-sidecar/src/api/interfaces.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; + +use crate::stack::{AddInterfaceRequest, StackHandle}; + +#[derive(Debug, serde::Deserialize)] +pub struct BleScanQuery { + #[serde(default = "default_ble_scan_timeout")] + pub timeout_secs: u64, + #[serde(default = "default_ble_scan_mode")] + pub mode: String, +} + +fn default_ble_scan_timeout() -> u64 { + 5 +} + +fn default_ble_scan_mode() -> String { + "all".into() +} + +pub async fn list_interfaces(State(stack): State>) -> Json { + let interfaces = stack.list_interfaces().await; + Json(serde_json::json!({ "interfaces": interfaces })) +} + +pub async fn add_interface( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.add_interface(body).await { + Ok(row) => Json(serde_json::json!({ "ok": true, "interface": row })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn enable_interface( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.set_interface_enabled(&id, true).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn disable_interface( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.set_interface_enabled(&id, false).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn rnode_presets(State(stack): State>) -> Json { + Json(stack.rnode_presets().await) +} + +pub async fn serial_ports(State(stack): State>) -> Json { + Json(stack.serial_ports().await) +} + +pub async fn ble_availability(State(stack): State>) -> Json { + Json(stack.ble_availability().await) +} + +pub async fn ble_scan( + State(stack): State>, + Query(query): Query, +) -> Json { + match stack.ble_scan(query.timeout_secs, &query.mode).await { + Ok(body) => Json(body), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e, "devices": [] })), + } +} diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs new file mode 100644 index 000000000..b8225a54d --- /dev/null +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; + +use crate::stack::{LxmfReactionRequest, LxmfResourceRequest, LxmfSendRequest, StackHandle}; + +pub async fn lxmf_send( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_send(body).await { + Ok(payload) => Json(serde_json::json!({ "ok": true, "message": payload })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn lxmf_reaction( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_reaction(body).await { + Ok(payload) => Json(serde_json::json!({ "ok": true, "message": payload })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn list_contacts(State(stack): State>) -> Json { + let contacts = stack.list_contacts().await; + Json(serde_json::json!({ "contacts": contacts })) +} + +pub async fn list_peers(State(stack): State>) -> Json { + let peers = stack.list_peers().await; + Json(serde_json::json!({ "peers": peers })) +} + +#[derive(Debug, serde::Deserialize)] +pub struct PingBody { + pub destination_hash: String, +} + +pub async fn ping( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.ping_destination(&body.destination_hash).await { + Ok(res) => Json(res), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn peer_path( + State(stack): State>, + Path(hash): Path, +) -> Json { + match stack.request_peer_path(&hash).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn peer_probe( + State(stack): State>, + Path(hash): Path, +) -> Json { + match stack.probe_peer(&hash).await { + Ok(res) => Json(res), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn lxmf_send_resource( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_send_resource(body).await { + Ok(payload) => Json(serde_json::json!({ "ok": true, "message": payload })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn lxmf_delete_message( + State(stack): State>, + Path(hash): Path, +) -> Json { + match stack.lxmf_delete_message(&hash).await { + Ok(removed) => Json(serde_json::json!({ "ok": true, "removed": removed })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs new file mode 100644 index 000000000..33bab29e9 --- /dev/null +++ b/reticulum-sidecar/src/api/mod.rs @@ -0,0 +1,163 @@ +//! HTTP + WebSocket API (Ratspeak-aligned contract; see docs/reticulum-sidecar-ipc.md). + +mod config; +mod identity; +mod interfaces; +mod lxmf; +mod nomad; +mod propagation; +mod status; +mod system; +mod ws; + +use std::sync::Arc; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::routing::{delete, get, post, put}; +use http::HeaderValue; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; + +use crate::stack::StackHandle; + +pub fn router(stack: Arc) -> Router { + Router::new() + .route("/api/v1/status", get(status::status)) + .route("/api/v1/app/info", get(status::app_info)) + .route("/api/v1/identity/status", get(identity::identity_status)) + .route( + "/api/v1/identity/generate", + post(identity::identity_generate), + ) + .route("/api/v1/identity/import", post(identity::identity_import)) + .route("/api/v1/identity/export", post(identity::identity_export)) + .route( + "/api/v1/identity/display-name", + post(identity::identity_set_display_name), + ) + .route("/api/v1/interfaces", get(interfaces::list_interfaces)) + .route("/api/v1/interfaces", post(interfaces::add_interface)) + .route( + "/api/v1/interfaces/{id}", + put(config::update_interface).delete(config::delete_interface), + ) + .route( + "/api/v1/interfaces/{id}/enable", + post(interfaces::enable_interface), + ) + .route( + "/api/v1/interfaces/{id}/disable", + post(interfaces::disable_interface), + ) + .route( + "/api/v1/config", + get(config::get_config).put(config::put_config), + ) + .route("/api/v1/config/import", post(config::import_config)) + .route("/api/v1/config/export", get(config::export_config)) + .route("/api/v1/config/audit", get(config::config_audit)) + .route("/api/v1/config/repair", post(config::config_repair)) + .route( + "/api/v1/stack/settings", + get(config::get_stack_settings).put(config::put_stack_settings), + ) + .route("/api/v1/rnode/presets", get(interfaces::rnode_presets)) + .route("/api/v1/serial/ports", get(interfaces::serial_ports)) + .route( + "/api/v1/ble/availability", + get(interfaces::ble_availability), + ) + .route("/api/v1/ble/scan", get(interfaces::ble_scan)) + .route("/api/v1/lxmf/send", post(lxmf::lxmf_send)) + .route("/api/v1/lxmf/reaction", post(lxmf::lxmf_reaction)) + .route("/api/v1/lxmf/resource", post(lxmf::lxmf_send_resource)) + .route( + "/api/v1/lxmf/messages/{hash}", + axum::routing::delete(lxmf::lxmf_delete_message), + ) + .route("/api/v1/contacts", get(lxmf::list_contacts)) + .route("/api/v1/peers", get(lxmf::list_peers)) + .route("/api/v1/peers/{hash}/path", post(lxmf::peer_path)) + .route("/api/v1/peers/{hash}/probe", post(lxmf::peer_probe)) + .route("/api/v1/ping", post(lxmf::ping)) + .route("/api/v1/topology", get(system::topology)) + .route("/api/v1/packets", get(system::list_packets).delete(system::clear_packets)) + .route("/api/v1/announces", delete(system::clear_announces)) + .route("/api/v1/propagation", get(propagation::list_propagation)) + .route("/api/v1/propagation/add", post(propagation::add_propagation_node)) + .route( + "/api/v1/propagation/{id}/preferred", + post(propagation::set_preferred_propagation), + ) + .route( + "/api/v1/propagation/sync", + post(propagation::start_propagation_sync), + ) + .route( + "/api/v1/propagation/sync/cancel", + post(propagation::cancel_propagation_sync), + ) + .route( + "/api/v1/propagation/auto-sync-interval", + post(propagation::set_propagation_auto_sync_interval), + ) + .route( + "/api/v1/propagation/{id}/enable", + post(propagation::enable_propagation), + ) + .route( + "/api/v1/propagation/{id}/disable", + post(propagation::disable_propagation), + ) + .route( + "/api/v1/nomadnetwork/nodes", + get(nomad::list_nomad_nodes), + ) + .route( + "/api/v1/nomadnetwork/nodes/favorite", + post(nomad::favorite_nomad_node), + ) + .route( + "/api/v1/nomadnetwork/page/{hash}", + get(nomad::get_nomad_page), + ) + .route( + "/api/v1/nomadnetwork/file/{hash}", + get(nomad::get_nomad_file), + ) + .route("/api/v1/stack/restart", post(system::stack_restart)) + .route("/api/v1/system/factory-reset", post(system::factory_reset)) + .route("/api/v1/diagnostics", get(system::diagnostics)) + .route("/api/v1/voice/status", get(system::voice_status)) + .route("/api/v1/games/status", get(system::games_status)) + .route("/api/v1/identities", get(system::list_identities)) + .route("/api/v1/identities/switch", post(system::switch_identity)) + .route("/ws", get(ws::ws_handler)) + .layer(DefaultBodyLimit::max(4 * 1024 * 1024)) + .layer(localhost_cors_layer()) + .with_state(stack) +} + +fn localhost_cors_layer() -> CorsLayer { + CorsLayer::new() + .allow_origin(AllowOrigin::predicate( + |origin: &HeaderValue, _request_parts| is_localhost_origin(origin), + )) + .allow_methods(Any) + .allow_headers(Any) +} + +fn is_localhost_origin(origin: &HeaderValue) -> bool { + let Ok(origin) = origin.to_str() else { + return false; + }; + let origin = origin.trim_end_matches('/'); + origin == "http://localhost" + || origin == "https://localhost" + || origin.starts_with("http://localhost:") + || origin.starts_with("https://localhost:") + || origin == "http://127.0.0.1" + || origin == "https://127.0.0.1" + || origin.starts_with("http://127.0.0.1:") + || origin.starts_with("https://127.0.0.1:") +} diff --git a/reticulum-sidecar/src/api/nomad.rs b/reticulum-sidecar/src/api/nomad.rs new file mode 100644 index 000000000..5a8a9ab6e --- /dev/null +++ b/reticulum-sidecar/src/api/nomad.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +use crate::stack::StackHandle; + +#[derive(Debug, Deserialize)] +pub struct NomadFavoriteBody { + pub destination_hash: String, + pub favorited: bool, +} + +pub async fn list_nomad_nodes(State(stack): State>) -> Json { + let nodes = stack.list_nomad_nodes().await; + Json(serde_json::json!({ "nodes": nodes })) +} + +pub async fn favorite_nomad_node( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack + .set_nomad_favorite(&body.destination_hash, body.favorited) + .await + { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +#[derive(Debug, Deserialize)] +pub struct NomadPageQuery { + pub path: String, +} + +pub async fn get_nomad_page( + State(stack): State>, + Path(hash): Path, + Query(query): Query, +) -> Json { + Json(stack.nomad_page(&hash, &query.path).await) +} + +#[derive(Debug, Deserialize)] +pub struct NomadFileQuery { + pub path: String, +} + +pub async fn get_nomad_file( + State(stack): State>, + Path(hash): Path, + Query(query): Query, +) -> Json { + Json(stack.nomad_file(&hash, &query.path).await) +} diff --git a/reticulum-sidecar/src/api/propagation.rs b/reticulum-sidecar/src/api/propagation.rs new file mode 100644 index 000000000..24ba8f672 --- /dev/null +++ b/reticulum-sidecar/src/api/propagation.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; + +use crate::stack::StackHandle; + +#[derive(Debug, Deserialize)] +pub struct PropagationAutoSyncIntervalBody { + pub interval_sec: u32, +} + +#[derive(Debug, Deserialize)] +pub struct PropagationSyncBody { + pub propagation_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct AddPropagationBody { + pub destination_hash: String, + pub name: Option, +} + +pub async fn add_propagation_node( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack + .add_propagation_node(&body.destination_hash, body.name) + .await + { + Ok(res) => Json(res), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn list_propagation(State(stack): State>) -> Json { + Json(stack.list_propagation().await) +} + +pub async fn set_preferred_propagation( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.set_preferred_propagation(&id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn set_propagation_auto_sync_interval( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack + .set_propagation_auto_sync_interval(body.interval_sec) + .await + { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn start_propagation_sync( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.start_propagation_sync(&body.propagation_id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn cancel_propagation_sync( + State(stack): State>, +) -> Json { + match stack.cancel_propagation_sync().await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn enable_propagation( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.set_propagation_enabled(&id, true).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn disable_propagation( + State(stack): State>, + Path(id): Path, +) -> Json { + match stack.set_propagation_enabled(&id, false).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} diff --git a/reticulum-sidecar/src/api/status.rs b/reticulum-sidecar/src/api/status.rs new file mode 100644 index 000000000..8fe42abb5 --- /dev/null +++ b/reticulum-sidecar/src/api/status.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::stack::StackHandle; + +#[derive(Serialize)] +pub struct StatusResponse { + pub status: &'static str, + pub version: String, + pub rns_ready: bool, + pub lxmf_ready: bool, +} + +#[derive(Serialize)] +pub struct AppInfoResponse { + pub sidecar_version: String, + pub rns_version: Option, + pub lxmf_version: Option, +} + +pub async fn status(State(stack): State>) -> Json { + Json(StatusResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION").to_string(), + rns_ready: stack.rns_ready().await, + lxmf_ready: stack.lxmf_ready().await, + }) +} + +pub async fn app_info(State(stack): State>) -> Json { + Json(AppInfoResponse { + sidecar_version: env!("CARGO_PKG_VERSION").to_string(), + rns_version: stack.rns_version(), + lxmf_version: stack.lxmf_version(), + }) +} diff --git a/reticulum-sidecar/src/api/system.rs b/reticulum-sidecar/src/api/system.rs new file mode 100644 index 000000000..ebf8312eb --- /dev/null +++ b/reticulum-sidecar/src/api/system.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; + +use crate::stack::StackHandle; + +pub async fn stack_restart(State(stack): State>) -> Json { + match stack.request_stack_restart().await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn factory_reset(State(stack): State>) -> Json { + match stack.factory_reset().await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn diagnostics(State(stack): State>) -> Json { + Json(stack.diagnostics_snapshot().await) +} + +pub async fn voice_status(State(stack): State>) -> Json { + Json(stack.voice_status().await) +} + +pub async fn games_status(State(stack): State>) -> Json { + Json(stack.games_status().await) +} + +pub async fn list_identities(State(stack): State>) -> Json { + Json(stack.list_identities().await) +} + +pub async fn switch_identity( + State(stack): State>, + Json(body): Json, +) -> Json { + let Some(id) = body.get("identity_id").and_then(|v| v.as_str()) else { + return Json(serde_json::json!({ "ok": false, "error": "identity_id required" })); + }; + match stack.switch_identity(id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn topology(State(stack): State>) -> Json { + Json(stack.topology_snapshot().await) +} + +pub async fn clear_announces(State(stack): State>) -> Json { + match stack.clear_announces().await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +#[derive(serde::Deserialize)] +pub struct PacketListQuery { + #[serde(default = "default_packet_limit")] + pub limit: usize, +} + +fn default_packet_limit() -> usize { + 500 +} + +pub async fn list_packets( + State(stack): State>, + Query(query): Query, +) -> Json { + let limit = query.limit.clamp(1, 2500); + Json(serde_json::json!({ "packets": stack.list_packets(limit) })) +} + +pub async fn clear_packets(State(stack): State>) -> Json { + stack.clear_packets(); + Json(serde_json::json!({ "ok": true })) +} diff --git a/reticulum-sidecar/src/api/ws.rs b/reticulum-sidecar/src/api/ws.rs new file mode 100644 index 000000000..7a28b6324 --- /dev/null +++ b/reticulum-sidecar/src/api/ws.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::IntoResponse; +use futures_util::StreamExt; + +use crate::stack::StackHandle; + +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(stack): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, stack)) +} + +async fn handle_ws(mut socket: WebSocket, stack: Arc) { + let mut rx = stack.subscribe_events(); + loop { + tokio::select! { + evt = rx.recv() => { + match evt { + Ok(payload) => { + if socket.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + incoming = socket.next() => { + match incoming { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(_)) => break, + } + } + } + } +} diff --git a/reticulum-sidecar/src/main.rs b/reticulum-sidecar/src/main.rs new file mode 100644 index 000000000..803b6c1c7 --- /dev/null +++ b/reticulum-sidecar/src/main.rs @@ -0,0 +1,99 @@ +//! Headless Reticulum sidecar for mesh-client. +//! +//! IPC contract aligns with Ratspeak `ratspeak-tauri` commands (see docs/reticulum-sidecar-ipc.md). + +mod api; +mod stack; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; +use tokio::sync::broadcast; +use tracing::{error, info}; + +use crate::stack::StackHandle; + +#[derive(Parser, Debug)] +#[command(name = "mesh-client-reticulum")] +struct Args { + #[arg(long, default_value = "127.0.0.1")] + host: String, + #[arg(long, default_value_t = 19437)] + port: u16, + #[arg(long)] + headless: bool, + #[arg(long)] + reticulum_config_dir: Option, + #[arg(long)] + storage_dir: Option, +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]") +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let args = Args::parse(); + if args.headless { + info!("mesh-client-reticulum headless mode"); + } + + if !is_loopback_host(&args.host) + && std::env::var("MESH_CLIENT_RETICULUM_BIND_ALL") + .ok() + .as_deref() + != Some("1") + { + error!( + host = %args.host, + "refusing to bind to non-loopback host without MESH_CLIENT_RETICULUM_BIND_ALL=1" + ); + std::process::exit(1); + } + + let config_dir = PathBuf::from( + args.reticulum_config_dir + .unwrap_or_else(|| "./reticulum-config".into()), + ); + let storage_dir = PathBuf::from( + args.storage_dir + .unwrap_or_else(|| "./reticulum-storage".into()), + ); + + 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 app = api::router(stack); + + let addr: SocketAddr = match format!("{}:{}", args.host, args.port).parse() { + Ok(addr) => addr, + Err(e) => { + error!(host = %args.host, port = args.port, error = %e, "invalid listen address"); + std::process::exit(1); + } + }; + info!(%addr, "listening"); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(e) => { + error!(%addr, error = %e, "failed to bind listen address"); + std::process::exit(1); + } + }; + if let Err(e) = axum::serve(listener, app).await { + error!(error = %e, "HTTP server exited with error"); + std::process::exit(1); + } +} diff --git a/reticulum-sidecar/src/stack/ble.rs b/reticulum-sidecar/src/stack/ble.rs new file mode 100644 index 000000000..bf3d830e9 --- /dev/null +++ b/reticulum-sidecar/src/stack/ble.rs @@ -0,0 +1,83 @@ +//! BLE availability probe and device scan (requires `rns-ble` feature). + +pub async fn ble_availability() -> serde_json::Value { + #[cfg(feature = "rns-ble")] + { + match probe_ble_adapter().await { + Ok(()) => serde_json::json!({ + "available": true, + "missing": [], + "permissions_granted": true, + "probe_failed": false + }), + Err(reason) => serde_json::json!({ + "available": false, + "missing": [reason], + "permissions_granted": false, + "probe_failed": true + }), + } + } + #[cfg(not(feature = "rns-ble"))] + { + serde_json::json!({ + "available": false, + "missing": ["rns-ble feature not enabled in this build"], + "permissions_granted": false, + "probe_failed": false + }) + } +} + +#[cfg(feature = "rns-ble")] +async fn probe_ble_adapter() -> Result<(), String> { + rns_interface::ble_rnode::scan_ble_devices(1) + .await + .map(|_| ()) +} + +/// Scan mode query: `peer` (Reticulum mesh), `rnode` (LoRa RNode hardware), or `all`. +pub async fn ble_scan(timeout_secs: u64, mode: &str) -> Result { + #[cfg(feature = "rns-ble")] + { + let timeout_secs = timeout_secs.clamp(1, 30); + let mut devices: Vec = Vec::new(); + + if mode == "peer" || mode == "all" { + let peers = rns_interface::ble_peer::scan_mesh_peers(timeout_secs).await?; + for peer in peers { + devices.push(serde_json::json!({ + "address": peer.ble_address, + "name": peer.identity_hash, + "rssi": peer.rssi, + "kind": "peer", + "identity_hash": peer.identity_hash, + })); + } + } + + if mode == "rnode" || mode == "all" { + let rnodes = rns_interface::ble_rnode::scan_ble_devices(timeout_secs).await?; + for dev in rnodes { + devices.push(serde_json::json!({ + "address": dev.address, + "name": dev.name, + "rssi": dev.rssi, + "kind": "rnode", + "bonded": dev.bonded, + })); + } + } + + if mode != "peer" && mode != "rnode" && mode != "all" { + return Err(format!("invalid scan mode: {mode}")); + } + + Ok(serde_json::json!({ "devices": devices })) + } + #[cfg(not(feature = "rns-ble"))] + { + let _ = (timeout_secs, mode); + Err("BLE feature not enabled in this build".into()) + } +} diff --git a/reticulum-sidecar/src/stack/config.rs b/reticulum-sidecar/src/stack/config.rs new file mode 100644 index 000000000..556a466b7 --- /dev/null +++ b/reticulum-sidecar/src/stack/config.rs @@ -0,0 +1,1310 @@ +//! rnsd-style INI config read/write (ConfigObj subset used by Reticulum). + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use uuid::Uuid; + +use super::types::{AddInterfaceRequest, InterfaceRow}; + +pub const CONFIG_FILENAME: &str = "config"; + +const SUPPORTED_TYPES: &[&str] = &[ + "AutoInterface", + "TCPClientInterface", + "RNodeInterface", + "UDPInterface", + "KISSInterface", + "PipeInterface", + "I2PInterface", + "RNodeMultiInterface", + "BlePeerInterface", +]; + +const SERIAL_PORT_IFACE_TYPES: &[&str] = &["rnode", "rnode_multi", "kiss"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportMode { + Merge, + Replace, +} + +impl ImportMode { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "merge" => Some(Self::Merge), + "replace" => Some(Self::Replace), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct StackSettings { + pub enable_transport: bool, + pub share_instance: bool, + pub loglevel: i32, + #[serde(default)] + pub announce_interval_sec: u32, +} + +#[derive(Debug, Clone)] +pub struct ImportResult { + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +struct IniBlock { + name: String, + values: HashMap, + order: Vec, +} + +#[derive(Debug, Clone)] +struct ParsedConfig { + reticulum: IniBlock, + logging: IniBlock, + interfaces: Vec, + /// Raw lines preserved for unknown top-level sections (future-proof). + extra_sections: Vec, +} + +pub fn config_path(config_dir: &Path) -> PathBuf { + config_dir.join(CONFIG_FILENAME) +} + +pub fn read_config(config_dir: &Path) -> Result { + let path = config_path(config_dir); + if path.exists() { + fs::read_to_string(&path).map_err(|e| e.to_string()) + } else { + Ok(default_config_content()) + } +} + +pub fn write_config(config_dir: &Path, content: &str) -> Result<(), String> { + parse_config(content)?; + fs::create_dir_all(config_dir).map_err(|e| e.to_string())?; + let path = config_path(config_dir); + let tmp_path = config_dir.join(format!("{CONFIG_FILENAME}.tmp")); + fs::write(&tmp_path, content).map_err(|e| e.to_string())?; + fs::rename(&tmp_path, &path).map_err(|e| e.to_string()) +} + +pub fn get_stack_settings(config_dir: &Path) -> Result { + let content = read_config(config_dir)?; + let parsed = parse_config(&content)?; + Ok(stack_settings_from_parsed(&parsed)) +} + +pub fn set_stack_settings(config_dir: &Path, settings: &StackSettings) -> Result<(), String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + parsed + .reticulum + .set("enable_transport", &bool_to_ini(settings.enable_transport)); + parsed + .reticulum + .set("share_instance", &bool_to_ini(settings.share_instance)); + parsed + .logging + .set("loglevel", &settings.loglevel.to_string()); + parsed + .reticulum + .set("announce_interval_sec", &settings.announce_interval_sec.to_string()); + write_config(config_dir, &serialize_config(&parsed)) +} + +pub fn interfaces_from_config(content: &str) -> Result, String> { + let parsed = parse_config(content)?; + Ok(interfaces_from_parsed(&parsed)) +} + +pub fn interfaces_from_config_dir(config_dir: &Path) -> Result, String> { + let content = read_config(config_dir)?; + interfaces_from_config(&content) +} + +pub fn sync_config_interfaces( + config_dir: &Path, + interfaces: &[InterfaceRow], +) -> Result<(), String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + parsed.interfaces = interfaces.iter().map(interface_row_to_block).collect(); + write_config(config_dir, &serialize_config(&parsed)) +} + +pub fn import_config( + config_dir: &Path, + content: &str, + mode: ImportMode, +) -> Result { + let incoming = parse_config(content)?; + let warnings = collect_unsupported_warnings(&incoming); + + let merged = match mode { + ImportMode::Replace => incoming, + ImportMode::Merge => { + let existing_content = read_config(config_dir)?; + let mut existing = parse_config(&existing_content)?; + merge_configs(&mut existing, &incoming); + existing + } + }; + + write_config(config_dir, &serialize_config(&merged))?; + Ok(ImportResult { warnings }) +} + +fn merge_configs(existing: &mut ParsedConfig, incoming: &ParsedConfig) { + for (k, v) in &incoming.reticulum.values { + existing.reticulum.set(k, v); + } + for (k, v) in &incoming.logging.values { + existing.logging.set(k, v); + } + for iface in &incoming.interfaces { + if let Some(idx) = existing + .interfaces + .iter() + .position(|i| i.name == iface.name) + { + existing.interfaces[idx] = iface.clone(); + } else { + existing.interfaces.push(iface.clone()); + } + } +} + +fn collect_unsupported_warnings(parsed: &ParsedConfig) -> Vec { + let mut warnings = Vec::new(); + for block in &parsed.interfaces { + if let Some(t) = block.get("type") { + if !SUPPORTED_TYPES.contains(&t) { + warnings.push(format!( + "interface \"{}\" has unsupported type \"{t}\" (kept in config)", + block.name + )); + } + } + } + warnings +} + +fn stack_settings_from_parsed(parsed: &ParsedConfig) -> StackSettings { + StackSettings { + enable_transport: parsed + .reticulum + .get_bool("enable_transport") + .unwrap_or(false), + share_instance: parsed.reticulum.get_bool("share_instance").unwrap_or(true), + loglevel: parsed + .logging + .get("loglevel") + .and_then(|v| v.parse().ok()) + .unwrap_or(4), + announce_interval_sec: parsed + .reticulum + .get("announce_interval_sec") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + } +} + +fn interfaces_from_parsed(parsed: &ParsedConfig) -> Vec { + parsed + .interfaces + .iter() + .filter_map(|block| interface_block_to_row(block)) + .collect() +} + +fn interface_block_to_row(block: &IniBlock) -> Option { + let raw_type = block.get("type")?; + if !SUPPORTED_TYPES.contains(&raw_type) { + return None; + } + let iface_type = config_type_to_ui(raw_type)?; + let enabled = block + .get_bool("enabled") + .or_else(|| block.get_bool("interface_enabled")) + .unwrap_or(false); + + let (host, port) = if iface_type == "tcp" { + ( + block.get("target_host").map(str::to_string), + block.get("target_port").and_then(|p| p.parse::().ok()), + ) + } else { + (None, None) + }; + + let serial_port = if SERIAL_PORT_IFACE_TYPES.contains(&iface_type) { + block.get("port").map(str::to_string) + } else { + None + }; + + let seed_addresses = if iface_type == "ble_peer" { + block + .get("seed_addresses") + .map(|s| { + s.split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + Some(InterfaceRow { + id: interface_id_from_name(&block.name), + name: block.name.clone(), + iface_type: iface_type.to_string(), + enabled, + status: if enabled { "up" } else { "down" }.into(), + host, + port, + preset: block.get("preset").map(str::to_string), + serial_port, + frequency: block.get("frequency").and_then(|v| v.parse().ok()), + bandwidth: block.get("bandwidth").and_then(|v| v.parse().ok()), + txpower: block.get("txpower").and_then(|v| v.parse().ok()), + spreading_factor: block + .get("spreadingfactor") + .or_else(|| block.get("spreading_factor")) + .and_then(|v| v.parse().ok()), + coding_rate: block.get("codingrate").and_then(|v| v.parse().ok()), + callsign: block.get("callsign").map(str::to_string), + id_interval: block.get("id_interval").and_then(|v| v.parse().ok()), + mode: block.get("mode").map(str::to_string), + seed_addresses, + }) +} + +fn interface_row_to_block(row: &InterfaceRow) -> IniBlock { + let mut block = IniBlock { + name: row.name.clone(), + values: HashMap::new(), + order: Vec::new(), + }; + block.set("type", &ui_type_to_config(&row.iface_type)); + if row.iface_type == "tcp" || row.iface_type == "udp" { + block.set("interface_enabled", &bool_to_ini(row.enabled)); + block.set("name", &row.name); + } else { + block.set("enabled", &bool_to_ini(row.enabled)); + } + + if row.iface_type == "tcp" || row.iface_type == "udp" { + if let Some(host) = &row.host { + block.set("target_host", host); + } + if let Some(port) = row.port { + block.set("target_port", &port.to_string()); + } + } + + if row.iface_type == "rnode" { + write_rnode_radio_fields(&mut block, row); + } + + if SERIAL_PORT_IFACE_TYPES.contains(&row.iface_type.as_str()) && row.iface_type != "rnode" { + if let Some(port) = &row.serial_port { + block.set("port", port); + } + } + + if row.iface_type == "ble_peer" && !row.seed_addresses.is_empty() { + block.set("seed_addresses", &row.seed_addresses.join(",")); + } + + block +} + +fn write_rnode_radio_fields(block: &mut IniBlock, row: &InterfaceRow) { + if let Some(port) = &row.serial_port { + block.set("port", port); + } + if let Some(v) = row.frequency { + block.set("frequency", &v.to_string()); + } + if let Some(v) = row.bandwidth { + block.set("bandwidth", &v.to_string()); + } + if let Some(v) = row.txpower { + block.set("txpower", &v.to_string()); + } + if let Some(v) = row.spreading_factor { + block.set("spreadingfactor", &v.to_string()); + } + if let Some(v) = row.coding_rate { + block.set("codingrate", &v.to_string()); + } + if let Some(v) = &row.callsign { + block.set("callsign", v); + } + if let Some(v) = row.id_interval { + block.set("id_interval", &v.to_string()); + } + if let Some(v) = &row.mode { + block.set("mode", v); + } + if let Some(v) = &row.preset { + block.set("preset", v); + } +} + +pub fn add_interface_to_config( + config_dir: &Path, + req: &AddInterfaceRequest, +) -> Result { + let id = Uuid::new_v4().to_string(); + let name = req + .name + .clone() + .unwrap_or_else(|| format!("{}-{}", req.iface_type, &id[..8])); + + let mut row = InterfaceRow { + id: interface_id_from_name(&name), + name, + iface_type: req.iface_type.clone(), + enabled: true, + status: "pending".into(), + host: req.host.clone(), + port: req.port, + preset: req.preset.clone(), + serial_port: req.serial_port.clone(), + frequency: req.frequency, + bandwidth: req.bandwidth, + txpower: req.txpower, + spreading_factor: req.spreading_factor, + coding_rate: req.coding_rate, + callsign: req.callsign.clone(), + id_interval: req.id_interval, + mode: req.mode.clone(), + seed_addresses: req.seed_addresses.clone(), + }; + + apply_preset_defaults(&mut row); + + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + if parsed + .interfaces + .iter() + .any(|b| interface_id_from_name(&b.name) == row.id) + { + row.id = format!("{}-{}", row.id, &id[..4]); + } + parsed.interfaces.push(interface_row_to_block(&row)); + write_config(config_dir, &serialize_config(&parsed))?; + Ok(row) +} + +pub fn update_interface_in_config( + config_dir: &Path, + id: &str, + patch: &UpdateInterfacePatch, +) -> Result { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let idx = parsed + .interfaces + .iter() + .position(|b| interface_id_from_name(&b.name) == id) + .ok_or_else(|| format!("interface not found: {id}"))?; + + let mut row = interface_block_to_row(&parsed.interfaces[idx]) + .ok_or_else(|| format!("interface not found or unsupported: {id}"))?; + + let preset_before = row.preset.clone(); + + if let Some(v) = &patch.name { + row.name = v.clone(); + } + if let Some(v) = &patch.iface_type { + row.iface_type = v.clone(); + } + if let Some(v) = patch.enabled { + row.enabled = v; + row.status = if v { "up" } else { "down" }.into(); + } + if patch.host.is_some() { + row.host = patch.host.clone(); + } + if patch.port.is_some() { + row.port = patch.port; + } + if patch.serial_port.is_some() { + row.serial_port = patch.serial_port.clone(); + } + if patch.preset.is_some() { + row.preset = patch.preset.clone(); + } + if patch.frequency.is_some() { + row.frequency = patch.frequency; + } + if patch.bandwidth.is_some() { + row.bandwidth = patch.bandwidth; + } + if patch.txpower.is_some() { + row.txpower = patch.txpower; + } + if patch.spreading_factor.is_some() { + row.spreading_factor = patch.spreading_factor; + } + if patch.coding_rate.is_some() { + row.coding_rate = patch.coding_rate; + } + if patch.callsign.is_some() { + row.callsign = patch.callsign.clone(); + } + if patch.id_interval.is_some() { + row.id_interval = patch.id_interval; + } + if patch.mode.is_some() { + row.mode = patch.mode.clone(); + } + if patch.seed_addresses.is_some() { + row.seed_addresses = patch.seed_addresses.clone().unwrap_or_default(); + } + + let preset_changed = patch.preset.is_some() && patch.preset != preset_before; + if preset_changed { + crate::stack::rf_profiles::force_apply_profile_defaults_to_row(&mut row); + } else { + apply_preset_defaults(&mut row); + } + + parsed.interfaces[idx] = interface_row_to_block(&row); + write_config(config_dir, &serialize_config(&parsed))?; + Ok(row) +} + +pub fn delete_interface_from_config(config_dir: &Path, id: &str) -> Result<(), String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let len_before = parsed.interfaces.len(); + parsed + .interfaces + .retain(|b| interface_id_from_name(&b.name) != id); + if parsed.interfaces.len() == len_before { + return Err(format!("interface not found: {id}")); + } + write_config(config_dir, &serialize_config(&parsed)) +} + +pub fn set_interface_enabled_in_config( + config_dir: &Path, + id: &str, + enabled: bool, +) -> Result<(), String> { + update_interface_in_config( + config_dir, + id, + &UpdateInterfacePatch { + enabled: Some(enabled), + ..UpdateInterfacePatch::default() + }, + )?; + Ok(()) +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct UpdateInterfacePatch { + pub name: Option, + #[serde(rename = "type")] + pub iface_type: Option, + pub enabled: Option, + pub host: Option, + pub port: Option, + pub preset: Option, + pub serial_port: Option, + pub frequency: Option, + pub bandwidth: Option, + pub txpower: Option, + pub spreading_factor: Option, + pub coding_rate: Option, + pub callsign: Option, + pub id_interval: Option, + pub mode: Option, + pub seed_addresses: Option>, +} + +/// Expand `preset` into concrete radio fields on disk when INI rows are incomplete. +pub fn repair_rnode_radio_fields_in_config(config_dir: &Path) -> Result { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut changed = false; + for block in &mut parsed.interfaces { + let Some(mut row) = interface_block_to_row(block) else { + continue; + }; + if !rnode_needs_preset_expansion(&row) { + continue; + } + apply_preset_defaults(&mut row); + *block = interface_row_to_block(&row); + changed = true; + } + if changed { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(changed) +} + +fn rnode_needs_preset_expansion(row: &InterfaceRow) -> bool { + if row.iface_type != "rnode" { + return false; + } + let preset = row.preset.as_deref().unwrap_or(""); + if !crate::stack::rf_profiles::known_preset_ids().iter().any(|id| id == preset) { + return false; + } + row.frequency.is_none() + || row.bandwidth.is_none() + || row.spreading_factor.is_none() + || row.coding_rate.is_none() + || row.txpower.is_none() +} + +fn apply_preset_defaults(row: &mut InterfaceRow) { + crate::stack::rf_profiles::apply_profile_defaults_to_row(row); +} + +/// INI block metadata for config audit (TCP enable-key checks). +#[derive(Debug, Clone)] +pub struct ConfigAuditIniBlock { + pub name: String, + pub iface_type: Option, + pub has_enabled_key: bool, + pub has_interface_enabled_key: bool, + pub enabled: bool, + pub has_name_field: bool, +} + +pub fn list_interface_ini_blocks_for_audit(config_dir: &Path) -> Result, String> { + let content = read_config(config_dir)?; + let parsed = parse_config(&content)?; + Ok(parsed + .interfaces + .iter() + .map(|block| ConfigAuditIniBlock { + name: block.name.clone(), + iface_type: block.get("type").map(str::to_string), + has_enabled_key: block.get("enabled").is_some(), + has_interface_enabled_key: block.get("interface_enabled").is_some(), + enabled: block + .get_bool("enabled") + .or_else(|| block.get_bool("interface_enabled")) + .unwrap_or(false), + has_name_field: block.get("name").is_some(), + }) + .collect()) +} + +pub fn repair_tcp_blocks_in_config(config_dir: &Path) -> Result, String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut repaired = Vec::new(); + for block in &mut parsed.interfaces { + if block.get("type") != Some("TCPClientInterface") { + continue; + } + let enabled = block + .get_bool("enabled") + .or_else(|| block.get_bool("interface_enabled")) + .unwrap_or(false); + block.set("interface_enabled", &bool_to_ini(enabled)); + if block.get("name").is_none() { + let section_name = block.name.clone(); + block.set("name", §ion_name); + } + if block.values.contains_key("enabled") { + block.values.remove("enabled"); + block.order.retain(|k| k != "enabled"); + } + repaired.push(block.name.clone()); + } + if !repaired.is_empty() { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(repaired) +} + +pub fn add_default_auto_interface(config_dir: &Path) -> Result { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + if parsed + .interfaces + .iter() + .any(|b| b.get("type") == Some("AutoInterface")) + { + return Ok(false); + } + let mut block = IniBlock::new("Default Interface"); + block.set("type", "AutoInterface"); + block.set("enabled", "Yes"); + block.set("name", "Default Interface"); + parsed.interfaces.insert(0, block); + write_config(config_dir, &serialize_config(&parsed))?; + Ok(true) +} + +pub fn normalize_legacy_preset_ids(config_dir: &Path) -> Result, String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut changed_names = Vec::new(); + for block in &mut parsed.interfaces { + let Some(mut row) = interface_block_to_row(block) else { + continue; + }; + let Some(preset) = row.preset.clone() else { + continue; + }; + let Some(profile) = crate::stack::rf_profiles::rf_profile_by_id(&preset) else { + continue; + }; + let Some(canonical) = profile.canonical_id.clone() else { + continue; + }; + row.preset = Some(canonical); + crate::stack::rf_profiles::force_apply_profile_defaults_to_row(&mut row); + *block = interface_row_to_block(&row); + changed_names.push(row.name); + } + if !changed_names.is_empty() { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(changed_names) +} + +pub fn apply_preset_defaults_to_config_rnodes(config_dir: &Path) -> Result, String> { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut changed_names = Vec::new(); + for block in &mut parsed.interfaces { + let Some(mut row) = interface_block_to_row(block) else { + continue; + }; + if row.iface_type != "rnode" { + continue; + } + let Some(preset) = row.preset.clone() else { + continue; + }; + let Some(profile) = crate::stack::rf_profiles::rf_profile_by_id(&preset) else { + continue; + }; + if crate::stack::rf_profiles::row_params_match_preset(&row) { + continue; + } + if let Some(canonical) = profile.canonical_id.clone() { + row.preset = Some(canonical); + } + crate::stack::rf_profiles::force_apply_profile_defaults_to_row(&mut row); + *block = interface_row_to_block(&row); + changed_names.push(row.name); + } + if !changed_names.is_empty() { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(changed_names) +} + +pub fn interface_id_from_name(name: &str) -> String { + let slug: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + slug.trim_matches('-').to_string() +} + +fn config_type_to_ui(raw: &str) -> Option<&'static str> { + match raw { + "AutoInterface" => Some("auto"), + "TCPClientInterface" => Some("tcp"), + "RNodeInterface" => Some("rnode"), + "UDPInterface" => Some("udp"), + "KISSInterface" => Some("kiss"), + "PipeInterface" => Some("pipe"), + "I2PInterface" => Some("i2p"), + "RNodeMultiInterface" => Some("rnode_multi"), + "BlePeerInterface" => Some("ble_peer"), + _ => None, + } +} + +fn ui_type_to_config(ui: &str) -> String { + match ui { + "auto" => "AutoInterface".into(), + "tcp" => "TCPClientInterface".into(), + "rnode" => "RNodeInterface".into(), + "udp" => "UDPInterface".into(), + "kiss" => "KISSInterface".into(), + "pipe" => "PipeInterface".into(), + "i2p" => "I2PInterface".into(), + "rnode_multi" => "RNodeMultiInterface".into(), + "ble_peer" => "BlePeerInterface".into(), + other => other.to_string(), + } +} + +fn bool_to_ini(v: bool) -> String { + if v { "Yes".into() } else { "No".into() } +} + +impl IniBlock { + fn new(name: impl Into) -> Self { + Self { + name: name.into(), + values: HashMap::new(), + order: Vec::new(), + } + } + + fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } + + fn get_bool(&self, key: &str) -> Option { + self.get(key).and_then(parse_bool) + } + + fn set(&mut self, key: &str, value: &str) { + if !self.values.contains_key(key) { + self.order.push(key.to_string()); + } + self.values.insert(key.to_string(), value.to_string()); + } +} + +fn parse_bool(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "yes" | "true" | "on" | "1" => Some(true), + "no" | "false" | "off" | "0" => Some(false), + _ => None, + } +} + +fn parse_config(input: &str) -> Result { + let mut reticulum = IniBlock::new("reticulum"); + let mut logging = IniBlock::new("logging"); + let mut interfaces: Vec = Vec::new(); + let mut extra_sections: Vec = Vec::new(); + + let mut current_top: Option = None; + let mut current_iface: Option = None; + + for (line_num, raw_line) in input.lines().enumerate() { + let line = strip_comment(raw_line).trim(); + if line.is_empty() { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + let open = line.chars().take_while(|&c| c == '[').count(); + let close = line.chars().rev().take_while(|&c| c == ']').count(); + if open != close { + return Err(format!("line {}: mismatched brackets", line_num + 1)); + } + let name = line[open..line.len() - close].trim(); + if name.is_empty() { + return Err(format!("line {}: empty section name", line_num + 1)); + } + + if open == 1 { + current_iface = None; + current_top = Some(name.to_string()); + if name != "reticulum" && name != "logging" && name != "interfaces" { + extra_sections.push(format!("[{name}]")); + } + } else if open == 2 { + if current_top.as_deref() != Some("interfaces") { + return Err(format!( + "line {}: interface subsection outside [interfaces]", + line_num + 1 + )); + } + interfaces.push(IniBlock::new(name.to_string())); + current_iface = Some(interfaces.len() - 1); + } else { + return Err(format!( + "line {}: nesting depth > 2 not supported", + line_num + 1 + )); + } + continue; + } + + let Some(eq) = line.find('=') else { + return Err(format!("line {}: unrecognized line", line_num + 1)); + }; + let key = line[..eq].trim(); + let value = line[eq + 1..].trim().trim_matches('"').to_string(); + if key.is_empty() { + return Err(format!("line {}: empty key", line_num + 1)); + } + + match current_top.as_deref() { + Some("reticulum") => reticulum.set(key, &value), + Some("logging") => logging.set(key, &value), + Some("interfaces") => { + if let Some(idx) = current_iface { + interfaces[idx].set(key, &value); + } + } + Some(other) => { + extra_sections.push(format!("{key} = {value} # section={other}")); + } + None => extra_sections.push(format!("{key} = {value}")), + } + } + + Ok(ParsedConfig { + reticulum, + logging, + interfaces, + extra_sections, + }) +} + +fn strip_comment(line: &str) -> &str { + let mut in_quote = false; + for (i, ch) in line.char_indices() { + match ch { + '"' => in_quote = !in_quote, + '#' if !in_quote => return &line[..i], + _ => {} + } + } + line +} + +fn serialize_config(parsed: &ParsedConfig) -> String { + let mut out = String::new(); + out.push_str("# mesh-client-reticulum sidecar config\n\n"); + write_block_section(&mut out, "reticulum", &parsed.reticulum); + out.push('\n'); + write_block_section(&mut out, "logging", &parsed.logging); + out.push_str("\n[interfaces]\n\n"); + for iface in &parsed.interfaces { + out.push_str(&format!("[[{}]]\n", iface.name)); + for key in &iface.order { + if let Some(value) = iface.values.get(key) { + out.push_str(&format!("{key} = {value}\n")); + } + } + out.push('\n'); + } + for line in &parsed.extra_sections { + out.push_str(line); + out.push('\n'); + } + out +} + +fn write_block_section(out: &mut String, section: &str, block: &IniBlock) { + out.push_str(&format!("[{section}]\n")); + for key in &block.order { + if let Some(value) = block.values.get(key) { + out.push_str(&format!("{key} = {value}\n")); + } + } +} + +fn default_config_content() -> String { + serialize_config(&ParsedConfig { + reticulum: { + let mut b = IniBlock::new("reticulum"); + b.set("enable_transport", "No"); + b.set("share_instance", "Yes"); + b.set("instance_name", "default"); + b + }, + logging: { + let mut b = IniBlock::new("logging"); + b.set("loglevel", "4"); + b + }, + interfaces: Vec::new(), + extra_sections: Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"[reticulum] +enable_transport = No +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] + +[[Auto Peer]] +type = AutoInterface +enabled = Yes + +[[TCP Upstream]] +type = TCPClientInterface +interface_enabled = true +target_host = 127.0.0.1 +target_port = 4242 + +[[LoRa Node]] +type = RNodeInterface +enabled = No +port = /dev/ttyUSB0 +frequency = 915000000 +bandwidth = 125000 +txpower = 7 +spreadingfactor = 8 +codingrate = 5 +"#; + + #[test] + fn parses_auto_tcp_rnode_with_enabled_variants() { + let parsed = parse_config(SAMPLE).unwrap(); + let rows = interfaces_from_parsed(&parsed); + assert_eq!(rows.len(), 3); + + let auto = rows.iter().find(|r| r.iface_type == "auto").unwrap(); + assert!(auto.enabled); + assert_eq!(auto.name, "Auto Peer"); + + let tcp = rows.iter().find(|r| r.iface_type == "tcp").unwrap(); + assert!(tcp.enabled); + assert_eq!(tcp.host.as_deref(), Some("127.0.0.1")); + assert_eq!(tcp.port, Some(4242)); + + let rnode = rows.iter().find(|r| r.iface_type == "rnode").unwrap(); + assert!(!rnode.enabled); + assert_eq!(rnode.serial_port.as_deref(), Some("/dev/ttyUSB0")); + assert_eq!(rnode.frequency, Some(915_000_000)); + assert_eq!(rnode.spreading_factor, Some(8)); + } + + #[test] + fn round_trip_preserves_interfaces() { + let parsed = parse_config(SAMPLE).unwrap(); + let serialized = serialize_config(&parsed); + let reparsed = parse_config(&serialized).unwrap(); + let rows = interfaces_from_parsed(&reparsed); + assert_eq!(rows.len(), 3); + } + + #[test] + fn kiss_and_rnode_multi_serial_port_round_trip() { + let content = r#" +[interfaces] +[[KISS Radio]] +type = KISSInterface +enabled = Yes +port = /dev/ttyUSB1 + +[[Multi RNode]] +type = RNodeMultiInterface +enabled = Yes +port = /dev/ttyACM0 +"#; + let parsed = parse_config(content).unwrap(); + let rows = interfaces_from_parsed(&parsed); + let kiss = rows.iter().find(|r| r.iface_type == "kiss").unwrap(); + assert_eq!(kiss.serial_port.as_deref(), Some("/dev/ttyUSB1")); + let multi = rows.iter().find(|r| r.iface_type == "rnode_multi").unwrap(); + assert_eq!(multi.serial_port.as_deref(), Some("/dev/ttyACM0")); + + let kiss_block = interface_row_to_block(kiss); + assert_eq!(kiss_block.get("port"), Some("/dev/ttyUSB1")); + let multi_block = interface_row_to_block(multi); + assert_eq!(multi_block.get("port"), Some("/dev/ttyACM0")); + } + + #[test] + fn rnode_tcp_serial_port_round_trip() { + let content = r#" +[interfaces] +[[WiFi RNode]] +type = RNodeInterface +enabled = Yes +port = tcp://192.168.1.10:7633 +frequency = 915000000 +bandwidth = 125000 +spreadingfactor = 8 +codingrate = 5 +txpower = 17 +"#; + let parsed = parse_config(content).unwrap(); + let rows = interfaces_from_parsed(&parsed); + let rnode = rows.iter().find(|r| r.iface_type == "rnode").unwrap(); + assert_eq!(rnode.serial_port.as_deref(), Some("tcp://192.168.1.10:7633")); + + let block = interface_row_to_block(rnode); + assert_eq!(block.get("port"), Some("tcp://192.168.1.10:7633")); + let serialized = serialize_config(&parsed); + assert!(serialized.contains("port = tcp://192.168.1.10:7633")); + } + + #[test] + fn ble_peer_seed_addresses_round_trip() { + let content = r#" +[interfaces] +[[BLE Peer]] +type = BlePeerInterface +enabled = Yes +seed_addresses = AA:BB:CC:DD:EE:FF,RNode 1234 +"#; + let parsed = parse_config(content).unwrap(); + let rows = interfaces_from_parsed(&parsed); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].iface_type, "ble_peer"); + assert_eq!( + rows[0].seed_addresses, + vec!["AA:BB:CC:DD:EE:FF".to_string(), "RNode 1234".to_string()] + ); + let serialized = serialize_config(&parsed); + assert!(serialized.contains("seed_addresses = AA:BB:CC:DD:EE:FF,RNode 1234")); + } + + #[test] + fn extra_sections_round_trip() { + let content = r#"[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] + +[[Auto Peer]] +type = AutoInterface +enabled = Yes + +[future_section] +future_key = future_value +"#; + let parsed = parse_config(content).unwrap(); + assert!(!parsed.extra_sections.is_empty()); + let serialized = serialize_config(&parsed); + assert!(serialized.contains("[future_section]")); + assert!(serialized.contains("future_key = future_value")); + let reparsed = parse_config(&serialized).unwrap(); + assert_eq!(parsed.extra_sections, reparsed.extra_sections); + } + + #[test] + fn write_config_rejects_invalid_content() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let err = write_config(&dir, "not valid ini [[[").unwrap_err(); + assert!(!err.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn stack_settings_from_sample() { + let parsed = parse_config(SAMPLE).unwrap(); + let settings = stack_settings_from_parsed(&parsed); + assert!(!settings.enable_transport); + assert!(settings.share_instance); + assert_eq!(settings.loglevel, 4); + } + + #[test] + fn import_merge_adds_interface() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_config(&dir, SAMPLE).unwrap(); + + let extra = r#" +[interfaces] +[[New TCP]] +type = TCPClientInterface +enabled = Yes +target_host = 10.0.0.1 +target_port = 5000 +"#; + import_config(&dir, extra, ImportMode::Merge).unwrap(); + let rows = interfaces_from_config_dir(&dir).unwrap(); + assert_eq!(rows.len(), 4); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_rnode_preset_writes_missing_radio_fields() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let content = r#" +[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] +[[BLE RNode]] +type = RNodeInterface +enabled = Yes +port = ble://a399d3be-fa79-45ab-a394-7d9299682617 +preset = rnode_us915 +"#; + write_config(&dir, content).unwrap(); + assert!(repair_rnode_radio_fields_in_config(&dir).unwrap()); + + let repaired = read_config(&dir).unwrap(); + assert!(repaired.contains("frequency = 914875000")); + assert!(repaired.contains("bandwidth = 125000")); + assert!(repaired.contains("spreadingfactor = 8")); + assert!(repaired.contains("codingrate = 5")); + assert!(repaired.contains("txpower = 17")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_rnode_adds_txpower_when_only_frequency_present() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let content = r#" +[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] +[[BLE RNode]] +type = RNodeInterface +enabled = Yes +port = ble://a399d3be-fa79-45ab-a394-7d9299682617 +preset = rnode_us915 +frequency = 915000000 +bandwidth = 125000 +spreadingfactor = 8 +codingrate = 5 +"#; + write_config(&dir, content).unwrap(); + assert!(repair_rnode_radio_fields_in_config(&dir).unwrap()); + + let repaired = read_config(&dir).unwrap(); + assert!(repaired.contains("txpower = 17")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn update_interface_applies_preset_defaults() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let content = r#" +[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] +[[BLE RNode]] +type = RNodeInterface +enabled = Yes +port = ble://a399d3be-fa79-45ab-a394-7d9299682617 +"#; + write_config(&dir, content).unwrap(); + + let row = update_interface_in_config( + &dir, + "ble-rnode", + &UpdateInterfacePatch { + preset: Some("rnode_us915".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.frequency, Some(914_875_000)); + assert_eq!(row.txpower, Some(17)); + + let updated = read_config(&dir).unwrap(); + assert!(updated.contains("frequency = 914875000")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn apply_preset_repair_overwrites_deviating_rnode_frequency() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let content = r#" +[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] +[[NV0N2]] +type = RNodeInterface +enabled = Yes +port = /dev/cu.usbserial-test +preset = rnode_us915 +frequency = 915000000 +bandwidth = 125000 +spreadingfactor = 8 +codingrate = 5 +"#; + write_config(&dir, content).unwrap(); + let repaired = apply_preset_defaults_to_config_rnodes(&dir).unwrap(); + assert_eq!(repaired, vec!["NV0N2"]); + + let updated = read_config(&dir).unwrap(); + assert!(updated.contains("frequency = 914875000")); + assert!(updated.contains("preset = rnode_us")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn update_interface_keeps_custom_frequency_when_preset_unchanged() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let content = r#" +[reticulum] +share_instance = Yes + +[logging] +loglevel = 4 + +[interfaces] +[[NV0N2]] +type = RNodeInterface +enabled = Yes +port = /dev/cu.usbserial-test +preset = rnode_us +frequency = 914875000 +bandwidth = 125000 +spreadingfactor = 8 +codingrate = 5 +txpower = 17 +"#; + write_config(&dir, content).unwrap(); + + let row = update_interface_in_config( + &dir, + "nv0n2", + &UpdateInterfacePatch { + preset: Some("rnode_us".into()), + frequency: Some(915_000_000), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.frequency, Some(915_000_000)); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/reticulum-sidecar/src/stack/config_audit.rs b/reticulum-sidecar/src/stack/config_audit.rs new file mode 100644 index 000000000..16868f34c --- /dev/null +++ b/reticulum-sidecar/src/stack/config_audit.rs @@ -0,0 +1,352 @@ +//! Config audit + repair for Reticulum interface INI. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::config::{ + self, interface_id_from_name, list_interface_ini_blocks_for_audit, StackSettings, +}; +use super::rf_profiles::{match_params_to_profile, rf_profile_by_id}; +use super::types::InterfaceRow; + +pub const SHARED_INSTANCE_NAME: &str = "SharedInstanceServer"; + +#[derive(Debug, Clone, Serialize)] +pub struct ConfigAuditIssue { + pub kind: String, + pub severity: String, + pub interface_id: Option, + pub interface_name: Option, + pub message: String, + pub repair_kind: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ConfigRepairRequest { + #[serde(default)] + pub repair_kinds: Vec, +} + +pub fn audit_config( + config_dir: &Path, + live_interfaces: &[InterfaceRow], + stack_settings: &StackSettings, + stack_running: bool, +) -> Result, String> { + let mut issues = Vec::new(); + let config_rows = config::interfaces_from_config_dir(config_dir).unwrap_or_default(); + + let live_by_name: HashMap = live_interfaces + .iter() + .map(|i| (i.name.clone(), i)) + .collect(); + + for block in list_interface_ini_blocks_for_audit(config_dir)? { + let id = interface_id_from_name(&block.name); + if block.iface_type.as_deref() == Some("TCPClientInterface") && block.enabled { + if block.has_enabled_key && !block.has_interface_enabled_key { + issues.push(issue( + "tcp_enable_key", + "error", + Some(id.clone()), + Some(block.name.clone()), + format!( + "TCP interface \"{}\" uses enabled=Yes but RNS requires interface_enabled", + block.name + ), + Some("repair_config"), + )); + } + if !block.has_name_field { + issues.push(issue( + "tcp_missing_name", + "warning", + Some(id.clone()), + Some(block.name.clone()), + format!("TCP interface \"{}\" missing name = field", block.name), + Some("repair_config"), + )); + } + } + } + + for row in &config_rows { + if row.iface_type == "rnode" && row.enabled { + audit_rnode_row(row, &mut issues); + } + } + + if stack_running { + for row in config_rows.iter().filter(|r| r.enabled) { + if row.iface_type != "tcp" { + continue; + } + if !live_by_name.contains_key(&row.name) { + issues.push(issue( + "ghost_interface", + "error", + Some(row.id.clone()), + Some(row.name.clone()), + format!( + "Interface \"{}\" enabled in config but not loaded by RNS", + row.name + ), + Some("repair_config"), + )); + } + } + } + + for live in live_interfaces { + if live.iface_type == "tcp" && live.enabled && live.status != "up" { + issues.push(issue( + "tcp_unreachable", + "warning", + Some(live.id.clone()), + Some(live.name.clone()), + format!("TCP interface \"{}\" is unreachable", live.name), + Some("disable"), + )); + } + if live.name == SHARED_INSTANCE_NAME { + issues.push(issue( + "runtime_only_interface", + "info", + Some(live.id.clone()), + Some(live.name.clone()), + "Runtime shared-instance server (not in config)".into(), + None, + )); + } + } + + let has_auto_config = config_rows + .iter() + .any(|r| r.iface_type == "auto" && r.enabled); + if stack_running && !has_auto_config { + issues.push(issue( + "missing_auto_interface", + "warning", + None, + None, + "No enabled AutoInterface — local LAN discovery is off".into(), + Some("add_auto"), + )); + } + + if stack_running { + if let Some(auto) = live_interfaces + .iter() + .find(|i| i.iface_type == "auto" || i.name == "Default Interface") + { + if auto.enabled && auto.status != "up" { + issues.push(issue( + "auto_interface_down", + "warning", + Some(auto.id.clone()), + Some(auto.name.clone()), + format!("AutoInterface \"{}\" is enabled but down", auto.name), + Some("restart_stack"), + )); + } + } + } + + let shared_live = live_interfaces.iter().find(|i| i.name == SHARED_INSTANCE_NAME); + if stack_settings.share_instance { + if stack_running && shared_live.map(|i| i.status.as_str()) != Some("up") { + issues.push(issue( + "missing_shared_instance", + "warning", + shared_live.map(|i| i.id.clone()), + Some(SHARED_INSTANCE_NAME.into()), + "share_instance is on but SharedInstanceServer is not up".into(), + Some("restart_stack"), + )); + } + } else if shared_live.is_some() { + issues.push(issue( + "shared_instance_unexpected", + "info", + shared_live.map(|i| i.id.clone()), + Some(SHARED_INSTANCE_NAME.into()), + "SharedInstanceServer is live but share_instance is off — restart stack".into(), + Some("restart_stack"), + )); + } + + let enabled_rnodes: Vec<&InterfaceRow> = live_interfaces + .iter() + .filter(|i| i.iface_type == "rnode" && i.enabled) + .collect(); + if enabled_rnodes.len() >= 2 { + let mut keys = HashSet::new(); + for r in &enabled_rnodes { + if let Some(p) = match_params_to_profile( + r.frequency, + r.bandwidth, + r.spreading_factor, + r.coding_rate, + ) { + keys.insert(p.id); + } else { + keys.insert(format!( + "custom:{}:{}:{}", + r.frequency.unwrap_or(0), + r.bandwidth.unwrap_or(0), + r.spreading_factor.unwrap_or(0) + )); + } + } + if keys.len() > 1 { + issues.push(issue( + "rf_cross_mismatch", + "warning", + None, + None, + "Multiple enabled RNodes use different RF parameters".into(), + Some("edit"), + )); + } + } + + for r in &enabled_rnodes { + if let Some(profile) = match_params_to_profile( + r.frequency, + r.bandwidth, + r.spreading_factor, + r.coding_rate, + ) { + if profile.tier == "fallback" { + issues.push(issue( + "rf_using_fallback", + "info", + Some(r.id.clone()), + Some(r.name.clone()), + format!( + "RNode \"{}\" uses global fallback profile {}", + r.name, profile.id + ), + Some("edit"), + )); + } + } + } + + Ok(issues) +} + +fn audit_rnode_row(row: &InterfaceRow, issues: &mut Vec) { + if let Some(ref preset) = row.preset { + if let Some(profile) = rf_profile_by_id(preset) { + if !super::rf_profiles::params_match_profile( + row.frequency, + row.bandwidth, + row.spreading_factor, + row.coding_rate, + &profile, + ) { + issues.push(issue( + "rf_preset_deviation", + "warning", + Some(row.id.clone()), + Some(row.name.clone()), + format!("RNode \"{}\" params differ from preset {}", row.name, preset), + Some("apply_preset"), + )); + } + if profile.canonical_id.is_some() && profile.tier == "legacy" { + issues.push(issue( + "rf_legacy_preset_id", + "info", + Some(row.id.clone()), + Some(row.name.clone()), + format!( + "Legacy preset \"{}\" — consider {}", + preset, + profile.canonical_id.as_deref().unwrap_or(preset) + ), + Some("repair_config"), + )); + } + } + } else if row.frequency.is_some() + && match_params_to_profile( + row.frequency, + row.bandwidth, + row.spreading_factor, + row.coding_rate, + ) + .is_none() + { + issues.push(issue( + "rf_unknown_params", + "warning", + Some(row.id.clone()), + Some(row.name.clone()), + format!( + "RNode \"{}\" RF params match no coordinated or fallback profile", + row.name + ), + Some("edit"), + )); + } +} + +fn issue( + kind: &str, + severity: &str, + interface_id: Option, + interface_name: Option, + message: String, + repair_kind: Option<&str>, +) -> ConfigAuditIssue { + ConfigAuditIssue { + kind: kind.into(), + severity: severity.into(), + interface_id, + interface_name, + message, + repair_kind: repair_kind.map(str::to_string), + } +} + +pub fn repair_config( + config_dir: &Path, + request: &ConfigRepairRequest, +) -> Result<(Vec, bool), String> { + let kinds: HashSet<&str> = request.repair_kinds.iter().map(|s| s.as_str()).collect(); + let repair_all = kinds.is_empty(); + let mut repaired = Vec::new(); + let mut restart_required = false; + + let run_repair_config = repair_all || kinds.contains("repair_config"); + let run_apply_preset = repair_all || kinds.contains("apply_preset") || kinds.contains("repair_config"); + + if run_repair_config { + for name in config::repair_tcp_blocks_in_config(config_dir)? { + repaired.push(format!("tcp:{name}")); + restart_required = true; + } + for name in config::normalize_legacy_preset_ids(config_dir)? { + repaired.push(format!("preset_id:{name}")); + restart_required = true; + } + } + if run_apply_preset { + for name in config::apply_preset_defaults_to_config_rnodes(config_dir)? { + repaired.push(format!("rnode_preset:{name}")); + restart_required = true; + } + } + if repair_all || kinds.contains("add_auto") { + if config::add_default_auto_interface(config_dir)? { + repaired.push("add_auto:Default Interface".into()); + restart_required = true; + } + } + + Ok((repaired, restart_required)) +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs new file mode 100644 index 000000000..59d397580 --- /dev/null +++ b/reticulum-sidecar/src/stack/live.rs @@ -0,0 +1,1310 @@ +//! Live rsReticulum bridge (optional runtime queries + LXMF send/receive). + +#[path = "lxmf_outbound.rs"] +mod lxmf_outbound; + +use std::collections::HashMap; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::AtomicBool; +use std::time::Duration; + +use lxmf_core::constants::{DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE}; +use lxmf_core::message::LxMessage; +use lxmf_core::router::LxmRouter; +use rns_identity::destination::Destination; +use rns_identity::identity::Identity; +use rns_runtime::link_client::LinkClient; +use rns_runtime::lifecycle::ShutdownSignal; +use rns_runtime::reticulum; +use rns_transport::messages::{ + AnnounceHandlerEvent, TransportMessage, TransportQuery, TransportQueryResponse, +}; +use tokio::sync::{RwLock, broadcast}; + +use super::StackHandle; +use super::config; +use super::nomad_file::nomad_file_name_from_path; +use super::nomad_timeouts; +use super::packet_log::{emit_wire_packet_event, wire_packet_from_tap, PacketLogBuffer}; +use super::persistence::PersistedState; +use super::propagation_bridge::PropagationBridge; +use super::types::{InterfaceRow, LxmfReactionRequest, LxmfResourceRequest, LxmfSendRequest, PeerRow}; +use super::via::{ + classify_interface, merge_live_interfaces_with_config, resolve_outbound_sent_via, + resolve_peer_sent_via, +}; +use lxmf_outbound::LxmfOutboundDriver; + +/// Cap blocking transport control queries so HTTP handlers return cached state +/// before the Electron IPC proxy GET timeout (10s default). +const TRANSPORT_QUERY_TIMEOUT: Duration = Duration::from_secs(8); + +/// Aspect Nomad Network nodes announce and serve page/file requests under. +const NOMAD_NODE_ASPECT: &str = "nomadnetwork.node"; + +#[cfg(feature = "rns-ble")] +struct BlePeerRuntimeState { + spawned: HashMap, + foreground_wake: Arc, +} + +pub struct LiveBridge { + config_dir: PathBuf, + storage_dir: PathBuf, + handle: reticulum::ReticulumHandle, + _shutdown: ShutdownSignal, + router: Arc>, + identity: Identity, + lxmf_hash_hex: String, + display_name: String, + peer_via_cache: Arc>>, + outbound: Arc>, + propagation: Arc, + sync_cancel: Arc, + event_tx: broadcast::Sender, + #[cfg(feature = "rns-ble")] + ble_peer_state: Arc>, +} + +impl LiveBridge { + pub async fn spawn( + config_dir: PathBuf, + storage_dir: PathBuf, + event_tx: broadcast::Sender, + packet_log: Arc, + inner: Arc>, + ) -> Result { + let config_str = config_dir + .to_str() + .ok_or("invalid config dir path")? + .to_string(); + let shutdown = ShutdownSignal::new(); + let is_foreground = Arc::new(AtomicBool::new(true)); + let handle = reticulum::init(Some(&config_str), None, shutdown.clone(), is_foreground) + .await + .map_err(|e| format!("RNS init failed: {e:?}"))?; + + handle + .enable_on_network_discovery(Arc::new( + lxmf_core::discovery_stamper::LxmfDiscoveryStamper::default(), + )) + .await; + + let (tap_tx, mut tap_rx) = broadcast::channel(256); + handle.register_packet_tap(tap_tx).await; + let packet_log_tap = packet_log.clone(); + let event_tx_tap = event_tx.clone(); + tokio::spawn(async move { + loop { + match tap_rx.recv().await { + Ok(evt) => { + let row = wire_packet_from_tap(&evt); + packet_log_tap.push(row.clone()); + emit_wire_packet_event(&event_tx_tap, &row); + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + let identity_path = config_dir.join("identity"); + let identity_configured = inner.read().await.identity.configured; + let identity = if identity_path.exists() { + Identity::from_file(&identity_path).map_err(|e| format!("load identity: {e}"))? + } else if identity_configured { + Identity::new() + } else { + return Err("identity not configured for live stack".into()); + }; + + if !identity_path.exists() { + identity + .to_file(&identity_path) + .map_err(|e| format!("save identity: {e}"))?; + } + + const LXMF_APP: &str = "lxmf.delivery"; + let lxmf_dest_hash = + Destination::hash_from_name_and_identity(LXMF_APP, Some(&identity.hash)); + let lxmf_hash_hex = hex::encode(lxmf_dest_hash); + let display_name = inner + .read() + .await + .identity + .display_name + .clone() + .unwrap_or_else(|| "Self".into()); + + let peer_via_cache: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + let mut router = LxmRouter::new(lxmf_core::router::RouterConfig::default()); + router.set_transport(handle.transport_tx.clone()); + + let cache_for_cb = peer_via_cache.clone(); + let event_tx_cb = event_tx.clone(); + let self_hash_cb = lxmf_hash_hex.clone(); + let self_name_cb = display_name.clone(); + let inner_for_cb = inner.clone(); + let config_dir_for_cb = config_dir.clone(); + let storage_dir_for_cb = storage_dir.clone(); + router.register_delivery_callback(move |msg| { + if !msg.incoming { + return; + } + let sender_hex = hex::encode(msg.source_hash); + let received_via = cache_for_cb + .lock() + .ok() + .and_then(|cache| cache.get(&sender_hex).cloned()) + .map(|iface| classify_interface(&iface).to_string()) + .unwrap_or_else(|| "network".into()); + let payload = lxmf_payload_from_message( + msg, + &self_hash_cb, + &self_name_cb, + Some(&received_via), + None, + "inbound", + ); + let inner = inner_for_cb.clone(); + let config_dir = config_dir_for_cb.clone(); + let storage_dir = storage_dir_for_cb.clone(); + let sender = sender_hex.clone(); + tokio::spawn(async move { + let mut state = inner.write().await; + state.upsert_contact(&sender, None); + if let Err(e) = state.save(&config_dir, &storage_dir) { + tracing::warn!("contact persist failed: {e}"); + } + }); + emit_lxmf_event(&event_tx_cb, payload); + }); + + #[cfg(feature = "rns-ble")] + let foreground_wake = Arc::new(tokio::sync::Notify::new()); + #[cfg(feature = "rns-ble")] + { + let event_tx_ble = event_tx.clone(); + let (ble_evt_tx, mut ble_evt_rx) = tokio::sync::mpsc::channel(64); + rns_interface::ble_peer::install_event_dispatcher(ble_evt_tx); + tokio::spawn(async move { + while let Some(evt) = ble_evt_rx.recv().await { + let payload = serde_json::to_value(&evt).unwrap_or(serde_json::json!({})); + let msg = serde_json::json!({ "type": "ble_peer", "payload": payload }); + let _ = event_tx_ble.send(msg.to_string()); + } + }); + } + + let bridge = Self { + config_dir: config_dir.clone(), + storage_dir: storage_dir.clone(), + handle: handle.clone(), + _shutdown: shutdown, + router: Arc::new(tokio::sync::Mutex::new(router)), + identity: identity.clone(), + lxmf_hash_hex: lxmf_hash_hex.clone(), + display_name: display_name.clone(), + peer_via_cache, + outbound: Arc::new(Mutex::new(LxmfOutboundDriver::new( + handle.transport_tx.clone(), + &identity, + lxmf_hash_hex.clone(), + display_name.clone(), + ))), + propagation: Arc::new(PropagationBridge::new( + handle.transport_tx.clone(), + lxmf_dest_hash, + storage_dir.join("propagation"), + )?), + sync_cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + event_tx: event_tx.clone(), + #[cfg(feature = "rns-ble")] + ble_peer_state: Arc::new(tokio::sync::Mutex::new(BlePeerRuntimeState { + spawned: HashMap::new(), + foreground_wake: foreground_wake.clone(), + })), + }; + + let preferred_prop_hash = { + let state = inner.read().await; + state + .preferred_propagation_id + .as_ref() + .and_then(|id| { + state + .propagation + .iter() + .find(|p| p.id == *id) + .and_then(|p| p.destination_hash.clone()) + }) + }; + + bridge.spawn_maintenance(event_tx); + + if let Some(hash_hex) = preferred_prop_hash { + bridge.set_outbound_propagation_node(Some(&hash_hex)).await; + } + + if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { + let _ = bridge.sync_ble_peer_interfaces(&ifaces).await; + } + + { + let mut state = inner.write().await; + state.rns_ready = true; + state.lxmf_ready = true; + } + + Ok(bridge) + } + + /// `hash_hex` is the announced Nomad node destination hash (used for the + /// path-table hops lookup); `identity_hash_hex` is the node's identity + /// hash recovered from its announce (`AnnounceHandlerEvent::identity_hash`), + /// required by `LinkClient::query` to rebuild the `nomadnetwork.node` + /// destination on our side. Falls back to `hash_hex` when the identity + /// hash hasn't been observed yet (e.g. node added without an announce); + /// the query will simply fail to resolve a path in that case. + pub async fn fetch_nomad_file( + &self, + hash_hex: &str, + identity_hash_hex: Option<&str>, + path: &str, + interfaces: &[InterfaceRow], + ) -> serde_json::Value { + let remote_hash = match parse_hash16(identity_hash_hex.unwrap_or(hash_hex)) { + Ok(h) => h, + Err(e) => { + return serde_json::json!({ "ok": false, "error": e }); + } + }; + let hops = self.hops_to_destination(hash_hex).await.unwrap_or(8); + let timeout_secs = nomad_timeouts::nomad_page_timeout_secs_for_interfaces(interfaces, hops); + let client = LinkClient::new(self.handle.transport_tx.clone(), self.identity.clone()); + match client + .query( + remote_hash, + NOMAD_NODE_ASPECT, + path, + Vec::new(), + hops, + Duration::from_secs(timeout_secs), + ) + .await + { + Ok(bytes) => { + let file_name = nomad_file_name_from_path(path); + let content_base64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &bytes, + ); + serde_json::json!({ + "ok": true, + "file_name": file_name, + "content_base64": content_base64, + }) + } + Err(e) => serde_json::json!({ "ok": false, "error": format!("{e}") }), + } + } + + /// See `fetch_nomad_file` for `hash_hex` / `identity_hash_hex` semantics. + pub async fn fetch_nomad_page( + &self, + hash_hex: &str, + identity_hash_hex: Option<&str>, + path: &str, + interfaces: &[InterfaceRow], + ) -> serde_json::Value { + let remote_hash = match parse_hash16(identity_hash_hex.unwrap_or(hash_hex)) { + Ok(h) => h, + Err(e) => { + return serde_json::json!({ "ok": false, "error": e }); + } + }; + let hops = self.hops_to_destination(hash_hex).await.unwrap_or(8); + let timeout_secs = nomad_timeouts::nomad_page_timeout_secs_for_interfaces(interfaces, hops); + let client = LinkClient::new(self.handle.transport_tx.clone(), self.identity.clone()); + match client + .query( + remote_hash, + NOMAD_NODE_ASPECT, + path, + Vec::new(), + hops, + Duration::from_secs(timeout_secs), + ) + .await + { + Ok(bytes) => { + let content = String::from_utf8_lossy(&bytes).into_owned(); + let content_type = if path.split('`').next().is_some_and(|p| p.ends_with(".mu")) { + "micron" + } else { + "text" + }; + serde_json::json!({ + "ok": true, + "content": content, + "content_type": content_type, + }) + } + Err(e) => serde_json::json!({ "ok": false, "error": format!("{e}") }), + } + } + + async fn query_control_timed( + &self, + query: TransportQuery, + ) -> Option { + match tokio::time::timeout( + TRANSPORT_QUERY_TIMEOUT, + self.handle.query_control(query), + ) + .await + { + Ok(resp) => resp, + Err(_) => { + tracing::debug!( + "transport control query timed out after {:?}", + TRANSPORT_QUERY_TIMEOUT + ); + None + } + } + } + + async fn hops_to_destination(&self, hash_hex: &str) -> Option { + let resp = self + .query_control_timed(TransportQuery::GetPathTable) + .await?; + let TransportQueryResponse::PathTable(entries) = resp else { + return None; + }; + let key = hash_hex.to_lowercase(); + entries + .iter() + .find(|e| hex::encode(e.hash).to_lowercase() == key) + .map(|e| e.hops) + } + + /// Register handler for Nomad Network node announces (`nomadnetwork.node`). + pub fn register_nomad_announce_handler( + &self, + inner: Arc>, + config_dir: PathBuf, + storage_dir: PathBuf, + ) { + let transport_tx = self.handle.transport_tx.clone(); + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + let (callback_tx, mut callback_rx) = + tokio::sync::mpsc::channel::(64); + if transport_tx + .send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(NOMAD_NODE_ASPECT.to_string()), + receive_path_responses: false, + callback_tx, + }) + .await + .is_err() + { + tracing::warn!("nomad announce handler registration failed: transport closed"); + return; + } + + while let Some(evt) = callback_rx.recv().await { + let hash_hex = hex::encode(evt.destination_hash); + let identity_hash_hex = evt.identity_hash.map(hex::encode); + let display_name = parse_nomad_display_name(evt.app_data.as_deref()); + let hops = Some(evt.hops); + let payload = { + let mut state = inner.write().await; + state.upsert_nomad_node( + &hash_hex, + identity_hash_hex.clone(), + display_name.clone(), + hops, + ); + if let Err(e) = state.save(&config_dir, &storage_dir) { + tracing::warn!("nomad node persist failed: {e}"); + } + serde_json::json!({ + "destination_hash": hash_hex, + "display_name": display_name, + "hops": evt.hops, + }) + }; + let frame = serde_json::json!({ "type": "nomadnetwork.node", "payload": payload }); + let _ = event_tx.send(frame.to_string()); + } + }); + } + + fn spawn_maintenance(&self, _event_tx: broadcast::Sender) { + let handle = self.handle.clone(); + let router = self.router.clone(); + let peer_via_cache = self.peer_via_cache.clone(); + let outbound = self.outbound.clone(); + let event_tx = self.event_tx.clone(); + let propagation = self.propagation.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(2)); + loop { + interval.tick().await; + let path_entries = match tokio::time::timeout( + TRANSPORT_QUERY_TIMEOUT, + handle.query_control(TransportQuery::GetPathTable), + ) + .await + { + Ok(Some(TransportQueryResponse::PathTable(entries))) => { + if let Ok(mut cache) = peer_via_cache.lock() { + cache.clear(); + for entry in &entries { + let key = hex::encode(entry.hash); + cache.insert(key, entry.interface.clone()); + } + } + entries + .iter() + .map(|e| (e.hash, e.hops, hex::encode(e.hash))) + .collect::>() + } + _ => { + tracing::debug!( + "maintenance path table query timed out after {:?}", + TRANSPORT_QUERY_TIMEOUT + ); + Vec::new() + } + }; + let mut router = router.lock().await; + if let Ok(mut driver) = outbound.lock() { + driver.update_path_table(&path_entries); + driver.process_tick(&mut router, &event_tx); + let known_identities = driver.known_identities_for_propagation(); + propagation.tick(&known_identities); + } else { + propagation.tick(&HashMap::new()); + } + } + }); + } + + /// Register handler for announces carrying identity public keys (LXMF path proofs). + pub fn register_lxmf_identity_announce_handler(&self) { + let transport_tx = self.handle.transport_tx.clone(); + let outbound = self.outbound.clone(); + tokio::spawn(async move { + let (callback_tx, mut callback_rx) = + tokio::sync::mpsc::channel::(64); + if transport_tx + .send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: None, + receive_path_responses: false, + callback_tx, + }) + .await + .is_err() + { + tracing::warn!("LXMF identity announce handler registration failed: transport closed"); + return; + } + + while let Some(evt) = callback_rx.recv().await { + if let Some(pub_key) = evt.public_key { + let dest_hex = hex::encode(evt.destination_hash); + if let Ok(mut driver) = outbound.lock() { + driver.register_identity_key(&dest_hex, pub_key); + } + } + } + }); + } + + pub async fn send_reaction( + &self, + req: &LxmfReactionRequest, + ) -> Result { + let dest = parse_hash16(&req.destination_hash)?; + let has_path = self + .outbound + .lock() + .map(|d| d.has_path_to(&req.destination_hash)) + .unwrap_or(false); + + let delivery_method = if has_path { + DeliveryMethod::Direct + } else { + let router = self.router.lock().await; + if router.outbound_propagation_node.is_some() { + DeliveryMethod::Propagated + } else { + return Ok(serde_json::json!({ + "ok": false, + "error": "no_propagation_node", + "destination_hash": req.destination_hash, + })); + } + }; + + let msg = LxMessage::new( + dest, + parse_hash16(&self.lxmf_hash_hex)?, + "", + &req.emoji, + delivery_method, + ); + let mut router = self.router.lock().await; + router + .try_send(msg) + .map_err(|e| format!("lxmf reaction send: {e:?}"))?; + + let ts_ms = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000) as i64; + let payload = serde_json::json!({ + "sender_hash": self.lxmf_hash_hex, + "sender_name": self.display_name, + "text": req.emoji, + "timestamp": ts_ms, + "to_hash": req.destination_hash, + "reaction_target": req.target_hash, + "direction": "outbound", + "delivery_status": "queued" + }); + + if let Ok(mut driver) = self.outbound.lock() { + driver.process_tick(&mut router, &self.event_tx); + } + + Ok(payload) + } + + pub async fn set_local_propagation_serving(&self, enabled: bool) { + let mut router = self.router.lock().await; + self.propagation.set_local_serving(enabled, &mut router); + } + + pub fn propagation_local_stats(&self) -> (usize, usize) { + self.propagation.local_stats() + } + + pub fn propagation_local_hash(&self) -> String { + self.propagation.local_dest_hash_hex() + } + + pub async fn start_propagation_sync(&self, destination_hash: &str) -> Result<(), String> { + let hash = parse_hash16(destination_hash)?; + self.sync_cancel.store(false, std::sync::atomic::Ordering::SeqCst); + if !self.propagation.start_sync(hash) { + return Err("propagation sync unavailable".into()); + } + self.propagation.spawn_sync_progress_emitter( + self.event_tx.clone(), + Arc::clone(&self.sync_cancel), + ); + Ok(()) + } + + pub fn propagation_is_local_serving(&self) -> bool { + self.propagation.is_local_serving() + } + + pub async fn cancel_propagation_sync(&self) { + self.sync_cancel + .store(true, std::sync::atomic::Ordering::SeqCst); + self.propagation.cancel_sync(); + } + + pub async fn set_outbound_propagation_node(&self, destination_hash: Option<&str>) { + let hash = destination_hash.and_then(lxmf_outbound::parse_propagation_hash); + let mut router = self.router.lock().await; + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_node(&mut router, hash); + } + } + + pub async fn fetch_interfaces(&self) -> Result, String> { + let config_rows = super::config::interfaces_from_config_dir(&self.config_dir).unwrap_or_default(); + let resp = self + .query_control_timed(TransportQuery::GetInterfaceStats) + .await; + let Some(TransportQueryResponse::InterfaceStats(stats)) = resp else { + tracing::debug!("live fetch_interfaces unavailable, using config rows"); + return Ok(config_rows); + }; + let live_rows: Vec = stats + .iter() + .enumerate() + .map(|(i, s)| InterfaceRow { + id: format!("rns-{i}"), + name: s.name.clone(), + iface_type: s.mode.clone(), + enabled: s.online, + status: if s.online { "up" } else { "down" }.into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }) + .collect(); + Ok(merge_live_interfaces_with_config(&config_rows, live_rows)) + } + + pub async fn fetch_peers(&self) -> Result, String> { + let resp = self + .query_control_timed(TransportQuery::GetPathTable) + .await; + let Some(TransportQueryResponse::PathTable(entries)) = resp else { + return Err("path table query timed out or unavailable".into()); + }; + if let Ok(mut cache) = self.peer_via_cache.lock() { + cache.clear(); + for entry in &entries { + let key = hex::encode(entry.hash); + cache.insert(key, entry.interface.clone()); + } + } + Ok(entries + .iter() + .map(|e| PeerRow { + destination_hash: hex::encode(e.hash), + display_name: None, + hops: Some(e.hops), + last_seen: Some(e.timestamp as u64), + interface: Some(e.interface.clone()), + path_hash: e.via.map(hex::encode), + via_hash: e.via.map(hex::encode), + }) + .collect()) + } + + pub async fn request_path(&self, hash: &str) -> Result<(), String> { + let dest = parse_hash16(hash)?; + self.handle + .transport_tx + .send(TransportMessage::RequestPath { + destination_hash: dest, + }) + .await + .map_err(|e| e.to_string()) + } + + pub async fn probe_peer(&self, hash: &str) -> Result { + let dest = parse_hash16(hash)?; + match self + .handle + .await_path(dest, std::time::Duration::from_secs(8)) + .await + { + Ok(hops) => Ok(serde_json::json!({ "ok": true, "hops": hops })), + Err(e) => Ok(serde_json::json!({ "ok": false, "error": format!("{e:?}") })), + } + } + + pub async fn send_lxmf(&self, req: &LxmfSendRequest) -> Result { + let dest = parse_hash16(&req.destination_hash)?; + let has_path = self + .outbound + .lock() + .map(|d| d.has_path_to(&req.destination_hash)) + .unwrap_or(false); + + let delivery_method = if has_path { + DeliveryMethod::Direct + } else { + let router = self.router.lock().await; + if router.outbound_propagation_node.is_some() { + DeliveryMethod::Propagated + } else { + return Ok(serde_json::json!({ + "ok": false, + "error": "no_propagation_node", + "destination_hash": req.destination_hash, + })); + } + }; + let delivery_method_str = match delivery_method { + DeliveryMethod::Direct => "direct", + DeliveryMethod::Propagated => "propagated", + DeliveryMethod::Opportunistic => "opportunistic", + DeliveryMethod::Paper => "paper", + }; + + let egress_via = match self.fetch_interfaces().await { + Ok(ifaces) if !ifaces.is_empty() => resolve_outbound_sent_via(&ifaces), + _ => { + let peer_iface = self + .peer_via_cache + .lock() + .ok() + .and_then(|cache| cache.get(&req.destination_hash).cloned()); + resolve_peer_sent_via(peer_iface.as_deref()) + } + }; + + let msg = LxMessage::new( + dest, + parse_hash16(&self.lxmf_hash_hex)?, + "", + &req.text, + delivery_method, + ); + let mut router = self.router.lock().await; + router + .try_send(msg) + .map_err(|e| format!("lxmf send: {e:?}"))?; + + let ts_ms = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000) as i64; + let mut payload = serde_json::json!({ + "sender_hash": self.lxmf_hash_hex, + "sender_name": self.display_name, + "text": req.text, + "timestamp": ts_ms, + "to_hash": req.destination_hash, + "reply_to_hash": req.reply_to_hash, + "reply_to_id": req.reply_to_id, + "direction": "outbound", + "delivery_method": delivery_method_str, + "sent_via": egress_via, + "received_via": egress_via, + "delivery_status": "queued" + }); + let hash_input = format!( + "{}:{}:{}", + payload["sender_hash"].as_str().unwrap_or_default(), + payload["timestamp"].as_i64().unwrap_or(0), + payload["text"].as_str().unwrap_or_default() + ); + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "message_hash".into(), + serde_json::Value::String(format!("{:032x}", super::persistence::stable_hash( + &hash_input + ))), + ); + } + + if let Ok(mut driver) = self.outbound.lock() { + driver.process_tick(&mut router, &self.event_tx); + } + + Ok(serde_json::json!({ + "ok": true, + "destination_hash": req.destination_hash, + "text": req.text, + "delivery_method": delivery_method_str, + "sent_via": egress_via, + "delivery_status": "queued", + "message": payload + })) + } + + pub async fn send_lxmf_resource( + &self, + req: &LxmfResourceRequest, + ) -> Result { + use base64::Engine as _; + + let file_bytes = base64::engine::general_purpose::STANDARD + .decode(req.data_base64.as_bytes()) + .map_err(|e| format!("invalid attachment base64: {e}"))?; + if file_bytes.is_empty() { + return Err("attachment data is empty".into()); + } + if file_bytes.len() > 16 * 1024 * 1024 { + return Err("attachment exceeds 16 MiB limit".into()); + } + + let dest = parse_hash16(&req.destination_hash)?; + let has_path = self + .outbound + .lock() + .map(|d| d.has_path_to(&req.destination_hash)) + .unwrap_or(false); + + let delivery_method = if has_path { + DeliveryMethod::Direct + } else { + let router = self.router.lock().await; + if router.outbound_propagation_node.is_some() { + DeliveryMethod::Propagated + } else { + return Ok(serde_json::json!({ + "ok": false, + "error": "no_propagation_node", + "destination_hash": req.destination_hash, + })); + } + }; + let delivery_method_str = match delivery_method { + DeliveryMethod::Direct => "direct", + DeliveryMethod::Propagated => "propagated", + DeliveryMethod::Opportunistic => "opportunistic", + DeliveryMethod::Paper => "paper", + }; + + let egress_via = match self.fetch_interfaces().await { + Ok(ifaces) if !ifaces.is_empty() => resolve_outbound_sent_via(&ifaces), + _ => { + let peer_iface = self + .peer_via_cache + .lock() + .ok() + .and_then(|cache| cache.get(&req.destination_hash).cloned()); + resolve_peer_sent_via(peer_iface.as_deref()) + } + }; + + let text = format!("[file:{}:{}]", req.file_name, req.mime_type); + let attachment_msgpack = + build_file_attachment_msgpack(&req.file_name, &file_bytes)?; + + let mut msg = LxMessage::new( + dest, + parse_hash16(&self.lxmf_hash_hex)?, + &req.file_name, + &text, + delivery_method, + ); + msg.set_msgpack_field(FIELD_FILE_ATTACHMENTS, attachment_msgpack) + .map_err(|e| format!("attachment field: {e:?}"))?; + + let mut router = self.router.lock().await; + router + .try_send(msg) + .map_err(|e| format!("lxmf resource send: {e:?}"))?; + + let ts_ms = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000) as i64; + let attachment_b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes); + let mut payload = serde_json::json!({ + "sender_hash": self.lxmf_hash_hex, + "sender_name": self.display_name, + "text": text, + "timestamp": ts_ms, + "to_hash": req.destination_hash, + "reply_to_hash": req.reply_to_hash, + "direction": "outbound", + "delivery_method": delivery_method_str, + "sent_via": egress_via, + "received_via": egress_via, + "delivery_status": "queued", + "attachment": { + "file_name": req.file_name, + "mime_type": req.mime_type, + "size_bytes": file_bytes.len(), + "data_base64": attachment_b64, + } + }); + let hash_input = format!( + "{}:{}:{}", + payload["sender_hash"].as_str().unwrap_or_default(), + payload["timestamp"].as_i64().unwrap_or(0), + payload["text"].as_str().unwrap_or_default() + ); + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "message_hash".into(), + serde_json::Value::String(format!( + "{:032x}", + super::persistence::stable_hash(&hash_input) + )), + ); + } + + if let Ok(mut driver) = self.outbound.lock() { + driver.process_tick(&mut router, &self.event_tx); + } + + Ok(serde_json::json!({ + "ok": true, + "destination_hash": req.destination_hash, + "delivery_method": delivery_method_str, + "sent_via": egress_via, + "delivery_status": "queued", + "message": payload + })) + } + + pub async fn apply_interfaces(&self, stack: &StackHandle) -> Result<(), String> { + let interfaces = stack.list_interfaces().await; + tracing::info!( + count = interfaces.len(), + "apply_interfaces: syncing {} interface(s) from config", + interfaces.len() + ); + self.sync_ble_peer_interfaces(&interfaces).await + } + + #[cfg(feature = "rns-ble")] + async fn sync_ble_peer_interfaces(&self, interfaces: &[InterfaceRow]) -> Result<(), String> { + let desired: HashMap = interfaces + .iter() + .filter(|i| i.iface_type == "ble_peer" && i.enabled) + .map(|i| (i.id.clone(), i)) + .collect(); + + let to_remove: Vec = { + let state = self.ble_peer_state.lock().await; + state + .spawned + .keys() + .filter(|id| !desired.contains_key(*id)) + .cloned() + .collect() + }; + + for id in to_remove { + self.teardown_ble_peer_by_config_id(&id).await; + } + + for (id, row) in desired { + let already = self.ble_peer_state.lock().await.spawned.contains_key(&id); + if already { + continue; + } + match self.spawn_ble_peer_for_row(row).await { + Ok(runtime_id) => { + self.ble_peer_state + .lock() + .await + .spawned + .insert(id.clone(), runtime_id); + self.emit_event( + "interface.state", + serde_json::json!({ "id": id, "action": "ble_peer_spawned" }), + ); + } + Err(e) => { + tracing::warn!(interface_id = %id, error = %e, "BLE Peer spawn failed"); + self.emit_event( + "interface.state", + serde_json::json!({ "id": id, "action": "ble_peer_failed", "error": e }), + ); + } + } + } + + Ok(()) + } + + #[cfg(not(feature = "rns-ble"))] + async fn sync_ble_peer_interfaces(&self, _interfaces: &[InterfaceRow]) -> Result<(), String> { + Ok(()) + } + + #[cfg(feature = "rns-ble")] + async fn spawn_ble_peer_for_row(&self, row: &InterfaceRow) -> Result { + let identity_hash = self.identity.hash.to_vec(); + let foreground_wake = { + self.ble_peer_state + .lock() + .await + .foreground_wake + .clone() + }; + reticulum::spawn_ble_peer_runtime( + &self.handle, + &row.name, + identity_hash, + None, + foreground_wake, + row.seed_addresses.clone(), + ) + .await + } + + #[cfg(feature = "rns-ble")] + async fn teardown_ble_peer_by_config_id(&self, config_id: &str) { + let runtime_id = { + let mut state = self.ble_peer_state.lock().await; + state.spawned.remove(config_id) + }; + if let Some(runtime_id) = runtime_id { + reticulum::teardown_ble_peer_interface(&self.handle, runtime_id).await; + self.emit_event( + "interface.state", + serde_json::json!({ "id": config_id, "action": "ble_peer_stopped" }), + ); + } + } + + fn emit_event(&self, event_type: &str, payload: serde_json::Value) { + let msg = serde_json::json!({ "type": event_type, "payload": payload }); + let _ = self.event_tx.send(msg.to_string()); + } +} + +pub(super) fn lxmf_payload_from_message( + msg: &LxMessage, + self_lxmf_hash: &str, + self_name: &str, + received_via: Option<&str>, + sent_via: Option<&str>, + direction: &str, +) -> serde_json::Value { + let sender_hex = hex::encode(msg.source_hash); + let to_hex = hex::encode(msg.destination_hash); + let is_outbound = direction == "outbound"; + let sender_hash = if is_outbound { + self_lxmf_hash + } else { + sender_hex.as_str() + }; + let sender_name = if is_outbound { + self_name + } else { + sender_hex.get(..12).unwrap_or(&sender_hex) + }; + let message_hash = msg + .hash + .map(hex::encode) + .or_else(|| msg.message_id.map(hex::encode)) + .unwrap_or_default(); + let ts_ms = (msg.timestamp * 1000.0) as i64; + let mut payload = serde_json::json!({ + "sender_hash": sender_hash, + "sender_name": sender_name, + "text": msg.content, + "timestamp": ts_ms, + "to_hash": to_hex, + "direction": direction, + "message_hash": message_hash + }); + if let Some(via) = received_via { + if let Some(obj) = payload.as_object_mut() { + obj.insert("received_via".into(), serde_json::Value::String(via.into())); + } + } + if let Some(via) = sent_via { + if let Some(obj) = payload.as_object_mut() { + obj.insert("sent_via".into(), serde_json::Value::String(via.into())); + } + } + if let Some(attachment) = attachment_json_from_message(msg) { + if let Some(obj) = payload.as_object_mut() { + if let Some(text) = attachment + .get("file_name") + .and_then(|n| n.as_str()) + .zip(attachment.get("mime_type").and_then(|m| m.as_str())) + { + obj.insert( + "text".into(), + serde_json::Value::String(format!("[file:{}:{}]", text.0, text.1)), + ); + } + obj.insert("attachment".into(), attachment); + } + } + if let Some(icon) = icon_appearance_json_from_message(msg) { + if let Some(obj) = payload.as_object_mut() { + obj.insert("icon_appearance".into(), icon); + } + } + payload +} + +fn build_file_attachment_msgpack(file_name: &str, data: &[u8]) -> Result, String> { + let attachment_value = rmpv::Value::Array(vec![rmpv::Value::Array(vec![ + rmpv::Value::String(file_name.into()), + rmpv::Value::Binary(data.to_vec()), + ])]); + let mut attachment_bytes = Vec::new(); + rmpv::encode::write_value(&mut attachment_bytes, &attachment_value) + .map_err(|e| format!("encode attachment msgpack: {e}"))?; + Ok(attachment_bytes) +} + +fn mime_from_file_name(file_name: &str) -> String { + let lower = file_name.to_lowercase(); + if lower.ends_with(".webm") { + "audio/webm".into() + } else if lower.ends_with(".ogg") { + "audio/ogg".into() + } else if lower.ends_with(".wav") { + "audio/wav".into() + } else if lower.ends_with(".mp3") { + "audio/mpeg".into() + } else if lower.ends_with(".png") { + "image/png".into() + } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg".into() + } else if lower.ends_with(".gif") { + "image/gif".into() + } else { + "application/octet-stream".into() + } +} + +fn rgb_triplet_from_msgpack(value: &rmpv::Value) -> Option<[u8; 3]> { + let bytes = match value { + rmpv::Value::Binary(bin) if bin.len() >= 3 => bin.as_slice(), + rmpv::Value::Array(arr) if arr.len() >= 3 => { + let r = arr.first()?.as_u64()? as u8; + let g = arr.get(1)?.as_u64()? as u8; + let b = arr.get(2)?.as_u64()? as u8; + return Some([r, g, b]); + } + _ => return None, + }; + Some([bytes[0], bytes[1], bytes[2]]) +} + +fn icon_appearance_json_from_message(msg: &LxMessage) -> Option { + let field = msg.get_field(FIELD_ICON_APPEARANCE)?; + let value = rmpv::decode::read_value(&mut Cursor::new(field.as_slice())).ok()?; + let arr = value.as_array()?; + let icon_name = arr.first()?.as_str()?.to_string(); + if icon_name.trim().is_empty() { + return None; + } + let fg = rgb_triplet_from_msgpack(arr.get(1)?)?; + let bg = rgb_triplet_from_msgpack(arr.get(2)?)?; + Some(serde_json::json!({ + "icon_name": icon_name, + "foreground_rgb": [fg[0], fg[1], fg[2]], + "background_rgb": [bg[0], bg[1], bg[2]], + })) +} + +fn attachment_json_from_message(msg: &LxMessage) -> Option { + use base64::Engine as _; + + let field = msg.get_field(FIELD_FILE_ATTACHMENTS)?; + let value = rmpv::decode::read_value(&mut Cursor::new(field.as_slice())).ok()?; + let files = value.as_array()?; + let first = files.first()?.as_array()?; + let file_name = first.first()?.as_str()?.to_string(); + let bytes = match first.get(1)? { + rmpv::Value::Binary(bin) => bin.clone(), + _ => return None, + }; + let mime_type = mime_from_file_name(&file_name); + Some(serde_json::json!({ + "file_name": file_name, + "mime_type": mime_type, + "size_bytes": bytes.len(), + "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes), + })) +} + +pub(super) fn emit_lxmf_event(event_tx: &broadcast::Sender, payload: serde_json::Value) { + let frame = serde_json::json!({ + "type": "lxmf_message", + "payload": payload + }); + let _ = event_tx.send(frame.to_string()); +} + +/// Nomad Network encodes node display names in announce app_data as msgpack +/// `[display_name_bytes, ...]` (see NomadNet / MeshChat wire compat). +fn parse_nomad_display_name(app_data: Option<&[u8]>) -> Option { + let bytes = app_data?; + if bytes.is_empty() { + return None; + } + if let Ok(value) = rmpv::decode::read_value(&mut Cursor::new(bytes)) { + if let rmpv::Value::Array(arr) = value { + if let Some(name) = arr.first().and_then(nomad_name_from_msgpack_value) { + return Some(name); + } + } + } + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn nomad_name_from_msgpack_value(value: &rmpv::Value) -> Option { + match value { + rmpv::Value::Binary(bin) => std::str::from_utf8(bin) + .ok() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + rmpv::Value::String(s) => { + let trimmed = s.as_str()?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + } +} + +pub(super) fn parse_hash16(hex_str: &str) -> Result<[u8; 16], String> { + let clean: String = hex_str.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + let bytes = hex::decode(if clean.len() >= 32 { + &clean[..32] + } else { + return Err("hash too short".into()); + }) + .map_err(|e| e.to_string())?; + let mut out = [0u8; 16]; + out.copy_from_slice(&bytes[..16]); + Ok(out) +} + +#[cfg(test)] +mod icon_appearance_tests { + use super::*; + use lxmf_core::constants::FIELD_ICON_APPEARANCE; + use lxmf_core::message::LxMessage; + + #[test] + fn icon_appearance_json_from_message_parses_msgpack_field() { + let mut buf = Vec::new(); + rmpv::encode::write_value( + &mut buf, + &rmpv::Value::Array(vec![ + rmpv::Value::String("hiking".into()), + rmpv::Value::Binary(vec![255, 255, 0]), + rmpv::Value::Binary(vec![0, 0, 255]), + ]), + ) + .expect("encode icon appearance"); + + let mut msg = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "hello", + DeliveryMethod::Direct, + ); + msg.set_field(FIELD_ICON_APPEARANCE, buf); + let json = icon_appearance_json_from_message(&msg).expect("icon json"); + assert_eq!(json["icon_name"], "hiking"); + assert_eq!(json["foreground_rgb"], serde_json::json!([255, 255, 0])); + assert_eq!(json["background_rgb"], serde_json::json!([0, 0, 255])); + } +} diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs new file mode 100644 index 000000000..48d36a44f --- /dev/null +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -0,0 +1,416 @@ +//! LXMF outbound delivery loop (Direct / Propagated) via LinkDeliveryManager. + +use std::collections::{HashMap, HashSet}; + +use bytes::Bytes; +use lxmf_core::constants::DeliveryMethod; +use lxmf_core::link_delivery::{DeliveryResult, LinkDeliveryManager}; +use lxmf_core::message::LxMessage; +use lxmf_core::router::{ + plan_direct_delivery, DirectDeliveryPlan, DirectDeliveryPlanInput, DirectReusableLinkState, + DirectRouteSnapshot, LxmRouter, OutboundAction, +}; +use rns_identity::identity::Identity; +use rns_transport::messages::{TransportMessage, TransportQuery}; +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +use super::{lxmf_payload_from_message, parse_hash16}; + +pub struct LxmfOutboundDriver { + transport_tx: mpsc::Sender, + link_delivery: LinkDeliveryManager, + route_hops: HashMap<[u8; 16], u8>, + known_identities: HashMap, + path_table_hashes: HashSet, + self_lxmf_hash: String, + self_display_name: String, +} + +impl LxmfOutboundDriver { + pub fn new( + transport_tx: mpsc::Sender, + identity: &Identity, + self_lxmf_hash: String, + self_display_name: String, + ) -> Self { + let mut driver = Self { + transport_tx: transport_tx.clone(), + link_delivery: LinkDeliveryManager::new( + transport_tx, + Some(identity.get_public_key()), + identity.get_signing_key(), + ), + route_hops: HashMap::new(), + known_identities: HashMap::new(), + path_table_hashes: HashSet::new(), + self_lxmf_hash: self_lxmf_hash.clone(), + self_display_name, + }; + driver.register_identity_key(&self_lxmf_hash, identity.get_public_key()); + driver + } + + pub fn register_identity_key(&mut self, dest_hash_hex: &str, public_key: [u8; 64]) { + self.known_identities + .insert(dest_hash_hex.to_lowercase(), public_key); + } + + pub fn known_identities_for_propagation(&self) -> HashMap { + self.known_identities.clone() + } + + pub fn set_propagation_node(&mut self, router: &mut LxmRouter, hash: Option<[u8; 16]>) { + router.set_outbound_propagation_node(hash); + } + + pub fn update_path_table(&mut self, entries: &[( [u8; 16], u8, String)]) { + self.route_hops.clear(); + self.path_table_hashes.clear(); + for (hash, hops, hex_key) in entries { + self.route_hops.insert(*hash, (*hops).max(1)); + self.path_table_hashes.insert(hex_key.clone()); + } + } + + pub fn has_path_to(&self, destination_hex: &str) -> bool { + self.path_table_hashes.contains(destination_hex) + } + + pub fn process_tick(&mut self, router: &mut LxmRouter, event_tx: &broadcast::Sender) { + let direct_inputs: HashMap<[u8; 16], DirectDeliveryPlanInput> = router + .pending_outbound + .iter() + .map(|message| message.destination_hash) + .collect::>() + .into_iter() + .map(|dest| { + let dest_hex = hex::encode(dest); + ( + dest, + DirectDeliveryPlanInput { + identity_known: self.known_identities.contains_key(&dest_hex.to_lowercase()) + || self.route_hops.contains_key(&dest), + route: direct_route_snapshot(&self.route_hops, dest), + reusable_link: direct_reusable_link_state(&self.link_delivery, dest), + }, + ) + }) + .collect(); + + let actions = router.process_outbound_with_direct(|message, _now| { + direct_inputs + .get(&message.destination_hash) + .cloned() + .unwrap_or(DirectDeliveryPlanInput { + identity_known: false, + route: None, + reusable_link: DirectReusableLinkState::None, + }) + }); + + if !actions.is_empty() { + self.execute_actions(router, actions); + } + + router.run_jobs_tick(); + + let results = self.link_delivery.tick(); + for result in results { + self.handle_delivery_result(router, event_tx, result); + } + } + + fn execute_actions(&mut self, router: &mut LxmRouter, actions: Vec) { + for action in actions { + match action { + OutboundAction::DeliverPropagated { message, prop_hash } => { + self.deliver_propagated(router, message, prop_hash); + } + OutboundAction::DeliverDirect { message, dest_hash } => { + self.deliver_direct(router, message, dest_hash, None); + } + OutboundAction::PlanDirect { + message, + dest_hash, + plan, + } => { + self.deliver_direct(router, message, dest_hash, Some(plan)); + } + OutboundAction::DeliverOpportunistic { message, dest_hash } => { + if let Ok(packed) = message.pack_payload() { + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(packed), + destination_hash: dest_hash, + }, + )); + } + } + OutboundAction::Failed(msg) | OutboundAction::Expired(msg) => { + tracing::warn!( + dest = %hex::encode(msg.destination_hash), + "LXMF outbound message failed or expired" + ); + } + } + } + } + + fn deliver_propagated( + &mut self, + router: &mut LxmRouter, + mut message: LxMessage, + prop_hash: [u8; 16], + ) { + let prop_hex = hex::encode(prop_hash); + if !self + .known_identities + .contains_key(&prop_hex.to_lowercase()) + { + queue_path_request(&self.transport_tx, prop_hash, false, "propagation node path"); + router.send(message); + return; + } + let Some(packed) = self.pack_for_propagation(&mut message, prop_hash) else { + router.send(message); + return; + }; + let hops = route_hops_for(&self.route_hops, prop_hash); + if let Err(err) = self + .link_delivery + .start_packed_delivery(message, prop_hash, hops, packed, false) + { + tracing::warn!( + prop = %prop_hex, + error = %err.error, + "propagated link delivery start failed" + ); + router.send(*err.message); + } + } + + fn deliver_direct( + &mut self, + router: &mut LxmRouter, + mut message: LxMessage, + dest_hash: [u8; 16], + planned: Option, + ) { + let dest_hex = hex::encode(dest_hash); + let plan = planned.unwrap_or_else(|| { + plan_direct_delivery( + &mut message, + DirectDeliveryPlanInput { + identity_known: self.known_identities.contains_key(&dest_hex.to_lowercase()) + || self.route_hops.contains_key(&dest_hash), + route: direct_route_snapshot(&self.route_hops, dest_hash), + reusable_link: direct_reusable_link_state(&self.link_delivery, dest_hash), + }, + now_f64(), + ) + }); + + match plan { + DirectDeliveryPlan::RequestPath { .. } | DirectDeliveryPlan::WaitForReusableLink => { + queue_path_request(&self.transport_tx, dest_hash, false, "direct delivery path"); + router.send(message); + } + DirectDeliveryPlan::DeferTerminalFailure | DirectDeliveryPlan::Fail => { + message.mark_failed(); + } + DirectDeliveryPlan::UseReusableLink | DirectDeliveryPlan::StartNewLink { .. } => { + let hops = match plan { + DirectDeliveryPlan::StartNewLink { hops } => hops, + _ => route_hops_for(&self.route_hops, dest_hash), + }; + if let Err(err) = self + .link_delivery + .start_delivery_with_report(message, dest_hash, hops) + { + tracing::warn!( + dest = %dest_hex, + error = %err.error, + "direct link delivery start failed" + ); + router.send(*err.message); + } + } + } + } + + fn pack_for_propagation( + &self, + message: &mut LxMessage, + prop_hash: [u8; 16], + ) -> Option> { + let dest_hex = hex::encode(message.destination_hash); + let target_cost = message.stamp_cost.unwrap_or(0); + let (packed, _, _) = message + .pack_propagated_encrypted_with_stamp( + |plaintext| { + self.encrypt_for_destination(&dest_hex, plaintext) + .ok_or_else(|| { + lxmf_core::message::MessageError::PackFailed(format!( + "no identity key for destination {dest_hex}" + )) + }) + }, + target_cost, + ) + .ok()?; + let _ = prop_hash; + Some(packed) + } + + fn encrypt_for_destination(&self, dest_hash_hex: &str, plaintext: &[u8]) -> Option> { + let pub_key = self.known_identities.get(&dest_hash_hex.to_lowercase())?; + let remote = Identity::from_public_key(pub_key).ok()?; + remote.encrypt(plaintext, None).ok() + } + + fn handle_delivery_result( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + result: DeliveryResult, + ) { + match result { + DeliveryResult::Complete { msg_hash, .. } => { + if let Some(hash) = msg_hash { + let _ = router.mark_outbound_delivered(&hash); + emit_outbound_status_by_hash(event_tx, &hash, "delivered"); + } + } + DeliveryResult::Rejected { msg_hash, message, .. } + | DeliveryResult::Failed { msg_hash, message, .. } => { + if let Some(hash) = msg_hash { + let _ = router.mark_outbound_failed(&hash); + emit_outbound_status_by_hash(event_tx, &hash, "failed"); + } + let method = delivery_method_label(message.method); + let payload = lxmf_payload_from_message( + &message, + &self.self_lxmf_hash, + &self.self_display_name, + None, + Some(method), + "outbound", + ); + emit_outbound_status(event_tx, &payload, "failed", method); + } + } + } +} + +fn delivery_method_label(method: DeliveryMethod) -> &'static str { + match method { + DeliveryMethod::Direct => "direct", + DeliveryMethod::Propagated => "propagated", + DeliveryMethod::Opportunistic => "opportunistic", + DeliveryMethod::Paper => "paper", + } +} + +pub fn emit_outbound_status( + event_tx: &broadcast::Sender, + message_payload: &serde_json::Value, + status: &str, + delivery_method: &str, +) { + let frame = serde_json::json!({ + "type": "lxmf_outbound_status", + "payload": { + "message_hash": message_payload.get("message_hash"), + "to_hash": message_payload.get("to_hash"), + "status": status, + "delivery_method": delivery_method, + } + }); + let _ = event_tx.send(frame.to_string()); +} + +fn emit_outbound_status_by_hash(event_tx: &broadcast::Sender, hash: &[u8; 32], status: &str) { + let frame = serde_json::json!({ + "type": "lxmf_outbound_status", + "payload": { + "message_hash": hex::encode(hash), + "status": status, + } + }); + let _ = event_tx.send(frame.to_string()); +} + +fn route_hops_for(route_hops: &HashMap<[u8; 16], u8>, dest_hash: [u8; 16]) -> u8 { + route_hops.get(&dest_hash).copied().unwrap_or(1).max(1) +} + +fn direct_route_snapshot( + route_hops: &HashMap<[u8; 16], u8>, + dest_hash: [u8; 16], +) -> Option { + route_hops + .get(&dest_hash) + .copied() + .map(|hops| DirectRouteSnapshot::new(dest_hash, hops)) +} + +fn direct_reusable_link_state( + link_delivery: &LinkDeliveryManager, + dest_hash: [u8; 16], +) -> DirectReusableLinkState { + if let Some(snapshot) = link_delivery.direct_link_snapshot(dest_hash) { + return match snapshot.delivery_state { + lxmf_core::link_delivery::DeliveryState::Idle => DirectReusableLinkState::Active, + lxmf_core::link_delivery::DeliveryState::Failed => { + DirectReusableLinkState::Closed { activated: false } + } + _ => DirectReusableLinkState::Pending, + }; + } + if let Some(snapshot) = link_delivery.backchannel_link_snapshot(dest_hash) { + if snapshot.queued_deliveries > 0 || snapshot.in_flight_deliveries > 0 { + DirectReusableLinkState::Pending + } else { + DirectReusableLinkState::Active + } + } else { + DirectReusableLinkState::None + } +} + +fn queue_path_request( + transport_tx: &mpsc::Sender, + request_hash: [u8; 16], + drop_existing: bool, + reason: &str, +) { + if drop_existing { + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let _ = transport_tx.try_send(TransportMessage::Rpc { + query: TransportQuery::DropPath { dest: request_hash }, + response_tx, + }); + } + if let Err(e) = transport_tx.try_send(TransportMessage::RequestPath { + destination_hash: request_hash, + }) { + tracing::warn!( + dest = %hex::encode(request_hash), + error = %e, + reason, + "failed to queue path request for LXMF delivery" + ); + } +} + +pub fn parse_propagation_hash(hex_str: &str) -> Option<[u8; 16]> { + parse_hash16(hex_str).ok() +} + +fn now_f64() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs new file mode 100644 index 000000000..0b0bf273f --- /dev/null +++ b/reticulum-sidecar/src/stack/mod.rs @@ -0,0 +1,1030 @@ +//! Persistent stack state + optional live RNS/LXMF bridge. + +pub mod config; +pub mod config_audit; +pub mod rf_profiles; +mod ble; +mod nomad_file; +mod nomad_timeouts; +mod packet_log; +mod persistence; +mod topology; +mod types; +mod via; + +#[cfg(feature = "rns-stack")] +mod live; +#[cfg(feature = "rns-stack")] +mod propagation_bridge; + +use std::path::PathBuf; +use std::sync::Arc; + +pub use config::{ImportMode, ImportResult, StackSettings, UpdateInterfacePatch}; +pub use config_audit::{ConfigAuditIssue, ConfigRepairRequest}; +use packet_log::{PacketLogBuffer, WirePacketRow, MAX_WIRE_PACKET_LOG}; +use persistence::PersistedState; +use tokio::sync::{RwLock, broadcast}; +pub use types::{ + AddInterfaceRequest, ContactRow, InterfaceRow, LxmfReactionRequest, LxmfResourceRequest, + LxmfSendRequest, NomadNodeRow, PeerRow, StackIdentity, +}; + +pub struct StackHandle { + pub config_dir: PathBuf, + pub storage_dir: PathBuf, + inner: Arc>, + event_tx: broadcast::Sender, + packet_log: Arc, + #[cfg(feature = "rns-stack")] + live: Option>, +} + +impl StackHandle { + pub async fn bootstrap( + config_dir: PathBuf, + storage_dir: PathBuf, + event_tx: broadcast::Sender, + ) -> Self { + if !config::config_path(&config_dir).exists() { + if let Ok(content) = config::read_config(&config_dir) { + let _ = config::write_config(&config_dir, &content); + } + } + + if let Err(e) = config::repair_rnode_radio_fields_in_config(&config_dir) { + tracing::warn!("failed to repair RNode radio fields in config: {e}"); + } + + let mut persisted = PersistedState::load(&config_dir, &storage_dir); + persisted.ensure_defaults(); + if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { + persisted.interfaces = ifaces; + } + + let inner = Arc::new(RwLock::new(persisted)); + + #[cfg(feature = "rns-stack")] + let packet_log = Arc::new(PacketLogBuffer::new(MAX_WIRE_PACKET_LOG)); + #[cfg(feature = "rns-stack")] + let live = match live::LiveBridge::spawn( + config_dir.clone(), + storage_dir.clone(), + event_tx.clone(), + packet_log.clone(), + inner.clone(), + ) + .await + { + Ok(bridge) => Some(Arc::new(bridge)), + Err(e) => { + tracing::warn!("live RNS bridge unavailable, using local stack: {e}"); + None + } + }; + + #[cfg(not(feature = "rns-stack"))] + let inner = inner; + #[cfg(feature = "rns-stack")] + let handle = Self { + config_dir, + storage_dir, + inner, + event_tx, + packet_log, + live, + }; + #[cfg(not(feature = "rns-stack"))] + let handle = Self { + config_dir, + storage_dir, + inner, + event_tx, + packet_log: Arc::new(PacketLogBuffer::new(MAX_WIRE_PACKET_LOG)), + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &handle.live { + live.register_nomad_announce_handler( + handle.inner.clone(), + handle.config_dir.clone(), + handle.storage_dir.clone(), + ); + live.register_lxmf_identity_announce_handler(); + } + handle.emit_stats().await; + handle + } + + fn emit_event(&self, event_type: &str, payload: serde_json::Value) { + let msg = serde_json::json!({ "type": event_type, "payload": payload }); + let _ = self.event_tx.send(msg.to_string()); + } + + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + pub fn list_packets(&self, limit: usize) -> Vec { + self.packet_log.snapshot(limit) + } + + pub fn clear_packets(&self) { + self.packet_log.clear(); + } + + async fn sync_interfaces_from_config(&self) { + if let Ok(ifaces) = config::interfaces_from_config_dir(&self.config_dir) { + let mut inner = self.inner.write().await; + inner.interfaces = ifaces; + } + } + + pub async fn emit_stats(&self) { + let inner = self.inner.read().await; + self.emit_event( + "stats_update", + serde_json::json!({ + "rns_ready": inner.rns_ready, + "lxmf_ready": inner.lxmf_ready, + "interface_count": inner.interfaces.len(), + "contact_count": inner.contacts.len(), + "peer_count": inner.peers.len(), + }), + ); + } + + pub async fn identity_status(&self) -> StackIdentity { + self.inner.read().await.identity.clone() + } + + pub async fn identity_generate( + &self, + display_name: Option, + ) -> Result { + let mut inner = self.inner.write().await; + let identity = inner.generate_identity(display_name)?; + inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + self.maybe_emit_identity_restart(); + Ok(identity) + } + + pub async fn identity_import( + &self, + mnemonic: &str, + display_name: Option, + ) -> Result { + let mut inner = self.inner.write().await; + let identity = inner.import_identity_mnemonic(mnemonic, display_name)?; + inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + self.maybe_emit_identity_restart(); + Ok(identity) + } + + pub async fn identity_export_backup( + &self, + passphrase: &str, + ) -> Result { + let inner = self.inner.read().await; + inner.export_identity_backup(passphrase) + } + + pub async fn identity_import_backup( + &self, + backup: serde_json::Value, + passphrase: &str, + ) -> Result { + let mut inner = self.inner.write().await; + let identity = inner.import_identity_backup(backup, passphrase)?; + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(identity) + } + + pub async fn set_display_name(&self, name: &str) -> Result<(), String> { + let mut inner = self.inner.write().await; + inner.identity.display_name = Some(name.to_string()); + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(()) + } + + pub async fn list_interfaces(&self) -> Vec { + let config_rows = match config::interfaces_from_config_dir(&self.config_dir) { + Ok(rows) => rows, + Err(_) => self.inner.read().await.interfaces.clone(), + }; + + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + if let Ok(rows) = live.fetch_interfaces().await { + if !rows.is_empty() { + return rows; + } + } + } + config_rows + } + + pub async fn add_interface(&self, req: AddInterfaceRequest) -> Result { + { + let inner = self.inner.read().await; + if !inner.identity.configured { + return Err("identity not configured".into()); + } + } + let row = config::add_interface_to_config(&self.config_dir, &req)?; + self.sync_interfaces_from_config().await; + self.emit_event("interface.state", serde_json::json!({ "action": "added" })); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(row) + } + + pub async fn update_interface( + &self, + id: &str, + patch: UpdateInterfacePatch, + ) -> Result { + let row = config::update_interface_in_config(&self.config_dir, id, &patch)?; + self.sync_interfaces_from_config().await; + self.emit_event( + "interface.state", + serde_json::json!({ "id": id, "action": "updated" }), + ); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(row) + } + + pub async fn delete_interface(&self, id: &str) -> Result<(), String> { + config::delete_interface_from_config(&self.config_dir, id)?; + self.sync_interfaces_from_config().await; + self.emit_event( + "interface.state", + serde_json::json!({ "id": id, "action": "deleted" }), + ); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(()) + } + + pub async fn set_interface_enabled(&self, id: &str, enabled: bool) -> Result<(), String> { + config::set_interface_enabled_in_config(&self.config_dir, id, enabled)?; + self.sync_interfaces_from_config().await; + self.emit_event( + "interface.state", + serde_json::json!({ "id": id, "enabled": enabled }), + ); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(()) + } + + pub async fn put_config_content(&self, content: &str) -> Result<(), String> { + config::write_config(&self.config_dir, content)?; + self.sync_interfaces_from_config().await; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(()) + } + + pub async fn import_config( + &self, + content: &str, + mode: ImportMode, + ) -> Result { + let result = config::import_config(&self.config_dir, content, mode)?; + self.sync_interfaces_from_config().await; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let _ = live.apply_interfaces(self).await; + } + Ok(result) + } + + pub async fn set_stack_settings(&self, settings: &StackSettings) -> Result<(), String> { + config::set_stack_settings(&self.config_dir, settings) + } + + pub async fn list_contacts(&self) -> Vec { + self.inner.read().await.contacts.clone() + } + + pub async fn list_peers(&self) -> Vec { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let fetched = live.fetch_peers().await; + let mut inner = self.inner.write().await; + let mut peers = merge_live_peer_fetch(&mut inner.peers, fetched); + let name_by_hash = topology::build_topology_name_map( + &inner.peers, + &inner.contacts, + &inner.nomad_nodes, + ); + topology::overlay_peer_display_names(&mut peers, &name_by_hash); + return peers; + } + let inner = self.inner.read().await; + let mut peers = inner.peers.clone(); + let name_by_hash = + topology::build_topology_name_map(&inner.peers, &inner.contacts, &inner.nomad_nodes); + topology::overlay_peer_display_names(&mut peers, &name_by_hash); + peers + } + + pub async fn request_peer_path(&self, hash: &str) -> Result<(), String> { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.request_path(hash).await; + if res.is_ok() { + self.emit_event("peers_updated", serde_json::json!({ "hash": hash })); + } + return res; + } + let _ = hash; + Ok(()) + } + + pub async fn probe_peer(&self, hash: &str) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.probe_peer(hash).await; + if res.is_ok() { + self.emit_event("peers_updated", serde_json::json!({ "hash": hash })); + } + return res; + } + let res = Ok(serde_json::json!({ "ok": true, "mode": "local", "hash": hash })); + if res.is_ok() { + self.emit_event("peers_updated", serde_json::json!({ "hash": hash })); + } + res + } + + pub async fn list_propagation(&self) -> serde_json::Value { + let inner = self.inner.read().await; + let preferred_id = inner.preferred_propagation_id.clone(); + let auto_sync_interval_sec = inner.auto_sync_interval_sec; + #[cfg(feature = "rns-stack")] + let local_stats = if let Some(live) = &self.live { + let (count, bytes) = live.propagation_local_stats(); + let serving = live.propagation_is_local_serving(); + Some((count, bytes, serving, live.propagation_local_hash())) + } else { + None + }; + let propagation: Vec = inner + .propagation + .iter() + .map(|p| { + let preferred = preferred_id.as_deref() == Some(p.id.as_str()); + let mut row = serde_json::json!({ + "id": p.id, + "name": p.name, + "hops": p.hops, + "enabled": p.enabled, + "status": p.status, + "preferred": preferred, + "destination_hash": p.destination_hash, + }); + #[cfg(feature = "rns-stack")] + if p.id == "local-prop" { + if let Some((count, bytes, serving, hash)) = &local_stats { + if let Some(obj) = row.as_object_mut() { + obj.insert( + "message_count".into(), + serde_json::Value::Number((*count).into()), + ); + obj.insert( + "storage_bytes".into(), + serde_json::Value::Number((*bytes).into()), + ); + obj.insert("enabled".into(), serde_json::Value::Bool(*serving)); + obj.insert( + "status".into(), + if *serving { + serde_json::Value::String("active".into()) + } else { + serde_json::Value::String("idle".into()) + }, + ); + obj.insert( + "destination_hash".into(), + serde_json::Value::String(hash.clone()), + ); + } + } + } + row + }) + .collect(); + serde_json::json!({ + "propagation": propagation, + "preferred_id": preferred_id, + "auto_sync_interval_sec": auto_sync_interval_sec, + }) + } + + pub async fn set_preferred_propagation(&self, id: &str) -> Result<(), String> { + let prop_hash = { + let mut inner = self.inner.write().await; + inner.set_preferred_propagation(id)?; + let hash = inner + .propagation + .iter() + .find(|p| p.id == id) + .and_then(|p| p.destination_hash.clone()); + inner.save(&self.config_dir, &self.storage_dir)?; + hash + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.set_outbound_propagation_node(prop_hash.as_deref()).await; + } + Ok(()) + } + + pub async fn set_propagation_auto_sync_interval(&self, sec: u32) -> Result<(), String> { + let mut inner = self.inner.write().await; + inner.set_auto_sync_interval_sec(sec); + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(()) + } + + pub async fn start_propagation_sync(&self, propagation_id: &str) -> Result<(), String> { + let prop_hash = { + let inner = self.inner.read().await; + inner + .propagation + .iter() + .find(|p| p.id == propagation_id) + .and_then(|p| p.destination_hash.clone()) + .ok_or_else(|| format!("propagation node not found: {propagation_id}"))? + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.start_propagation_sync(&prop_hash).await?; + return Ok(()); + } + let mut inner = self.inner.write().await; + inner.start_propagation_sync(propagation_id)?; + inner.save(&self.config_dir, &self.storage_dir)?; + self.emit_event( + "propagation_sync", + inner.propagation_sync.clone(), + ); + Ok(()) + } + + pub async fn cancel_propagation_sync(&self) -> Result<(), String> { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.cancel_propagation_sync().await; + return Ok(()); + } + let mut inner = self.inner.write().await; + inner.cancel_propagation_sync(); + inner.save(&self.config_dir, &self.storage_dir)?; + self.emit_event( + "propagation_sync", + inner.propagation_sync.clone(), + ); + Ok(()) + } + + pub async fn set_propagation_enabled(&self, id: &str, enabled: bool) -> Result<(), String> { + if id == "local-prop" { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.set_local_propagation_serving(enabled).await; + } + } + let mut inner = self.inner.write().await; + inner.set_propagation_enabled(id, enabled)?; + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(()) + } + + pub async fn add_propagation_node( + &self, + destination_hash: &str, + name: Option, + ) -> Result { + let mut inner = self.inner.write().await; + let row = inner.add_propagation_node(destination_hash, name)?; + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(serde_json::json!({ "ok": true, "node": row })) + } + + pub async fn ping_destination(&self, destination_hash: &str) -> Result { + let started = std::time::Instant::now(); + let probe = self.probe_peer(destination_hash).await?; + let rtt_ms = started.elapsed().as_millis() as u64; + let ok = probe.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + Ok(serde_json::json!({ "ok": ok, "rtt_ms": rtt_ms })) + } + + pub async fn topology_snapshot(&self) -> serde_json::Value { + let peers = self.list_peers().await; + let (mut nodes, edges) = topology::build_topology(&peers); + let inner = self.inner.read().await; + let name_by_hash = topology::build_topology_name_map( + &inner.peers, + &inner.contacts, + &inner.nomad_nodes, + ); + topology::merge_topology_display_names(&mut nodes, &name_by_hash); + serde_json::json!({ "nodes": nodes, "edges": edges }) + } + + pub async fn clear_announces(&self) -> Result<(), String> { + let mut inner = self.inner.write().await; + inner.clear_peers(); + inner.save(&self.config_dir, &self.storage_dir)?; + self.emit_event("peers_updated", serde_json::json!({ "cleared": true })); + Ok(()) + } + + pub async fn list_nomad_nodes(&self) -> Vec { + self.inner.read().await.nomad_nodes.clone() + } + + pub async fn set_nomad_favorite(&self, hash: &str, favorited: bool) -> Result<(), String> { + let mut inner = self.inner.write().await; + inner.set_nomad_favorite(hash, favorited); + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(()) + } + + #[cfg(feature = "rns-stack")] + async fn nomad_identity_hash_for(&self, hash: &str) -> Option { + let key = hash.to_lowercase(); + self.inner + .read() + .await + .nomad_nodes + .iter() + .find(|n| n.destination_hash.to_lowercase() == key) + .and_then(|n| n.identity_hash.clone()) + } + + pub async fn nomad_page(&self, hash: &str, path: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let interfaces = self.inner.read().await.interfaces.clone(); + let identity_hash = self.nomad_identity_hash_for(hash).await; + return live + .fetch_nomad_page(hash, identity_hash.as_deref(), path, &interfaces) + .await; + } + let _ = (hash, path); + serde_json::json!({ + "ok": false, + "error": "nomad page fetch requires live rns-stack sidecar" + }) + } + + pub async fn nomad_file(&self, hash: &str, path: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let interfaces = self.inner.read().await.interfaces.clone(); + let identity_hash = self.nomad_identity_hash_for(hash).await; + return live + .fetch_nomad_file(hash, identity_hash.as_deref(), path, &interfaces) + .await; + } + let _ = (hash, path); + serde_json::json!({ + "ok": false, + "error": "nomad file fetch requires live rns-stack sidecar" + }) + } + + pub async fn lxmf_send(&self, req: LxmfSendRequest) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.send_lxmf(&req).await?; + let payload = res.get("message").cloned().unwrap_or(res.clone()); + if payload.get("text").is_some() { + self.emit_event("lxmf_message", payload.clone()); + } + return Ok(serde_json::json!({ + "ok": true, + "message": payload, + "sent_via": res.get("sent_via"), + })); + } + let mut inner = self.inner.write().await; + let res = inner.send_lxmf_local(&req)?; + inner.save(&self.config_dir, &self.storage_dir)?; + let payload = res.clone(); + drop(inner); + self.emit_event("lxmf_message", payload); + Ok(res) + } + + fn maybe_emit_identity_restart(&self) { + #[cfg(feature = "rns-stack")] + if self.live.is_some() { + self.emit_event("stack_restart_requested", serde_json::json!({ "ok": true })); + } + } + + pub async fn lxmf_reaction( + &self, + req: LxmfReactionRequest, + ) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.send_reaction(&req).await?; + self.emit_event("lxmf_message", res.clone()); + return Ok(res); + } + let mut inner = self.inner.write().await; + let res = inner.send_reaction(&req)?; + inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + self.emit_event("lxmf_message", res.clone()); + Ok(res) + } + + pub async fn rnode_presets(&self) -> serde_json::Value { + rf_profiles::presets_wire_json() + } + + pub async fn serial_ports(&self) -> serde_json::Value { + serde_json::json!({ "ports": enumerate_serial_ports() }) + } + + pub async fn ble_availability(&self) -> serde_json::Value { + ble::ble_availability().await + } + + pub async fn ble_scan(&self, timeout_secs: u64, mode: &str) -> Result { + ble::ble_scan(timeout_secs, mode).await + } + + pub async fn lxmf_send_resource( + &self, + req: LxmfResourceRequest, + ) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.send_lxmf_resource(&req).await?; + if res.get("ok").and_then(|v| v.as_bool()) == Some(true) { + let payload = res.get("message").cloned().unwrap_or(res.clone()); + self.emit_event("resource.received", payload.clone()); + self.emit_event("lxmf_message", payload); + } + return Ok(res); + } + let mut inner = self.inner.write().await; + let res = inner.send_resource_local(&req)?; + inner.save(&self.config_dir, &self.storage_dir)?; + let payload = res.clone(); + drop(inner); + self.emit_event("resource.received", payload.clone()); + self.emit_event("lxmf_message", payload.clone()); + Ok(payload) + } + + pub async fn lxmf_delete_message(&self, message_hash: &str) -> Result { + let mut inner = self.inner.write().await; + let removed = inner.delete_message_by_hash(message_hash)?; + inner.save(&self.config_dir, &self.storage_dir)?; + Ok(removed) + } + + pub async fn request_stack_restart(&self) -> Result<(), String> { + self.emit_event("stack_restart_requested", serde_json::json!({ "ok": true })); + Ok(()) + } + + pub async fn factory_reset(&self) -> Result<(), String> { + let mut inner = self.inner.write().await; + inner.factory_reset_state()?; + inner.save(&self.config_dir, &self.storage_dir)?; + self.emit_stats().await; + Ok(()) + } + + pub async fn diagnostics_snapshot(&self) -> serde_json::Value { + let inner = self.inner.read().await; + let live_interfaces = self.list_interfaces().await; + let interfaces: Vec = live_interfaces + .iter() + .map(|i| { + serde_json::json!({ + "id": i.id, + "name": i.name, + "type": i.iface_type, + "enabled": i.enabled, + "status": i.status, + "host": i.host, + "port": i.port, + "preset": i.preset, + "serial_port": i.serial_port, + "frequency": i.frequency, + }) + }) + .collect(); + serde_json::json!({ + "rns_ready": inner.rns_ready, + "lxmf_ready": inner.lxmf_ready, + "interface_count": live_interfaces.len(), + "contact_count": inner.contacts.len(), + "peer_count": inner.peers.len(), + "message_count": inner.messages.len(), + "interfaces": interfaces, + }) + } + + pub async fn config_audit(&self) -> Result, String> { + let settings = config::get_stack_settings(&self.config_dir)?; + let live = self.list_interfaces().await; + let inner = self.inner.read().await; + let stack_running = inner.rns_ready; + config_audit::audit_config(&self.config_dir, &live, &settings, stack_running) + } + + pub async fn config_repair( + &self, + request: config_audit::ConfigRepairRequest, + ) -> Result<(Vec, bool), String> { + config_audit::repair_config(&self.config_dir, &request) + } + + pub async fn voice_status(&self) -> serde_json::Value { + serde_json::json!({ + "available": cfg!(feature = "rns-stack"), + "enabled": false, + "codec": "opus", + "reason": "LXST voice pipeline pending rsLXST integration" + }) + } + + pub async fn games_status(&self) -> serde_json::Value { + serde_json::json!({ + "available": true, + "enabled": false, + "reason": "LRGP games pending lrgp-rs integration" + }) + } + + pub async fn list_identities(&self) -> serde_json::Value { + let identity = self.inner.read().await.identity.clone(); + serde_json::json!({ + "identities": [{ + "id": "default", + "display_name": identity.display_name, + "identity_hash": identity.identity_hash, + "lxmf_hash": identity.lxmf_hash, + "active": true, + "configured": identity.configured, + }] + }) + } + + pub async fn switch_identity(&self, identity_id: &str) -> Result<(), String> { + if identity_id != "default" { + return Err("only default identity is available in this build".into()); + } + Ok(()) + } + + pub async fn rns_ready(&self) -> bool { + self.inner.read().await.rns_ready + } + + pub async fn lxmf_ready(&self) -> bool { + self.inner.read().await.lxmf_ready + } + + pub fn rns_version(&self) -> Option { + #[cfg(feature = "rns-stack")] + { + Some("rsReticulum".into()) + } + #[cfg(not(feature = "rns-stack"))] + { + None + } + } + + pub fn lxmf_version(&self) -> Option { + #[cfg(feature = "rns-stack")] + { + Some("rsLXMF".into()) + } + #[cfg(not(feature = "rns-stack"))] + { + None + } + } +} + +fn enumerate_serial_ports() -> Vec { + let mut ports: Vec = Vec::new(); + + #[cfg(target_os = "macos")] + { + if let Ok(entries) = std::fs::read_dir("/dev") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("cu.") { + let path = format!("/dev/{name}"); + ports.push(serde_json::json!({ "path": path, "label": name })); + } + } + } + } + + #[cfg(target_os = "linux")] + { + if let Ok(entries) = std::fs::read_dir("/dev") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("ttyUSB") || name.starts_with("ttyACM") { + let path = format!("/dev/{name}"); + ports.push(serde_json::json!({ "path": path, "label": name })); + } + } + } + } + + #[cfg(target_os = "windows")] + { + // No std library serial enumeration; users enter COM ports manually. + } + + ports.sort_by(|a, b| { + a.get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("path").and_then(|v| v.as_str()).unwrap_or("")) + }); + ports +} + +/// Replaces the in-memory peer cache with a live path-table fetch result. +fn sync_live_peer_cache(cache: &mut Vec, fetched: Vec) -> Vec { + let merged: Vec = fetched + .into_iter() + .map(|mut peer| { + if peer.display_name.is_none() { + if let Some(prev) = cache + .iter() + .find(|p| p.destination_hash == peer.destination_hash) + { + peer.display_name = prev.display_name.clone(); + } + } + peer + }) + .collect(); + *cache = merged.clone(); + merged +} + +/// Apply a live path-table fetch: update cache only when non-empty; otherwise keep last known peers. +fn merge_live_peer_fetch( + cache: &mut Vec, + fetched: Result, String>, +) -> Vec { + match fetched { + Ok(peers) if !peers.is_empty() => sync_live_peer_cache(cache, peers), + Ok(_) => { + tracing::debug!("live fetch_peers returned empty path table, using cache"); + cache.clone() + } + Err(e) => { + tracing::debug!("live fetch_peers failed: {e}"); + cache.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::broadcast; + use uuid::Uuid; + + fn temp_stack_dirs() -> (PathBuf, PathBuf) { + let id = Uuid::new_v4(); + let config = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{id}")); + let storage = std::env::temp_dir().join(format!("mesh_reticulum_store_{id}")); + std::fs::create_dir_all(&config).expect("config dir"); + std::fs::create_dir_all(&storage).expect("storage dir"); + (config, storage) + } + + #[test] + fn merge_live_peer_fetch_preserves_cache_on_empty_or_error() { + let mut cache = vec![PeerRow { + destination_hash: "abc".into(), + display_name: None, + hops: Some(1), + last_seen: None, + interface: None, + path_hash: None, + via_hash: None, + }]; + let empty = merge_live_peer_fetch(&mut cache, Ok(vec![])); + assert_eq!(empty.len(), 1); + assert_eq!(cache.len(), 1); + + let err = merge_live_peer_fetch(&mut cache, Err("path table query unavailable".into())); + assert_eq!(err.len(), 1); + assert_eq!(cache.len(), 1); + } + + #[test] + fn merge_live_peer_fetch_replaces_cache_when_non_empty() { + let mut cache = Vec::new(); + let row = PeerRow { + destination_hash: "deadbeef".into(), + display_name: Some("peer".into()), + hops: Some(2), + last_seen: Some(1), + interface: Some("tcp".into()), + path_hash: None, + via_hash: None, + }; + let fetched = merge_live_peer_fetch(&mut cache, Ok(vec![row.clone()])); + assert_eq!(fetched.len(), 1); + assert_eq!(cache.len(), 1); + assert_eq!(cache[0].destination_hash, row.destination_hash); + } + + #[test] + fn sync_live_peer_cache_replaces_including_empty() { + let mut cache = vec![PeerRow { + destination_hash: "abc".into(), + display_name: None, + hops: Some(1), + last_seen: None, + interface: None, + path_hash: None, + via_hash: None, + }]; + let fetched = sync_live_peer_cache(&mut cache, vec![]); + assert!(fetched.is_empty()); + assert!(cache.is_empty()); + } + + #[test] + fn sync_live_peer_cache_updates_non_empty() { + let mut cache = Vec::new(); + let row = PeerRow { + destination_hash: "deadbeef".into(), + display_name: Some("peer".into()), + hops: Some(2), + last_seen: Some(1), + interface: Some("tcp".into()), + path_hash: None, + via_hash: None, + }; + let fetched = sync_live_peer_cache(&mut cache, vec![row.clone()]); + assert_eq!(fetched.len(), 1); + assert_eq!(cache.len(), 1); + assert_eq!(cache[0].destination_hash, row.destination_hash); + } + + #[test] + fn upsert_nomad_node_updates_existing_display_name() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let mut state = PersistedState::load(&config_dir, &storage_dir); + state.upsert_nomad_node("abc123", None, Some("Forum".into()), Some(2)); + state.upsert_nomad_node("ABC123", None, Some("Updated Forum".into()), Some(3)); + assert_eq!(state.nomad_nodes.len(), 1); + assert_eq!(state.nomad_nodes[0].display_name.as_deref(), Some("Updated Forum")); + assert_eq!(state.nomad_nodes[0].hops, Some(3)); + assert_eq!(state.nomad_nodes[0].status.as_deref(), Some("online")); + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + + #[tokio::test] + 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; + handle.clear_announces().await.expect("clear announces"); + assert!(handle.list_peers().await.is_empty()); + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } +} diff --git a/reticulum-sidecar/src/stack/nomad_file.rs b/reticulum-sidecar/src/stack/nomad_file.rs new file mode 100644 index 000000000..d4622dbaa --- /dev/null +++ b/reticulum-sidecar/src/stack/nomad_file.rs @@ -0,0 +1,31 @@ +//! Nomad Network file download helpers. + +/// Basename for a Nomad `/file/...` request path (matches nomadnet Node.serve_file naming). +pub fn nomad_file_name_from_path(path: &str) -> String { + path.strip_prefix("/file/") + .unwrap_or(path) + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("downloaded_file") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::nomad_file_name_from_path; + + #[test] + fn file_name_from_path_uses_basename() { + assert_eq!( + nomad_file_name_from_path("/file/docs/readme.txt"), + "readme.txt" + ); + assert_eq!(nomad_file_name_from_path("/file/image.png"), "image.png"); + } + + #[test] + fn file_name_from_path_falls_back_when_empty() { + assert_eq!(nomad_file_name_from_path("/file/"), "downloaded_file"); + } +} diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs new file mode 100644 index 000000000..91a1b7867 --- /dev/null +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -0,0 +1,96 @@ +//! Nomad page fetch timeouts (MeshChat + Python RNS per-hop scaling on RF). + +use super::via::resolve_outbound_sent_via; +use super::types::InterfaceRow; + +/// MeshChat `NomadnetDownloader.download()` path_lookup_timeout default. +pub const NOMAD_PATH_LOOKUP_SECS: u64 = 15; + +/// MeshChat TCP link_establishment_timeout default. +pub const NOMAD_TCP_LINK_ESTABLISH_SECS: u64 = 15; + +/// Grace for RTT-scaled link.request transfer after path + link stages. +pub const NOMAD_TCP_TRANSFER_GRACE_SECS: u64 = 15; + +/// Python RNS `DEFAULT_PER_HOP_TIMEOUT`. +pub const NOMAD_RF_PER_HOP_TIMEOUT_SECS: u64 = 6; + +/// Python RNS first-hop component in link establishment. +pub const NOMAD_RF_FIRST_HOP_SECS: u64 = 6; + +/// Extra grace for slow RF page transfers. +pub const NOMAD_RF_TRANSFER_GRACE_SECS: u64 = 30; + +/// RNS transport default overall cap. +pub const NOMAD_RF_MAX_OVERALL_SECS: u64 = 180; + +fn bounded_hops(hops: u8) -> u64 { + u64::from(hops.clamp(1, 32)) +} + +/// Overall sidecar Link query deadline in seconds. +pub fn nomad_page_overall_timeout_secs(egress_via: &str, hops: u8) -> u64 { + if egress_via == "rf" { + let bounded_hops = bounded_hops(hops); + let link_establish = + NOMAD_RF_FIRST_HOP_SECS + NOMAD_RF_PER_HOP_TIMEOUT_SECS * bounded_hops; + let total = NOMAD_PATH_LOOKUP_SECS + link_establish + NOMAD_RF_TRANSFER_GRACE_SECS; + total.min(NOMAD_RF_MAX_OVERALL_SECS) + } else { + NOMAD_PATH_LOOKUP_SECS + NOMAD_TCP_LINK_ESTABLISH_SECS + NOMAD_TCP_TRANSFER_GRACE_SECS + } +} + +/// Resolve egress from enabled interfaces and compute overall timeout. +pub fn nomad_page_timeout_secs_for_interfaces(interfaces: &[InterfaceRow], hops: u8) -> u64 { + let egress = resolve_outbound_sent_via(interfaces); + nomad_page_overall_timeout_secs(egress, hops) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stack::types::InterfaceRow; + + fn iface(iface_type: &str) -> InterfaceRow { + InterfaceRow { + id: "1".into(), + name: "test".into(), + iface_type: iface_type.into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + } + } + + #[test] + fn tcp_timeout_matches_meshchat_stages() { + assert_eq!(nomad_page_overall_timeout_secs("tcp", 8), 45); + assert_eq!(nomad_page_overall_timeout_secs("network", 1), 45); + } + + #[test] + fn rf_timeout_scales_with_hops_and_caps() { + assert_eq!(nomad_page_overall_timeout_secs("rf", 1), 57); + assert_eq!(nomad_page_overall_timeout_secs("rf", 8), 99); + assert_eq!(nomad_page_overall_timeout_secs("rf", 32), 180); + } + + #[test] + fn timeout_from_interfaces_prefers_rnode() { + let ifaces = vec![iface("tcp"), iface("rnode")]; + assert_eq!(nomad_page_timeout_secs_for_interfaces(&ifaces, 8), 99); + } +} diff --git a/reticulum-sidecar/src/stack/packet_log.rs b/reticulum-sidecar/src/stack/packet_log.rs new file mode 100644 index 000000000..e29825931 --- /dev/null +++ b/reticulum-sidecar/src/stack/packet_log.rs @@ -0,0 +1,127 @@ +use std::collections::VecDeque; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +pub const MAX_WIRE_PACKET_LOG: usize = 2500; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WirePacketRow { + pub ts: u64, + pub direction: String, + pub interface_id: u64, + pub interface_name: String, + pub raw_hex: String, + pub rssi: Option, + pub snr: Option, + pub q: Option, + pub packet_type: Option, + pub header_type: Option, + pub destination_hash: Option, + pub transport_type: Option, + pub context: Option, +} + +#[derive(Debug)] +pub struct PacketLogBuffer { + max: usize, + inner: Mutex>, +} + +impl PacketLogBuffer { + pub fn new(max: usize) -> Self { + Self { + max, + inner: Mutex::new(VecDeque::new()), + } + } + + pub fn push(&self, row: WirePacketRow) { + if let Ok(mut buf) = self.inner.lock() { + if buf.len() >= self.max { + buf.pop_front(); + } + buf.push_back(row); + } + } + + pub fn snapshot(&self, limit: usize) -> Vec { + self.inner + .lock() + .ok() + .map(|buf| { + let start = buf.len().saturating_sub(limit); + buf.iter().skip(start).cloned().collect() + }) + .unwrap_or_default() + } + + pub fn clear(&self) { + if let Ok(mut buf) = self.inner.lock() { + buf.clear(); + } + } +} + +pub fn emit_wire_packet_event(event_tx: &broadcast::Sender, row: &WirePacketRow) { + let msg = serde_json::json!({ "type": "wire_packet", "payload": row }); + let _ = event_tx.send(msg.to_string()); +} + +#[cfg(feature = "rns-stack")] +pub fn wire_packet_from_tap(evt: &rns_transport::messages::PacketTapEvent) -> WirePacketRow { + use rns_transport::messages::PacketTapDirection; + WirePacketRow { + ts: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + direction: match evt.direction { + PacketTapDirection::Rx => "rx".into(), + PacketTapDirection::Tx => "tx".into(), + }, + interface_id: evt.interface_id, + interface_name: evt.interface_name.clone(), + raw_hex: hex::encode(&evt.raw), + rssi: evt.rssi, + snr: evt.snr, + q: evt.q, + packet_type: evt.packet_type.clone(), + header_type: evt.header_type.clone(), + destination_hash: evt.destination_hash.map(hex::encode), + transport_type: evt.transport_type.clone(), + context: evt.context.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_buffer_drops_oldest_at_cap() { + let buf = PacketLogBuffer::new(3); + for i in 0..5 { + buf.push(WirePacketRow { + ts: i, + direction: "rx".into(), + interface_id: 1, + interface_name: "test".into(), + raw_hex: "aa".into(), + rssi: None, + snr: None, + q: None, + packet_type: None, + header_type: None, + destination_hash: None, + transport_type: None, + context: None, + }); + } + let snap = buf.snapshot(10); + assert_eq!(snap.len(), 3); + assert_eq!(snap[0].ts, 2); + assert_eq!(snap[2].ts, 4); + } +} diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs new file mode 100644 index 000000000..4990b7323 --- /dev/null +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -0,0 +1,607 @@ +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use uuid::Uuid; + +use serde::Deserialize; + +use super::types::*; +use super::via::resolve_outbound_sent_via; + +const STATE_FILE: &str = "mesh_client_stack.json"; + +pub struct PersistedState { + pub identity: StackIdentity, + pub interfaces: Vec, + pub contacts: Vec, + pub peers: Vec, + pub propagation: Vec, + pub messages: Vec, + pub rns_ready: bool, + pub lxmf_ready: bool, + pub preferred_propagation_id: Option, + pub propagation_sync: serde_json::Value, + pub auto_sync_interval_sec: u32, + pub nomad_nodes: Vec, +} + +impl PersistedState { + pub fn load(config_dir: &Path, storage_dir: &Path) -> Self { + let _ = fs::create_dir_all(config_dir); + let _ = fs::create_dir_all(storage_dir); + let path = storage_dir.join(STATE_FILE); + if path.exists() { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(state) = serde_json::from_str::(&raw) { + return state; + } + } + } + Self::default_empty() + } + + fn default_empty() -> Self { + Self { + identity: StackIdentity::default(), + interfaces: Vec::new(), + contacts: Vec::new(), + peers: Vec::new(), + propagation: Vec::new(), + messages: Vec::new(), + rns_ready: false, + lxmf_ready: false, + preferred_propagation_id: None, + propagation_sync: serde_json::Value::Null, + auto_sync_interval_sec: 3600, + nomad_nodes: Vec::new(), + } + } + + pub fn ensure_defaults(&mut self) { + if self.propagation.is_empty() { + self.propagation.push(PropagationRow { + id: "local-prop".into(), + name: "Local propagation (offline inbox)".to_string(), + hops: Some(0), + enabled: false, + status: "unknown".into(), + destination_hash: None, + }); + } + self.sync_local_propagation_hash(); + } + + pub fn sync_local_propagation_hash(&mut self) { + if !self.identity.configured { + return; + } + if let Some(node) = self.propagation.iter_mut().find(|p| p.id == "local-prop") { + node.destination_hash = Some(self.identity.lxmf_hash.clone()); + } + } + + pub fn add_propagation_node( + &mut self, + destination_hash: &str, + name: Option, + ) -> Result { + let hash = destination_hash.trim().to_lowercase(); + if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("destination_hash must be 32 hex characters".into()); + } + if self.propagation.iter().any(|p| { + p.destination_hash + .as_ref() + .map(|d| d.to_lowercase() == hash) + .unwrap_or(false) + }) { + return Err("propagation node already exists".into()); + } + let id = format!("pn-{}", &hash[..8]); + let row = PropagationRow { + id, + name: name.unwrap_or_else(|| format!("Propagation node {}", &hash[..8])), + hops: None, + enabled: true, + status: "known".into(), + destination_hash: Some(hash), + }; + self.propagation.push(row.clone()); + Ok(row) + } + + pub fn save(&self, _config_dir: &Path, storage_dir: &Path) -> Result<(), String> { + let path = storage_dir.join(STATE_FILE); + let mut value = serde_json::to_value(self).map_err(|e| e.to_string())?; + if let Some(identity) = value.get_mut("identity").and_then(|v| v.as_object_mut()) { + identity.remove("mnemonic"); + } + let raw = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?; + fs::write(path, raw).map_err(|e| e.to_string()) + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn random_hash() -> String { + format!("{:032x}", Uuid::new_v4().as_u128()) + } + + pub fn generate_identity( + &mut self, + display_name: Option, + ) -> Result { + let identity_hash = Self::random_hash(); + let lxmf_hash = Self::random_hash(); + let mnemonic = generate_mnemonic_12(); + self.identity = StackIdentity { + configured: true, + identity_hash, + lxmf_hash, + display_name, + mnemonic: Some(mnemonic), + }; + self.rns_ready = true; + self.lxmf_ready = true; + self.sync_local_propagation_hash(); + Ok(self.identity.clone()) + } + + pub fn import_identity_mnemonic( + &mut self, + mnemonic: &str, + display_name: Option, + ) -> Result { + let words: Vec<&str> = mnemonic.split_whitespace().collect(); + if words.len() != 12 { + return Err("mnemonic must be 12 words".into()); + } + let identity_hash = format!("{:032x}", stable_hash(mnemonic)); + let lxmf_hash = format!("{:032x}", stable_hash(&format!("lxmf:{mnemonic}"))); + self.identity = StackIdentity { + configured: true, + identity_hash, + lxmf_hash, + display_name, + mnemonic: Some(mnemonic.to_string()), + }; + self.rns_ready = true; + self.lxmf_ready = true; + self.sync_local_propagation_hash(); + Ok(self.identity.clone()) + } + + pub fn export_identity_backup(&self, passphrase: &str) -> Result { + if !self.identity.configured { + return Err("no identity configured".into()); + } + let _ = passphrase; + Ok(serde_json::json!({ + "format": "mesh-client.identity.v1", + "identity_hash": self.identity.identity_hash, + "lxmf_hash": self.identity.lxmf_hash, + "display_name": self.identity.display_name, + "exported_at": Self::now_secs() + })) + } + + pub fn import_identity_backup( + &mut self, + backup: serde_json::Value, + passphrase: &str, + ) -> Result { + let _ = passphrase; + let format = backup.get("format").and_then(|v| v.as_str()).unwrap_or(""); + if format != "mesh-client.identity.v1" && format != "ratspeak.identity.v2" { + return Err("unsupported backup format".into()); + } + let identity_hash = backup + .get("identity_hash") + .and_then(|v| v.as_str()) + .ok_or("missing identity_hash")? + .to_string(); + let lxmf_hash = backup + .get("lxmf_hash") + .and_then(|v| v.as_str()) + .ok_or("missing lxmf_hash")? + .to_string(); + let display_name = backup + .get("display_name") + .and_then(|v| v.as_str()) + .map(str::to_string); + self.identity = StackIdentity { + configured: true, + identity_hash, + lxmf_hash, + display_name, + mnemonic: None, + }; + self.rns_ready = true; + self.lxmf_ready = true; + self.sync_local_propagation_hash(); + Ok(self.identity.clone()) + } + + pub fn add_interface(&mut self, req: AddInterfaceRequest) -> Result { + if !self.identity.configured { + return Err("identity not configured".into()); + } + let id = Uuid::new_v4().to_string(); + let name = req + .name + .unwrap_or_else(|| format!("{}-{}", req.iface_type, &id[..8])); + let row = InterfaceRow { + id: id.clone(), + name, + iface_type: req.iface_type.clone(), + enabled: true, + status: "pending".into(), + host: req.host, + port: req.port, + preset: req.preset, + serial_port: req.serial_port, + frequency: req.frequency, + bandwidth: req.bandwidth, + txpower: req.txpower, + spreading_factor: req.spreading_factor, + coding_rate: req.coding_rate, + callsign: req.callsign, + id_interval: req.id_interval, + mode: req.mode, + seed_addresses: req.seed_addresses, + }; + self.interfaces.push(row.clone()); + self.rns_ready = true; + Ok(row) + } + + pub fn set_interface_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> { + let iface = self + .interfaces + .iter_mut() + .find(|i| i.id == id) + .ok_or_else(|| format!("interface not found: {id}"))?; + iface.enabled = enabled; + iface.status = if enabled { "up" } else { "down" }.into(); + Ok(()) + } + + pub fn set_propagation_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> { + let node = self + .propagation + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| format!("propagation node not found: {id}"))?; + node.enabled = enabled; + node.status = if enabled { "active" } else { "idle" }.into(); + Ok(()) + } + + pub fn set_preferred_propagation(&mut self, id: &str) -> Result<(), String> { + if !self.propagation.iter().any(|p| p.id == id) { + return Err(format!("propagation node not found: {id}")); + } + self.preferred_propagation_id = Some(id.to_string()); + Ok(()) + } + + pub fn start_propagation_sync(&mut self, propagation_id: &str) -> Result<(), String> { + if !self.propagation.iter().any(|p| p.id == propagation_id) { + return Err(format!("propagation node not found: {propagation_id}")); + } + self.propagation_sync = serde_json::json!({ + "active": true, + "progress": 0, + "message": null, + "propagation_id": propagation_id, + }); + Ok(()) + } + + pub fn cancel_propagation_sync(&mut self) { + self.propagation_sync = serde_json::json!({ + "active": false, + "progress": 0, + "message": null, + }); + } + + pub fn set_auto_sync_interval_sec(&mut self, sec: u32) { + self.auto_sync_interval_sec = sec; + } + + pub fn upsert_nomad_node( + &mut self, + hash: &str, + identity_hash: Option, + display_name: Option, + hops: Option, + ) { + let key = hash.to_lowercase(); + let now = Self::now_secs(); + if let Some(node) = self + .nomad_nodes + .iter_mut() + .find(|n| n.destination_hash.to_lowercase() == key) + { + if identity_hash.is_some() { + node.identity_hash = identity_hash; + } + if display_name.is_some() { + node.display_name = display_name; + } + if hops.is_some() { + node.hops = hops; + } + node.last_seen = Some(now); + node.status = Some("online".into()); + return; + } + self.nomad_nodes.push(NomadNodeRow { + destination_hash: hash.to_string(), + identity_hash, + display_name, + last_seen: Some(now), + favorited: false, + hops, + status: Some("online".into()), + }); + } + + pub fn set_nomad_favorite(&mut self, hash: &str, favorited: bool) { + let key = hash.to_lowercase(); + if let Some(node) = self + .nomad_nodes + .iter_mut() + .find(|n| n.destination_hash.to_lowercase() == key) + { + node.favorited = favorited; + return; + } + self.nomad_nodes.push(NomadNodeRow { + destination_hash: hash.to_string(), + identity_hash: None, + display_name: None, + last_seen: Some(Self::now_secs()), + favorited, + hops: None, + status: Some("unknown".into()), + }); + } + + pub fn clear_peers(&mut self) { + self.peers.clear(); + } + + pub fn upsert_contact(&mut self, hash: &str, name: Option) { + if let Some(c) = self + .contacts + .iter_mut() + .find(|c| c.destination_hash == hash) + { + if name.is_some() { + c.display_name = name; + } + c.last_heard = Some(Self::now_secs()); + return; + } + self.contacts.push(ContactRow { + destination_hash: hash.to_string(), + display_name: name, + last_heard: Some(Self::now_secs()), + favorited: false, + }); + } + + pub fn send_lxmf_local(&mut self, req: &LxmfSendRequest) -> Result { + if !self.identity.configured { + return Err("identity not configured".into()); + } + let ts = Self::now_secs(); + self.upsert_contact(&req.destination_hash, None); + let sent_via = resolve_outbound_sent_via(&self.interfaces); + let mut payload = serde_json::json!({ + "sender_hash": self.identity.lxmf_hash, + "sender_name": self.identity.display_name.clone().unwrap_or_else(|| "Self".into()), + "text": req.text, + "timestamp": ts * 1000, + "to_hash": req.destination_hash, + "reply_to_hash": req.reply_to_hash, + "reply_to_id": req.reply_to_id, + "direction": "outbound", + "sent_via": sent_via, + "received_via": sent_via + }); + let hash_input = format!( + "{}:{}:{}", + payload["sender_hash"].as_str().unwrap_or_default(), + payload["timestamp"].as_i64().unwrap_or(0), + payload["text"].as_str().unwrap_or_default() + ); + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "message_hash".into(), + serde_json::Value::String(format!("{:032x}", stable_hash(&hash_input))), + ); + } + self.messages.push(payload.clone()); + Ok(payload) + } + + pub fn send_reaction( + &mut self, + req: &LxmfReactionRequest, + ) -> Result { + let ts = Self::now_secs(); + Ok(serde_json::json!({ + "sender_hash": self.identity.lxmf_hash, + "sender_name": self.identity.display_name.clone().unwrap_or_else(|| "Self".into()), + "text": req.emoji, + "timestamp": ts * 1000, + "to_hash": req.destination_hash, + "reaction_target": req.target_hash, + "direction": "outbound" + })) + } + + pub fn factory_reset_state(&mut self) -> Result<(), String> { + let interfaces = self.interfaces.clone(); + *self = Self::default_empty(); + self.interfaces = interfaces; + self.ensure_defaults(); + Ok(()) + } + + pub fn send_resource_local( + &mut self, + req: &LxmfResourceRequest, + ) -> Result { + if !self.identity.configured { + return Err("identity not configured".into()); + } + let ts = Self::now_secs(); + self.upsert_contact(&req.destination_hash, None); + let text = format!("[file:{}:{}]", req.file_name, req.mime_type); + let mut payload = serde_json::json!({ + "sender_hash": self.identity.lxmf_hash, + "sender_name": self.identity.display_name.clone().unwrap_or_else(|| "Self".into()), + "text": text, + "timestamp": ts * 1000, + "to_hash": req.destination_hash, + "reply_to_hash": req.reply_to_hash, + "direction": "outbound", + "attachment": { + "file_name": req.file_name, + "mime_type": req.mime_type, + "size_bytes": req.data_base64.len(), + } + }); + let hash_input = format!( + "{}:{}:{}", + payload["sender_hash"].as_str().unwrap_or_default(), + payload["timestamp"].as_i64().unwrap_or(0), + payload["text"].as_str().unwrap_or_default() + ); + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "message_hash".into(), + serde_json::Value::String(format!("{:032x}", stable_hash(&hash_input))), + ); + } + self.messages.push(payload.clone()); + Ok(payload) + } + + pub fn delete_message_by_hash(&mut self, message_hash: &str) -> Result { + let before = self.messages.len(); + self.messages + .retain(|m| m.get("message_hash").and_then(|v| v.as_str()) != Some(message_hash)); + Ok(self.messages.len() < before) + } +} + +pub(crate) fn stable_hash(s: &str) -> u128 { + let mut h: u128 = 0xcbf29ce484222325; + for b in s.bytes() { + h ^= b as u128; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +fn generate_mnemonic_12() -> String { + const WORDS: &[&str] = &[ + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", + "juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo", "sierra", + "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu", + ]; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut picked = Vec::new(); + for i in 0..12 { + let mut hasher = DefaultHasher::new(); + seed.hash(&mut hasher); + i.hash(&mut hasher); + let idx = (hasher.finish() as usize) % WORDS.len(); + picked.push(WORDS[idx]); + } + picked.join(" ") +} + +impl serde::Serialize for PersistedState { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut s = serializer.serialize_struct("PersistedState", 12)?; + s.serialize_field("identity", &self.identity)?; + s.serialize_field("interfaces", &self.interfaces)?; + s.serialize_field("contacts", &self.contacts)?; + s.serialize_field("peers", &self.peers)?; + s.serialize_field("propagation", &self.propagation)?; + s.serialize_field("messages", &self.messages)?; + s.serialize_field("rns_ready", &self.rns_ready)?; + s.serialize_field("lxmf_ready", &self.lxmf_ready)?; + s.serialize_field("preferred_propagation_id", &self.preferred_propagation_id)?; + s.serialize_field("propagation_sync", &self.propagation_sync)?; + s.serialize_field("auto_sync_interval_sec", &self.auto_sync_interval_sec)?; + s.serialize_field("nomad_nodes", &self.nomad_nodes)?; + s.end() + } +} + +impl<'de> serde::Deserialize<'de> for PersistedState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + identity: StackIdentity, + interfaces: Vec, + contacts: Vec, + peers: Vec, + propagation: Vec, + messages: Vec, + rns_ready: bool, + lxmf_ready: bool, + #[serde(default)] + preferred_propagation_id: Option, + #[serde(default)] + propagation_sync: serde_json::Value, + #[serde(default)] + auto_sync_interval_sec: u32, + #[serde(default)] + nomad_nodes: Vec, + } + let raw = Raw::deserialize(deserializer)?; + Ok(Self { + identity: raw.identity, + interfaces: raw.interfaces, + contacts: raw.contacts, + peers: raw.peers, + propagation: raw.propagation, + messages: raw.messages, + rns_ready: raw.rns_ready, + lxmf_ready: raw.lxmf_ready, + preferred_propagation_id: raw.preferred_propagation_id, + propagation_sync: if raw.propagation_sync.is_null() { + serde_json::Value::Null + } else { + raw.propagation_sync + }, + auto_sync_interval_sec: raw.auto_sync_interval_sec, + nomad_nodes: raw.nomad_nodes, + }) + } +} diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs new file mode 100644 index 000000000..a560312a9 --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -0,0 +1,173 @@ +//! Live propagation node serving and sync against remote propagation nodes. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use lxmf_core::propagation_node::{PropagationNode, PropagationNodeConfig}; +use lxmf_core::propagation_sync::{PropagationSyncTask, SyncTaskState}; +use lxmf_core::router::LxmRouter; +use rns_transport::messages::TransportMessage; +use tokio::sync::{broadcast, mpsc}; + +pub struct PropagationBridge { + local_dest_hash: [u8; 16], + local_node: Arc>, + sync_task: Mutex, + local_serving: AtomicBool, +} + +impl PropagationBridge { + pub fn new( + transport_tx: mpsc::Sender, + local_dest_hash: [u8; 16], + storage_dir: PathBuf, + ) -> Result { + std::fs::create_dir_all(&storage_dir).map_err(|e| e.to_string())?; + let local_node = Arc::new(Mutex::new( + PropagationNode::with_storage( + PropagationNodeConfig::default(), + local_dest_hash, + storage_dir, + ) + .map_err(|e| format!("propagation storage init: {e}"))?, + )); + let sync_task = PropagationSyncTask::with_shared_node(transport_tx, local_node.clone()); + Ok(Self { + local_dest_hash, + local_node, + sync_task: Mutex::new(sync_task), + local_serving: AtomicBool::new(false), + }) + } + + pub fn local_dest_hash_hex(&self) -> String { + hex::encode(self.local_dest_hash) + } + + pub fn set_local_serving(&self, enabled: bool, router: &mut LxmRouter) { + self.local_serving.store(enabled, Ordering::SeqCst); + router.set_propagation_enabled(enabled); + } + + pub fn is_local_serving(&self) -> bool { + self.local_serving.load(Ordering::SeqCst) + } + + pub fn local_stats(&self) -> (usize, usize) { + self.local_node + .lock() + .map(|node| (node.message_count(), node.total_size())) + .unwrap_or((0, 0)) + } + + pub fn start_sync(&self, remote_hash: [u8; 16]) -> bool { + let mut task = match self.sync_task.lock() { + Ok(task) => task, + Err(_) => return false, + }; + task.request_sync_now(remote_hash); + true + } + + pub fn cancel_sync(&self) { + if let Ok(mut task) = self.sync_task.lock() { + task.state = SyncTaskState::Failed; + } + } + + pub fn sync_active(&self) -> bool { + self.sync_task + .lock() + .map(|task| { + !matches!( + task.state, + SyncTaskState::Idle | SyncTaskState::Complete | SyncTaskState::Failed + ) + }) + .unwrap_or(false) + } + + pub fn sync_progress(&self) -> f64 { + self.sync_task.lock().map(|task| match task.state { + SyncTaskState::Idle => 0.0, + SyncTaskState::Establishing => 10.0, + SyncTaskState::Offering => 25.0, + SyncTaskState::AwaitingResponse => 40.0, + SyncTaskState::Transferring => 70.0, + SyncTaskState::Complete => 100.0, + SyncTaskState::Failed => 0.0, + }).unwrap_or(0.0) + } + + pub fn tick(&self, known_identities: &HashMap) { + if let Ok(mut task) = self.sync_task.lock() { + task.drain_events(known_identities); + task.tick(); + } + } + + pub fn spawn_sync_progress_emitter( + self: &Arc, + event_tx: broadcast::Sender, + cancel: Arc, + ) { + let bridge = Arc::clone(self); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(500)); + let started = Instant::now(); + const SYNC_STALL_TIMEOUT: Duration = Duration::from_secs(60); + loop { + interval.tick().await; + if cancel.load(Ordering::SeqCst) { + bridge.cancel_sync(); + break; + } + let active = bridge.sync_active(); + let progress = bridge.sync_progress(); + if active && progress <= 10.0 && started.elapsed() > SYNC_STALL_TIMEOUT { + bridge.cancel_sync(); + let payload = serde_json::json!({ + "active": false, + "progress": 0.0, + "message": "propagation node unreachable", + }); + let frame = serde_json::json!({ + "type": "propagation_sync", + "payload": payload, + }); + let _ = event_tx.send(frame.to_string()); + break; + } + let payload = serde_json::json!({ + "active": active, + "progress": progress, + "message": null, + }); + let frame = serde_json::json!({ + "type": "propagation_sync", + "payload": payload, + }); + let _ = event_tx.send(frame.to_string()); + if !active && progress >= 99.0 { + break; + } + if !active && progress == 0.0 { + break; + } + } + let payload = serde_json::json!({ + "active": false, + "progress": 100.0, + "message": null, + }); + let frame = serde_json::json!({ + "type": "propagation_sync", + "payload": payload, + }); + let _ = event_tx.send(frame.to_string()); + }); + } +} diff --git a/reticulum-sidecar/src/stack/rf_profiles.rs b/reticulum-sidecar/src/stack/rf_profiles.rs new file mode 100644 index 000000000..14fb538d7 --- /dev/null +++ b/reticulum-sidecar/src/stack/rf_profiles.rs @@ -0,0 +1,210 @@ +//! Worldwide coordinated + fallback RNode RF profiles (parity with src/shared/reticulumRnodeRfProfiles.json). + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct RfProfile { + pub id: String, + pub tier: String, + pub label: String, + pub region: String, + pub frequency: u64, + pub bandwidth: u32, + pub spreading_factor: u8, + pub coding_rate: u8, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub canonical_id: Option, +} + +#[derive(Debug, Deserialize)] +struct RfProfilesFile { + profiles: Vec, +} + +fn load_profiles() -> Vec { + const JSON: &str = include_str!("../../../src/shared/reticulumRnodeRfProfiles.json"); + serde_json::from_str::(JSON) + .map(|f| f.profiles) + .unwrap_or_default() +} + +pub fn all_rf_profiles() -> Vec { + load_profiles() +} + +pub fn rf_profile_by_id(id: &str) -> Option { + load_profiles().into_iter().find(|p| p.id == id) +} + +pub fn known_preset_ids() -> Vec { + load_profiles().into_iter().map(|p| p.id).collect() +} + +pub fn presets_wire_json() -> serde_json::Value { + let profiles = load_profiles(); + let mut coordinated = Vec::new(); + let mut fallback = Vec::new(); + let mut legacy = Vec::new(); + for p in profiles { + let entry = serde_json::json!({ + "id": p.id, + "label": p.label, + "tier": p.tier, + "region": p.region, + "frequency": p.frequency, + "bandwidth": p.bandwidth, + "spreading_factor": p.spreading_factor, + "coding_rate": p.coding_rate, + "notes": p.notes, + "canonical_id": p.canonical_id, + }); + match p.tier.as_str() { + "coordinated" => coordinated.push(entry), + "fallback" => fallback.push(entry), + _ => legacy.push(entry), + } + } + let mut all = coordinated.clone(); + all.extend(fallback.clone()); + all.extend(legacy.clone()); + serde_json::json!({ + "coordinated": coordinated, + "fallback": fallback, + "legacy": legacy, + "presets": all, + }) +} + +pub fn apply_profile_defaults_to_row(row: &mut crate::stack::types::InterfaceRow) { + if row.iface_type != "rnode" { + return; + } + let Some(preset_id) = row.preset.as_deref() else { + return; + }; + let Some(profile) = rf_profile_by_id(preset_id) else { + return; + }; + row.frequency.get_or_insert(profile.frequency); + row.bandwidth.get_or_insert(profile.bandwidth); + row.spreading_factor.get_or_insert(profile.spreading_factor); + row.coding_rate.get_or_insert(profile.coding_rate); + row.txpower.get_or_insert(17); +} + +/// Overwrite RF fields from the row's preset profile (config repair when params deviate). +pub fn force_apply_profile_defaults_to_row(row: &mut crate::stack::types::InterfaceRow) { + if row.iface_type != "rnode" { + return; + } + let Some(preset_id) = row.preset.as_deref() else { + return; + }; + let Some(profile) = rf_profile_by_id(preset_id) else { + return; + }; + row.frequency = Some(profile.frequency); + row.bandwidth = Some(profile.bandwidth); + row.spreading_factor = Some(profile.spreading_factor); + row.coding_rate = Some(profile.coding_rate); + row.txpower = Some(17); +} + +pub fn row_params_match_preset(row: &crate::stack::types::InterfaceRow) -> bool { + let Some(preset_id) = row.preset.as_deref() else { + return true; + }; + let Some(profile) = rf_profile_by_id(preset_id) else { + return true; + }; + params_match_profile( + row.frequency, + row.bandwidth, + row.spreading_factor, + row.coding_rate, + &profile, + ) +} + +pub fn params_match_profile( + frequency: Option, + bandwidth: Option, + spreading_factor: Option, + coding_rate: Option, + profile: &RfProfile, +) -> bool { + frequency == Some(profile.frequency) + && bandwidth == Some(profile.bandwidth) + && spreading_factor == Some(profile.spreading_factor) + && coding_rate.unwrap_or(5) == profile.coding_rate +} + +pub fn match_params_to_profile( + frequency: Option, + bandwidth: Option, + spreading_factor: Option, + coding_rate: Option, +) -> Option { + if frequency.is_none() || bandwidth.is_none() || spreading_factor.is_none() { + return None; + } + load_profiles() + .into_iter() + .find(|p| params_match_profile(frequency, bandwidth, spreading_factor, coding_rate, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn us_coordinated_is_914875_mhz() { + let us = rf_profile_by_id("rnode_us").expect("rnode_us"); + assert_eq!(us.frequency, 914_875_000); + assert_eq!(us.spreading_factor, 8); + } + + #[test] + fn legacy_us915_aliases_us_frequency() { + let legacy = rf_profile_by_id("rnode_us915").expect("rnode_us915"); + let us = rf_profile_by_id("rnode_us").expect("rnode_us"); + assert_eq!(legacy.frequency, us.frequency); + assert_eq!(legacy.canonical_id.as_deref(), Some("rnode_us")); + } + + #[test] + fn eu868_fallback_is_867_2_mhz() { + let eu = rf_profile_by_id("rnode_eu868").expect("rnode_eu868"); + assert_eq!(eu.frequency, 867_200_000); + } + + #[test] + fn force_apply_overwrites_wrong_frequency() { + let mut row = crate::stack::types::InterfaceRow { + id: "nv0n2".into(), + name: "NV0N2".into(), + iface_type: "rnode".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: Some("rnode_us915".into()), + serial_port: None, + frequency: Some(915_000_000), + bandwidth: Some(125_000), + txpower: None, + spreading_factor: Some(8), + coding_rate: Some(5), + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }; + assert!(!row_params_match_preset(&row)); + force_apply_profile_defaults_to_row(&mut row); + assert_eq!(row.frequency, Some(914_875_000)); + assert!(row_params_match_preset(&row)); + } +} diff --git a/reticulum-sidecar/src/stack/topology.rs b/reticulum-sidecar/src/stack/topology.rs new file mode 100644 index 000000000..50fa95a42 --- /dev/null +++ b/reticulum-sidecar/src/stack/topology.rs @@ -0,0 +1,247 @@ +use std::collections::{HashMap, HashSet}; + +use super::types::{ContactRow, NomadNodeRow, PeerRow, TopologyEdge}; + +const SELF_ID: &str = "self"; + +/// Build topology nodes and edges from path-table peers. +/// +/// RNS `via_hash` is the immediate next-hop **transport id**, which may differ from a hub's +/// destination hash. Relay nodes referenced only as `via` are synthesized when missing. +pub fn build_topology(peers: &[PeerRow]) -> (Vec, Vec) { + let mut peer_by_hash: HashMap = HashMap::new(); + for peer in peers { + if peer.destination_hash.is_empty() { + continue; + } + peer_by_hash + .entry(peer.destination_hash.clone()) + .or_insert_with(|| peer.clone()); + } + + let mut edges: Vec = Vec::new(); + let mut edge_keys: HashSet<(String, String)> = HashSet::new(); + + for peer in peers { + if peer.destination_hash.is_empty() { + continue; + } + let target = peer.destination_hash.clone(); + let source = peer + .via_hash + .as_ref() + .filter(|via| !via.is_empty()) + .cloned() + .unwrap_or_else(|| SELF_ID.into()); + let key = (source.clone(), target.clone()); + if edge_keys.insert(key) { + edges.push(TopologyEdge { source, target }); + } + + if let Some(via) = peer.via_hash.as_ref() { + if !via.is_empty() && !peer_by_hash.contains_key(via) { + let relay_hops = peer.hops.map(|h| h.saturating_sub(1)); + peer_by_hash.entry(via.clone()).or_insert(PeerRow { + destination_hash: via.clone(), + display_name: None, + hops: relay_hops, + last_seen: peer.last_seen, + interface: peer.interface.clone(), + path_hash: None, + via_hash: None, + }); + } + } + } + + infer_self_to_via_edges(&mut edges, &mut edge_keys); + + let mut nodes: Vec = peer_by_hash.into_values().collect(); + nodes.sort_by(|a, b| a.destination_hash.cmp(&b.destination_hash)); + edges.sort_by(|a, b| { + a.source + .cmp(&b.source) + .then_with(|| a.target.cmp(&b.target)) + }); + (nodes, edges) +} + +/// When a relay is only referenced as `via` (not its own path-table row), link it to self. +fn infer_self_to_via_edges(edges: &mut Vec, edge_keys: &mut HashSet<(String, String)>) { + let mut has_incoming = HashSet::new(); + for edge in edges.iter() { + has_incoming.insert(edge.target.clone()); + } + let vias: Vec = edges + .iter() + .filter(|e| e.source != SELF_ID) + .map(|e| e.source.clone()) + .collect::>() + .into_iter() + .filter(|via| !has_incoming.contains(via)) + .collect(); + for via in vias { + let key = (SELF_ID.into(), via.clone()); + if edge_keys.insert(key) { + edges.push(TopologyEdge { + source: SELF_ID.into(), + target: via, + }); + } + } +} + +/// Overlay cached display names onto topology nodes (path table rows omit names). +pub fn merge_topology_display_names(nodes: &mut [PeerRow], name_by_hash: &HashMap) { + for node in nodes.iter_mut() { + if node.display_name.as_ref().is_some_and(|n| !n.is_empty()) { + continue; + } + if let Some(name) = name_by_hash.get(&node.destination_hash) { + node.display_name = Some(name.clone()); + } + } +} + +/// Collect human-readable labels from peers, LXMF contacts, and Nomad announces. +pub fn build_topology_name_map( + peers: &[PeerRow], + contacts: &[ContactRow], + nomad_nodes: &[NomadNodeRow], +) -> HashMap { + let mut name_by_hash = HashMap::new(); + for peer in peers { + if let Some(name) = peer.display_name.as_ref().filter(|n| !n.is_empty()) { + name_by_hash.insert(peer.destination_hash.clone(), name.clone()); + } + } + for contact in contacts { + if let Some(name) = contact.display_name.as_ref().filter(|n| !n.is_empty()) { + name_by_hash + .entry(contact.destination_hash.clone()) + .or_insert_with(|| name.clone()); + } + } + for node in nomad_nodes { + if let Some(name) = node.display_name.as_ref().filter(|n| !n.is_empty()) { + name_by_hash + .entry(node.destination_hash.clone()) + .or_insert_with(|| name.clone()); + } + } + name_by_hash +} + +/// Overlay known display names onto path-table peer rows. +pub fn overlay_peer_display_names(peers: &mut [PeerRow], name_by_hash: &HashMap) { + merge_topology_display_names(peers, name_by_hash); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn peer(dest: &str, hops: u8, via: Option<&str>) -> PeerRow { + PeerRow { + destination_hash: dest.into(), + display_name: None, + hops: Some(hops), + last_seen: Some(1), + interface: Some("tcp".into()), + path_hash: via.map(str::to_string), + via_hash: via.map(str::to_string), + } + } + + #[test] + fn direct_peer_edges_from_self() { + let (nodes, edges) = build_topology(&[peer("aa", 1, None)]); + assert_eq!(nodes.len(), 1); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].source, "self"); + assert_eq!(edges[0].target, "aa"); + } + + #[test] + fn multi_hop_chain_uses_via_as_edge_source() { + let hub = "hub11111111111111"; + let leaf = "leaf22222222222222"; + let (nodes, edges) = build_topology(&[ + peer(hub, 1, None), + peer(leaf, 2, Some(hub)), + ]); + assert_eq!(nodes.len(), 2); + assert!(edges.iter().any(|e| e.source == "self" && e.target == hub)); + assert!(edges.iter().any(|e| e.source == hub && e.target == leaf)); + } + + #[test] + fn relay_node_created_when_only_referenced_as_via() { + let hub = "hub33333333333333"; + let leaf = "leaf44444444444444"; + let (nodes, edges) = build_topology(&[peer(leaf, 2, Some(hub))]); + assert_eq!(nodes.len(), 2); + assert!(nodes.iter().any(|n| n.destination_hash == hub)); + assert!(edges.iter().any(|e| e.source == "self" && e.target == hub)); + assert!(edges.iter().any(|e| e.source == hub && e.target == leaf)); + } + + #[test] + fn infer_self_link_for_relay_only_via() { + let relay = "relay5555555555555"; + let leaf = "leaf66666666666666"; + let (_, edges) = build_topology(&[peer(leaf, 3, Some(relay))]); + assert!(edges.iter().any(|e| e.source == "self" && e.target == relay)); + assert!(edges.iter().any(|e| e.source == relay && e.target == leaf)); + } + + #[test] + fn build_topology_name_map_includes_nomad_nodes() { + let names = build_topology_name_map( + &[], + &[], + &[NomadNodeRow { + destination_hash: "abc".into(), + identity_hash: None, + display_name: Some("Forum".into()), + last_seen: None, + favorited: false, + hops: Some(2), + status: None, + }], + ); + assert_eq!(names.get("abc").map(String::as_str), Some("Forum")); + } + + #[test] + fn merge_topology_display_names_overlays_cached_names() { + let mut nodes = vec![PeerRow { + destination_hash: "abc".into(), + display_name: None, + hops: Some(1), + last_seen: None, + interface: None, + path_hash: None, + via_hash: None, + }]; + let mut names = HashMap::new(); + names.insert("abc".into(), "Alice".into()); + merge_topology_display_names(&mut nodes, &names); + assert_eq!(nodes[0].display_name.as_deref(), Some("Alice")); + } + + #[test] + fn mixed_direct_and_multi_hop_peers() { + let hub = "hub77777777777777"; + let leaf = "leaf88888888888888"; + let (nodes, edges) = build_topology(&[ + peer("direct99", 1, None), + peer(hub, 1, None), + peer(leaf, 2, Some(hub)), + ]); + assert_eq!(nodes.len(), 3); + assert!(edges.iter().any(|e| e.source == "self" && e.target == "direct99")); + assert!(edges.iter().any(|e| e.source == "self" && e.target == hub)); + assert!(edges.iter().any(|e| e.source == hub && e.target == leaf)); + } +} diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs new file mode 100644 index 000000000..04adcbc12 --- /dev/null +++ b/reticulum-sidecar/src/stack/types.rs @@ -0,0 +1,144 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackIdentity { + pub configured: bool, + pub identity_hash: String, + pub lxmf_hash: String, + pub display_name: Option, + pub mnemonic: Option, +} + +impl Default for StackIdentity { + fn default() -> Self { + Self { + configured: false, + identity_hash: String::new(), + lxmf_hash: String::new(), + display_name: None, + mnemonic: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterfaceRow { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub iface_type: String, + pub enabled: bool, + pub status: String, + pub host: Option, + pub port: Option, + pub preset: Option, + pub serial_port: Option, + pub frequency: Option, + pub bandwidth: Option, + pub txpower: Option, + pub spreading_factor: Option, + pub coding_rate: Option, + pub callsign: Option, + pub id_interval: Option, + pub mode: Option, + #[serde(default)] + pub seed_addresses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactRow { + pub destination_hash: String, + pub display_name: Option, + pub last_heard: Option, + pub favorited: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerRow { + pub destination_hash: String, + pub display_name: Option, + pub hops: Option, + pub last_seen: Option, + pub interface: Option, + pub path_hash: Option, + #[serde(default)] + pub via_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopologyEdge { + pub source: String, + pub target: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PropagationRow { + pub id: String, + pub name: String, + pub hops: Option, + pub enabled: bool, + pub status: String, + #[serde(default)] + pub destination_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NomadNodeRow { + pub destination_hash: String, + /// Identity hash recovered from the node's `nomadnetwork.node` announce + /// (`AnnounceHandlerEvent::identity_hash`); required to rebuild the + /// destination for page/file link queries via `LinkClient::query`. + #[serde(default)] + pub identity_hash: Option, + pub display_name: Option, + pub last_seen: Option, + #[serde(default)] + pub favorited: bool, + pub hops: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AddInterfaceRequest { + #[serde(rename = "type")] + pub iface_type: String, + pub name: Option, + pub host: Option, + pub port: Option, + pub preset: Option, + pub serial_port: Option, + pub frequency: Option, + pub bandwidth: Option, + pub txpower: Option, + pub spreading_factor: Option, + pub coding_rate: Option, + pub callsign: Option, + pub id_interval: Option, + pub mode: Option, + #[serde(default)] + pub seed_addresses: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfSendRequest { + pub destination_hash: String, + pub text: String, + pub reply_to_hash: Option, + pub reply_to_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfReactionRequest { + pub destination_hash: String, + pub target_hash: String, + pub emoji: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfResourceRequest { + pub destination_hash: String, + pub file_name: String, + pub mime_type: String, + pub data_base64: String, + pub reply_to_hash: Option, +} diff --git a/reticulum-sidecar/src/stack/via.rs b/reticulum-sidecar/src/stack/via.rs new file mode 100644 index 000000000..579336e6f --- /dev/null +++ b/reticulum-sidecar/src/stack/via.rs @@ -0,0 +1,303 @@ +//! Reticulum LXMF transport classification (RF / TCP / network). + +use super::types::InterfaceRow; + +/// Classify an RNS interface name or UI type into a transport marker. +pub fn classify_interface(name_or_type: &str) -> &'static str { + let lower = name_or_type.to_ascii_lowercase(); + if lower.contains("rnode") || lower == "rnode" { + "rf" + } else if lower.contains("tcp") || lower == "tcp" { + "tcp" + } else { + "network" + } +} + +/// Pick the primary outbound transport from enabled stub interfaces. +pub fn resolve_stub_sent_via(interfaces: &[InterfaceRow]) -> &'static str { + let mut fallback = "network"; + for iface in interfaces.iter().filter(|i| i.enabled) { + match classify_interface(&iface.iface_type) { + "rf" => return "rf", + "tcp" => fallback = "tcp", + _ => {} + } + } + fallback +} + +/// Outbound LXMF transport: local egress interface (RNode → RF), not the peer path-table label. +pub fn resolve_outbound_sent_via(interfaces: &[InterfaceRow]) -> &'static str { + resolve_stub_sent_via(interfaces) +} + +fn live_matches_config(live_row: &InterfaceRow, cfg: &InterfaceRow) -> bool { + live_row.id == cfg.id || live_row.name == cfg.name +} + +/// Union config with live RNS stats: every configured interface is returned; live rows +/// overlay status/enabled when names match. Config-only rows (e.g. failed USB open) stay +/// visible with `status: down`. +pub fn merge_live_interfaces_with_config( + config: &[InterfaceRow], + live: Vec, +) -> Vec { + let mut merged: Vec = Vec::with_capacity(config.len().max(live.len())); + + for cfg in config { + if let Some(mut live_row) = live + .iter() + .find(|l| live_matches_config(l, cfg)) + .cloned() + { + live_row.id = cfg.id.clone(); + live_row.iface_type = cfg.iface_type.clone(); + live_row.host = cfg.host.clone(); + live_row.port = cfg.port; + live_row.preset = cfg.preset.clone(); + live_row.serial_port = cfg.serial_port.clone(); + live_row.frequency = cfg.frequency; + live_row.bandwidth = cfg.bandwidth; + live_row.txpower = cfg.txpower; + live_row.spreading_factor = cfg.spreading_factor; + live_row.coding_rate = cfg.coding_rate; + live_row.callsign = cfg.callsign.clone(); + live_row.id_interval = cfg.id_interval; + live_row.mode = cfg.mode.clone(); + // Config INI is the source of truth for user enable/disable; live stats only + // report carrier status (online), which must not flip `enabled` in the UI. + live_row.enabled = cfg.enabled; + merged.push(live_row); + } else { + let mut row = cfg.clone(); + if row.enabled { + row.status = "down".into(); + } + merged.push(row); + } + } + + for live_row in live { + if !config.iter().any(|c| live_matches_config(&live_row, c)) { + merged.push(live_row); + } + } + + merged +} + +/// Resolve transport for a peer destination hash from a path-table interface name. +pub fn resolve_peer_sent_via(peer_interface: Option<&str>) -> &'static str { + match peer_interface { + Some(name) if !name.is_empty() => classify_interface(name), + _ => "network", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stack::types::InterfaceRow; + + #[test] + fn classify_rnode_variants() { + assert_eq!(classify_interface("rnode"), "rf"); + assert_eq!(classify_interface("RNodeInterface"), "rf"); + assert_eq!(classify_interface("My RNode LoRa"), "rf"); + } + + #[test] + fn classify_tcp_variants() { + assert_eq!(classify_interface("tcp"), "tcp"); + assert_eq!(classify_interface("TCPClientInterface"), "tcp"); + } + + #[test] + fn classify_network_fallback() { + assert_eq!(classify_interface("auto"), "network"); + assert_eq!(classify_interface("AutoInterface"), "network"); + assert_eq!(classify_interface("unknown"), "network"); + } + + #[test] + fn resolve_stub_prefers_rnode_interface_mode() { + let ifaces = vec![InterfaceRow { + id: "1".into(), + name: "LoRa".into(), + iface_type: "RNodeInterface".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }]; + assert_eq!(resolve_stub_sent_via(&ifaces), "rf"); + assert_eq!(resolve_outbound_sent_via(&ifaces), "rf"); + } + + #[test] + fn merge_live_interfaces_uses_config_rnode_over_live_lora_mode() { + let config = vec![InterfaceRow { + id: "usb0".into(), + name: "LoRa".into(), + iface_type: "rnode".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: Some("/dev/ttyUSB0".into()), + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }]; + let live = vec![InterfaceRow { + id: "rns-0".into(), + name: "LoRa".into(), + iface_type: "LoRa".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }]; + let merged = merge_live_interfaces_with_config(&config, live); + assert_eq!(resolve_outbound_sent_via(&merged), "rf"); + assert_eq!(merged[0].iface_type, "rnode"); + } + + fn sample_iface(id: &str, name: &str, iface_type: &str, enabled: bool, status: &str) -> InterfaceRow { + InterfaceRow { + id: id.into(), + name: name.into(), + iface_type: iface_type.into(), + enabled, + status: status.into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + } + } + + #[test] + fn merge_live_interfaces_keeps_config_only_rows_as_down() { + let config = vec![ + sample_iface("heltec-v3", "Heltec V3", "rnode", true, "up"), + sample_iface("auto", "Default Interface", "auto", true, "up"), + sample_iface("tcp", "RNS Testnet", "tcp", true, "up"), + ]; + let live = vec![ + InterfaceRow { + id: "rns-0".into(), + name: "Default Interface".into(), + iface_type: "Auto".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }, + InterfaceRow { + id: "rns-1".into(), + name: "RNS Testnet".into(), + iface_type: "TCP".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }, + ]; + let merged = merge_live_interfaces_with_config(&config, live); + assert_eq!(merged.len(), 3); + let heltec = merged.iter().find(|r| r.name == "Heltec V3").unwrap(); + assert_eq!(heltec.status, "down"); + assert_eq!(heltec.iface_type, "rnode"); + } + + #[test] + fn merge_live_interfaces_preserves_config_enabled_when_live_offline() { + let config = vec![sample_iface("nv0n2", "NV0N2", "rnode", true, "down")]; + let live = vec![InterfaceRow { + id: "rns-0".into(), + name: "NV0N2".into(), + iface_type: "Full".into(), + enabled: false, + status: "down".into(), + host: None, + port: None, + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: None, + seed_addresses: Vec::new(), + }]; + let merged = merge_live_interfaces_with_config(&config, live); + assert_eq!(merged.len(), 1); + assert!(merged[0].enabled); + assert_eq!(merged[0].status, "down"); + } +} diff --git a/scripts/apply-rsReticulum-packet-tap.sh b/scripts/apply-rsReticulum-packet-tap.sh new file mode 100755 index 000000000..5c27c24c7 --- /dev/null +++ b/scripts/apply-rsReticulum-packet-tap.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Apply mesh-client rsReticulum packet-tap overlay for rns-stack local builds. +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-packet-tap.patch" +RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" + +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 + +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 + +current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" +if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then + echo "checking out rsReticulum ${RS_RETICULUM_REF} (was ${current_head:0:12})" + git -C "${RNS_DIR}" fetch origin --tags + git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" +fi + +git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" +git -C "${RNS_DIR}" apply "${PATCH_FILE}" +echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF}" diff --git a/scripts/check-environment.mjs b/scripts/check-environment.mjs new file mode 100644 index 000000000..ab9ff96e9 --- /dev/null +++ b/scripts/check-environment.mjs @@ -0,0 +1,490 @@ +#!/usr/bin/env node +/** + * Local development environment validator. + * Run before pnpm install: node scripts/check-environment.mjs + * After pnpm is available: pnpm run check:environment + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { userInfo } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); + +const PLATFORM_LABELS = { + darwin: 'macOS', + linux: 'Linux', + win32: 'Windows', +}; + +/** + * @param {string} output + * @returns {{ major: number, minor: number, patch: number } | null} + */ +export function parseVersion(output) { + const match = String(output).match(/(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +/** + * @param {string} found + * @param {string} requiredExpr e.g. ">=22.13.0" + */ +export function versionGte(found, requiredExpr) { + const min = parseVersion(requiredExpr.replace(/^>=\s*/, '')); + const actual = parseVersion(found); + if (!min || !actual) return false; + if (actual.major !== min.major) return actual.major > min.major; + if (actual.minor !== min.minor) return actual.minor > min.minor; + return actual.patch >= min.patch; +} + +/** + * @typedef {'pass' | 'fail' | 'warn'} CheckStatus + * @typedef {'required' | 'optional'} CheckSeverity + * @typedef {{ status: CheckStatus, severity: CheckSeverity, label: string, detail?: string, hint?: string }} CheckResult + */ + +/** + * @param {CheckResult} check + * @returns {string[]} + */ +export function formatCheckResult(check) { + const icon = check.status === 'pass' ? '✅' : check.status === 'fail' ? '❌' : '⚠️'; + const lines = []; + const detail = check.detail ? ` — ${check.detail}` : ''; + lines.push(`${icon} ${check.label}${detail}`); + if (check.hint && check.status !== 'pass') { + lines.push(` → ${check.hint}`); + } + return lines; +} + +/** + * @param {CheckResult[]} checks + * @returns {0 | 1} + */ +export function resolveExitCode(checks) { + const requiredFailed = checks.some((c) => c.severity === 'required' && c.status === 'fail'); + return requiredFailed ? 1 : 0; +} + +function commandOutput(command, args) { + const res = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe' }); + if (res.status !== 0) return null; + return (res.stdout || res.stderr || '').trim(); +} + +function commandOk(command, args) { + const res = spawnSync(command, args, { stdio: 'ignore' }); + return res.status === 0; +} + +function readEngines() { + const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); + return { + node: pkg.engines?.node ?? '>=22.13.0', + pnpm: pkg.engines?.pnpm ?? '>=10.0.0', + }; +} + +function checkGit() { + const out = commandOutput('git', ['--version']); + if (!out) { + return { + status: 'fail', + severity: 'required', + label: 'Git', + detail: 'not found', + hint: 'Install Git — see docs/development-environment.md for your platform', + }; + } + return { + status: 'pass', + severity: 'required', + label: 'Git', + detail: out.replace(/^git version /i, ''), + }; +} + +function checkNode(nodeEngine) { + const version = process.version; + if (!versionGte(version, nodeEngine)) { + return { + status: 'fail', + severity: 'required', + label: `Node.js ${nodeEngine.replace('>=', '')}+ required`, + detail: `found ${version}`, + hint: 'Install via nvm: nvm install 22 — or winget install OpenJS.NodeJS on Windows', + }; + } + return { + status: 'pass', + severity: 'required', + label: `Node.js ${nodeEngine.replace('>=', '')}+`, + detail: version, + }; +} + +function checkPnpm(pnpmEngine) { + const out = commandOutput('pnpm', ['--version']); + if (!out) { + return { + status: 'fail', + severity: 'required', + label: `pnpm ${pnpmEngine.replace('>=', '')}+ required`, + detail: 'not found', + hint: 'corepack enable && corepack prepare pnpm@10 --activate', + }; + } + if (!versionGte(out, pnpmEngine)) { + return { + status: 'fail', + severity: 'required', + label: `pnpm ${pnpmEngine.replace('>=', '')}+ required`, + detail: `found v${out}`, + hint: 'corepack enable && corepack prepare pnpm@10 --activate', + }; + } + return { + status: 'pass', + severity: 'required', + label: `pnpm ${pnpmEngine.replace('>=', '')}+`, + detail: `v${out}`, + }; +} + +function checkNodeModules(root = repoRoot) { + if (existsSync(join(root, 'node_modules'))) { + return { + status: 'pass', + severity: 'required', + label: 'Dependencies installed', + detail: 'node_modules present', + }; + } + return { + status: 'fail', + severity: 'required', + label: 'Dependencies installed', + detail: 'node_modules missing', + hint: 'Run: pnpm install', + }; +} + +function checkPlatformBuildDeps() { + const platform = process.platform; + + if (platform === 'darwin') { + if (commandOk('xcode-select', ['-p'])) { + return { + status: 'pass', + severity: 'required', + label: 'macOS build dependencies', + detail: 'Xcode Command Line Tools configured', + }; + } + return { + status: 'fail', + severity: 'required', + label: 'macOS build dependencies missing', + hint: 'Run: pnpm run setup:build-deps — or xcode-select --install', + }; + } + + if (platform === 'linux') { + const hasGpp = commandOk('g++', ['--version']); + const hasGcc = commandOk('gcc', ['--version']); + const hasMake = commandOk('make', ['--version']); + if ((hasGpp || hasGcc) && hasMake) { + const compiler = hasGpp ? 'g++' : 'gcc'; + return { + status: 'pass', + severity: 'required', + label: 'Linux build dependencies', + detail: `${compiler} and make found`, + }; + } + const missing = []; + if (!hasGpp && !hasGcc) missing.push('g++/gcc'); + if (!hasMake) missing.push('make'); + return { + status: 'fail', + severity: 'required', + label: 'Linux build dependencies missing', + detail: `missing ${missing.join(', ')}`, + hint: 'Run: pnpm run setup:build-deps', + }; + } + + if (platform === 'win32') { + if (commandOk('where', ['cl'])) { + return { + status: 'pass', + severity: 'required', + label: 'Windows build dependencies', + detail: 'MSVC compiler (cl) found', + }; + } + return { + status: 'fail', + severity: 'required', + label: 'Windows build dependencies missing', + hint: "Install Visual Studio Build Tools with 'Desktop development with C++' workload", + }; + } + + return { + status: 'fail', + severity: 'required', + label: 'Platform build dependencies', + detail: `unsupported platform: ${platform}`, + hint: 'See docs/development-environment.md', + }; +} + +function resolvePythonCommand() { + if (process.platform === 'win32') { + if (commandOk('py', ['-3', '--version'])) return { cmd: 'py', args: ['-3'] }; + if (commandOk('python', ['--version'])) return { cmd: 'python', args: [] }; + return null; + } + if (commandOk('python3', ['--version'])) return { cmd: 'python3', args: [] }; + return null; +} + +function checkPython() { + const py = resolvePythonCommand(); + if (!py) { + return { + status: 'warn', + severity: 'optional', + label: 'Python 3 not found (optional)', + hint: 'Needed for MkDocs, yamllint, and node-gyp on Linux — see docs/development-environment.md', + }; + } + const out = commandOutput(py.cmd, [...py.args, '--version']); + return { + status: 'pass', + severity: 'optional', + label: 'Python 3', + detail: out ?? 'found', + }; +} + +function checkPip() { + const py = resolvePythonCommand(); + if (!py) { + return { + status: 'warn', + severity: 'optional', + label: 'pip not found (optional)', + hint: 'Install Python 3 with pip — pip install yamllint for pre-commit', + }; + } + const pipArgs = py.cmd === 'py' ? ['-3', '-m', 'pip', '--version'] : ['-m', 'pip', '--version']; + const out = commandOutput(py.cmd, pipArgs); + if (!out) { + return { + status: 'warn', + severity: 'optional', + label: 'pip not found (optional)', + hint: 'pip install yamllint — or pnpm run docs:install for MkDocs deps', + }; + } + return { + status: 'pass', + severity: 'optional', + label: 'pip', + detail: out.split('\n')[0], + }; +} + +function checkRust() { + const out = commandOutput('cargo', ['--version']); + if (!out) { + return { + status: 'warn', + severity: 'optional', + label: 'Rust/cargo not found (optional)', + hint: 'Needed for Reticulum sidecar — install via https://rustup.rs/', + }; + } + return { + status: 'pass', + severity: 'optional', + label: 'Rust/cargo', + detail: out, + }; +} + +function checkActionlint() { + const binName = process.platform === 'win32' ? 'actionlint.exe' : 'actionlint'; + const localPath = join(repoRoot, '.githooks', 'bin', binName); + if (existsSync(localPath)) { + return { + status: 'pass', + severity: 'optional', + label: 'actionlint', + detail: localPath, + }; + } + const out = commandOutput('actionlint', ['--version']); + if (out) { + return { + status: 'pass', + severity: 'optional', + label: 'actionlint', + detail: out.split('\n')[0], + }; + } + return { + status: 'warn', + severity: 'optional', + label: 'actionlint not found (optional)', + hint: 'Run: pnpm run setup:actionlint — needed for pre-commit workflow linting', + }; +} + +function checkYamllint() { + const out = commandOutput('yamllint', ['--version']); + if (!out) { + return { + status: 'warn', + severity: 'optional', + label: 'yamllint not found (optional)', + hint: 'pip install yamllint — or brew install yamllint / sudo apt install yamllint', + }; + } + return { + status: 'pass', + severity: 'optional', + label: 'yamllint', + detail: out.split('\n')[0], + }; +} + +function checkDocker() { + const out = commandOutput('docker', ['--version']); + if (!out) { + return { + status: 'warn', + severity: 'optional', + label: 'Docker not found (optional)', + hint: 'Needed to run act locally — see docs/development-environment.md § CI workflow tooling', + }; + } + return { + status: 'pass', + severity: 'optional', + label: 'Docker', + detail: out, + }; +} + +function checkLinuxDialout() { + if (process.platform !== 'linux') return null; + + const { username } = userInfo(); + const user = process.env.USER || username; + if (!user) { + return { + status: 'warn', + severity: 'optional', + label: 'Linux dialout group (optional)', + detail: 'could not determine current user', + hint: 'Run: pnpm run setup:dialout — then log out and back in', + }; + } + + const res = spawnSync('id', ['-nG', user], { encoding: 'utf8', stdio: 'pipe' }); + const groups = (res.stdout || '').toString(); + const inDialout = groups.split(/\s+/).includes('dialout'); + + if (inDialout) { + return { + status: 'pass', + severity: 'optional', + label: 'Linux dialout group', + detail: `user ${user} is a member`, + }; + } + return { + status: 'warn', + severity: 'optional', + label: 'Linux dialout group (optional)', + detail: `user ${user} not in dialout`, + hint: 'Run: pnpm run setup:dialout — then log out and back in for USB serial access', + }; +} + +/** + * @returns {CheckResult[]} + */ +export function runChecks(options = {}) { + const root = options.repoRoot ?? repoRoot; + const engines = options.engines ?? readEngines(); + + const checks = [ + checkGit(), + checkNode(engines.node), + checkPnpm(engines.pnpm), + options.skipNodeModules ? null : checkNodeModules(root), + checkPlatformBuildDeps(), + checkPython(), + checkPip(), + checkRust(), + checkActionlint(), + checkYamllint(), + checkDocker(), + checkLinuxDialout(), + ].filter(Boolean); + + return checks; +} + +function printSummary(checks) { + const requiredFailed = checks.filter((c) => c.severity === 'required' && c.status === 'fail'); + const optionalWarnings = checks.filter((c) => c.severity === 'optional' && c.status === 'warn'); + + console.log('━'.repeat(40)); + if (requiredFailed.length === 0) { + console.log('✅ Required checks passed.'); + if (optionalWarnings.length > 0) { + console.log( + `⚠️ ${optionalWarnings.length} optional item(s) above may be worth fixing later.`, + ); + } + } else { + console.log('❌ Required checks failed. Fix items above, then re-run.'); + } +} + +function main() { + const platformLabel = PLATFORM_LABELS[process.platform] ?? process.platform; + console.log(`Mesh Client environment check (platform: ${platformLabel})\n`); + + const checks = runChecks(); + + for (const check of checks) { + for (const line of formatCheckResult(check)) { + console.log(line); + } + console.log(''); + } + + printSummary(checks); + process.exit(resolveExitCode(checks)); +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/scripts/check-environment.test.mjs b/scripts/check-environment.test.mjs new file mode 100644 index 000000000..42fe5d823 --- /dev/null +++ b/scripts/check-environment.test.mjs @@ -0,0 +1,110 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { + formatCheckResult, + parseVersion, + resolveExitCode, + versionGte, +} from './check-environment.mjs'; + +describe('check-environment parseVersion', () => { + it('parses v-prefixed semver strings', () => { + expect(parseVersion('v22.13.0')).toEqual({ major: 22, minor: 13, patch: 0 }); + }); + + it('parses plain semver strings', () => { + expect(parseVersion('18.19.0')).toEqual({ major: 18, minor: 19, patch: 0 }); + }); + + it('parses version embedded in command output', () => { + expect(parseVersion('git version 2.43.0')).toEqual({ major: 2, minor: 43, patch: 0 }); + }); + + it('returns null for unparseable strings', () => { + expect(parseVersion('not-a-version')).toBeNull(); + }); +}); + +describe('check-environment versionGte', () => { + it('accepts versions at or above the minimum', () => { + expect(versionGte('v22.13.0', '>=22.13.0')).toBe(true); + expect(versionGte('22.14.0', '>=22.13.0')).toBe(true); + expect(versionGte('23.0.0', '>=22.13.0')).toBe(true); + }); + + it('rejects versions below the minimum', () => { + expect(versionGte('v18.19.0', '>=22.13.0')).toBe(false); + expect(versionGte('22.12.9', '>=22.13.0')).toBe(false); + }); + + it('compares pnpm-style versions', () => { + expect(versionGte('10.0.0', '>=10.0.0')).toBe(true); + expect(versionGte('9.15.0', '>=10.0.0')).toBe(false); + }); +}); + +describe('check-environment formatCheckResult', () => { + it('formats pass results without hints', () => { + expect( + formatCheckResult({ + status: 'pass', + severity: 'required', + label: 'Git', + detail: '2.43.0', + }), + ).toEqual(['✅ Git — 2.43.0']); + }); + + it('formats fail results with hints', () => { + expect( + formatCheckResult({ + status: 'fail', + severity: 'required', + label: 'pnpm 10+ required', + detail: 'not found', + hint: 'corepack enable', + }), + ).toEqual(['❌ pnpm 10+ required — not found', ' → corepack enable']); + }); + + it('formats warn results with hints', () => { + expect( + formatCheckResult({ + status: 'warn', + severity: 'optional', + label: 'Docker not found (optional)', + hint: 'Install Docker', + }), + ).toEqual(['⚠️ Docker not found (optional)', ' → Install Docker']); + }); +}); + +describe('check-environment resolveExitCode', () => { + it('returns 1 when a required check fails', () => { + expect( + resolveExitCode([ + { status: 'pass', severity: 'required', label: 'Git' }, + { status: 'fail', severity: 'required', label: 'Node.js' }, + ]), + ).toBe(1); + }); + + it('returns 0 when only optional checks warn', () => { + expect( + resolveExitCode([ + { status: 'pass', severity: 'required', label: 'Git' }, + { status: 'warn', severity: 'optional', label: 'Docker' }, + ]), + ).toBe(0); + }); + + it('returns 0 when all required checks pass', () => { + expect( + resolveExitCode([ + { status: 'pass', severity: 'required', label: 'Git' }, + { status: 'pass', severity: 'required', label: 'Node.js' }, + ]), + ).toBe(0); + }); +}); diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index 086526f58..0904eaec8 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -230,6 +230,21 @@ export const MESH_ADVERT_COMMERCIAL_FALSE_FRIENDS = { /** Raw packet log panel — route/transport labels and protocol enum copy. */ export const RAW_PACKET_LOG_PREFIX = 'rawPacketLog.'; +/** Reticulum RNS header column labels under rawPacketLog.reticulum.* */ +export const RAW_PACKET_LOG_RETICULUM_PREFIX = 'rawPacketLog.reticulum.'; + +/** RX/TX direction tokens must stay verbatim when English uses them. */ +export const RAW_PACKET_LOG_RETICULUM_VERBATIM_LEAF_KEYS = new Set(['rx', 'tx']); + +export const RETICULUM_TOPOLOGY_SELF_KEY = 'reticulumTopology.self'; +export const RETICULUM_TOPOLOGY_HOP_BADGE_KEY = 'reticulumTopology.hopBadge'; + +/** Topology graph label for the local node — short pronoun, not a phrase. */ +export const RETICULUM_TOPOLOGY_SELF_FALSE_FRIEND_RES = [ + { re: /pour vous/i, hint: 'use short pronoun "Vous", not phrase "pour vous"' }, + { re: /pobrać/i, hint: 'use pronoun "Ty", not download phrase "Możesz pobrać"' }, +]; + export const RAW_PACKET_LOG_PROTOCOL_KEYS = new Set([ 'transportLegendHint', 'transportCodesAbsent', @@ -242,6 +257,16 @@ export const RAW_PACKET_LOG_SHORT_LABEL_KEYS = new Set([ 'transportHeading', ]); +/** Flasher UI when no USB serial ports are enumerated. */ +export const FLASHER_NO_SERIAL_PORTS_KEYS = new Set([ + 'flasher.noSerialPorts', + 'flasher.errors.noSerialPorts', +]); + +/** French MT inverts "No … found" into affirmative "trouvé(s):" labels. */ +export const FR_NO_SERIAL_PORTS_INVERTED_RE = /\btrouvés?\s*:/i; +export const FR_NO_SERIAL_PORTS_NEGATION_RE = /\b(aucun|pas de|non trouv|introuvable)/i; + /** MyMemory/CAT padding with dot runs in short UI labels. */ export const CAT_DOT_PADDING_RE = /\.{4,}/; @@ -339,7 +364,539 @@ export const MESH_CLIENT_SPACED_RE = /Mesh\s+-\s+Client/; /** Lowercase mesh-client product name with CAT spaces around the hyphen. */ export const MESH_CLIENT_LOWERCASE_SPACED_RE = /mesh\s+-\s+client/i; -/** App → MeshCore Open wire (experimental) toggle copy. */ +/** Reticulum connection panel strings added with Phase B sidecar scaffold. */ +export const RETICULUM_CONNECTION_PANEL_LEAF_KEYS = new Set([ + 'reticulumStackTitle', + 'reticulumStackHint', + 'reticulumStartStack', + 'reticulumStopStack', + 'reticulumRestartStack', + 'reticulumAutostart', + 'reticulumStackRunning', + 'reticulumNetworkTitle', + 'reticulumNetworkEmpty', + 'reticulumNetworkUnknown', + 'reticulumNetworkDisabled', + 'reticulumSidecarMissing', + 'reticulumSidecarCargoMissing', + 'reticulumSidecarStartFailed', +]); + +/** networkPanel.* top-level Reticulum keys (not nested objects). */ +export const RETICULUM_NETWORK_PANEL_TOP_LEAF_KEYS = new Set([ + 'reticulumConfigImportFailed', + 'reticulumConfigNotFound', +]); + +/** Leaf keys that must not remain identical to English in Reticulum UI copy. */ +export const RETICULUM_MUST_TRANSLATE_LEAF_KEYS = new Set([ + 'reticulumStackRunning', + 'reticulumStopStack', + 'reticulumConfigNotFound', + 'shareInstance', + 'serialPort', +]); + +/** + * @param {string} flatKey + * @param {string} leafKey + * @param {string} enVal + */ +export function reticulumRequiresTranslation(flatKey, leafKey, enVal) { + if (RETICULUM_MUST_TRANSLATE_LEAF_KEYS.has(leafKey)) return true; + if (leafKey === 'confirmTitle' && /Reticulum stack/i.test(enVal)) return true; + if (leafKey === 'title' && /Import Reticulum config/i.test(enVal)) return true; + if (leafKey === 'confirm' && enVal === 'Import') return true; + if (leafKey === 'confirm' && enVal === 'Reset') return true; + if (leafKey === 'delete' && enVal === 'Delete') return true; + if (leafKey === 'deleteConfirm' && enVal === 'Delete') return true; + if (flatKey.endsWith('reticulumIdentity.title') && enVal === 'Reticulum identity') return true; + return false; +} + +/** MT mistranslates UI Disable as parallax / unrelated accessibility jargon. */ +export const RETICULUM_DISABLE_PARALLAX_RE = /parallax/i; + +/** MT mistranslates network Host as recording venue / unrelated nouns. */ +export const RETICULUM_HOST_FALSE_FRIEND_RES = [ + { + re: /Aufnehmende/i, + hint: 'reticulumInterfaces.host must be Host/hostname, not recording-facility wording', + }, +]; + +/** MT mistranslates routing peers as colleagues, pressure, points, or people. */ +export const RETICULUM_PEER_NAME_FALSE_FRIEND_RES = [ + { + re: /^Pressione$/i, + hint: 'reticulumPeers.name must be peer wording, not Italian "Pressione" (pressure)', + }, + { + re: /^Ponto$/i, + hint: 'reticulumPeers.name must be "Par/Peer", not Portuguese "Ponto" (point)', + }, + { + re: /^Kolega$/i, + hint: 'reticulumPeers.name must be "Peer/Węzeł", not Polish colleague "Kolega"', + }, + { re: /^Равны$/i, hint: 'reticulumPeers.name must be peer/node wording, not Russian "Равны"' }, + { re: /^Kişi$/i, hint: 'reticulumPeers.name must be "Eş/Peer", not Turkish "Kişi" (person)' }, + { re: /^同事$/, hint: 'reticulumPeers.name must be 对等节点/节点, not office colleague 同事' }, + { + re: /^Sesama Rekan Kerja$/i, + hint: 'reticulumPeers.name must be peer wording, not Indonesian coworker phrase', + }, + { + re: /^Колега$/i, + hint: 'reticulumPeers.name must be "Вузол/Peer", not Ukrainian office colleague "Колега"', + }, + { re: /^동료$/, hint: 'reticulumPeers.name must be "피어", not office colleague "동료"' }, + { + re: /^Compañeros$/i, + hint: 'reticulumPeers.name must be "Par/Peer", not Spanish companions "Compañeros"', + }, + { + re: /^Gleichrangige$/i, + hint: 'reticulumPeers.name must be "Peer", not truncated German adjective "Gleichrangige"', + }, +]; + +/** MT confuses software stack running with chimney/pallet stacks or starting. */ +export const RETICULUM_STACK_RUNNING_FALSE_FRIENDS = { + ru: [ + { + re: /дымов/i, + hint: 'reticulumStackRunning must be "Стек работает", not chimney stack "дымовая труба"', + }, + ], + pl: [ + { + re: /^Bieżący stos$/i, + hint: 'reticulumStackRunning must mean running, not "current stack" (Bieżący stos)', + }, + ], + uk: [ + { + re: /^Запуск стека$/i, + hint: 'reticulumStackRunning must be "Стек працює", not "Запуск стека" (starting)', + }, + ], + de: [ + { + re: /^Stapel läuft$/i, + hint: 'use software "Stack läuft", not physical pallet "Stapel läuft"', + }, + ], + nl: [ + { + re: /^Stapel loopt$/i, + hint: 'use "Stack actief/draait", not physical pallet "Stapel loopt"', + }, + ], + 'pt-BR': [ + { + re: /Empilhamento EM/i, + hint: 'use "Pilha em execução", not garbled "Empilhamento EM execução"', + }, + ], +}; + +/** MT mistranslates Stop stack as road barriers or physical pallet stacks. */ +export const RETICULUM_STOP_STACK_FALSE_FRIENDS = { + pl: [ + { + re: /ogranicznik/i, + hint: 'reticulumStopStack must be "Zatrzymaj stos", not road barrier "ograniczników"', + }, + ], + de: [ + { + re: /^Stapel stoppen$/i, + hint: 'match "Stack stoppen", not physical pallet "Stapel stoppen"', + }, + ], +}; + +/** Enable toggle mistranslated as Edit. */ +export const RETICULUM_ENABLE_EDIT_FALSE_FRIEND_RES = [ + { + re: /^Редактировать$/i, + hint: 'reticulum enable must be "Включить", not "Редактировать" (Edit)', + }, +]; + +/** Russian sidecar mistranslated as baby stroller (коляска). */ +export const RETICULUM_SIDECAR_STROLLER_RE = /коляск/i; + +/** Extra probe column false friends beyond anemometer/transducer checks. */ +export const RETICULUM_PROBE_EXTRA_FALSE_FRIEND_RES = [ + { re: /^Taster$/i, hint: 'reticulumPeers.probe is probe action, not button "Taster"' }, + { + re: /^Deşmeyin$/i, + hint: 'reticulumPeers.probe must be noun "Sonda", not imperative "Deşmeyin"', + }, +]; + +/** Propagation nodes title mistranslated as reproduction or anatomical nodules. */ +export const RETICULUM_PROPAGATION_TITLE_FALSE_FRIEND_RES = [ + { + re: /Voortplanting/i, + hint: 'use "Propagatie", not biological reproduction "Voortplanting"', + }, + { + re: /Nódulos/i, + hint: 'use "Nós de propagação", not anatomical "Nódulos"', + }, +]; + +/** Peer table Actions column mistranslated as interventions/measures. */ +export const RETICULUM_PEERS_ACTIONS_FALSE_FRIEND_RES = [ + { re: /^Maßnahmen$/i, hint: 'use "Aktionen" for table Actions column' }, + { re: /^Opatření$/i, hint: 'use "Akce" for table Actions column' }, + { re: /^Interventions$/i, hint: 'use "Actions" equivalent, not "Interventions"' }, +]; + +/** MT mistranslates mesh Probe as weather/anemometer/transducer wording. */ +export const RETICULUM_PROBE_FALSE_FRIEND_RES = [ + { + re: /anemometer/i, + hint: 'reticulumPeers.probe must be short probe wording, not anemometer copy', + }, + { + re: /Преобразователь/i, + hint: 'reticulumPeers.probe must be "Зонд/Probe", not transducer wording', + }, + { + re: /del tronco/i, + hint: 'reticulumPeers.probe must be "Sonda/Probe", not "del tronco" garbage', + }, + { + re: /Датчик/i, + hint: 'mesh probe must be "Зонд", not physical sensor "Датчик"', + }, + { + re: /датчик/i, + hint: 'mesh probe must be "зонд", not physical sensor "датчик"', + }, +]; + +/** MT turns "other peers" into office colleagues on stack transport toggle. */ +export const RETICULUM_OTHER_PEERS_COLLEAGUE_RES = [ + { re: /\bKollegen\b/i, hint: 'use networking "Peers", not German office colleague "Kollegen"' }, + { re: /\bcolleagues\b/i, hint: 'use networking "peers", not office "colleagues"' }, +]; + +/** Leaf keys allowed to stay identical to English in Reticulum UI copy. */ +export const RETICULUM_IDENTICAL_OK_LEAF_KEYS = new Set([ + 'reticulumNetworkUnknown', + 'hops', + 'port', + 'hashLabel', +]); + +/** @param {string} flatKey */ +export function isReticulumUiFlatKey(flatKey) { + if (flatKey === 'aria.switchToReticulum') return true; + if (flatKey.startsWith('connectionPanel.reticulum')) return true; + if (flatKey.startsWith('networkPanel.reticulum')) return true; + if (flatKey.startsWith('adminPanel.reticulum')) return true; + return false; +} + +/** + * @param {string} flatKey + * @param {string} leafKey + * @param {string} val + * @param {string} enVal + */ +export function isReticulumIdenticalEnglishOk(flatKey, leafKey, val, enVal) { + if (RETICULUM_IDENTICAL_OK_LEAF_KEYS.has(leafKey)) return true; + if (leafKey === 'hashLabel' && /LXMF/i.test(val) && /LXMF/i.test(enVal)) return true; + if (flatKey.endsWith('reticulumPeers.name') && /^Peer$/i.test(val)) return true; + return false; +} + +export const RETICULUM_SIDECAR_BUILD_CMD = 'pnpm run reticulum:sidecar:build'; + +/** MyMemory often breaks npm script colons or translates Rust/cargo as common nouns. */ +export const RETICULUM_SIDECAR_BUILD_SPACED_RE = /reticulum\s*:\s*sidecar\s*:\s*build/i; + +const RETICULUM_STACK_HINT_PROTOCOL_TOKENS = ['LXMF', 'TCP', 'Auto']; + +const RUSTUP_INSTALL_URL_HOST = 'rustup.rs'; + +/** @param {string} text */ +function containsRustupInstallUrl(text) { + const urlRe = /https?:\/\/[a-z0-9.-]+/gi; + let match; + while ((match = urlRe.exec(text)) !== null) { + try { + const candidate = match[0]; + const parsed = new URL(candidate.endsWith('/') ? candidate : `${candidate}/`); + if (parsed.hostname === RUSTUP_INSTALL_URL_HOST) return true; + } catch { + // ignore malformed URL-like fragments in locale strings + } + } + return false; +} + +/** Programming-language Rust mistranslated as corrosion, karat, cargo freight, etc. */ +const RUST_PROGRAMMING_FALSE_FRIEND_RES = [ + { re: /óxido/i, hint: 'use programming language "Rust", not Spanish "óxido" (oxide)' }, + { re: /\bKarat\b/i, hint: 'use programming language "Rust", not Indonesian "Karat"' }, + { re: /\bPas\b/i, hint: 'use programming language "Rust", not Turkish "Pas" (rust corrosion)' }, + { re: /\brez\b/i, hint: 'use programming language "Rust", not Czech "rez" (corrosion)' }, + { re: /[Rr]dzy\b/, hint: 'use programming language "Rust", not Polish "rdzy" (corrosion)' }, + { re: /rouille/i, hint: 'use programming language "Rust", not French "rouille" (corrosion)' }, + { re: /roest/i, hint: 'use programming language "Rust", not Dutch/German corrosion wording' }, + { re: /ferrugem/i, hint: 'use programming language "Rust", not Portuguese "ferrugem"' }, + { re: /ржав/i, hint: 'use programming language "Rust", not Russian rust-corrosion wording' }, + { re: /іржав/i, hint: 'use programming language "Rust", not Ukrainian rust-corrosion wording' }, + { re: /antiruggine/i, hint: 'use programming language "Rust", not Italian "antiruggine"' }, + { re: /錆/, hint: 'use programming language "Rust", not Japanese 錆 (corrosion)' }, + { + re: /러스트/, + hint: 'use Latin "Rust" for the programming language, not Korean transliteration', + }, +]; + +/** cargo package manager mistranslated as freight/load. */ +const CARGO_TOOLCHAIN_FALSE_FRIEND_RES = [ + { re: /\bFracht\b/i, hint: 'use Rust package manager "cargo", not German "Fracht" (freight)' }, + { + re: /\bcarga\b/i, + hint: 'use Rust package manager "cargo", not Spanish/Portuguese "carga" (load)', + }, + { re: /\bladunku\b/i, hint: 'use Rust package manager "cargo", not Polish "ładunku" (load)' }, + { re: /\bnáklad\b/i, hint: 'use Rust package manager "cargo", not Czech "náklad" (load)' }, + { + re: /\bвантаж\b/i, + hint: 'use Rust package manager "cargo", not Ukrainian "вантаж" (cargo freight)', + }, + { re: /\bгруз\b/i, hint: 'use Rust package manager "cargo", not Russian "груз" (freight)' }, + { + re: /\bkargo\b/i, + hint: 'use Rust package manager "cargo", not Turkish/Indonesian freight "kargo"', + }, + { re: /\blading\b/i, hint: 'use Rust package manager "cargo", not Dutch "lading" (freight)' }, + { re: /\bcarico\b/i, hint: 'use Rust package manager "cargo", not Italian "carico" (load)' }, + { re: /\b화물\b/, hint: 'use Rust package manager "cargo", not Korean 化물 (freight)' }, + { re: /\b货物\b/, hint: 'use Rust package manager "cargo", not Chinese 货物 (freight)' }, + { re: /\bカーゴ\b/, hint: 'use Rust package manager "cargo", not Japanese katakana freight' }, +]; + +/** + * @param {string} enVal + * @param {string} val + * @returns {string[]} + */ +export function reticulumConnectionPanelLiteralIssues(enVal, val) { + const issues = []; + if (enVal.includes(RETICULUM_SIDECAR_BUILD_CMD)) { + if (!val.includes(RETICULUM_SIDECAR_BUILD_CMD)) { + issues.push( + `reticulum sidecar build command must appear exactly as \`${RETICULUM_SIDECAR_BUILD_CMD}\``, + ); + } + if (RETICULUM_SIDECAR_BUILD_SPACED_RE.test(val) && !val.includes(RETICULUM_SIDECAR_BUILD_CMD)) { + issues.push('reticulum:sidecar:build command must not insert spaces around colons'); + } + } + if (enVal.includes('mesh-client') && MESH_CLIENT_LOWERCASE_SPACED_RE.test(val)) { + issues.push('use "mesh-client" without spaces around the hyphen (not "mesh - client")'); + } + if (/\bRust\b/.test(enVal) && !/\bRust\b/.test(val)) { + issues.push('keep programming language name "Rust" untranslated'); + } + if (enVal.includes('(cargo)') && !/\bcargo\b/.test(val)) { + issues.push('keep Rust package manager name "cargo" untranslated'); + } + if (containsRustupInstallUrl(enVal) && !containsRustupInstallUrl(val)) { + issues.push('keep rustup install URL https://rustup.rs verbatim'); + } + return issues; +} + +/** + * @param {LocaleQualityCtx} ctx + * @returns {string[]} + */ +function checkReticulumConnectionPanelIssues(ctx) { + const { locale, flatKey, val, enVal, leafKey } = ctx; + const issues = []; + if (!isReticulumUiFlatKey(flatKey)) { + return issues; + } + + const isConnectionTopLevel = + flatKey.startsWith('connectionPanel.') && RETICULUM_CONNECTION_PANEL_LEAF_KEYS.has(leafKey); + const isNetworkTopLevel = + flatKey.startsWith('networkPanel.') && RETICULUM_NETWORK_PANEL_TOP_LEAF_KEYS.has(leafKey); + const isNestedReticulum = + flatKey.startsWith('connectionPanel.reticulum') || + flatKey.startsWith('networkPanel.reticulum') || + flatKey.startsWith('adminPanel.reticulum'); + + if ( + !isConnectionTopLevel && + !isNetworkTopLevel && + !isNestedReticulum && + flatKey !== 'aria.switchToReticulum' + ) { + return issues; + } + + if ( + locale !== 'en' && + val === enVal && + reticulumRequiresTranslation(flatKey, leafKey, enVal) && + !isReticulumIdenticalEnglishOk(flatKey, leafKey, val, enVal) + ) { + issues.push(`"${leafKey}" is still identical to English — translate the UI text`); + } + + if (leafKey === 'reticulumNetworkDisabled' && locale !== 'en' && /^disabled$/i.test(val)) { + issues.push('translate reticulumNetworkDisabled — do not leave English "disabled"'); + } + + if (leafKey === 'reticulumStackHint' && locale !== 'en') { + for (const token of RETICULUM_STACK_HINT_PROTOCOL_TOKENS) { + if (enVal.includes(token) && !val.includes(token)) { + issues.push(`reticulumStackHint must preserve protocol token "${token}"`); + } + } + } + + if ( + (flatKey.endsWith('.disable') || flatKey.endsWith('.enable')) && + enVal === 'Disable' && + RETICULUM_DISABLE_PARALLAX_RE.test(val) + ) { + issues.push('reticulum disable label must not use parallax/accessibility false-friend wording'); + } + + if (flatKey.endsWith('reticulumInterfaces.host') && enVal === 'Host') { + for (const { re, hint } of RETICULUM_HOST_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum host false friend: ${hint}`); + } + } + } + + if (flatKey.endsWith('reticulumPeers.name') && enVal === 'Peer') { + for (const { re, hint } of RETICULUM_PEER_NAME_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum peer name false friend: ${hint}`); + } + } + } + + if (flatKey.endsWith('reticulumPeers.probe') && enVal === 'Probe') { + for (const { re, hint } of RETICULUM_PROBE_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum probe false friend: ${hint}`); + } + } + for (const { re, hint } of RETICULUM_PROBE_EXTRA_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum probe false friend: ${hint}`); + } + } + } + + if (flatKey.endsWith('reticulumPeers.actions') && enVal === 'Actions') { + for (const { re, hint } of RETICULUM_PEERS_ACTIONS_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum peers actions false friend: ${hint}`); + } + } + } + + if ( + flatKey.endsWith('reticulumPropagation.title') && + enVal === 'Propagation nodes' && + locale !== 'en' + ) { + for (const { re, hint } of RETICULUM_PROPAGATION_TITLE_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum propagation title false friend: ${hint}`); + } + } + } + + if (leafKey === 'reticulumStackRunning' && locale !== 'en') { + for (const { re, hint } of RETICULUM_STACK_RUNNING_FALSE_FRIENDS[locale] ?? []) { + if (re.test(val)) { + issues.push(`reticulum stack running false friend: ${hint}`); + } + } + } + + if (leafKey === 'reticulumStopStack' && locale !== 'en') { + for (const { re, hint } of RETICULUM_STOP_STACK_FALSE_FRIENDS[locale] ?? []) { + if (re.test(val)) { + issues.push(`reticulum stop stack false friend: ${hint}`); + } + } + } + + if ( + (flatKey.endsWith('.enable') || flatKey.endsWith('reticulumPropagation.enable')) && + enVal === 'Enable' + ) { + for (const { re, hint } of RETICULUM_ENABLE_EDIT_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum enable false friend: ${hint}`); + } + } + } + + if ( + flatKey.endsWith('reticulumInterfaces.bleAvailable') && + enVal.includes('sidecar') && + RETICULUM_SIDECAR_STROLLER_RE.test(val) + ) { + issues.push( + 'reticulum sidecar false friend: use sidecar process wording, not stroller "коляска"', + ); + } + + if (flatKey.endsWith('reticulumStackSettings.enableTransport') && enVal.includes('other peers')) { + for (const { re, hint } of RETICULUM_OTHER_PEERS_COLLEAGUE_RES) { + if (re.test(val)) { + issues.push(`reticulum transport peers false friend: ${hint}`); + } + } + } + + for (const issue of reticulumConnectionPanelLiteralIssues(enVal, val)) { + issues.push(issue); + } + + if (leafKey === 'reticulumSidecarMissing' || leafKey === 'reticulumSidecarCargoMissing') { + for (const { re, hint } of RUST_PROGRAMMING_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum sidecar Rust false friend: ${hint}`); + } + } + } + + if (leafKey === 'reticulumSidecarCargoMissing') { + for (const { re, hint } of CARGO_TOOLCHAIN_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulum sidecar cargo false friend: ${hint}`); + } + } + } + + if (leafKey === 'reticulumSidecarStartFailed' && locale === 'ja' && /網膜/.test(val)) { + issues.push('reticulumSidecarStartFailed uses 網膜 (retina) — use Reticulum stack wording'); + } + + return issues; +} + export const MESHCORE_OPEN_WIRE_APP_LEAF_KEYS = new Set([ 'meshcoreOpenWireExperimentalTitle', 'meshcoreOpenWireCompatLabel', @@ -693,8 +1250,24 @@ export const CHAT_PANEL_MUST_TRANSLATE_LEAF_KEYS = new Set([ 'emptyNoDmMessages', 'emptyNoMessagesYet', 'emptyConnectFirst', + 'attachFile', + 'attachFileHint', + 'attachDmOnly', + 'composePlaceholderSelectDm', + 'selectDmFirst', + 'emptySelectDm', + 'noDmConversations', + 'noDmConversationsReticulum', + 'reticulumSendDelivered', + 'reticulumSendSending', + 'reticulumSendFailed', ]); +/** Reticulum DM-only chat copy — contact must not become customer inquiry (문의). */ +export const CHAT_RETICULUM_CONTACT_FALSE_FRIENDS = { + ko: [{ re: /문의/, hint: 'use 연락처 (contact), not customer inquiry "문의"' }], +}; + /** chatPanel outbox / date divider keys checked for known auto-translate false friends. */ export const CHAT_PANEL_OUTBOX_UI_LEAF_KEYS = new Set([ 'outboxStatusQueued', @@ -900,11 +1473,61 @@ const UPGRADE_ACCESS_FALSE_FRIENDS = [ const ACL_LISTING_AD_FALSE_FRIEND_RES = [/ACL-advertentie/i, /Iklan ACL/i]; /** Leaf keys where English ends with … and locale must not use ASCII dot runs. */ -export const ELLIPSIS_HYGIENE_LEAF_KEYS = new Set(['channelLoading', 'savingChannel']); +export const ELLIPSIS_HYGIENE_LEAF_KEYS = new Set([ + 'channelLoading', + 'savingChannel', + 'autoConnectingTo', + 'autoReconnectInProgress', + 'stageAutoConnectingBle', + 'stageWaitingNobleBleMeshtastic', + 'stageWaitingNobleBleMeshcore', +]); + +/** connectionPanel.autoReconnectInProgress — must mean reconnect, not initial connect. */ +export const AUTO_RECONNECT_IN_PROGRESS_KEY = 'connectionPanel.autoReconnectInProgress'; + +export const AUTO_RECONNECT_IN_PROGRESS_FALSE_FRIENDS = { + uk: [ + { + re: /^Триває автоматичне підключення/i, + hint: 'autoReconnectInProgress must use повторне/перепідключення (reconnect), not generic підключення (connect)', + }, + ], + it: [ + { + re: /^Connessione automatica/i, + hint: 'autoReconnectInProgress must be "Riconnessione automatica…", not initial "Connessione automatica"', + }, + ], + cs: [ + { + re: /^Probíhá automatické připojení/i, + hint: 'autoReconnectInProgress must use opětovné připojení (reconnect), not generic připojení', + }, + ], +}; /** CAT / Memsource placeholder tokens (e.g. __ PH0 __) that must be {{name}} instead. */ export const CAT_PH_PLACEHOLDER_RE = /__\s*PH\s*\d/i; +/** Bare CAT placeholder residue (e.g. "PH 0") without __ wrappers. */ +export const CAT_BARE_PH_PLACEHOLDER_RE = /\bPH\s*\d+\b/; + +/** HTML tags leaked from CAT export (e.g. ). */ +export const HTML_TAG_RESIDUE_RE = /<\/?[a-z][\w-]*\b/i; + +/** Keys rendered via react-i18next — allowed to contain markup like . */ +export const I18N_TRANS_HTML_KEYS = new Set(['connectionPanel.meshcoreBlePairingHint']); + +/** + * Keys where angle brackets are wire-format notation (e.g. v1_), + * not HTML tags. + */ +export const I18N_ANGLE_BRACKET_PLACEHOLDER_KEYS = new Set([ + 'connectionPanel.meshcoreMqttIdentity.noPrivateKey', + 'connectionPanel.meshcoreMqttIdentity.usernameBuildFailed', +]); + /** i18next interpolation names in appearance order (for duplicate names, set dedupes). */ const placeholderNameSetCache = new Map(); @@ -947,15 +1570,48 @@ export function interpolationPlaceholderIssues(enVal, val) { export const LOCALE_ARTIFACT_RES = [ //i, - //i, + //i, ]; +/** HTML numeric entities leaked from CAT export (e.g. line feed). */ +export const HTML_ENTITY_RESIDUE_RE = /&#\d+;/; + +/** Bracket placeholders from CAT/MyMemory (e.g. [Data] dostarczenia). */ +export const BRACKET_CAT_PLACEHOLDER_RE = /\[(?:Data|Date|Time)\]/i; + +/** peerDetailModal probe toast keys — same probe wording rules as reticulumPeers.probe. */ +export const PEER_DETAIL_PROBE_LEAF_KEYS = new Set(['probeHops', 'probeLocal', 'probeFailed']); + /** Brand / product names preserved verbatim when present in English. */ // GPIO is a hardware acronym that must not be translated or expanded in UI strings. -export const PROTECTED_BRANDS = ['TAK', 'Discord', 'Meshtastic', 'MeshCore', 'MQTT', 'GPIO']; +export const PROTECTED_BRANDS = [ + 'TAK', + 'Discord', + 'Meshtastic', + 'MeshCore', + 'MQTT', + 'GPIO', + 'Reticulum', + 'Colorado Mesh', + 'LetsMesh', + "Liam's", + 'MeshMapper', + 'Ripple Networks', + 'Nomad Network', + 'mesh-client', + 'Giphy', + 'GitHub', + 'RNode', + 'Heltec', + 'CalTopo', + 'LoRa', +]; const BRAND_WORD_RES = new Map([ ['TAK', /\bTAK\b/g], @@ -964,8 +1620,67 @@ const BRAND_WORD_RES = new Map([ ['MeshCore', /\bMeshCore\b/g], ['MQTT', /\bMQTT\b/g], ['GPIO', /\bGPIO\b/g], + ['Reticulum', /\bReticulum\b/g], + ['Colorado Mesh', /Colorado Mesh/g], + ['LetsMesh', /\bLetsMesh\b/g], + ["Liam's", /Liam['\u2019]s/g], + ['MeshMapper', /\bMeshMapper\b/g], + ['Ripple Networks', /Ripple Networks/g], + ['Nomad Network', /Nomad Network/g], + ['mesh-client', /mesh-client/g], + ['Giphy', /\bGiphy\b/g], + ['GitHub', /\bGitHub\b/g], + ['RNode', /\bRNode\b/g], + ['Heltec', /\bHeltec\b/g], + ['CalTopo', /\bCalTopo\b/g], + ['LoRa', /\bLoRa\b/g], ]); +/** Protocol/stack tokens preserved when present in English (not brand names). */ +export const PROTECTED_PROTOCOL_TOKENS = [ + 'RNS', + 'LXMF', + 'TCP', + 'UDP', + 'I2P', + 'KISS', + 'BLE', + 'USB', + 'HTTP', + 'Wi-Fi', + 'macOS', + 'Windows', + 'Linux', +]; + +const PROTOCOL_TOKEN_RES = new Map([ + ['RNS', /\bRNS\b/g], + ['LXMF', /\bLXMF\b/g], + ['TCP', /\bTCP\b/g], + ['UDP', /\bUDP\b/g], + ['I2P', /\bI2P\b/g], + ['KISS', /\bKISS\b/g], + ['BLE', /\bBLE\b/g], + ['USB', /\bUSB\b/g], + ['HTTP', /\bHTTP\b/g], + ['Wi-Fi', /Wi-Fi/g], + ['macOS', /\bmacOS\b/g], + ['Windows', /\bWindows\b/g], + ['Linux', /\bLinux\b/g], +]); + +export const RETICULUM_RUNTIME_PREFIX = 'diagnosticsPanel.reticulum.runtime.'; +export const RETICULUM_RUNTIME_PROTOCOL_TOKENS = ['RNS', 'LXMF']; + +export const ROUTING_PORT_PREFIX = 'diagnosticsPanel.routingPort.'; +export const ROUTING_PORT_TOKENS = [ + 'NodeInfo', + 'Telemetry', + 'NeighborInfo', + 'DiscoveryFlood', + 'RoomAdvert', +]; + // UTF-8 Cyrillic (etc.) misread as Latin-1 in JSON. const MOJIBAKE_RE = /Ð[\u0080-\u00FF]{2,}|Ã[\u0080-\u00BF]{2,}|Â[\u0080-\u00BF]{2,}/; @@ -1055,6 +1770,61 @@ export function protectedBrandIssues(enVal, val, brands = PROTECTED_BRANDS) { return issues; } +function protocolTokenOccurrenceCount(text, token) { + const re = PROTOCOL_TOKEN_RES.get(token); + if (!re) return 0; + return (text.match(re) || []).length; +} + +/** + * @param {string} enVal + * @param {string} val + * @param {string[]} [tokens] + * @returns {string[]} + */ +export function protectedProtocolTokenIssues(enVal, val, tokens = PROTECTED_PROTOCOL_TOKENS) { + const issues = []; + for (const token of tokens) { + const enCount = protocolTokenOccurrenceCount(enVal, token); + if (enCount === 0) continue; + const locCount = protocolTokenOccurrenceCount(val, token); + if (locCount < enCount) { + issues.push( + `Protocol token "${token}" missing: English has ${enCount} occurrence(s), locale has ${locCount}`, + ); + } + } + return issues; +} + +/** + * @param {LocaleQualityCtx} ctx + * @returns {string[]} + */ +function checkReticulumRuntimeAndRoutingPortIssues(ctx) { + const { flatKey, val, enVal } = ctx; + const issues = []; + if (flatKey.startsWith(RETICULUM_RUNTIME_PREFIX)) { + for (const token of RETICULUM_RUNTIME_PROTOCOL_TOKENS) { + if (!enVal.includes(token)) continue; + if (!val.includes(token)) { + issues.push(`reticulum runtime copy must preserve protocol token "${token}"`); + } + } + issues.push(...protectedProtocolTokenIssues(enVal, val, RETICULUM_RUNTIME_PROTOCOL_TOKENS)); + } + if (flatKey.startsWith('connectionPanel.reticulumInterfaces.picker')) { + issues.push(...protectedProtocolTokenIssues(enVal, val, ['BLE', 'RNode'])); + } + if (flatKey.startsWith(ROUTING_PORT_PREFIX)) { + const leaf = flatKey.slice(ROUTING_PORT_PREFIX.length); + if (ROUTING_PORT_TOKENS.includes(leaf) && val !== enVal) { + issues.push(`routingPort.${leaf} must equal English protocol identifier verbatim`); + } + } + return issues; +} + /** * @typedef {{ locale: string, flatKey: string, val: string, enVal: string, leafKey: string }} LocaleQualityCtx */ @@ -1064,12 +1834,22 @@ export function protectedBrandIssues(enVal, val, brands = PROTECTED_BRANDS) { * @returns {string[]} */ function checkCatEncodingAndMeshtasticIssues(ctx) { - const { locale, flatKey, val, enVal } = ctx; + const { locale, flatKey, val, enVal, leafKey } = ctx; const issues = []; if (CAT_PH_PLACEHOLDER_RE.test(val)) { issues.push('CAT/XLIFF __ PH __ placeholder residue is not allowed'); } + if (CAT_BARE_PH_PLACEHOLDER_RE.test(val)) { + issues.push('CAT placeholder residue (bare PH N) is not allowed — use {{name}} interpolation'); + } + + if (HTML_TAG_RESIDUE_RE.test(val)) { + if (!I18N_TRANS_HTML_KEYS.has(flatKey) && !I18N_ANGLE_BRACKET_PLACEHOLDER_KEYS.has(flatKey)) { + issues.push('HTML tag residue is not allowed in locale strings'); + } + } + for (const re of LOCALE_ARTIFACT_RES) { if (re.test(val)) { issues.push(`CAT/XLIFF/Memsource XML residue is not allowed (matched ${re})`); @@ -1113,6 +1893,25 @@ function checkCatEncodingAndMeshtasticIssues(ctx) { issues.push('CAT/Qt plural-form placeholder residue is not allowed'); } + if (HTML_ENTITY_RESIDUE_RE.test(val)) { + issues.push('HTML numeric entity residue (e.g. ) is not allowed'); + } + + if (BRACKET_CAT_PLACEHOLDER_RE.test(val)) { + issues.push('CAT bracket placeholder residue (e.g. [Data]) is not allowed'); + } + + if ( + leafKey === 'nameLabel' && + enVal === 'Name:' && + locale !== 'en' && + /Gerald|Junior|&#/i.test(val) + ) { + issues.push( + 'nameLabel must be a short "Name:" label — remove CAT sample-name or entity garbage', + ); + } + if ( locale === 'fr' && (flatKey.startsWith(CHANNEL_URL_PREFIX) || FR_MESH_CHANNEL_KEYS.has(flatKey)) && @@ -1123,12 +1922,45 @@ function checkCatEncodingAndMeshtasticIssues(ctx) { return issues; } +/** + * Reticulum peer detail, ping, and related UI outside connectionPanel.* nesting. + * + * @param {LocaleQualityCtx} ctx + * @returns {string[]} + */ +function checkReticulumPeerAndPingIssues(ctx) { + const { locale, flatKey, val, enVal, leafKey } = ctx; + const issues = []; + + if ( + flatKey.startsWith('peerDetailModal.') && + PEER_DETAIL_PROBE_LEAF_KEYS.has(leafKey) && + enVal.includes('Probe') + ) { + for (const { re, hint } of RETICULUM_PROBE_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`peerDetailModal probe false friend: ${hint}`); + } + } + } + + if (flatKey === 'reticulumPing.failed' && enVal.includes('Ping failed') && locale !== 'en') { + if (/зв['\s]*язк|связ(и|ь)/i.test(val) && !/пінг|ping|эхо|sond|prob/i.test(val)) { + issues.push( + 'reticulumPing.failed must mention ping/probe, not generic connection "зв\'язку/связи"', + ); + } + } + + return issues; +} + /** * @param {LocaleQualityCtx} ctx * @returns {string[]} */ function checkMustTranslateAndFormFieldIssues(ctx) { - const { locale, val, enVal, leafKey } = ctx; + const { locale, flatKey, val, enVal, leafKey } = ctx; const issues = []; if (locale !== 'en' && MUST_TRANSLATE_LEAF_KEYS.has(leafKey) && val === enVal) { issues.push(`"${leafKey}" is still identical to English — translate the UI text`); @@ -1186,6 +2018,23 @@ function checkMustTranslateAndFormFieldIssues(ctx) { ) { issues.push('use Unicode ellipsis (…) instead of ASCII dots when English uses …'); } + + if ( + locale !== 'en' && + ELLIPSIS_HYGIENE_LEAF_KEYS.has(leafKey) && + enVal.endsWith('…') && + /…{2,}/.test(val) + ) { + issues.push('use a single Unicode ellipsis (…), not repeated ……'); + } + + if (flatKey === AUTO_RECONNECT_IN_PROGRESS_KEY && locale !== 'en') { + for (const { re, hint } of AUTO_RECONNECT_IN_PROGRESS_FALSE_FRIENDS[locale] ?? []) { + if (re.test(val)) { + issues.push(`autoReconnectInProgress false friend: ${hint}`); + } + } + } return issues; } @@ -1259,6 +2108,26 @@ function checkMeshAdvertAndRawPacketLogIssues(ctx) { issues.push('payloadLabel looks too long — use a short label (e.g. Payload)'); } } + if (flatKey.startsWith(RAW_PACKET_LOG_RETICULUM_PREFIX)) { + if ( + RAW_PACKET_LOG_RETICULUM_VERBATIM_LEAF_KEYS.has(packetLeaf) && + (enVal === 'RX' || enVal === 'TX') && + val !== enVal + ) { + issues.push( + `rawPacketLog reticulum ${packetLeaf} must stay verbatim "${enVal}" (radio direction token)`, + ); + } + if ( + packetLeaf === 'destination' && + enVal === 'Destination' && + /^[:;.,!?]+$/.test(val.trim()) + ) { + issues.push( + 'rawPacketLog reticulum destination must be a label, not punctuation-only garbage', + ); + } + } if (locale === 'uk' && packetLeaf === 'transportHeading' && /Телепортувати/i.test(val)) { issues.push( 'transportHeading must be mesh transport header label, not verb "teleport" (Телепортувати)', @@ -1269,6 +2138,28 @@ function checkMeshAdvertAndRawPacketLogIssues(ctx) { } } + if (flatKey === RETICULUM_TOPOLOGY_SELF_KEY && enVal === 'You' && locale !== 'en') { + if (val === 'You') { + issues.push('reticulumTopology.self must be translated (local pronoun for "You")'); + } + for (const { re, hint } of RETICULUM_TOPOLOGY_SELF_FALSE_FRIEND_RES) { + if (re.test(val)) { + issues.push(`reticulumTopology.self false friend: ${hint}`); + } + } + if (val.split(/\s+/).length > 2) { + issues.push('reticulumTopology.self should be a short pronoun, not a multi-word phrase'); + } + } + + if ( + flatKey === RETICULUM_TOPOLOGY_HOP_BADGE_KEY && + enVal.includes('{{count}}') && + !val.includes('{{count}}') + ) { + issues.push('reticulumTopology.hopBadge must preserve {{count}} placeholder'); + } + if ( locale === 'de' && (flatKey.startsWith('radioPanel.deviceRoles') || flatKey.startsWith('roleInfo.roles.')) @@ -1282,6 +2173,27 @@ function checkMeshAdvertAndRawPacketLogIssues(ctx) { return issues; } +/** + * @param {LocaleQualityCtx} ctx + * @returns {string[]} + */ +function checkFlasherIssues(ctx) { + const { locale, flatKey, val, enVal } = ctx; + const issues = []; + if ( + FLASHER_NO_SERIAL_PORTS_KEYS.has(flatKey) && + /^No .* found/i.test(enVal) && + locale === 'fr' && + FR_NO_SERIAL_PORTS_INVERTED_RE.test(val) && + !FR_NO_SERIAL_PORTS_NEGATION_RE.test(val) + ) { + issues.push( + 'flasher noSerialPorts must express absence (aucun/pas de), not affirmative "trouvé(s):"', + ); + } + return issues; +} + /** * @param {LocaleQualityCtx} ctx * @returns {string[]} @@ -1469,6 +2381,17 @@ function checkChatPanelIssues(ctx) { } } } + + if ( + locale !== 'en' && + (flatKey === 'chatPanel.emptySelectDm' || flatKey === 'chatPanel.noDmConversationsReticulum') + ) { + for (const { re, hint } of CHAT_RETICULUM_CONTACT_FALSE_FRIENDS[locale] ?? []) { + if (re.test(val)) { + issues.push(`chatPanel reticulum contact false friend: ${hint}`); + } + } + } return issues; } @@ -1978,6 +2901,7 @@ const LOCALE_STRING_QUALITY_CHECKS = [ checkRadioPanelChannelIssues, checkRoomsPanelFalseFriendIssues, checkMeshAdvertAndRawPacketLogIssues, + checkFlasherIssues, checkRoomsGuestPasswordAndNlMeshIssues, checkMqttWifiAndScriptIssues, checkRoomsPanelTranslationIssues, @@ -1985,9 +2909,12 @@ const LOCALE_STRING_QUALITY_CHECKS = [ checkRoomsPanelMembersIssues, checkAppPanelReduceMotionAndBrandIssues, checkMeshcoreOpenWireIssues, + checkReticulumConnectionPanelIssues, + checkReticulumPeerAndPingIssues, checkUkrainianApostropheIssues, checkMeshcoreReactionAndConnectionIssues, checkMeshcorePathHashIssues, + checkReticulumRuntimeAndRoutingPortIssues, ]; /** diff --git a/scripts/check-i18n-quality.test.mjs b/scripts/check-i18n-quality.test.mjs index 0446b3580..c6c08428a 100644 --- a/scripts/check-i18n-quality.test.mjs +++ b/scripts/check-i18n-quality.test.mjs @@ -6,6 +6,8 @@ import { localeStringQualityIssues, nodeListPanelConnectionCrossKeyIssues, protectedBrandIssues, + protectedProtocolTokenIssues, + RETICULUM_RUNTIME_PREFIX, roomsSavedPasswordsCrossKeyIssues, roomsSidebarMarkerCrossKeyIssues, } from './check-i18n-quality.mjs'; @@ -245,6 +247,48 @@ describe('localeStringQualityIssues', () => { expectIssue(issues, 'use Unicode ellipsis (…) instead of ASCII dots'); }); + it('flags ASCII ellipsis on connectionPanel.autoConnectingTo', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'connectionPanel.autoConnectingTo', + val: 'Automatische Verbindung mit {{deviceName}}...', + enVal: 'Auto-connecting to {{deviceName}}…', + }); + expectIssue(issues, 'use Unicode ellipsis (…) instead of ASCII dots'); + }); + + it('flags double Unicode ellipsis on Noble BLE wait stage copy', () => { + const issues = localeStringQualityIssues({ + locale: 'zh', + flatKey: 'connectionPanel.stageWaitingNobleBleMeshtastic', + val: '正在等待 Meshtastic Bluetooth 完成 — 完成后 MeshCore 将自动连接到 {{deviceName}}……', + enVal: + 'Waiting for Meshtastic Bluetooth to finish — MeshCore will connect to {{deviceName}} automatically when it is done…', + }); + expectIssue(issues, 'use a single Unicode ellipsis (…), not repeated'); + }); + + it('flags autoReconnectInProgress connect-vs-reconnect false friend in Ukrainian', () => { + const issues = localeStringQualityIssues({ + locale: 'uk', + flatKey: 'connectionPanel.autoReconnectInProgress', + val: 'Триває автоматичне підключення…', + enVal: 'Auto-reconnect in progress…', + }); + expectIssue(issues, 'autoReconnectInProgress false friend'); + }); + + it('passes valid autoReconnectInProgress in Ukrainian', () => { + expect( + localeStringQualityIssues({ + locale: 'uk', + flatKey: 'connectionPanel.autoReconnectInProgress', + val: 'Триває автоматичне повторне підключення…', + enVal: 'Auto-reconnect in progress…', + }), + ).toEqual([]); + }); + it('flags retryRemoteChannels loading-channel false friend in Spanish', () => { const issues = localeStringQualityIssues({ locale: 'es', @@ -1330,6 +1374,76 @@ describe('roomsPanel saved passwords per-key quality', () => { expectIssue(issues, 'teleport'); }); + it('flags non-verbatim RX in rawPacketLog.reticulum.rx', () => { + const issues = localeStringQualityIssues({ + locale: 'cs', + flatKey: 'rawPacketLog.reticulum.rx', + val: 'Recept', + enVal: 'RX', + }); + expectIssue(issues, 'rawPacketLog reticulum rx must stay verbatim "RX"'); + }); + + it('flags CAT ph tag in rawPacketLog.reticulum.rx', () => { + const issues = localeStringQualityIssues({ + locale: 'pt-BR', + flatKey: 'rawPacketLog.reticulum.rx', + val: 'RX', + enVal: 'RX', + }); + expectIssue(issues, 'CAT/XLIFF/Memsource XML residue'); + }); + + it('flags punctuation-only rawPacketLog.reticulum.destination', () => { + const issues = localeStringQualityIssues({ + locale: 'tr', + flatKey: 'rawPacketLog.reticulum.destination', + val: ':', + enVal: 'Destination', + }); + expectIssue(issues, 'punctuation-only garbage'); + }); + + it('flags bare PH 0 in reticulumTopology.hopBadge', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumTopology.hopBadge', + val: 'PH 0', + enVal: '{{count}}h', + }); + expectIssue(issues, 'hopBadge must preserve {{count}} placeholder'); + }); + + it('flags untranslated reticulumTopology.self', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumTopology.self', + val: 'You', + enVal: 'You', + }); + expectIssue(issues, 'reticulumTopology.self must be translated'); + }); + + it('flags HTML span residue in rawPacketLog.reticulum.packetType', () => { + const issues = localeStringQualityIssues({ + locale: 'es', + flatKey: 'rawPacketLog.reticulum.packetType', + val: 'Tipo de bulto', + enVal: 'Packet type', + }); + expectIssue(issues, 'HTML tag residue'); + }); + + it('flags inverted French flasher.noSerialPorts', () => { + const issues = localeStringQualityIssues({ + locale: 'fr', + flatKey: 'flasher.noSerialPorts', + val: 'Ports USB-série trouvés :', + enVal: 'No USB serial ports found.', + }); + expectIssue(issues, 'must express absence'); + }); + const enMeshcoreOpenWireCompatHint = 'When enabled, mesh-client sends keyed text replies (@[Name#key]), compact r: reactions, and g: Giphy GIFs. This may not match the official companion wire format; receivers need MeshCore Open-aware clients.'; @@ -1463,6 +1577,185 @@ describe('roomsPanel saved passwords per-key quality', () => { }); expectIssue(issues, 'Ukrainian apostrophe words must not have a space'); }); + + it('flags spaced reticulum sidecar build command', () => { + const enVal = + 'Reticulum sidecar not built. From the mesh-client repo run `pnpm run reticulum:sidecar:build` (requires Rust).'; + const issues = localeStringQualityIssues({ + locale: 'ko', + flatKey: 'connectionPanel.reticulumSidecarMissing', + val: 'Sidecar missing. Run `pnpm run reticulum: sidecar: build` (Rust).', + enVal, + }); + expectIssue(issues, 'reticulum sidecar build command must appear exactly'); + }); + + it('flags Rust translated as corrosion in reticulumSidecarMissing', () => { + const enVal = + 'Reticulum sidecar not built. From the mesh-client repo run `pnpm run reticulum:sidecar:build` (requires Rust).'; + const issues = localeStringQualityIssues({ + locale: 'es', + flatKey: 'connectionPanel.reticulumSidecarMissing', + val: 'Sidecar no construido. Ejecute `pnpm run reticulum:sidecar:build` (requiere óxido).', + enVal, + }); + expectIssue(issues, 'reticulum sidecar Rust false friend'); + }); + + it('allows Interface cognate for reticulumNetworkUnknown', () => { + expect( + localeStringQualityIssues({ + locale: 'fr', + flatKey: 'connectionPanel.reticulumNetworkUnknown', + val: 'Interface', + enVal: 'Interface', + }), + ).toEqual([]); + }); + + it('flags German parallax false friend on reticulum disable', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'connectionPanel.reticulumInterfaces.disable', + val: 'Horizontaler Parallaxeffekt', + enVal: 'Disable', + }); + expectIssue(issues, 'parallax'); + }); + + it('flags untranslated reticulumStackRunning', () => { + const issues = localeStringQualityIssues({ + locale: 'it', + flatKey: 'connectionPanel.reticulumStackRunning', + val: 'Stack running', + enVal: 'Stack running', + }); + expectIssue(issues, 'still identical to English'); + }); + + it('flags Italian pressure false friend on reticulumPeers.name', () => { + const issues = localeStringQualityIssues({ + locale: 'it', + flatKey: 'connectionPanel.reticulumPeers.name', + val: 'Pressione', + enVal: 'Peer', + }); + expectIssue(issues, 'reticulum peer name false friend'); + }); + + it('flags Russian chimney stack false friend on reticulumStackRunning', () => { + const issues = localeStringQualityIssues({ + locale: 'ru', + flatKey: 'connectionPanel.reticulumStackRunning', + val: 'Работает дымовая труба', + enVal: 'Stack running', + }); + expectIssue(issues, 'reticulum stack running false friend'); + }); + + it('flags Polish road-barrier false friend on reticulumStopStack', () => { + const issues = localeStringQualityIssues({ + locale: 'pl', + flatKey: 'connectionPanel.reticulumStopStack', + val: 'Stos ograniczników', + enVal: 'Stop stack', + }); + expectIssue(issues, 'reticulum stop stack false friend'); + }); + + it('flags Russian Edit false friend on reticulum enable', () => { + const issues = localeStringQualityIssues({ + locale: 'ru', + flatKey: 'connectionPanel.reticulumInterfaces.enable', + val: 'Редактировать', + enVal: 'Enable', + }); + expectIssue(issues, 'reticulum enable false friend'); + }); + + it('flags Korean inquiry false friend on emptySelectDm', () => { + const issues = localeStringQualityIssues({ + locale: 'ko', + flatKey: 'chatPanel.emptySelectDm', + val: '위의 문의를 선택하세요.', + enVal: + 'Reticulum chat is direct message only. Pick a contact above or open one from the Nodes tab.', + }); + expectIssue(issues, 'chatPanel reticulum contact false friend'); + }); + + it('flags Ukrainian sensor false friend on peerDetailModal probeHops', () => { + const issues = localeStringQualityIssues({ + locale: 'uk', + flatKey: 'peerDetailModal.probeHops', + val: 'Датчик OK — {{hops}} стрибок(и).', + enVal: 'Probe OK — {{hops}} hop(s).', + }); + expectIssue(issues, 'peerDetailModal probe false friend'); + }); + + it('flags Ukrainian connection false friend on reticulumPing.failed', () => { + const issues = localeStringQualityIssues({ + locale: 'uk', + flatKey: 'reticulumPing.failed', + val: "Помилка зв 'язку: {{error}}", + enVal: 'Ping failed: {{error}}', + }); + expectIssue(issues, 'reticulumPing.failed must mention ping'); + }); + + it('flags HTML entity residue on nameLabel', () => { + const issues = localeStringQualityIssues({ + locale: 'fr', + flatKey: 'connectionPanel.reticulumIdentity.nameLabel', + val: 'Nom : ', + enVal: 'Name:', + }); + expectIssue(issues, 'HTML numeric entity residue'); + }); + + it('flags CAT sample name on nameLabel', () => { + const issues = localeStringQualityIssues({ + locale: 'id', + flatKey: 'connectionPanel.reticulumIdentity.nameLabel', + val: 'Name: Gerald Abram Foeh Junior', + enVal: 'Name:', + }); + expectIssue(issues, 'nameLabel must be a short'); + }); + + it('flags CAT XML residue on reticulumPing.run', () => { + const issues = localeStringQualityIssues({ + locale: 'id', + flatKey: 'reticulumPing.run', + val: 'ping', + enVal: 'Ping', + }); + expectIssue(issues, 'CAT/XLIFF/Memsource XML residue'); + }); + + it('flags bracket [Data] placeholder on reticulumSendDelivered', () => { + const issues = localeStringQualityIssues({ + locale: 'pl', + flatKey: 'chatPanel.reticulumSendDelivered', + val: '[Data] dostarczenia', + enVal: 'Delivered', + }); + expectIssue(issues, 'CAT bracket placeholder residue'); + }); + + it('flags untranslated reticulumSendDelivered', () => { + const issues = localeStringQualityIssues({ + locale: 'uk', + flatKey: 'chatPanel.reticulumSendDelivered', + val: 'Delivered', + enVal: 'Delivered', + }); + expectIssue( + issues, + 'reticulumSendDelivered" is still identical to English — translate the UI text', + ); + }); }); describe('protectedBrandIssues', () => { @@ -1487,4 +1780,45 @@ describe('protectedBrandIssues', () => { const issues = protectedBrandIssues('GPIO pin — encoder A', 'Pin de codificador A'); expectIssue(issues, 'Brand "GPIO" missing'); }); + + it('flags missing Colorado Mesh multi-word brand', () => { + const issues = protectedBrandIssues( + 'Colorado Mesh needs WebSocket on port 1883.', + 'Colorado necesita WebSocket en el puerto 1883.', + ); + expectIssue(issues, 'Brand "Colorado Mesh" missing'); + }); + + it('flags missing mesh-client hyphenated product name', () => { + const issues = protectedBrandIssues( + 'Quit mesh-client completely and reopen it.', + 'Cierre la aplicación por completo y vuelva a abrirla.', + ); + expectIssue(issues, 'Brand "mesh-client" missing'); + }); +}); + +describe('protectedProtocolTokenIssues', () => { + it('flags missing RNS when English has one', () => { + const issues = protectedProtocolTokenIssues('RNS stack is not ready', 'La pila no está lista'); + expectIssue(issues, 'Protocol token "RNS" missing'); + }); + + it('passes when RNS is preserved', () => { + expect( + protectedProtocolTokenIssues('RNS stack is not ready', 'La pila RNS no está lista'), + ).toEqual([]); + }); +}); + +describe('checkReticulumRuntimeAndRoutingPortIssues', () => { + it('flags missing RNS on reticulum runtime keys', () => { + const issues = localeStringQualityIssues({ + locale: 'es', + flatKey: `${RETICULUM_RUNTIME_PREFIX}rnsNotReady`, + enVal: 'RNS stack is not ready', + val: 'La pila no está lista', + }); + expectIssue(issues, 'reticulum runtime copy must preserve protocol token "RNS"'); + }); }); diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index 862624c61..d6ca4ed97 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -4,10 +4,12 @@ * * 1. Extracts all t('key') / t("key") call sites from renderer source. * 2. Verifies every key resolves to an existing path in en/translation.json. - * 3. Verifies every key in en/translation.json exists in every other locale file (warn only). - * 4. Fails on CAT/XLIFF/Memsource residue in non-English strings; fails if {{placeholder}} + * 3. Fails when en/translation.json contains keys with no usage (static t(), registered + * dynamic prefixes, quoted key literals in src/, or tabs.* from TAB_SLOT_IDS). + * 4. Verifies every key in en/translation.json exists in every other locale file (warn only). + * 5. Fails on CAT/XLIFF/Memsource residue in non-English strings; fails if {{placeholder}} * name sets differ from English for the same key. - * 5. Fails on locale quality issues (mojibake, broken meshtastic://, false friends, etc.) + * 6. Fails on locale quality issues (mojibake, broken meshtastic://, false friends, etc.) * via check-i18n-quality.mjs — including modulePanel.* strings still identical to English, * appPanel.reduceMotionDesc loading-spinner false friends, appPanel.debugSnapshot* * copied-toast false friends and mixed EN "snapshot" residue, rawPacketLog protocol tokens, @@ -15,15 +17,28 @@ * wire / g: GIF composer strings (protocol tokens, companion-wire false friends, Open-aware), * connectionBanner serialReselectAction MT garbage, meshcoreGifHint bare-id false friends, * meshcoreReactionEmojiOption contact/fabric false friends, Ukrainian broken apostrophe spacing, - * and roomsPanel collapse/expand hotel-room wording; MeshCore path-hash hop-count brewing false + * and roomsPanel collapse/expand hotel-room wording; connectionPanel Noble BLE wait/auto-connect + * stage strings (Unicode ellipsis hygiene, autoReconnectInProgress reconnect false friends); + * MeshCore path-hash hop-count brewing false * friends, CAT/Qt plural-form residue (', "plural form:"), short label parenthesis garbage, - * and meshcorePathHashModeHint CLI literal set path.hash.mode {0|1|2}. + * and meshcorePathHashModeHint CLI literal set path.hash.mode {0|1|2}; Reticulum identity/interface/ + * peer/propagation UI (must-translate stack/config strings, disable parallax false friends, peer/ + * probe/host/transport colleague false friends, sidecar build/Rust/cargo literals); peerDetailModal + * probe toasts; CAT HTML entities, bracket + * [Data] placeholders, bare PH N / / HTML tag residue, and sample-name garbage on nameLabel; + * rawPacketLog.reticulum RX/TX verbatim tokens and destination punctuation garbage; + * reticulumTopology.self pronoun and hopBadge {{count}} placeholder; + * flasher.noSerialPorts French inverted "trouvé(s):" empty-state wording. * * Backfill untranslated modulePanel copy: pnpm run i18n:auto-translate -- --audit --prefix modulePanel. * * Branch-only quality pass (keys new/changed in en vs git HEAD): * pnpm run check:i18n:branch * + * Prune unused keys (dry-run by default): + * pnpm run i18n:prune-unused + * pnpm run i18n:prune-unused -- --write + * * Add a comment // i18n-ok on the same line to suppress a dynamic-key warning. */ @@ -40,6 +55,7 @@ import { roomsSavedPasswordsCrossKeyIssues, roomsSidebarMarkerCrossKeyIssues, } from './check-i18n-quality.mjs'; +import { collectUsedI18nKeys, DYNAMIC_T_PREFIXES } from './i18n-unused-keys.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const LOCALES_DIR = @@ -162,22 +178,6 @@ const T_STATIC_RE = /\bt\(\s*['"]([^'"]+)['"]\s*[),]/g; // Match t(`prefix.${expr}`) or t(`prefix.${expr}.suffix`) — dynamic keys with registered prefixes. const T_TEMPLATE_RE = /\bt\(\s*`([^`]*)\$\{[^}]+\}([^`]*)`\s*[),]/g; -/** - * Known dynamic t() template prefixes. Each entry verifies English keys exist for every - * variant the template can resolve to. Add a row when introducing a new t(`…${var}…`) site. - */ -const DYNAMIC_T_PREFIXES = [ - { prefix: 'chatPanel.fetchStoreForwardHistoryError.', leafKeys: true }, - { prefix: 'radioPanel.deviceRoles.', suffixes: ['label', 'description'] }, - { prefix: 'radioPanel.rebroadcastModes.', suffixes: ['label', 'description'] }, - { prefix: 'radioPanel.displayUnits.', suffixes: ['label'] }, - { prefix: 'radioPanel.oledTypes.', suffixes: ['label'] }, - { prefix: 'radioPanel.displayModes.', suffixes: ['label'] }, - { prefix: 'radioPanel.btPairingModes.', suffixes: ['label'] }, - { prefix: 'meshcoreTelemetryPrivacy.', leafKeys: true }, - { prefix: 'diagnosticsPanel.foreignLoraProximitySnippet.', leafKeys: true }, -]; - const en = flatten(readJson(EN_FILE)); const enKeys = new Set(Object.keys(en)); const branchEnglishKeys = BRANCH_ONLY ? resolveBranchEnglishKeys(en) : null; @@ -286,7 +286,7 @@ for (const file of files) { const prefix = extractTemplatePrefix(m[1], m[2]); if (!prefix) { console.error( - `Unregistered dynamic t() template at ${relative(join(__dirname, '..'), file)}:${idx + 1} — add prefix to DYNAMIC_T_PREFIXES in check-i18n.mjs`, + `Unregistered dynamic t() template at ${relative(join(__dirname, '..'), file)}:${idx + 1} — add prefix to DYNAMIC_T_PREFIXES in i18n-unused-keys.mjs`, ); errors++; } @@ -294,7 +294,18 @@ for (const file of files) { }); } -// ── 2. Check completeness across locale files (warn only — rate limits can leave gaps) ── +// ── 2. Unused English keys (no static/dynamic/literal usage in src/) ───────── +if (!BRANCH_ONLY) { + const { unused: unusedEnKeys } = collectUsedI18nKeys(join(__dirname, '../src'), EN_FILE); + for (const key of unusedEnKeys) { + console.error( + `Unused key in en/translation.json: "${key}" — remove or add usage (see pnpm run i18n:prune-unused)`, + ); + errors++; + } +} + +// ── 3. Check completeness across locale files (warn only — rate limits can leave gaps) ── const localeDirs = readLocalesDirEntries().filter((d) => { const full = join(LOCALES_DIR, d); return statSync(full).isDirectory() && d !== 'en'; @@ -365,7 +376,7 @@ const FORBIDDEN_HOP_TOKENS = [ '酒花', ]; -// ── 3. Locale string quality: no CAT/XML artifacts; {{name}} sets match English; +// ── 4. Locale string quality: no CAT/XML artifacts; {{name}} sets match English; // no leading/trailing whitespace or BOM that English lacks; brand names preserved. for (const dir of localeDirs) { const localePath = join(LOCALES_DIR, dir, 'translation.json'); diff --git a/scripts/check-log-panel-filter.mjs b/scripts/check-log-panel-filter.mjs index 32e475b5f..fea522f6e 100644 --- a/scripts/check-log-panel-filter.mjs +++ b/scripts/check-log-panel-filter.mjs @@ -43,6 +43,17 @@ const DEVICE_FILES = { path.join(ROOT, 'src', 'renderer', 'runtime', 'useMeshcoreRuntime.ts'), path.join(ROOT, 'src', 'renderer', 'hooks', 'meshcore', 'meshcoreLegacyConnEvents.ts'), ], + reticulum: [ + path.join(ROOT, 'src', 'main', 'reticulum-sidecar-manager.ts'), + path.join(ROOT, 'src', 'main', 'reticulum-sidecar-path.ts'), + path.join(ROOT, 'src', 'main', 'ipc', 'reticulum-handlers.ts'), + path.join(ROOT, 'src', 'renderer', 'runtime', 'useReticulumRuntime.ts'), + path.join(ROOT, 'src', 'renderer', 'components', 'ReticulumNetworkPanel.tsx'), + path.join(ROOT, 'src', 'renderer', 'lib', 'reticulum', 'reticulumLocalInterfaceLogging.ts'), + path.join(ROOT, 'src', 'renderer', 'lib', 'reticulum', 'reticulumBleAdapterLease.ts'), + path.join(ROOT, 'src', 'renderer', 'lib', 'reticulum', 'reticulumSidecarReads.ts'), + path.join(ROOT, 'src', 'renderer', 'lib', 'reticulum', 'useReticulumSidecarApi.ts'), + ], }; const SUPPRESSED = /\/\/\s*log-filter-ok\b/; diff --git a/scripts/check-protocol-string-gates.mjs b/scripts/check-protocol-string-gates.mjs index bfa0f8fe4..f64f8cf23 100644 --- a/scripts/check-protocol-string-gates.mjs +++ b/scripts/check-protocol-string-gates.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Guardrail: discourage new `protocol === 'meshcore'` feature gates in renderer components. + * Guardrail: discourage new `protocol === 'meshcore'|'reticulum'` feature gates in renderer components. * Dual-protocol wiring (runtime selection, MQTT storage keys) may stay on the allowlist. * * To permit a file, add its path (relative to repo root) to ALLOWLIST below with a short reason. @@ -13,11 +13,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const COMPONENTS_DIR = path.join(ROOT, 'src', 'renderer', 'components'); -const PATTERN = /protocol\s*===\s*['"]meshcore['"]/; +const PATTERNS = [/protocol\s*===\s*['"]meshcore['"]/, /protocol\s*===\s*['"]reticulum['"]/]; /** Files still allowed to compare protocol strings directly (migrate incrementally). */ const ALLOWLIST = new Set([ - 'src/renderer/App.tsx', 'src/renderer/components/AppPanel.tsx', 'src/renderer/components/ChatComposer.tsx', 'src/renderer/components/ChatPanel.tsx', @@ -50,14 +49,17 @@ function main() { const rel = path.relative(ROOT, filePath).replaceAll('\\', '/'); if (ALLOWLIST.has(rel)) continue; const src = fs.readFileSync(filePath, 'utf8'); - if (!PATTERN.test(src)) continue; - const lineNum = src.split('\n').findIndex((line) => PATTERN.test(line)) + 1; - violations.push(`${rel}:${lineNum}`); + for (const pattern of PATTERNS) { + if (!pattern.test(src)) continue; + const lineNum = src.split('\n').findIndex((line) => pattern.test(line)) + 1; + violations.push(`${rel}:${lineNum} (${pattern})`); + break; + } } if (violations.length > 0) { console.error( - 'check-protocol-string-gates: use ProtocolCapabilities instead of protocol === "meshcore" in:\n', + 'check-protocol-string-gates: use ProtocolCapabilities instead of protocol string compares in:\n', ); for (const v of violations) console.error(` ${v}`); console.error( diff --git a/scripts/clean-jsr-temp-dirs.mjs b/scripts/clean-jsr-temp-dirs.mjs new file mode 100644 index 000000000..2eff35b0a --- /dev/null +++ b/scripts/clean-jsr-temp-dirs.mjs @@ -0,0 +1,28 @@ +/** + * Remove stale @jsr/_tmp_* directories left by pnpm install races. + * + * Failure point: pnpm renames @jsr/_tmp_* → package dir; concurrent work can + * leave ENOENT/ENOTEMPTY on retry (Windows packaging, Flatpak offline install). + */ +import { existsSync, readdirSync, rmSync } from 'fs'; +import path from 'path'; + +/** @param {string} rootDir */ +export function cleanJsrTempDirs(rootDir) { + if (!existsSync(rootDir)) return; + + for (const ent of readdirSync(rootDir, { withFileTypes: true })) { + if (!ent.isDirectory()) continue; + const full = path.join(rootDir, ent.name); + if (ent.name === '@jsr') { + for (const child of readdirSync(full, { withFileTypes: true })) { + if (child.isDirectory() && child.name.startsWith('_tmp_')) { + rmSync(path.join(full, child.name), { recursive: true, force: true }); + } + } + } + if (ent.name === 'node_modules' || ent.name.startsWith('@')) { + cleanJsrTempDirs(full); + } + } +} diff --git a/scripts/clean-jsr-temp-dirs.test.mjs b/scripts/clean-jsr-temp-dirs.test.mjs new file mode 100644 index 000000000..3198a640b --- /dev/null +++ b/scripts/clean-jsr-temp-dirs.test.mjs @@ -0,0 +1,53 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanJsrTempDirs } from './clean-jsr-temp-dirs.mjs'; + +describe('cleanJsrTempDirs', () => { + /** @type {string[]} */ + const tempRoots = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function makeTempRoot() { + const root = mkdtempSync(path.join(tmpdir(), 'clean-jsr-')); + tempRoots.push(root); + return root; + } + + it('removes @jsr/_tmp_* dirs and keeps real packages', () => { + const root = makeTempRoot(); + const jsrDir = path.join(root, 'node_modules', '@jsr'); + mkdirSync(path.join(jsrDir, '_tmp_abc123'), { recursive: true }); + mkdirSync(path.join(jsrDir, 'meshtastic__core'), { recursive: true }); + writeFileSync(path.join(jsrDir, 'meshtastic__core', 'package.json'), '{}'); + + cleanJsrTempDirs(path.join(root, 'node_modules')); + + const names = readdirSync(jsrDir).sort(); + expect(names).toEqual(['meshtastic__core']); + }); + + it('cleans nested @jsr/_tmp_* under nested node_modules', () => { + const root = makeTempRoot(); + const nested = path.join(root, 'node_modules', '@scope', 'node_modules', '@jsr'); + mkdirSync(path.join(nested, '_tmp_nested'), { recursive: true }); + mkdirSync(path.join(nested, 'meshtastic__transport-http'), { recursive: true }); + + cleanJsrTempDirs(path.join(root, 'node_modules')); + + expect(readdirSync(nested)).toEqual(['meshtastic__transport-http']); + }); + + it('no-ops when root is missing', () => { + expect(() => + cleanJsrTempDirs(path.join(makeTempRoot(), 'missing', 'node_modules')), + ).not.toThrow(); + }); +}); diff --git a/scripts/dist-win-hoisted-install.mjs b/scripts/dist-win-hoisted-install.mjs index dc1a6c2bb..ab4fc25d1 100644 --- a/scripts/dist-win-hoisted-install.mjs +++ b/scripts/dist-win-hoisted-install.mjs @@ -7,33 +7,13 @@ * already runs frozen-lockfile install with workspace nodeLinker: hoisted. */ import { spawnSync } from 'child_process'; -import { existsSync, readdirSync, rmSync } from 'fs'; import { fileURLToPath } from 'url'; import path from 'path'; +import { cleanJsrTempDirs } from './clean-jsr-temp-dirs.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); -/** @param {string} rootDir */ -function cleanJsrTempDirs(rootDir) { - if (!existsSync(rootDir)) return; - - for (const ent of readdirSync(rootDir, { withFileTypes: true })) { - if (!ent.isDirectory()) continue; - const full = path.join(rootDir, ent.name); - if (ent.name === '@jsr') { - for (const child of readdirSync(full, { withFileTypes: true })) { - if (child.isDirectory() && child.name.startsWith('_tmp_')) { - rmSync(path.join(full, child.name), { recursive: true, force: true }); - } - } - } - if (ent.name === 'node_modules' || ent.name.startsWith('@')) { - cleanJsrTempDirs(full); - } - } -} - if (process.env.CI === 'true') { console.log('[dist-win-hoist] CI environment — skipping redundant hoisted install.'); process.exit(0); diff --git a/scripts/fix-locale-brand-tokens.mjs b/scripts/fix-locale-brand-tokens.mjs new file mode 100644 index 000000000..c32cbd02b --- /dev/null +++ b/scripts/fix-locale-brand-tokens.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * One-shot repair: when a locale string dropped a protected brand/token from English, + * copy the English value so check:i18n brand preservation passes. + */ +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { protectedBrandIssues } from './check-i18n-quality.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const LOCALES_DIR = join(ROOT, 'src/renderer/locales'); + +function flatten(obj, prefix = '') { + /** @type {Record} */ + const out = {}; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(out, flatten(value, path)); + } else if (typeof value === 'string') { + out[path] = value; + } + } + return out; +} + +function unflatten(flat) { + /** @type {Record} */ + const out = {}; + for (const [path, value] of Object.entries(flat)) { + const parts = path.split('.'); + let cursor = out; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!cursor[part] || typeof cursor[part] !== 'object') { + cursor[part] = {}; + } + cursor = /** @type {Record} */ (cursor[part]); + } + cursor[parts[parts.length - 1]] = value; + } + return out; +} + +const enFlat = flatten(JSON.parse(readFileSync(join(LOCALES_DIR, 'en/translation.json'), 'utf8'))); +let totalFixed = 0; + +for (const locale of readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name !== 'en') + .map((d) => d.name)) { + const filePath = join(LOCALES_DIR, locale, 'translation.json'); + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + const flat = flatten(parsed); + /** @type {Record} */ + const merged = { ...enFlat }; + let fixed = 0; + for (const [key, enVal] of Object.entries(enFlat)) { + const locVal = flat[key]; + if (typeof locVal !== 'string') continue; + if (protectedBrandIssues(enVal, locVal).length > 0) { + merged[key] = enVal; + fixed++; + } else { + merged[key] = locVal; + } + } + if (fixed > 0) { + writeFileSync(filePath, `${JSON.stringify(unflatten(merged), null, 2)}\n`, 'utf8'); + console.log(`${locale}: restored brands in ${fixed} keys`); + totalFixed += fixed; + } +} + +console.log(`Done. ${totalFixed} locale key(s) reset to English for brand preservation.`); diff --git a/scripts/flatpak-pnpm-install.mjs b/scripts/flatpak-pnpm-install.mjs new file mode 100644 index 000000000..1229c7b38 --- /dev/null +++ b/scripts/flatpak-pnpm-install.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Offline pnpm install for Flatpak builds with retry on @jsr temp-dir races. + * + * Failure point: pnpm install races renaming @jsr/_tmp_* during hoisted offline + * install inside flatpak-builder sandbox. Fallback: clean stale temps and retry. + */ +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import { cleanJsrTempDirs } from './clean-jsr-temp-dirs.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const STORE_DIR = '/run/build/mesh-client/flatpak-node/pnpm-store'; +const maxAttempts = 3; + +let lastStatus = 1; + +for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (attempt > 1) { + cleanJsrTempDirs(path.join(projectRoot, 'node_modules')); + } + + const result = spawnSync( + 'pnpm', + ['install', '--frozen-lockfile', '--offline', '--ignore-scripts', '--store-dir', STORE_DIR], + { + cwd: projectRoot, + stdio: 'inherit', + }, + ); + lastStatus = result.status ?? 1; + if (lastStatus === 0) { + process.exit(0); + } + if (attempt < maxAttempts) { + console.warn( + `[flatpak-pnpm] pnpm install failed (attempt ${attempt}/${maxAttempts}), retrying…`, + ); + } +} + +process.exit(lastStatus); diff --git a/scripts/flatpak-pnpm-install.test.mjs b/scripts/flatpak-pnpm-install.test.mjs new file mode 100644 index 000000000..a8d421a7d --- /dev/null +++ b/scripts/flatpak-pnpm-install.test.mjs @@ -0,0 +1,21 @@ +// @vitest-environment node +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +const scriptPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'flatpak-pnpm-install.mjs', +); + +describe('flatpak-pnpm-install.mjs', () => { + it('exits non-zero when offline store is unavailable outside sandbox', () => { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: path.resolve(path.dirname(scriptPath), '..'), + encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/flatpak-pnpm|ERR_PNPM|offline|no such file/i); + }); +}); diff --git a/scripts/gen-firmware-configs.mjs b/scripts/gen-firmware-configs.mjs new file mode 100644 index 000000000..3b8fc8c65 --- /dev/null +++ b/scripts/gen-firmware-configs.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +const ROM = { + PLATFORM_AVR: 0x90, + PLATFORM_ESP32: 0x80, + PLATFORM_NRF52: 0x70, + PRODUCT_RAK4631: 0x10, + MODEL_11: 0x11, + MODEL_12: 0x12, + PRODUCT_RNODE: 0x03, + MODEL_A1: 0xa1, + MODEL_A6: 0xa6, + MODEL_A4: 0xa4, + MODEL_A9: 0xa9, + MODEL_A3: 0xa3, + MODEL_A8: 0xa8, + MODEL_A2: 0xa2, + MODEL_A7: 0xa7, + MODEL_A5: 0xa5, + MODEL_AA: 0xaa, + MODEL_AC: 0xac, + PRODUCT_T32_10: 0xb2, + MODEL_BA: 0xba, + MODEL_BB: 0xbb, + PRODUCT_T32_20: 0xb0, + MODEL_B3: 0xb3, + MODEL_B8: 0xb8, + PRODUCT_T32_21: 0xb1, + MODEL_B4: 0xb4, + MODEL_B9: 0xb9, + MODEL_B4_TCXO: 0x04, + MODEL_B9_TCXO: 0x09, + PRODUCT_H32_V2: 0xc0, + MODEL_C4: 0xc4, + MODEL_C9: 0xc9, + PRODUCT_H32_V3: 0xc1, + MODEL_C5: 0xc5, + MODEL_CA: 0xca, + PRODUCT_H32_V4: 0xc3, + MODEL_C8: 0xc8, + PRODUCT_HELTEC_T114: 0xc2, + MODEL_C6: 0xc6, + MODEL_C7: 0xc7, + PRODUCT_TBEAM: 0xe0, + MODEL_E4: 0xe4, + MODEL_E9: 0xe9, + MODEL_E3: 0xe3, + MODEL_E8: 0xe8, + PRODUCT_TBEAM_S_V1: 0xea, + MODEL_DB: 0xdb, + MODEL_DC: 0xdc, + PRODUCT_TDECK: 0xd0, + MODEL_D4: 0xd4, + MODEL_D9: 0xd9, + PRODUCT_TECHO: 0x15, + MODEL_16: 0x16, + MODEL_17: 0x17, +}; + +const htmlPath = process.argv[2] ?? '/tmp/rnode-flasher-index.html'; +const html = fs.readFileSync(htmlPath, 'utf8'); +const start = html.indexOf('products: ['); +const end = html.indexOf("// Liam's default config"); +const productsSrc = html + .slice(start + 'products: '.length, end) + .trim() + .replace(/,\s*$/, ''); +const products = new Function('ROM', `return (${productsSrc});`)(ROM); + +function fmtValue(v, indent) { + if (v === null || v === undefined) return 'undefined'; + if (typeof v === 'number') return `0x${v.toString(16).toUpperCase().padStart(2, '0')}`; + if (typeof v === 'string') return JSON.stringify(v); + if (Array.isArray(v)) { + if (v.length === 0) return '[]'; + return `[\n${v.map((item) => `${indent} ${fmtValue(item, indent + ' ')}`).join(',\n')}\n${indent}]`; + } + if (typeof v === 'object') { + const entries = Object.entries(v).filter(([, val]) => val !== undefined); + if (entries.length === 0) return '{}'; + return `{\n${entries + .map(([k, val]) => { + const key = + k === 'flash_files' + ? k + : /^\d+$/.test(k) || /^0x[0-9a-f]+$/i.test(k) + ? JSON.stringify(k.startsWith('0x') ? k : `0x${Number(k).toString(16)}`) + : k; + if (k === 'flash_files' && typeof val === 'object' && val !== null) { + const fileEntries = Object.entries(val) + .map( + ([addr, fname]) => + ` ${JSON.stringify(addr.startsWith('0x') ? addr : `0x${parseInt(addr, 10).toString(16)}`)}: ${JSON.stringify(fname)}`, + ) + .join(',\n'); + return `${indent} flash_files: {\n${fileEntries}\n${indent} }`; + } + return `${indent} ${key}: ${fmtValue(val, indent + ' ')}`; + }) + .join(',\n')}\n${indent}}`; + } + return String(v); +} + +const body = products.map((p) => fmtValue(p, ' ')).join(',\n'); + +const out = `/** Ported from liamcottle/rnode-flasher index.html product catalog. */ +import type { RNodeProduct } from './types'; + +export const FIRMWARE_PRODUCTS: RNodeProduct[] = [ +${body}, +]; +`; + +const dest = path.join(ROOT, 'src/renderer/lib/flasher/firmwareConfigs.ts'); +fs.mkdirSync(path.dirname(dest), { recursive: true }); +fs.writeFileSync(dest, out); +console.log(`Wrote ${products.length} products to ${dest}`); diff --git a/scripts/i18n-unused-keys.mjs b/scripts/i18n-unused-keys.mjs new file mode 100644 index 000000000..25b9d750b --- /dev/null +++ b/scripts/i18n-unused-keys.mjs @@ -0,0 +1,217 @@ +/** + * Find translation keys in en/translation.json with no static or registered-dynamic usage. + * Shared by check-i18n.mjs (--audit-unused) and one-off audits. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** @typedef {{ prefix: string, leafKeys?: boolean, suffixes?: string[] }} DynamicTPrefix */ + +/** Keep in sync with DYNAMIC_T_PREFIXES in check-i18n.mjs */ +export const DYNAMIC_T_PREFIXES = [ + { prefix: 'chatPanel.fetchStoreForwardHistoryError.', leafKeys: true }, + { prefix: 'radioPanel.deviceRoles.', suffixes: ['label', 'description'] }, + { prefix: 'radioPanel.regions.', suffixes: ['label'] }, + { prefix: 'radioPanel.modemPresets.', suffixes: ['label'] }, + { prefix: 'radioPanel.rebroadcastModes.', suffixes: ['label', 'description'] }, + { prefix: 'modulePanel.inputEvents.', suffixes: ['label'] }, + { prefix: 'modulePanel.serialModes.', suffixes: ['label'] }, + { prefix: 'modulePanel.takTeamColors.', suffixes: ['label'] }, + { prefix: 'modulePanel.teamRoles.', suffixes: ['label'] }, + { prefix: 'radioPanel.displayUnits.', suffixes: ['label'] }, + { prefix: 'radioPanel.oledTypes.', suffixes: ['label'] }, + { prefix: 'radioPanel.displayModes.', suffixes: ['label'] }, + { prefix: 'radioPanel.btPairingModes.', suffixes: ['label'] }, + { prefix: 'meshcoreTelemetryPrivacy.', leafKeys: true }, + { prefix: 'appPanel.theme.', suffixes: ['label', 'description'] }, + { prefix: 'appPanel.themePreset.', leafKeys: true }, + { prefix: 'diagnosticsPanel.foreignLoraProximitySnippet.', leafKeys: true }, + { prefix: 'diagnosticsPanel.routingPort.', leafKeys: true }, + { prefix: 'diagnosticsPanel.reticulum.action.', leafKeys: true }, + { prefix: 'diagnosticsPanel.reticulum.audit.', leafKeys: true }, + { prefix: 'diagnosticsPanel.reticulum.remedy.', leafKeys: true }, + { prefix: 'diagnosticsPanel.reticulum.runtime.', leafKeys: true }, + { prefix: 'connectionPanel.reticulumInterfaces.status.', leafKeys: true }, + { prefix: 'connectionPanel.bleOwner.', leafKeys: true }, +]; + +export function flatten(obj, prefix = '') { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null) { + Object.assign(out, flatten(v, key)); + } else { + out[key] = v; + } + } + return out; +} + +function collectSourceFiles(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + if (entry === 'locales' || entry === 'node_modules') continue; + results.push(...collectSourceFiles(full)); + } else if (/\.(ts|tsx|mjs|js)$/.test(entry)) { + results.push(full); + } + } + return results; +} + +const T_STATIC_RE = /\b(?:t|i18n\.t)\(\s*['"]([^'"]+)['"]\s*[),]/g; +const T_TEMPLATE_RE = /\bt\(\s*`([^`]*)\$\{[^}]+\}([^`]*)`\s*[),]/g; +const QUOTED_LITERAL_RE = /['"]([^'"]+)['"]/g; +const I18N_OK_RE = /\/\/\s*i18n-ok\b/; + +/** tabs.* keys resolved via appTabMappings tabLabelKey() and TAB_SLOT_IDS. */ +function collectDynamicTabKeys(enKeys) { + const tabSlotIdsPath = join(__dirname, '../src/renderer/lib/tabSlotIds.ts'); + const src = readFileSync(tabSlotIdsPath, 'utf8'); + const slotMatch = src.match(/export const TAB_SLOT_IDS = \[([\s\S]*?)\] as const/); + if (!slotMatch) return new Set(); + const slots = [...slotMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].toLowerCase()); + const used = new Set([ + 'tabs.nomadnetwork', + 'tabs.contacts', + 'tabs.peers', + 'tabs.repeaters', + 'tabs.rooms', + 'tabs.network', + ]); + for (const slot of slots) { + const key = `tabs.${slot}`; + if (enKeys.has(key)) used.add(key); + } + return used; +} + +/** + * @param {string} srcRoot Defaults to repo src/ + * @param {string} enFile Defaults to en/translation.json + */ +export function collectUsedI18nKeys( + srcRoot = join(__dirname, '../src'), + enFile = join(__dirname, '../src/renderer/locales/en/translation.json'), +) { + const enFlat = flatten(JSON.parse(readFileSync(enFile, 'utf8'))); + const enKeys = new Set(Object.keys(enFlat)); + const registeredPrefixes = new Set(DYNAMIC_T_PREFIXES.map((e) => e.prefix)); + + const usedStatic = new Set(); + const usedLiteralRef = new Set(); + const activeDynamicPrefixes = new Set(); + const unregisteredDynamicSites = []; + + for (const file of collectSourceFiles(srcRoot)) { + const src = readFileSync(file, 'utf8'); + const lines = src.split('\n'); + lines.forEach((line, idx) => { + if (I18N_OK_RE.test(line)) return; + for (const m of line.matchAll(T_STATIC_RE)) { + usedStatic.add(m[1]); + } + for (const m of line.matchAll(T_TEMPLATE_RE)) { + const combined = `${m[1]}${m[2]}`; + let matched = false; + for (const prefix of registeredPrefixes) { + if (combined.startsWith(prefix)) { + activeDynamicPrefixes.add(prefix); + matched = true; + } + } + if (!matched) { + unregisteredDynamicSites.push({ file, line: idx + 1, combined }); + } + } + for (const m of line.matchAll(QUOTED_LITERAL_RE)) { + if (enKeys.has(m[1])) usedLiteralRef.add(m[1]); + } + }); + } + + for (const key of collectDynamicTabKeys(enKeys)) { + usedLiteralRef.add(key); + } + + const usedDynamic = new Set(); + for (const entry of DYNAMIC_T_PREFIXES) { + if (!activeDynamicPrefixes.has(entry.prefix)) continue; + for (const key of enKeys) { + if (!key.startsWith(entry.prefix)) continue; + if (entry.leafKeys) { + usedDynamic.add(key); + continue; + } + const rest = key.slice(entry.prefix.length); + const dot = rest.indexOf('.'); + if (dot <= 0) continue; + const suffix = rest.slice(dot + 1); + if (entry.suffixes?.includes(suffix)) usedDynamic.add(key); + } + } + + const used = new Set([...usedStatic, ...usedDynamic, ...usedLiteralRef]); + + /** i18next plural suffixes when the base key is referenced */ + for (const key of [...usedStatic, ...usedLiteralRef]) { + for (const pluralSuffix of ['_one', '_other', '_zero', '_two', '_few', '_many']) { + const pluralKey = `${key}${pluralSuffix}`; + if (enKeys.has(pluralKey)) used.add(pluralKey); + } + } + + const unused = [...enKeys].filter((k) => !used.has(k)).sort(); + return { + enKeys, + usedStatic, + usedLiteralRef, + usedDynamic, + activeDynamicPrefixes: [...activeDynamicPrefixes], + unregisteredDynamicSites, + unused, + }; +} + +/** + * Remove flat keys from nested locale object (mutates copy). + * + * @param {Record} obj + * @param {Set} flatKeysToRemove + * @param {string} [prefix] + */ +export function pruneNestedLocale(obj, flatKeysToRemove, prefix = '') { + for (const key of Object.keys(obj)) { + const flatKey = prefix ? `${prefix}.${key}` : key; + const val = obj[key]; + if (typeof val === 'object' && val !== null) { + pruneNestedLocale(/** @type {Record} */ (val), flatKeysToRemove, flatKey); + if (Object.keys(/** @type {Record} */ (val)).length === 0) { + Reflect.deleteProperty(obj, key); + } + } else if (flatKeysToRemove.has(flatKey)) { + Reflect.deleteProperty(obj, key); + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const { unused, unregisteredDynamicSites, activeDynamicPrefixes } = collectUsedI18nKeys(); + console.log(`Active dynamic prefixes: ${activeDynamicPrefixes.join(', ') || '(none)'}`); + console.log(`Unused keys: ${unused.length}`); + if (unregisteredDynamicSites.length > 0) { + console.warn(`Unregistered dynamic t() sites: ${unregisteredDynamicSites.length}`); + for (const site of unregisteredDynamicSites.slice(0, 10)) { + console.warn(` ${site.file}:${site.line} → ${site.combined}`); + } + } + for (const key of unused) console.log(key); +} diff --git a/scripts/i18n-unused-keys.test.mjs b/scripts/i18n-unused-keys.test.mjs new file mode 100644 index 000000000..43053e542 --- /dev/null +++ b/scripts/i18n-unused-keys.test.mjs @@ -0,0 +1,26 @@ +// @vitest-environment node +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectUsedI18nKeys, pruneNestedLocale } from '../scripts/i18n-unused-keys.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EN_FILE = join(__dirname, '../src/renderer/locales/en/translation.json'); + +describe('i18n-unused-keys', () => { + it('finds no unused keys after audit prune', () => { + const { unused } = collectUsedI18nKeys(join(__dirname, '../src'), EN_FILE); + expect(unused).toEqual([]); + }); + + it('pruneNestedLocale removes flat keys from nested objects', () => { + const tree = { + chatPanel: { used: 'ok', stale: 'remove me' }, + tabs: { chat: 'Chat' }, + }; + pruneNestedLocale(tree, new Set(['chatPanel.stale'])); + expect(tree).toEqual({ chatPanel: { used: 'ok' }, tabs: { chat: 'Chat' } }); + }); +}); diff --git a/scripts/prune-i18n-unused.mjs b/scripts/prune-i18n-unused.mjs new file mode 100644 index 000000000..2a53da0c0 --- /dev/null +++ b/scripts/prune-i18n-unused.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Remove unused translation keys from all locale files (English is source of truth). + * + * Usage: + * node scripts/prune-i18n-unused.mjs # dry-run (default) + * node scripts/prune-i18n-unused.mjs --write # apply removals + */ + +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { collectUsedI18nKeys, flatten, pruneNestedLocale } from './i18n-unused-keys.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = join(__dirname, '../src/renderer/locales'); +const WRITE = process.argv.includes('--write'); + +const { unused } = collectUsedI18nKeys(); +const toRemove = new Set(unused); + +if (toRemove.size === 0) { + console.log('No unused keys to prune.'); + process.exit(0); +} + +console.log(`${WRITE ? 'Pruning' : 'Dry-run:'} ${toRemove.size} unused key(s)`); + +const localeDirs = readdirSync(LOCALES_DIR).filter((d) => { + const full = join(LOCALES_DIR, d); + return statSync(full).isDirectory(); +}); + +for (const dir of localeDirs) { + const path = join(LOCALES_DIR, dir, 'translation.json'); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const before = Object.keys(flatten(json)).length; + pruneNestedLocale(json, toRemove); + const after = Object.keys(flatten(json)).length; + const removed = before - after; + if (removed > 0) { + console.log(` ${dir}: removed ${removed} key(s) (${before} → ${after})`); + if (WRITE) { + writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`, 'utf8'); + } + } +} + +if (!WRITE) { + console.log('\nRe-run with --write to apply.'); +} diff --git a/scripts/update.sh b/scripts/update.sh index 3caf85e2a..69398aa5c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -27,6 +27,58 @@ get_version() { || echo "" } +# Get resolved rustc version (empty if not installed) +get_rustc_version() { + if command -v rustc > /dev/null 2>&1; then + rustc --version 2> /dev/null | awk '{print $2}' + else + echo '' + fi +} + +# Update Rust toolchain when rustup or Homebrew rust is available +update_rust_toolchain() { + if command -v rustup > /dev/null 2>&1; then + echo 'Updating Rust toolchain (rustup update)...' + rustup update + return 0 + fi + if [ "$(uname -s 2> /dev/null || true)" = 'Darwin' ] && command -v brew > /dev/null 2>&1; then + if brew list rust > /dev/null 2>&1; then + echo 'rustup not found; upgrading Homebrew rust...' + brew upgrade rust + return 0 + fi + fi + if command -v cargo > /dev/null 2>&1; then + echo 'cargo found without rustup — skipping automatic Rust update.' + echo ' Prefer https://rustup.rs for CI parity, or upgrade via your package manager.' + return 0 + fi + echo 'Rust not installed — skipping toolchain update and sidecar rebuild (optional; see docs/development-environment.md#reticulum-sidecar-optional).' + return 0 +} + +# Rebuild Reticulum sidecar after dependency/toolchain updates +rebuild_reticulum_sidecar() { + if [ ! -f 'reticulum-sidecar/Cargo.toml' ]; then + return 0 + fi + if ! command -v cargo > /dev/null 2>&1; then + echo 'cargo not on PATH — skipping Reticulum sidecar rebuild.' + return 0 + fi + echo 'Rebuilding Reticulum sidecar (cargo build)...' + local sidecar_dir='reticulum-sidecar' + local rns_runtime='../rsReticulum/crates/rns-runtime/Cargo.toml' + local lxmf_core='../rsLXMF/crates/lxmf-core/Cargo.toml' + if [ -f "${sidecar_dir}/${rns_runtime}" ] && [ -f "${sidecar_dir}/${lxmf_core}" ]; then + (cd reticulum-sidecar && cargo build --features rns-stack,rns-ble,rns-rnode-tcp) + else + (cd reticulum-sidecar && cargo build) + fi +} + # Print a highlighted warning box for an updated package warn_box() { local pkg="$1" old_ver="$2" new_ver="$3" url="$4" @@ -88,6 +140,11 @@ for entry in "${WATCH_ENTRIES[@]}"; do idx=$((idx + 1)) done +OLD_RUSTC="$(get_rustc_version)" +if [ -n "${OLD_RUSTC}" ]; then + echo " rustc = ${OLD_RUSTC}" +fi + # --- Run updates --- echo '' echo 'Running pnpm update...' @@ -105,8 +162,20 @@ echo '' echo 'Running pnpm prune...' pnpm prune -# --- Detect and warn on updates --- HAS_WARNING=0 + +echo '' +update_rust_toolchain +NEW_RUSTC="$(get_rustc_version)" +if [ -n "${OLD_RUSTC}" ] && [ -n "${NEW_RUSTC}" ] && [ "${OLD_RUSTC}" != "${NEW_RUSTC}" ]; then + warn_box 'rustc (rustup/brew)' "${OLD_RUSTC}" "${NEW_RUSTC}" 'https://rustup.rs/' + echo ' Reason tracked: Reticulum sidecar toolchain — run pnpm run reticulum:sidecar:build if rebuild failed' + HAS_WARNING=1 +fi + +rebuild_reticulum_sidecar + +# --- Detect and warn on watched pnpm packages --- for i in "${!KEYS[@]}"; do key="${KEYS[$i]}" display="${DISPLAYS[$i]}" diff --git a/src/main/ble-coexistence-coordinator.test.ts b/src/main/ble-coexistence-coordinator.test.ts new file mode 100644 index 000000000..16884bafd --- /dev/null +++ b/src/main/ble-coexistence-coordinator.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + BleCoexistenceCoordinator, + BlePeripheralConflictError, + BleScanBusyError, + normalizeBleMac, +} from './ble-coexistence-coordinator'; + +describe('BleCoexistenceCoordinator', () => { + it('normalizes MAC addresses for registry keys', () => { + expect(normalizeBleMac('AA:BB:CC:DD:EE:FF')).toBe('aa:bb:cc:dd:ee:ff'); + expect(normalizeBleMac('AABBCCDDEEFF')).toBe('aa:bb:cc:dd:ee:ff'); + }); + + it('registers and unregisters peripheral ownership', () => { + const coordinator = new BleCoexistenceCoordinator(); + coordinator.register('AA:BB:CC:DD:EE:01', 'noble:meshtastic'); + expect(coordinator.getState().connections).toEqual([ + { mac: 'aa:bb:cc:dd:ee:01', owner: 'noble:meshtastic' }, + ]); + coordinator.unregister('AA:BB:CC:DD:EE:01', 'noble:meshtastic'); + expect(coordinator.getState().connections).toEqual([]); + }); + + it('rejects registering the same MAC to a different owner', () => { + const coordinator = new BleCoexistenceCoordinator(); + coordinator.register('aa:bb:cc:dd:ee:02', 'noble:meshcore'); + expect(() => { + coordinator.register('AA:BB:CC:DD:EE:02', 'reticulum'); + }).toThrow(BlePeripheralConflictError); + }); + + it('serializes scan leases without disconnecting noble sessions', async () => { + const noble = { + pauseScanningForExternalScan: vi.fn().mockResolvedValue(undefined), + resumeScanningAfterExternalScan: vi.fn().mockResolvedValue(undefined), + }; + const coordinator = new BleCoexistenceCoordinator(); + coordinator.setNobleManager(noble as never); + + await coordinator.acquireScan('noble'); + expect(coordinator.getState().scanOwner).toBe('noble'); + + await expect(coordinator.acquireScan('reticulum')).rejects.toBeInstanceOf(BleScanBusyError); + + coordinator.releaseScan('noble'); + await coordinator.acquireScan('reticulum'); + expect(noble.pauseScanningForExternalScan).toHaveBeenCalled(); + expect(coordinator.getState().scanOwner).toBe('reticulum'); + + coordinator.releaseScan('reticulum'); + expect(noble.resumeScanningAfterExternalScan).toHaveBeenCalled(); + expect(coordinator.getState().scanOwner).toBeNull(); + }); +}); diff --git a/src/main/ble-coexistence-coordinator.ts b/src/main/ble-coexistence-coordinator.ts new file mode 100644 index 000000000..792bc1508 --- /dev/null +++ b/src/main/ble-coexistence-coordinator.ts @@ -0,0 +1,132 @@ +import { sanitizeLogMessage } from './log-service'; +import type { NobleBleManager } from './noble-ble-manager'; + +export type BlePeripheralOwner = + 'noble:meshtastic' | 'noble:meshcore' | 'webbt:meshtastic' | 'webbt:meshcore' | 'reticulum'; + +export type BleScanOwner = 'noble' | 'reticulum' | 'webbt'; + +export interface BleRegisteredConnection { + mac: string; + owner: BlePeripheralOwner; +} + +export interface BleCoexistenceState { + connections: BleRegisteredConnection[]; + scanOwner: BleScanOwner | null; +} + +export class BlePeripheralConflictError extends Error { + readonly mac: string; + readonly existingOwner: BlePeripheralOwner; + + constructor(mac: string, existingOwner: BlePeripheralOwner) { + super(`Bluetooth device ${mac} is already in use by ${existingOwner}`); + this.name = 'BlePeripheralConflictError'; + this.mac = mac; + this.existingOwner = existingOwner; + } +} + +export class BleScanBusyError extends Error { + readonly scanOwner: BleScanOwner; + + constructor(scanOwner: BleScanOwner) { + super(`Bluetooth scan in progress (${scanOwner})`); + this.name = 'BleScanBusyError'; + this.scanOwner = scanOwner; + } +} + +/** Normalize MAC / BLE address for registry keys (case-insensitive, colon-separated). */ +export function normalizeBleMac(mac: string): string { + const trimmed = mac.trim(); + if (!trimmed) return trimmed; + const hex = trimmed.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length === 12) { + return hex.match(/.{1,2}/g)!.join(':'); + } + return trimmed.toLowerCase(); +} + +/** + * Cooperative BLE coexistence: peripheral ownership registry + scan-only mutex. + * Multiple stacks may hold GATT links to different devices simultaneously. + */ +export class BleCoexistenceCoordinator { + private connections = new Map(); + private scanOwner: BleScanOwner | null = null; + private nobleManager: NobleBleManager | null = null; + private nobleScanPausedForExternal = false; + + setNobleManager(manager: NobleBleManager): void { + this.nobleManager = manager; + } + + getState(): BleCoexistenceState { + return { + connections: [...this.connections.entries()].map(([mac, owner]) => ({ mac, owner })), + scanOwner: this.scanOwner, + }; + } + + register(mac: string, owner: BlePeripheralOwner): void { + const key = normalizeBleMac(mac); + if (!key) return; + const existing = this.connections.get(key); + if (existing && existing !== owner) { + throw new BlePeripheralConflictError(key, existing); + } + this.connections.set(key, owner); + } + + unregister(mac: string, owner: BlePeripheralOwner): void { + const key = normalizeBleMac(mac); + if (!key) return; + if (this.connections.get(key) === owner) { + this.connections.delete(key); + } + } + + assertCanConnect(owner: BlePeripheralOwner, mac: string): void { + const key = normalizeBleMac(mac); + if (!key) return; + const existing = this.connections.get(key); + if (existing && existing !== owner) { + throw new BlePeripheralConflictError(key, existing); + } + } + + async acquireScan(owner: BleScanOwner): Promise { + if (this.scanOwner === owner) return; + if (this.scanOwner !== null) { + throw new BleScanBusyError(this.scanOwner); + } + if (owner === 'reticulum' && this.nobleManager) { + await this.nobleManager.pauseScanningForExternalScan(); + this.nobleScanPausedForExternal = true; + } + this.scanOwner = owner; + } + + releaseScan(owner: BleScanOwner): void { + if (this.scanOwner !== owner) return; + this.scanOwner = null; + if (owner === 'reticulum' && this.nobleScanPausedForExternal && this.nobleManager) { + this.nobleScanPausedForExternal = false; + void this.nobleManager.resumeScanningAfterExternalScan().catch((err: unknown) => { + console.debug( + '[BleCoexistence] resumeScanningAfterExternalScan failed (ignored):', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + }); + } + } + + /** Stop Noble scan without disconnecting GATT sessions (Reticulum picker on darwin/win32). */ + async pauseNobleScan(): Promise { + await this.acquireScan('reticulum'); + } +} + +export const bleCoexistenceCoordinator = new BleCoexistenceCoordinator(); diff --git a/src/main/database.ts b/src/main/database.ts index 763596bb6..7aa63f268 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -20,6 +20,7 @@ import { runSchemaUpgrade, } from './db-schema-sync'; import { sanitizeLogMessage } from './log-service'; +import { buildFtsMatchQuery, isMessageFtsReady } from './messageFts'; /** Re-export for callers/tests that track the on-disk `user_version`. */ export { CURRENT_SCHEMA_VERSION, DatabaseSchemaTooNewError, isDatabaseSchemaTooNewError }; @@ -394,6 +395,18 @@ export function mergeDatabase(sourcePath: string) { export function searchMessages(query: string, limit = 50): unknown[] { const db = getDatabase(); + const ftsQuery = buildFtsMatchQuery(query); + if (ftsQuery && isMessageFtsReady(db)) { + return db + .prepare( + `SELECT m.id, m.sender_id, m.sender_name, m.payload, m.channel, m.timestamp, m.to_node + FROM messages m + INNER JOIN messages_fts ON messages_fts.rowid = m.id + WHERE messages_fts MATCH ? + ORDER BY m.timestamp DESC LIMIT ?`, + ) + .all(ftsQuery, limit); + } const like = `%${escapeSqlLikePattern(query)}%`; return db .prepare( @@ -405,6 +418,18 @@ export function searchMessages(query: string, limit = 50): unknown[] { export function searchMeshcoreMessages(query: string, limit = 50): unknown[] { const db = getDatabase(); + const ftsQuery = buildFtsMatchQuery(query); + if (ftsQuery && isMessageFtsReady(db)) { + return db + .prepare( + `SELECT m.id, m.sender_id, m.sender_name, m.payload, m.channel_idx, m.timestamp, m.to_node + FROM meshcore_messages m + INNER JOIN meshcore_messages_fts ON meshcore_messages_fts.rowid = m.id + WHERE meshcore_messages_fts MATCH ? + ORDER BY m.timestamp DESC LIMIT ?`, + ) + .all(ftsQuery, limit); + } const like = `%${escapeSqlLikePattern(query)}%`; return db .prepare( diff --git a/src/main/db-schema-sync.test.ts b/src/main/db-schema-sync.test.ts index 27764cfa0..0d06f904a 100644 --- a/src/main/db-schema-sync.test.ts +++ b/src/main/db-schema-sync.test.ts @@ -49,6 +49,10 @@ describe('runSchemaUpgrade', { timeout: 30_000 }, () => { expect(db.pragma('user_version', { simple: true })).toBe(CURRENT_SCHEMA_VERSION); const mcCols = db.prepare('PRAGMA table_info(meshcore_contacts)').all() as { name: string }[]; expect(mcCols.some((c) => c.name === 'public_key')).toBe(true); + const reticulumCols = db.prepare('PRAGMA table_info(reticulum_messages)').all() as { + name: string; + }[]; + expect(reticulumCols.some((c) => c.name === 'received_via')).toBe(true); db.close(); }); diff --git a/src/main/db-schema-sync.ts b/src/main/db-schema-sync.ts index 9548b27e0..f563b50a1 100644 --- a/src/main/db-schema-sync.ts +++ b/src/main/db-schema-sync.ts @@ -15,9 +15,10 @@ import { MESHCORE_LAST_ADVERT_MAX_FUTURE_SKEW_SEC } from '../shared/meshcoreLast import { meshProtocolSqlInList } from '../shared/meshProtocol'; import type { NodeSqliteDB } from './db-compat'; import { sanitizeLogMessage } from './log-service'; +import { ensureMessageFtsTables } from './messageFts'; /** Bumped when ensureSchema behavior changes in a non-idempotent way (rare). */ -export const CURRENT_SCHEMA_VERSION = 36; +export const CURRENT_SCHEMA_VERSION = 41; /** Thrown when on-disk `user_version` exceeds this build's {@link CURRENT_SCHEMA_VERSION}. */ export class DatabaseSchemaTooNewError extends Error { @@ -188,6 +189,45 @@ export const CANONICAL_TABLES_DDL = ` UNIQUE(node_id, path_hash) ); + CREATE TABLE IF NOT EXISTS reticulum_destinations ( + destination_hash TEXT PRIMARY KEY, + display_name TEXT, + last_heard INTEGER, + favorited INTEGER DEFAULT 0, + icon_name TEXT, + icon_color TEXT + ); + + CREATE TABLE IF NOT EXISTS reticulum_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + sender_name TEXT, + payload TEXT NOT NULL, + timestamp INTEGER NOT NULL, + to_hash TEXT, + reply_to_hash TEXT, + message_hash TEXT, + received_via TEXT + ); + + CREATE TABLE IF NOT EXISTS blocked_contacts ( + protocol TEXT NOT NULL, + identity_id TEXT NOT NULL, + blocked_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (protocol, identity_id, blocked_hash) + ); + + CREATE TABLE IF NOT EXISTS reticulum_identity_activity ( + destination_hash TEXT NOT NULL, + aspect TEXT NOT NULL, + identity_hash TEXT, + last_seen INTEGER NOT NULL, + hops INTEGER, + PRIMARY KEY (destination_hash, aspect) + ); + CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -234,7 +274,10 @@ export const INDEX_DDLS: readonly string[] = [ `CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_packet_dedup ON messages(sender_id, packet_id) WHERE packet_id IS NOT NULL`, - 'CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard)', + 'CREATE INDEX IF NOT EXISTS idx_reticulum_msgs_ts ON reticulum_messages(timestamp)', + 'CREATE INDEX IF NOT EXISTS idx_reticulum_msgs_identity ON reticulum_messages(identity_id, timestamp DESC)', + 'CREATE INDEX IF NOT EXISTS idx_blocked_contacts_lookup ON blocked_contacts(protocol, identity_id)', + 'CREATE INDEX IF NOT EXISTS idx_reticulum_activity_dest ON reticulum_identity_activity(destination_hash)', 'CREATE INDEX IF NOT EXISTS idx_mc_msgs_ts ON meshcore_messages(timestamp)', 'CREATE INDEX IF NOT EXISTS idx_mc_msgs_channel_id ON meshcore_messages(channel_idx, id DESC)', `CREATE UNIQUE INDEX IF NOT EXISTS idx_mc_msg_dedup @@ -341,6 +384,21 @@ export const DESIRED_COLUMNS: Readonly { + let tmpDir: string; + + afterEach(() => { + setIdentityVaultPathForTests(null); + lockIdentityVault(); + resetIdentityVaultUnlockRateLimitForTests(); + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('encrypt/decrypt round-trip preserves plaintext', async () => { + const plaintext = JSON.stringify({ mnemonic: 'alpha beta gamma', lxmf: 'abc123' }); + const envelope = await encryptVaultSecret('test-passcode-1234', plaintext); + const decrypted = await decryptVaultSecret('test-passcode-1234', envelope); + expect(decrypted).toBe(plaintext); + }); + + it('rejects wrong passcode on decrypt', async () => { + const envelope = await encryptVaultSecret('correct-pass', 'secret-data'); + await expect(decryptVaultSecret('wrong-pass', envelope)).rejects.toThrow('decryption failed'); + }); + + it('setPasscode writes vault file and unlock stores secret in session', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-vault-')); + setIdentityVaultPathForTests(path.join(tmpDir, 'identity-vault.json')); + + const secret = '{"identity":"backup"}'; + const setResult = await setIdentityVaultPasscode('vault-pass-9999', secret); + expect(setResult.ok).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'identity-vault.json'))).toBe(true); + + lockIdentityVault(); + const unlockResult = await unlockIdentityVault('vault-pass-9999'); + expect(unlockResult.ok).toBe(true); + }); + + it('rate-limits repeated failed unlock attempts', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-vault-')); + setIdentityVaultPathForTests(path.join(tmpDir, 'identity-vault.json')); + await setIdentityVaultPasscode('vault-pass-9999', '{"identity":"backup"}'); + lockIdentityVault(); + + for (let i = 0; i < 5; i++) { + const res = await unlockIdentityVault('wrong-pass'); + expect(res.ok).toBe(false); + } + const blocked = await unlockIdentityVault('vault-pass-9999'); + expect(blocked.ok).toBe(false); + expect(blocked.error).toMatch(/too many unlock attempts/i); + }); +}); diff --git a/src/main/identityVault.ts b/src/main/identityVault.ts new file mode 100644 index 000000000..7573d541f --- /dev/null +++ b/src/main/identityVault.ts @@ -0,0 +1,235 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { app } from 'electron'; + +const VAULT_VERSION = 1; +const MIN_PASSCODE_LENGTH = 8; +const MAX_PASSCODE_LENGTH = 256; +const MAX_SECRET_BYTES = 512 * 1024; +/** scrypt params (~64 MiB) — Node crypto only; avoids argon2 native Electron ABI rebuilds. */ +const SCRYPT_N = 65536; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2; +const AES_KEY_BYTES = 32; + +export interface VaultEnvelope { + version: typeof VAULT_VERSION; + saltB64: string; + ivB64: string; + tagB64: string; + ciphertextB64: string; +} + +export interface IdentityVaultStatus { + configured: boolean; + unlocked: boolean; +} + +export interface IdentityVaultActionResult { + ok: boolean; + error?: string; +} + +let unlockedSecret: string | null = null; +let vaultPathOverride: string | null = null; + +const UNLOCK_MAX_ATTEMPTS = 5; +const UNLOCK_WINDOW_MS = 60_000; +let unlockAttemptWindowStart = 0; +let unlockAttemptCount = 0; + +function checkUnlockRateLimit(): string | null { + const now = Date.now(); + if (now - unlockAttemptWindowStart > UNLOCK_WINDOW_MS) { + unlockAttemptWindowStart = now; + unlockAttemptCount = 0; + } + if (unlockAttemptCount >= UNLOCK_MAX_ATTEMPTS) { + return 'too many unlock attempts; try again later'; + } + unlockAttemptCount += 1; + return null; +} + +/** Test hook: reset unlock rate limit state. */ +export function resetIdentityVaultUnlockRateLimitForTests(): void { + unlockAttemptWindowStart = 0; + unlockAttemptCount = 0; +} + +/** Test hook: override vault file path (null restores default). */ +export function setIdentityVaultPathForTests(next: string | null): void { + vaultPathOverride = next; +} + +export function getIdentityVaultPath(): string { + if (vaultPathOverride) return vaultPathOverride; + return path.join(app.getPath('userData'), 'reticulum', 'identity-vault.json'); +} + +function validatePasscode(passcode: string): string | null { + if (typeof passcode !== 'string') return 'invalid passcode'; + const len = passcode.length; + if (len < MIN_PASSCODE_LENGTH || len > MAX_PASSCODE_LENGTH) { + return 'passcode length out of range'; + } + return null; +} + +function validateSecret(secret: string): string | null { + if (typeof secret !== 'string') return 'invalid secret'; + if (Buffer.byteLength(secret, 'utf8') > MAX_SECRET_BYTES) return 'secret too large'; + return null; +} + +function deriveKey(passcode: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => { + crypto.scrypt( + passcode, + salt, + AES_KEY_BYTES, + { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM }, + (err, key) => { + if (err) reject(err); + else resolve(key); + }, + ); + }); +} + +export async function encryptVaultSecret( + passcode: string, + plaintext: string, +): Promise { + const passcodeError = validatePasscode(passcode); + if (passcodeError) throw new Error(passcodeError); + const secretError = validateSecret(plaintext); + if (secretError) throw new Error(secretError); + + const salt = crypto.randomBytes(16); + const key = await deriveKey(passcode, salt); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + key.fill(0); + + return { + version: VAULT_VERSION, + saltB64: salt.toString('base64'), + ivB64: iv.toString('base64'), + tagB64: tag.toString('base64'), + ciphertextB64: ciphertext.toString('base64'), + }; +} + +export async function decryptVaultSecret( + passcode: string, + envelope: VaultEnvelope, +): Promise { + const passcodeError = validatePasscode(passcode); + if (passcodeError) throw new Error(passcodeError); + if (envelope.version !== VAULT_VERSION) throw new Error('unsupported vault version'); + + const salt = Buffer.from(envelope.saltB64, 'base64'); + const iv = Buffer.from(envelope.ivB64, 'base64'); + const tag = Buffer.from(envelope.tagB64, 'base64'); + const ciphertext = Buffer.from(envelope.ciphertextB64, 'base64'); + const key = await deriveKey(passcode, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + try { + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + 'utf8', + ); + key.fill(0); + return plaintext; + } catch { + key.fill(0); + throw new Error('decryption failed'); + } +} + +function readEnvelopeFromDisk(): VaultEnvelope | null { + const vaultPath = getIdentityVaultPath(); + if (!fs.existsSync(vaultPath)) return null; + try { + const raw = fs.readFileSync(vaultPath, 'utf8'); + const parsed = JSON.parse(raw) as VaultEnvelope; + if ( + parsed?.version !== VAULT_VERSION || + typeof parsed.saltB64 !== 'string' || + typeof parsed.ivB64 !== 'string' || + typeof parsed.tagB64 !== 'string' || + typeof parsed.ciphertextB64 !== 'string' + ) { + return null; + } + return parsed; + } catch { + // catch-no-log-ok corrupt or missing vault envelope reads as not configured + return null; + } +} + +function writeEnvelopeToDisk(envelope: VaultEnvelope): void { + const vaultPath = getIdentityVaultPath(); + fs.mkdirSync(path.dirname(vaultPath), { recursive: true }); + fs.writeFileSync(vaultPath, JSON.stringify(envelope), { encoding: 'utf8', mode: 0o600 }); +} + +export function getIdentityVaultStatus(): IdentityVaultStatus { + return { + configured: readEnvelopeFromDisk() != null, + unlocked: unlockedSecret != null, + }; +} + +export function getUnlockedVaultSecret(): string | null { + return unlockedSecret; +} + +export async function setIdentityVaultPasscode( + passcode: string, + secret: string, +): Promise { + try { + const envelope = await encryptVaultSecret(passcode, secret); + writeEnvelopeToDisk(envelope); + unlockedSecret = secret; + return { ok: true }; + } catch (err) { + // catch-no-log-ok failure returned to IPC caller as { ok: false, error } + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function unlockIdentityVault(passcode: string): Promise { + const rateError = checkUnlockRateLimit(); + if (rateError) return { ok: false, error: rateError }; + const envelope = readEnvelopeFromDisk(); + if (!envelope) return { ok: false, error: 'vault not configured' }; + try { + unlockedSecret = await decryptVaultSecret(passcode, envelope); + unlockAttemptCount = 0; + return { ok: true }; + } catch (err) { + unlockedSecret = null; + // catch-no-log-ok wrong passcode returned to IPC caller as { ok: false, error } + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export function lockIdentityVault(): IdentityVaultActionResult { + unlockedSecret = null; + return { ok: true }; +} diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 6fb1b3af4..78fe1399d 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -241,6 +241,43 @@ describe('MQTT IPC handlers (source contract)', () => { }); }); +describe('Reticulum sidecar IPC handlers (source contract)', () => { + const RETICULUM_HANDLERS_SOURCE = readFileSync( + join(__dirname, 'ipc/reticulum-handlers.ts'), + 'utf8', + ); + const RETICULUM_DB_HANDLERS_SOURCE = readFileSync( + join(__dirname, 'ipc/reticulum-db-handlers.ts'), + 'utf8', + ); + + it('registers reticulum lifecycle and proxy handlers', () => { + expect(INDEX_SOURCE).toContain('registerReticulumIpcHandlers'); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:start'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:stop'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:getStatus'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyGet'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPost'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPut'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyDelete'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:readDefaultConfigFile'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain( + "ipcMain.handle('reticulum:showConfigImportDialog'", + ); + expect(INDEX_SOURCE).toContain('registerReticulumDbIpcHandlers'); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("ipcMain.handle('db:getReticulumMessages'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("ipcMain.handle('db:saveReticulumMessage'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("'db:searchReticulumMessages'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("ipcMain.handle('db:deleteReticulumMessage'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("ipcMain.handle('db:clearReticulumMessages'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("'db:getBlockedContacts'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain("'db:getReticulumIdentityActivity'"); + expect(RETICULUM_DB_HANDLERS_SOURCE).toContain( + "ipcMain.handle('db:deleteReticulumDestination'", + ); + }); +}); + describe('HTTP bridge IPC handlers (source contract)', () => { it('registers all four HTTP bridge handlers', () => { expect(INDEX_SOURCE).toContain("ipcMain.handle('http:preflight'"); diff --git a/src/main/index.ts b/src/main/index.ts index 3802954f5..09a5c0272 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,7 +36,13 @@ import type { MeshProtocol } from '../shared/meshProtocol'; import { MESH_PROTOCOL_SET } from '../shared/meshProtocol'; import { effectiveMessageTimestampMs } from '../shared/messageTimestampSkew'; import { sanitizeUnicodeReactionScalar } from '../shared/reactionEmoji'; +import type { ReticulumSidecarStatus } from '../shared/reticulum-types'; import type { TAKServerStatus, TAKSettings } from '../shared/tak-types'; +import { + bleCoexistenceCoordinator, + type BlePeripheralOwner, + type BleScanOwner, +} from './ble-coexistence-coordinator'; import { formatChatExportLines } from './chatExportFormat'; import { addContactToGroup, @@ -83,6 +89,9 @@ import { formatDatabaseSchemaTooNewMessage, showFatalStartupError } from './fata import { fetchLinkPreview } from './fetchLinkPreview'; import { isValidHttpHostname } from './httpHostValidation'; import { registerGpsIpcHandlers } from './ipc/gps-handlers'; +import { registerReticulumDbIpcHandlers } from './ipc/reticulum-db-handlers'; +import { registerReticulumIpcHandlers, wireReticulumSidecarBridge } from './ipc/reticulum-handlers'; +import { registerReticulumIdentityIpcHandlers } from './ipc/reticulum-identity-handlers'; import { registerTakIpcHandlers } from './ipc/tak-handlers'; import { clearLogFile, @@ -104,8 +113,11 @@ import { resolveMqttBrokerClientId } from './mqtt-broker-client-id'; import { MQTTManager, parsePsk } from './mqtt-manager'; import { handleNobleBleToRadioWrite } from './noble-ble-ipc'; import { NobleBleManager, type NobleSessionId } from './noble-ble-manager'; +import { assertReticulumAttachmentPathJailed } from './reticulum-attachment-path'; +import { ReticulumSidecarManager } from './reticulum-sidecar-manager'; import type { TakServerManager } from './tak-server-manager'; import { getCheckNowFromMenu, initUpdater } from './updater'; +import { assertIpcSender, validateIpcSender } from './validate-ipc-sender'; import { buildWindowsAboutDocumentHtml } from './windows-about-html'; // Route main-process console through log file + Log panel (must run before other code logs) @@ -201,10 +213,17 @@ function isWindowStateOnScreen(state: WindowState): boolean { const mqttManager = new MQTTManager(); const meshcoreMqttAdapter = new MeshcoreMqttAdapter(); const nobleBleManager = new NobleBleManager(); +bleCoexistenceCoordinator.setNobleManager(nobleBleManager); /** TAK status before the lazy-loaded `TakServerManager` module is imported. */ const IDLE_TAK_STATUS: TAKServerStatus = { running: false, port: 8089, clientCount: 0 }; +const IDLE_RETICULUM_STATUS: ReticulumSidecarStatus = { + running: false, + port: 0, + pid: null, +}; + /** MAC address format: XX:XX:XX:XX:XX:XX */ function isMacAddress(value: string): boolean { const parts = value.split(':'); @@ -215,6 +234,16 @@ function isMacAddress(value: string): boolean { let takServerManager: TakServerManager | null = null; let takServerManagerLoadPromise: Promise | null = null; +let reticulumSidecarManager: ReticulumSidecarManager | null = null; + +function ensureReticulumSidecarManager(): ReticulumSidecarManager { + if (!reticulumSidecarManager) { + reticulumSidecarManager = new ReticulumSidecarManager(); + wireReticulumSidecarBridge(reticulumSidecarManager, () => mainWindow); + } + return reticulumSidecarManager; +} + function attachTakForwarders(manager: TakServerManager): void { manager.on('status', (status) => { if (mainWindow) mainWindow.webContents.send('tak:status', status); @@ -278,6 +307,14 @@ async function shutdownAppResources(): Promise { err instanceof Error ? err.message : err, ); // log-injection-ok internal cleanup } + try { + void reticulumSidecarManager?.stop(); + } catch (err) { + console.debug( + '[main] Reticulum sidecar stop during shutdown (ignored):', + err instanceof Error ? err.message : err, + ); // log-injection-ok internal cleanup + } try { mqttManager.disconnect(); meshcoreMqttAdapter.disconnect(); @@ -359,6 +396,15 @@ function buildBadgePng(): Buffer { // Pending Serial callback let pendingSerialCallback: ((portId: string) => void) | null = null; +let pendingSerialSelectionTimer: ReturnType | null = null; +const SERIAL_PORT_SELECTION_TIMEOUT_MS = 120_000; + +function clearPendingSerialSelectionTimer(): void { + if (pendingSerialSelectionTimer) { + clearTimeout(pendingSerialSelectionTimer); + pendingSerialSelectionTimer = null; + } +} // Last serial port discovery set: only allow selection IPC to resolve with ids from this set // (empty string always allowed = cancel). Prevents arbitrary id injection from a compromised renderer. let lastSerialPortIds = new Set(); @@ -450,29 +496,6 @@ function safeMeshcoreChannelIndex(value: unknown): number { return Math.trunc(n); } -/** Validate IPC sender origin to prevent untrusted renderers from invoking privileged handlers. */ -function validateIpcSender(event: Electron.IpcMainInvokeEvent): boolean { - const frame = event.senderFrame; - if (!frame) return false; - try { - const url = new URL(frame.url); - const isDev = !app.isPackaged; - if (isDev) { - return ( - url.protocol === 'file:' || - url.protocol === 'mesh-client:' || - (url.protocol === 'http:' && - (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) || - url.protocol === 'https:' - ); - } - return url.protocol === 'file:' || url.protocol === 'mesh-client:'; - } catch { - // catch-no-log-ok invalid URL in frame is expected; treat as untrusted - return false; - } -} - function validateSaveMessage(message: unknown): asserts message is Record & { sender_id: number; sender_name: string; @@ -1071,9 +1094,13 @@ function applyAboutPanelOptions(): void { const credits = [ `Version ${version}`, '', - 'Cross-platform Electron desktop client for Meshtastic and MeshCore on macOS, Linux, and Windows with multi-language support, BLE, USB serial, Wi‑Fi/TCP, MQTT, local SQLite history, and routing diagnostics.', + 'Cross-platform Electron desktop client for Meshtastic, MeshCore, and Reticulum on macOS, Linux, and Windows with multi-language support, BLE, USB serial, Wi‑Fi/TCP, MQTT, local SQLite history, and routing diagnostics.', + '', + 'Reticulum support uses a bundled AGPL-3.0 sidecar (mesh-client-reticulum). See docs/reticulum.md and docs/license.md.', + '', + 'Reticulum stack inspiration: Ratspeak (https://github.com/ratspeak/Ratspeak)', '', - 'License: MIT', + 'License: MIT (application code). AGPL-3.0 applies to the bundled Reticulum sidecar binary.', 'Author: Colorado Mesh', '', `Website: ${HELP_URL_WEBSITE}`, @@ -1628,18 +1655,22 @@ function createWindow() { // Store callback so we can resolve it when the user picks a port pendingSerialCallback = callback; + clearPendingSerialSelectionTimer(); console.debug(`[IPC] select-serial-port: discovered ${portList.length} port(s)`); - // Auto-cancel after 60s to prevent indefinite block if renderer unmounts mid-flow - setTimeout(() => { + // Auto-cancel if the picker is left open too long (flashing can take longer than 60s) + pendingSerialSelectionTimer = setTimeout(() => { if (pendingSerialCallback === callback) { - console.warn('[IPC] Serial port selection callback stale after 60s — auto-cancelling'); + console.warn( + '[IPC] Serial port selection callback stale after timeout — auto-cancelling', + ); pendingSerialCallback(''); pendingSerialCallback = null; lastSerialPortIds.clear(); } - }, 60_000); + pendingSerialSelectionTimer = null; + }, SERIAL_PORT_SELECTION_TIMEOUT_MS); lastSerialPortIds = new Set(portList.map((p) => p.portId)); // Send port list to renderer for selection @@ -1980,6 +2011,7 @@ ipcMain.on('serial-port-selected', (_event, portId: unknown) => { return; } console.debug('[IPC] serial-port-selected:', sanitizeLogMessage(id || '(cancelled)')); + clearPendingSerialSelectionTimer(); pendingSerialCallback(id); pendingSerialCallback = null; lastSerialPortIds.clear(); @@ -1987,6 +2019,7 @@ ipcMain.on('serial-port-selected', (_event, portId: unknown) => { // ─── IPC: Cancel Serial selection ─────────────────────────────────── ipcMain.on('serial-port-cancelled', () => { + clearPendingSerialSelectionTimer(); if (pendingSerialCallback) { pendingSerialCallback(''); // Empty string cancels the request pendingSerialCallback = null; @@ -2483,6 +2516,77 @@ nobleBleManager.on( ); // ─── Noble BLE: IPC command handlers ──────────────────────────────── +const BLE_PERIPHERAL_OWNERS = new Set([ + 'noble:meshtastic', + 'noble:meshcore', + 'webbt:meshtastic', + 'webbt:meshcore', + 'reticulum', +]); +const BLE_SCAN_OWNERS = new Set(['noble', 'reticulum', 'webbt']); + +ipcMain.handle('bleCoexistence:register', (event, mac: unknown, owner: unknown) => { + assertIpcSender(event, 'bleCoexistence:register'); + if ( + typeof mac !== 'string' || + typeof owner !== 'string' || + !BLE_PERIPHERAL_OWNERS.has(owner as BlePeripheralOwner) + ) { + throw new Error('bleCoexistence:register: invalid mac or owner'); + } + bleCoexistenceCoordinator.register(mac, owner as BlePeripheralOwner); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:unregister', (event, mac: unknown, owner: unknown) => { + assertIpcSender(event, 'bleCoexistence:unregister'); + if ( + typeof mac !== 'string' || + typeof owner !== 'string' || + !BLE_PERIPHERAL_OWNERS.has(owner as BlePeripheralOwner) + ) { + throw new Error('bleCoexistence:unregister: invalid mac or owner'); + } + bleCoexistenceCoordinator.unregister(mac, owner as BlePeripheralOwner); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:assertCanConnect', (event, owner: unknown, mac: unknown) => { + assertIpcSender(event, 'bleCoexistence:assertCanConnect'); + if ( + typeof mac !== 'string' || + typeof owner !== 'string' || + !BLE_PERIPHERAL_OWNERS.has(owner as BlePeripheralOwner) + ) { + throw new Error('bleCoexistence:assertCanConnect: invalid mac or owner'); + } + bleCoexistenceCoordinator.assertCanConnect(owner as BlePeripheralOwner, mac); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:getState', (event) => { + assertIpcSender(event, 'bleCoexistence:getState'); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:acquireScan', async (event, owner: unknown) => { + assertIpcSender(event, 'bleCoexistence:acquireScan'); + if (typeof owner !== 'string' || !BLE_SCAN_OWNERS.has(owner as BleScanOwner)) { + throw new Error('bleCoexistence:acquireScan: owner must be noble, reticulum, or webbt'); + } + await bleCoexistenceCoordinator.acquireScan(owner as BleScanOwner); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:releaseScan', (event, owner: unknown) => { + assertIpcSender(event, 'bleCoexistence:releaseScan'); + if (typeof owner !== 'string' || !BLE_SCAN_OWNERS.has(owner as BleScanOwner)) { + throw new Error('bleCoexistence:releaseScan: owner must be noble, reticulum, or webbt'); + } + bleCoexistenceCoordinator.releaseScan(owner as BleScanOwner); + return bleCoexistenceCoordinator.getState(); +}); +ipcMain.handle('bleCoexistence:pauseNobleScan', async (event) => { + assertIpcSender(event, 'bleCoexistence:pauseNobleScan'); + await bleCoexistenceCoordinator.pauseNobleScan(); + return bleCoexistenceCoordinator.getState(); +}); + ipcMain.handle('noble-ble-start-scan', async (_event, sessionId: unknown) => { if (sessionId !== 'meshtastic' && sessionId !== 'meshcore') { throw new Error('noble-ble-start-scan: sessionId must be meshtastic or meshcore'); @@ -3170,6 +3274,7 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet = new Set([ 'meshcoreLastSelfNodeId', 'storeForwardAutoFetchHistory', 'reduceMotion', + 'reticulumAutostart', /** Legacy blob; prefer meshtasticRemoteAdminKey: per-node keys. */ 'meshtasticRemoteAdminKeyByNode', ]); @@ -4173,6 +4278,64 @@ ipcMain.handle('chat:export', async (event, messages: unknown) => { } }); +ipcMain.handle('chat:saveReticulumAttachment', async (event, opts: unknown) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + if (!opts || typeof opts !== 'object') throw new Error('opts must be an object'); + const o = opts as Record; + const fileName = typeof o.fileName === 'string' ? path.basename(o.fileName) : 'attachment'; + const dataBase64 = typeof o.dataBase64 === 'string' ? o.dataBase64 : ''; + const promptSave = o.promptSave !== false; + if (!dataBase64 || dataBase64.length > 16 * 1024 * 1024) { + throw new Error('dataBase64 invalid or too large'); + } + try { + const buf = Buffer.from(dataBase64, 'base64'); + if (buf.length > 16 * 1024 * 1024) throw new Error('decoded attachment too large'); + let targetPath: string; + if (promptSave) { + if (!mainWindow) return { success: false }; + const ext = path.extname(fileName) || ''; + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Save attachment', + defaultPath: fileName, + filters: ext ? [{ name: ext.slice(1), extensions: [ext.slice(1)] }] : undefined, + }); + if (result.canceled || !result.filePath) return { success: false }; + targetPath = result.filePath; + } else { + const dir = path.join(app.getPath('userData'), 'reticulum', 'attachments'); + await fs.promises.mkdir(dir, { recursive: true }); + const safeName = fileName.replace(/[^\w.-]+/g, '_').slice(0, 120) || 'attachment'; + targetPath = path.join(dir, `${Date.now()}-${safeName}`); + } + await fs.promises.writeFile(targetPath, buf); + return { success: true, path: targetPath }; + } catch (err) { + console.error( + '[IPC] chat:saveReticulumAttachment failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); + +ipcMain.handle('chat:showItemInFolder', (event, filePath: unknown) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + if (typeof filePath !== 'string' || !filePath.trim()) { + throw new Error('filePath must be a non-empty string'); + } + try { + shell.showItemInFolder(assertReticulumAttachmentPathJailed(filePath)); + return { ok: true }; + } catch (err) { + console.error( + '[IPC] chat:showItemInFolder failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); + ipcMain.handle('meshtastic:xmodemPickUpload', async (event) => { if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); if (!mainWindow) return null; @@ -4285,21 +4448,41 @@ ipcMain.handle('chat:outbox:add', (_event, entry: unknown) => { ipcMain.handle( 'chat:outbox:updateStatus', - (_event, id: unknown, status: unknown, error?: unknown, nextRetryAt?: unknown) => { + ( + _event, + id: unknown, + status: unknown, + error?: unknown, + nextRetryAt?: unknown, + attemptCount?: unknown, + ) => { if (typeof id !== 'number' || !Number.isInteger(id)) throw new Error('chat:outbox:updateStatus: invalid id'); if (typeof status !== 'string' || !OUTBOX_VALID_STATUSES.has(status)) throw new Error('chat:outbox:updateStatus: invalid status'); const db = getDatabase(); - db.prepareOnce( - 'UPDATE chat_outbox SET status = ?, error = ?, next_retry_at = ?, updated_at = ? WHERE id = ?', - ).run( - status, - typeof error === 'string' ? error : null, - typeof nextRetryAt === 'number' ? nextRetryAt : null, - Date.now(), - id, - ); + if (typeof attemptCount === 'number' && Number.isInteger(attemptCount)) { + db.prepareOnce( + 'UPDATE chat_outbox SET status = ?, error = ?, next_retry_at = ?, attempt_count = ?, updated_at = ? WHERE id = ?', + ).run( + status, + typeof error === 'string' ? error : null, + typeof nextRetryAt === 'number' ? nextRetryAt : null, + attemptCount, + Date.now(), + id, + ); + } else { + db.prepareOnce( + 'UPDATE chat_outbox SET status = ?, error = ?, next_retry_at = ?, updated_at = ? WHERE id = ?', + ).run( + status, + typeof error === 'string' ? error : null, + typeof nextRetryAt === 'number' ? nextRetryAt : null, + Date.now(), + id, + ); + } }, ); @@ -5483,6 +5666,15 @@ registerTakIpcHandlers({ validateTakSettings, }); +registerReticulumIpcHandlers({ + idleStatus: IDLE_RETICULUM_STATUS, + ensureManager: ensureReticulumSidecarManager, + getManager: () => reticulumSidecarManager, + getMainWindow: () => mainWindow, +}); +registerReticulumDbIpcHandlers({ ipcMain }); +registerReticulumIdentityIpcHandlers({ ipcMain }); + // ─── App lifecycle ───────────────────────────────────────────────── // ─── Second-instance handler ──────────────────────────────────────── // Registered here (before whenReady) so it's ready before any second @@ -5655,6 +5847,14 @@ app.on('will-quit', () => { err instanceof Error ? err.message : err, ); // log-injection-ok internal cleanup } + try { + void reticulumSidecarManager?.stop(); + } catch (err) { + console.debug( + '[main] Reticulum sidecar stop during will-quit (ignored):', + err instanceof Error ? err.message : err, + ); // log-injection-ok internal cleanup + } try { mqttManager.disconnect(); meshcoreMqttAdapter.disconnect(); diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts new file mode 100644 index 000000000..4a8fce0aa --- /dev/null +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -0,0 +1,440 @@ +import type { IpcMain } from 'electron'; + +import { isMeshProtocol } from '../../shared/meshProtocol'; +import { finishDbIpcHandler, getDbForIpc } from '../db-ipc-lifecycle'; +import { buildFtsMatchQuery, isMessageFtsReady } from '../messageFts'; +import { sanitizeReticulumAttachmentPathForDb } from '../reticulum-attachment-path'; +import { assertIpcSender } from '../validate-ipc-sender'; + +const ALLOWED_DELIVERY_STATUS = new Set([ + 'sending', + 'pending', + 'delivered', + 'failed', + 'received', + 'queued', +]); + +export interface ReticulumDbIpcDeps { + ipcMain: IpcMain; +} + +export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): void { + ipcMain.handle('db:getReticulumMessages', (event, identityId: string, limit = 500) => { + try { + assertIpcSender(event, 'db:getReticulumMessages'); + if (typeof identityId !== 'string' || identityId.length > 128) return []; + const safeLimit = Math.min(Math.max(1, Number(limit) || 500), 10000); + const db = getDbForIpc('db:getReticulumMessages'); + if (!db) return []; + const rows = db + .prepareOnce( + 'SELECT * FROM reticulum_messages WHERE identity_id = ? ORDER BY timestamp DESC LIMIT ?', + ) + .all(identityId, safeLimit) as Record[]; + rows.reverse(); + return rows; + } catch (err) { + finishDbIpcHandler('db:getReticulumMessages', err); + } + }); + + ipcMain.handle('db:saveReticulumMessage', (event, message: unknown) => { + try { + assertIpcSender(event, 'db:saveReticulumMessage'); + if (!message || typeof message !== 'object') { + throw new Error('db:saveReticulumMessage: message must be an object'); + } + const m = message as Record; + const identityId = m.identity_id; + const senderId = m.sender_id; + const payload = m.payload; + if (typeof identityId !== 'string' || identityId.length > 128) { + throw new Error('db:saveReticulumMessage: identity_id invalid'); + } + if (typeof senderId !== 'string' || senderId.length > 128) { + throw new Error('db:saveReticulumMessage: sender_id invalid'); + } + if (typeof payload !== 'string' || payload.length > 65536) { + throw new Error('db:saveReticulumMessage: payload invalid'); + } + const timestamp = Number(m.timestamp); + if (!Number.isFinite(timestamp)) { + throw new Error('db:saveReticulumMessage: timestamp invalid'); + } + const receivedVia = + typeof m.received_via === 'string' && + ['rf', 'tcp', 'network', 'mqtt', 'both'].includes(m.received_via) + ? m.received_via + : null; + const db = getDbForIpc('db:saveReticulumMessage'); + if (!db) return { changes: 0 }; + const messageHash = typeof m.message_hash === 'string' ? m.message_hash.slice(0, 128) : null; + const deliveryStatus = + typeof m.delivery_status === 'string' && ALLOWED_DELIVERY_STATUS.has(m.delivery_status) + ? m.delivery_status.slice(0, 32) + : null; + const truncatedTimestamp = Math.trunc(timestamp); + const senderName = typeof m.sender_name === 'string' ? m.sender_name.slice(0, 128) : null; + const toHash = typeof m.to_hash === 'string' ? m.to_hash.slice(0, 128) : null; + const replyToHash = + typeof m.reply_to_hash === 'string' ? m.reply_to_hash.slice(0, 128) : null; + const attachmentPath = sanitizeReticulumAttachmentPathForDb( + typeof m.attachment_path === 'string' ? m.attachment_path : null, + ); + const deliveryAttempts = + m.delivery_attempts != null && Number.isFinite(Number(m.delivery_attempts)) + ? Math.trunc(Number(m.delivery_attempts)) + : 0; + const nextDeliveryAttemptAt = + m.next_delivery_attempt_at != null && Number.isFinite(Number(m.next_delivery_attempt_at)) + ? Math.trunc(Number(m.next_delivery_attempt_at)) + : null; + + if ( + messageHash && + !messageHash.startsWith('reticulum-pending-') && + deliveryStatus && + deliveryStatus !== 'sending' + ) { + db.prepareOnce( + `DELETE FROM reticulum_messages + WHERE identity_id = ? AND sender_id = ? AND payload = ? + AND message_hash LIKE 'reticulum-pending-%' + AND ABS(timestamp - ?) <= 60000`, + ).run(identityId, senderId, payload, truncatedTimestamp); + } + + if (messageHash) { + const existing = db + .prepareOnce( + 'SELECT id FROM reticulum_messages WHERE identity_id = ? AND message_hash = ? LIMIT 1', + ) + .get(identityId, messageHash) as { id?: number } | undefined; + if (existing?.id != null) { + db.prepareOnce( + `UPDATE reticulum_messages + SET delivery_status = COALESCE(?, delivery_status), + received_via = COALESCE(?, received_via), + sender_name = COALESCE(?, sender_name) + WHERE id = ?`, + ).run(deliveryStatus, receivedVia, senderName, existing.id); + return { changes: 1 }; + } + } + + db.prepareOnce( + `INSERT INTO reticulum_messages (identity_id, sender_id, sender_name, payload, timestamp, to_hash, reply_to_hash, message_hash, received_via, delivery_status, delivery_attempts, next_delivery_attempt_at, attachment_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + identityId, + senderId, + senderName, + payload, + truncatedTimestamp, + toHash, + replyToHash, + messageHash, + receivedVia, + deliveryStatus, + deliveryAttempts, + nextDeliveryAttemptAt, + attachmentPath, + ); + return { changes: 1 }; + } catch (err) { + finishDbIpcHandler('db:saveReticulumMessage', err); + } + }); + + ipcMain.handle('db:getReticulumDestinations', (event) => { + try { + assertIpcSender(event, 'db:getReticulumDestinations'); + const db = getDbForIpc('db:getReticulumDestinations'); + if (!db) return []; + return db + .prepareOnce('SELECT * FROM reticulum_destinations ORDER BY last_heard DESC') + .all() as Record[]; + } catch (err) { + finishDbIpcHandler('db:getReticulumDestinations', err); + } + }); + + ipcMain.handle('db:deleteReticulumDestination', (event, destinationHash: string) => { + try { + assertIpcSender(event, 'db:deleteReticulumDestination'); + if (typeof destinationHash !== 'string' || destinationHash.length > 128) { + return { changes: 0 }; + } + const db = getDbForIpc('db:deleteReticulumDestination'); + if (!db) return { changes: 0 }; + const result = db + .prepareOnce('DELETE FROM reticulum_destinations WHERE destination_hash = ?') + .run(destinationHash); + return { changes: result.changes ?? 0 }; + } catch (err) { + finishDbIpcHandler('db:deleteReticulumDestination', err); + } + }); + + ipcMain.handle( + 'db:searchReticulumMessages', + (event, identityId: string, query: string, limit = 200) => { + try { + assertIpcSender(event, 'db:searchReticulumMessages'); + if (typeof identityId !== 'string' || identityId.length > 128) return []; + if (typeof query !== 'string' || query.length > 256) return []; + const safeLimit = Math.min(Math.max(1, Number(limit) || 200), 5000); + const db = getDbForIpc('db:searchReticulumMessages'); + if (!db) return []; + const ftsQuery = buildFtsMatchQuery(query); + if (ftsQuery && isMessageFtsReady(db)) { + return db + .prepareOnce( + `SELECT r.* FROM reticulum_messages r + INNER JOIN reticulum_messages_fts ON reticulum_messages_fts.rowid = r.id + WHERE r.identity_id = ? AND reticulum_messages_fts MATCH ? + ORDER BY r.timestamp DESC LIMIT ?`, + ) + .all(identityId, ftsQuery, safeLimit) as Record[]; + } + const pattern = `%${query.replace(/[%_]/g, '')}%`; + return db + .prepareOnce( + `SELECT * FROM reticulum_messages + WHERE identity_id = ? AND payload LIKE ? COLLATE NOCASE + ORDER BY timestamp DESC LIMIT ?`, + ) + .all(identityId, pattern, safeLimit) as Record[]; + } catch (err) { + finishDbIpcHandler('db:searchReticulumMessages', err); + } + }, + ); + + ipcMain.handle('db:deleteReticulumMessage', (event, identityId: string, messageHash: string) => { + try { + assertIpcSender(event, 'db:deleteReticulumMessage'); + if (typeof identityId !== 'string' || identityId.length > 128) return { changes: 0 }; + if (typeof messageHash !== 'string' || messageHash.length > 128) return { changes: 0 }; + const db = getDbForIpc('db:deleteReticulumMessage'); + if (!db) return { changes: 0 }; + const result = db + .prepareOnce('DELETE FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?') + .run(identityId, messageHash); + return { changes: result.changes ?? 0 }; + } catch (err) { + finishDbIpcHandler('db:deleteReticulumMessage', err); + } + }); + + ipcMain.handle('db:upsertReticulumDestination', (event, row: unknown) => { + try { + assertIpcSender(event, 'db:upsertReticulumDestination'); + if (!row || typeof row !== 'object') { + throw new Error('db:upsertReticulumDestination: row must be an object'); + } + const r = row as Record; + const hash = r.destination_hash; + if (typeof hash !== 'string' || hash.length > 128) { + throw new Error('db:upsertReticulumDestination: destination_hash invalid'); + } + const db = getDbForIpc('db:upsertReticulumDestination'); + if (!db) return { changes: 0 }; + db.prepareOnce( + `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, favorited, icon_name, icon_color) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(destination_hash) DO UPDATE SET + display_name = COALESCE(excluded.display_name, reticulum_destinations.display_name), + last_heard = COALESCE(excluded.last_heard, reticulum_destinations.last_heard), + favorited = excluded.favorited, + icon_name = COALESCE(excluded.icon_name, reticulum_destinations.icon_name), + icon_color = COALESCE(excluded.icon_color, reticulum_destinations.icon_color)`, + ).run( + hash, + typeof r.display_name === 'string' ? r.display_name.slice(0, 128) : null, + r.last_heard != null && Number.isFinite(Number(r.last_heard)) + ? Math.trunc(Number(r.last_heard)) + : null, + r.favorited === true || r.favorited === 1 ? 1 : 0, + typeof r.icon_name === 'string' ? r.icon_name.slice(0, 64) : null, + typeof r.icon_color === 'string' ? r.icon_color.slice(0, 32) : null, + ); + return { changes: 1 }; + } catch (err) { + finishDbIpcHandler('db:upsertReticulumDestination', err); + } + }); + + ipcMain.handle( + 'db:markStaleReticulumOutbound', + (event, identityId: string, staleAfterMs: number) => { + try { + assertIpcSender(event, 'db:markStaleReticulumOutbound'); + if (typeof identityId !== 'string' || identityId.length > 128) return { changes: 0 }; + const rawStale = + typeof staleAfterMs === 'number' && Number.isFinite(staleAfterMs) + ? staleAfterMs + : 86_400_000; + const staleMs = Math.min(Math.max(60_000, rawStale), 30 * 86_400_000); + const cutoff = Date.now() - staleMs; + const db = getDbForIpc('db:markStaleReticulumOutbound'); + if (!db) return { changes: 0 }; + const result = db + .prepareOnce( + `UPDATE reticulum_messages + SET delivery_status = 'failed' + WHERE identity_id = ? + AND delivery_status IN ('sending', 'pending') + AND timestamp < ?`, + ) + .run(identityId, cutoff); + return { changes: result.changes ?? 0 }; + } catch (err) { + finishDbIpcHandler('db:markStaleReticulumOutbound', err); + } + }, + ); + + ipcMain.handle('db:clearReticulumMessages', (event, identityId: string) => { + try { + assertIpcSender(event, 'db:clearReticulumMessages'); + if (typeof identityId !== 'string' || identityId.length > 128) return { changes: 0 }; + const db = getDbForIpc('db:clearReticulumMessages'); + if (!db) return { changes: 0 }; + const result = db + .prepareOnce('DELETE FROM reticulum_messages WHERE identity_id = ?') + .run(identityId); + return { changes: result.changes ?? 0 }; + } catch (err) { + finishDbIpcHandler('db:clearReticulumMessages', err); + } + }); + + ipcMain.handle('db:vacuumReticulumTables', (event) => { + try { + assertIpcSender(event, 'db:vacuumReticulumTables'); + const db = getDbForIpc('db:vacuumReticulumTables'); + if (!db) return { ok: false }; + db.execScript('VACUUM'); + return { ok: true }; + } catch (err) { + finishDbIpcHandler('db:vacuumReticulumTables', err); + } + }); + + ipcMain.handle('db:getBlockedContacts', (event, protocol: string, identityId: string) => { + try { + assertIpcSender(event, 'db:getBlockedContacts'); + if (!isMeshProtocol(protocol)) return []; + if (typeof identityId !== 'string' || identityId.length > 128) return []; + const db = getDbForIpc('db:getBlockedContacts'); + if (!db) return []; + return db + .prepareOnce( + 'SELECT blocked_hash, created_at FROM blocked_contacts WHERE protocol = ? AND identity_id = ? ORDER BY created_at DESC', + ) + .all(protocol, identityId) as { blocked_hash: string; created_at: number }[]; + } catch (err) { + finishDbIpcHandler('db:getBlockedContacts', err); + } + }); + + ipcMain.handle( + 'db:blockContact', + (event, protocol: string, identityId: string, blockedHash: string) => { + try { + assertIpcSender(event, 'db:blockContact'); + if (!isMeshProtocol(protocol)) return { changes: 0 }; + if (typeof identityId !== 'string' || identityId.length > 128) return { changes: 0 }; + if (typeof blockedHash !== 'string' || blockedHash.length > 128) return { changes: 0 }; + const db = getDbForIpc('db:blockContact'); + if (!db) return { changes: 0 }; + db.prepareOnce( + `INSERT INTO blocked_contacts (protocol, identity_id, blocked_hash, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(protocol, identity_id, blocked_hash) DO NOTHING`, + ).run(protocol, identityId, blockedHash.toLowerCase(), Date.now()); + return { changes: 1 }; + } catch (err) { + finishDbIpcHandler('db:blockContact', err); + } + }, + ); + + ipcMain.handle( + 'db:unblockContact', + (event, protocol: string, identityId: string, blockedHash: string) => { + try { + assertIpcSender(event, 'db:unblockContact'); + if (!isMeshProtocol(protocol)) return { changes: 0 }; + if (typeof identityId !== 'string' || identityId.length > 128) return { changes: 0 }; + if (typeof blockedHash !== 'string' || blockedHash.length > 128) return { changes: 0 }; + const db = getDbForIpc('db:unblockContact'); + if (!db) return { changes: 0 }; + const result = db + .prepareOnce( + 'DELETE FROM blocked_contacts WHERE protocol = ? AND identity_id = ? AND blocked_hash = ?', + ) + .run(protocol, identityId, blockedHash.toLowerCase()); + return { changes: result.changes ?? 0 }; + } catch (err) { + finishDbIpcHandler('db:unblockContact', err); + } + }, + ); + + ipcMain.handle('db:getReticulumIdentityActivity', (event, destinationHash: string) => { + try { + assertIpcSender(event, 'db:getReticulumIdentityActivity'); + if (typeof destinationHash !== 'string' || destinationHash.length > 128) return []; + const db = getDbForIpc('db:getReticulumIdentityActivity'); + if (!db) return []; + return db + .prepareOnce( + 'SELECT * FROM reticulum_identity_activity WHERE destination_hash = ? ORDER BY last_seen DESC', + ) + .all(destinationHash.toLowerCase()) as Record[]; + } catch (err) { + finishDbIpcHandler('db:getReticulumIdentityActivity', err); + } + }); + + ipcMain.handle('db:upsertReticulumIdentityActivity', (event, row: unknown) => { + try { + assertIpcSender(event, 'db:upsertReticulumIdentityActivity'); + if (!row || typeof row !== 'object') return { changes: 0 }; + const r = row as Record; + const destinationHash = r.destination_hash; + const aspect = r.aspect; + if (typeof destinationHash !== 'string' || destinationHash.length > 128) + return { changes: 0 }; + if (typeof aspect !== 'string' || aspect.length > 128) return { changes: 0 }; + const lastSeen = Number(r.last_seen); + if (!Number.isFinite(lastSeen)) return { changes: 0 }; + const identityHash = + typeof r.identity_hash === 'string' ? r.identity_hash.slice(0, 128) : null; + const hops = + r.hops != null && Number.isFinite(Number(r.hops)) ? Math.trunc(Number(r.hops)) : null; + const db = getDbForIpc('db:upsertReticulumIdentityActivity'); + if (!db) return { changes: 0 }; + db.prepareOnce( + `INSERT INTO reticulum_identity_activity (destination_hash, aspect, identity_hash, last_seen, hops) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(destination_hash, aspect) DO UPDATE SET + identity_hash = COALESCE(excluded.identity_hash, reticulum_identity_activity.identity_hash), + last_seen = excluded.last_seen, + hops = COALESCE(excluded.hops, reticulum_identity_activity.hops)`, + ).run( + destinationHash.toLowerCase(), + aspect.slice(0, 128), + identityHash, + Math.trunc(lastSeen), + hops, + ); + return { changes: 1 }; + } catch (err) { + finishDbIpcHandler('db:upsertReticulumIdentityActivity', err); + } + }); +} diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts new file mode 100644 index 000000000..61387c5ff --- /dev/null +++ b/src/main/ipc/reticulum-handlers.ts @@ -0,0 +1,148 @@ +import type { BrowserWindow } from 'electron'; +import { ipcMain } from 'electron'; + +import type { ReticulumSidecarStatus } from '../../shared/reticulum-types'; +import { sanitizeLogMessage } from '../log-service'; +import { + readFirstExistingConfig, + showReticulumConfigImportDialog, +} from '../reticulum-config-paths'; +import type { ReticulumSidecarManager } from '../reticulum-sidecar-manager'; +import { assertIpcSender } from '../validate-ipc-sender'; + +export interface ReticulumIpcDeps { + idleStatus: ReticulumSidecarStatus; + ensureManager: () => ReticulumSidecarManager; + getManager: () => ReticulumSidecarManager | null; + getMainWindow: () => BrowserWindow | null; +} + +function isExpectedReticulumProxyError(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('not running') || + message.includes('404') || + lower.includes('fetch failed') || + lower.includes('aborted') || + lower.includes('timeout') + ); +} + +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; + const pathSuffix = apiPath ? ` path=${apiPath}` : ''; + log(`[ReticulumIPC] ${method} failed${pathSuffix}:`, sanitizeLogMessage(message)); +} + +function assertProxyApiPath(apiPath: unknown): string { + if (typeof apiPath !== 'string') { + throw new Error('Reticulum proxy path must be a string'); + } + return apiPath; +} + +/** Register Reticulum sidecar IPC handlers (`reticulum:*`). */ +export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { + const { idleStatus, ensureManager, getManager } = deps; + + ipcMain.handle('reticulum:start', async (event, opts) => { + assertIpcSender(event, 'reticulum:start'); + try { + console.debug('[ReticulumIPC] start'); + const m = ensureManager(); + return await m.start(opts ?? {}); + } catch (err) { + console.error( + '[ReticulumIPC] start failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } + }); + + ipcMain.handle('reticulum:stop', async (event) => { + assertIpcSender(event, 'reticulum:stop'); + console.debug('[ReticulumIPC] stop'); + await getManager()?.stop(); + }); + + ipcMain.handle('reticulum:getStatus', (event) => { + assertIpcSender(event, 'reticulum:getStatus'); + return getManager()?.getStatus() ?? idleStatus; + }); + + ipcMain.handle('reticulum:proxyGet', async (event, apiPath: unknown) => { + assertIpcSender(event, 'reticulum:proxyGet'); + const pathArg = assertProxyApiPath(apiPath); + try { + const m = ensureManager(); + return await m.proxyGet(pathArg); + } catch (err) { + logReticulumProxyFailure('proxyGet', err, pathArg); + throw err; + } + }); + + ipcMain.handle('reticulum:proxyPost', async (event, apiPath: unknown, body: unknown) => { + assertIpcSender(event, 'reticulum:proxyPost'); + const pathArg = assertProxyApiPath(apiPath); + try { + const m = ensureManager(); + return await m.proxyPost(pathArg, body); + } catch (err) { + logReticulumProxyFailure('proxyPost', err, pathArg); + throw err; + } + }); + + ipcMain.handle('reticulum:proxyPut', async (event, apiPath: unknown, body: unknown) => { + assertIpcSender(event, 'reticulum:proxyPut'); + const pathArg = assertProxyApiPath(apiPath); + try { + const m = ensureManager(); + return await m.proxyPut(pathArg, body); + } catch (err) { + logReticulumProxyFailure('proxyPut', err, pathArg); + throw err; + } + }); + + ipcMain.handle('reticulum:proxyDelete', async (event, apiPath: unknown) => { + assertIpcSender(event, 'reticulum:proxyDelete'); + const pathArg = assertProxyApiPath(apiPath); + try { + const m = ensureManager(); + return await m.proxyDelete(pathArg); + } catch (err) { + logReticulumProxyFailure('proxyDelete', err, pathArg); + throw err; + } + }); + + ipcMain.handle('reticulum:readDefaultConfigFile', (event) => { + assertIpcSender(event, 'reticulum:readDefaultConfigFile'); + return readFirstExistingConfig(); + }); + + ipcMain.handle('reticulum:showConfigImportDialog', async (event) => { + assertIpcSender(event, 'reticulum:showConfigImportDialog'); + return showReticulumConfigImportDialog(); + }); +} + +export function wireReticulumSidecarBridge( + manager: ReticulumSidecarManager, + getMainWindow: () => BrowserWindow | null, +): void { + manager.on('event', (evt) => { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + win.webContents.send('reticulum:event', evt); + }); + manager.on('status', (status) => { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + win.webContents.send('reticulum:status', status); + }); +} diff --git a/src/main/ipc/reticulum-identity-handlers.ts b/src/main/ipc/reticulum-identity-handlers.ts new file mode 100644 index 000000000..d4bc48b16 --- /dev/null +++ b/src/main/ipc/reticulum-identity-handlers.ts @@ -0,0 +1,89 @@ +import type { IpcMain } from 'electron'; + +import { + getIdentityVaultStatus, + lockIdentityVault, + setIdentityVaultPasscode, + unlockIdentityVault, +} from '../identityVault'; +import { sanitizeLogMessage } from '../log-service'; +import { assertIpcSender } from '../validate-ipc-sender'; + +export interface ReticulumIdentityIpcDeps { + ipcMain: IpcMain; +} + +const MAX_VAULT_SECRET_BYTES = 512 * 1024; + +function validateVaultPasscodeInput(passcode: unknown): string | null { + if (typeof passcode !== 'string') return 'passcode must be a string'; + if (passcode.length < 8 || passcode.length > 256) return 'passcode length out of range'; + return null; +} + +function validateVaultSecretInput(secret: unknown): string | null { + if (typeof secret !== 'string') return 'secret must be a string'; + if (Buffer.byteLength(secret, 'utf8') > MAX_VAULT_SECRET_BYTES) return 'secret too large'; + return null; +} + +/** Register Reticulum identity vault IPC handlers (`vault:*`). */ +export function registerReticulumIdentityIpcHandlers({ ipcMain }: ReticulumIdentityIpcDeps): void { + ipcMain.handle('vault:setPasscode', async (event, passcode: unknown, secret: unknown) => { + assertIpcSender(event, 'vault:setPasscode'); + const passcodeError = validateVaultPasscodeInput(passcode); + if (passcodeError) return { ok: false, error: passcodeError }; + const secretError = validateVaultSecretInput(secret); + if (secretError) return { ok: false, error: secretError }; + try { + return await setIdentityVaultPasscode(passcode as string, secret as string); + } catch (err) { + console.warn( + '[vault:setPasscode] failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + return { ok: false, error: 'set passcode failed' }; + } + }); + + ipcMain.handle('vault:unlock', async (event, passcode: unknown) => { + assertIpcSender(event, 'vault:unlock'); + const passcodeError = validateVaultPasscodeInput(passcode); + if (passcodeError) return { ok: false, error: passcodeError }; + try { + return await unlockIdentityVault(passcode as string); + } catch (err) { + console.warn( + '[vault:unlock] failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + return { ok: false, error: 'unlock failed' }; + } + }); + + ipcMain.handle('vault:lock', (event) => { + assertIpcSender(event, 'vault:lock'); + try { + return lockIdentityVault(); + } catch (err) { + console.warn( + '[vault:lock] failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + return { ok: false, error: 'lock failed' }; + } + }); + + ipcMain.handle('vault:status', (event) => { + assertIpcSender(event, 'vault:status'); + try { + return getIdentityVaultStatus(); + } catch (err) { + console.warn( + '[vault:status] failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + return { configured: false, unlocked: false }; + } + }); +} diff --git a/src/main/meshcore-mqtt-adapter.ts b/src/main/meshcore-mqtt-adapter.ts index 2a1fc668e..876b6e7f9 100644 --- a/src/main/meshcore-mqtt-adapter.ts +++ b/src/main/meshcore-mqtt-adapter.ts @@ -12,6 +12,7 @@ import { } from '../shared/meshtasticMqttReconnect'; import { computeMqttReconnectDelayMs } from '../shared/mqttReconnectSchedule'; import { sanitizeLogMessage } from './log-service'; +import { forceEndMqttClient } from './mqtt-client-teardown'; export type { MeshcoreMqttChatEnvelopeV1 } from '../shared/meshcoreMqttEnvelope'; @@ -218,16 +219,9 @@ export class MeshcoreMqttAdapter extends EventEmitter { } this.retryCount = 0; if (this.client) { - try { - this.client.removeAllListeners(); - this.client.end(true); - } catch (e) { - console.warn( - '[MeshCore MQTT] disconnect', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } + const stale = this.client; this.client = null; + forceEndMqttClient(stale); } this.lastSettings = null; this.setStatus('disconnected'); @@ -245,12 +239,7 @@ export class MeshcoreMqttAdapter extends EventEmitter { // cannot tear down the new client 30s later (Bug 3 fix). this.clearConnectTimers(); if (this.client) { - try { - this.client.removeAllListeners(); - this.client.end(true); - } catch { - // catch-no-log-ok forced end before reconnect - } + forceEndMqttClient(this.client); this.client = null; } const isV1Username = /^v1_[0-9A-Fa-f]{64}$/i.test(settings.username ?? ''); @@ -322,6 +311,18 @@ export class MeshcoreMqttAdapter extends EventEmitter { this.setStatus('connecting'); this.connectAbortByWatchdog = false; this.client = mqtt.connect(connectOpts); + this.client.on('error', (err) => { + this.clearConnectTimers(); + console.error( + '[MeshCore MQTT] client error', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + this.emit('error', err instanceof Error ? err.message : String(err)); + // Unblock the UI immediately — 'close' may arrive many seconds later. + if (this.status === 'connecting') { + this.setStatus('disconnected'); + } + }); this.connectAckTimer = setTimeout(() => { this.connectAckTimer = null; if (this.status !== 'connecting' || !this.client) return; @@ -329,13 +330,9 @@ export class MeshcoreMqttAdapter extends EventEmitter { const msg = `MeshCore MQTT: timed out before MQTT session (no CONNACK within ${MESHCORE_MQTT_CONNECT_ACK_MS / 1000}s). Check host, port, WebSocket path /mqtt, TLS, and network (firewall, VPN, DNS).`; console.error('[MeshCore MQTT]', sanitizeLogMessage(msg)); this.emit('error', msg); - try { - this.client.removeAllListeners(); - this.client.end(true); - } catch { - // catch-no-log-ok forced end during stuck connect - } + const stale = this.client; this.client = null; + forceEndMqttClient(stale); this.setStatus('disconnected'); }, MESHCORE_MQTT_CONNECT_ACK_MS); this.client.on('connect', () => { @@ -437,18 +434,6 @@ export class MeshcoreMqttAdapter extends EventEmitter { } this.emit('chatMessage', { topic, ...env }); }); - this.client.on('error', (err) => { - this.clearConnectTimers(); - console.error( - '[MeshCore MQTT] client error', - sanitizeLogMessage(err instanceof Error ? err.message : String(err)), - ); - this.emit('error', err instanceof Error ? err.message : String(err)); - // Unblock the UI immediately — 'close' may arrive many seconds later. - if (this.status === 'connecting') { - this.setStatus('disconnected'); - } - }); this.client.on('close', () => { this.clearWssPing(); this.clearConnectionWatchdog(); @@ -646,8 +631,7 @@ export class MeshcoreMqttAdapter extends EventEmitter { } this.pendingReconnect = false; if (this.client) { - this.client.removeAllListeners(); - this.client.end(true); + forceEndMqttClient(this.client); this.client = null; } if (this.status === 'error') { diff --git a/src/main/messageFts.test.ts b/src/main/messageFts.test.ts new file mode 100644 index 000000000..a4f71d004 --- /dev/null +++ b/src/main/messageFts.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { buildFtsMatchQuery } from './messageFts'; + +describe('buildFtsMatchQuery', () => { + it('returns null for empty input', () => { + expect(buildFtsMatchQuery('')).toBeNull(); + expect(buildFtsMatchQuery(' ')).toBeNull(); + }); + + it('builds prefix match tokens', () => { + expect(buildFtsMatchQuery('hello world')).toBe('"hello"* "world"*'); + }); + + it('strips fts special chars from tokens', () => { + expect(buildFtsMatchQuery('foo*bar')).toBe('"foobar"*'); + }); +}); diff --git a/src/main/messageFts.ts b/src/main/messageFts.ts new file mode 100644 index 000000000..d8cd939cd --- /dev/null +++ b/src/main/messageFts.ts @@ -0,0 +1,89 @@ +import type { NodeSqliteDB } from './db-compat'; + +const FTS_TABLES = [ + { + fts: 'messages_fts', + source: 'messages', + rowid: 'id', + column: 'payload', + triggers: ['messages_fts_ai', 'messages_fts_ad', 'messages_fts_au'], + }, + { + fts: 'meshcore_messages_fts', + source: 'meshcore_messages', + rowid: 'id', + column: 'payload', + triggers: ['meshcore_messages_fts_ai', 'meshcore_messages_fts_ad', 'meshcore_messages_fts_au'], + }, + { + fts: 'reticulum_messages_fts', + source: 'reticulum_messages', + rowid: 'id', + column: 'payload', + triggers: [ + 'reticulum_messages_fts_ai', + 'reticulum_messages_fts_ad', + 'reticulum_messages_fts_au', + ], + }, +] as const; + +function ftsTableExists(db: NodeSqliteDB, name: string): boolean { + const row = db + .prepareOnce(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) + .get(name) as { 1: number } | undefined; + return row != null; +} + +/** Build an FTS5 MATCH query from user input (prefix match per token). */ +export function buildFtsMatchQuery(raw: string): string | null { + const tokens = raw + .trim() + .split(/\s+/) + .map((t) => t.replace(/["*]/g, '').trim()) + .filter((t) => t.length > 0); + if (tokens.length === 0) return null; + return tokens.map((t) => `"${t.replace(/"/g, '""')}"*`).join(' '); +} + +function createFtsForTable( + db: NodeSqliteDB, + fts: string, + source: string, + rowid: string, + column: string, + triggers: readonly [string, string, string], +): void { + if (ftsTableExists(db, fts)) return; + + db.execScript( + `CREATE VIRTUAL TABLE ${fts} USING fts5(${column}, content='${source}', content_rowid='${rowid}')`, + ); + + const [ai, ad, au] = triggers; + db.execScript(` + CREATE TRIGGER ${ai} AFTER INSERT ON ${source} BEGIN + INSERT INTO ${fts}(rowid, ${column}) VALUES (new.${rowid}, new.${column}); + END; + CREATE TRIGGER ${ad} AFTER DELETE ON ${source} BEGIN + INSERT INTO ${fts}(${fts}, rowid, ${column}) VALUES('delete', old.${rowid}, old.${column}); + END; + CREATE TRIGGER ${au} AFTER UPDATE ON ${source} BEGIN + INSERT INTO ${fts}(${fts}, rowid, ${column}) VALUES('delete', old.${rowid}, old.${column}); + INSERT INTO ${fts}(rowid, ${column}) VALUES (new.${rowid}, new.${column}); + END; + `); + + db.execScript(`INSERT INTO ${fts}(rowid, ${column}) SELECT ${rowid}, ${column} FROM ${source}`); +} + +/** Idempotent FTS5 virtual tables + sync triggers for message search. */ +export function ensureMessageFtsTables(db: NodeSqliteDB): void { + for (const spec of FTS_TABLES) { + createFtsForTable(db, spec.fts, spec.source, spec.rowid, spec.column, spec.triggers); + } +} + +export function isMessageFtsReady(db: NodeSqliteDB): boolean { + return ftsTableExists(db, 'messages_fts'); +} diff --git a/src/main/mqtt-client-teardown.test.ts b/src/main/mqtt-client-teardown.test.ts new file mode 100644 index 000000000..94b7b7960 --- /dev/null +++ b/src/main/mqtt-client-teardown.test.ts @@ -0,0 +1,25 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; + +import { attachMqttClientErrorSink, forceEndMqttClient } from './mqtt-client-teardown'; + +describe('mqtt-client-teardown', () => { + it('attachMqttClientErrorSink absorbs late error events', () => { + const client = new EventEmitter() as EventEmitter & { end: ReturnType }; + client.end = vi.fn(); + attachMqttClientErrorSink(client as never); + expect(() => { + client.emit('error', new Error('connack timeout')); + }).not.toThrow(); + }); + + it('forceEndMqttClient ends client and absorbs post-end errors', () => { + const client = new EventEmitter() as EventEmitter & { end: ReturnType }; + client.end = vi.fn(); + forceEndMqttClient(client as never); + expect(client.end).toHaveBeenCalledWith(true); + expect(() => { + client.emit('error', new Error('connack timeout')); + }).not.toThrow(); + }); +}); diff --git a/src/main/mqtt-client-teardown.ts b/src/main/mqtt-client-teardown.ts new file mode 100644 index 000000000..fc2059cd0 --- /dev/null +++ b/src/main/mqtt-client-teardown.ts @@ -0,0 +1,19 @@ +import type { MqttClient } from 'mqtt'; + +/** + * mqtt.js may emit `error` (e.g. connack timeout) after `end()` if internal timers + * were not cleared yet. With no listener that becomes an uncaught exception. + */ +export function attachMqttClientErrorSink(client: MqttClient): void { + client.on('error', () => {}); +} + +/** Force-disconnect without racing mqtt.js internal connack/keepalive timers. */ +export function forceEndMqttClient(client: MqttClient): void { + attachMqttClientErrorSink(client); + try { + client.end(true); + } catch { + // catch-no-log-ok forced end during stuck connect/teardown + } +} diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index fee356901..776847bad 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -30,6 +30,7 @@ import { } from '../shared/nodeNameUtils'; import { MESHTASTIC_TAPBACK_DATA_EMOJI_FLAG } from '../shared/reactionEmoji'; import { sanitizeLogMessage } from './log-service'; +import { forceEndMqttClient } from './mqtt-client-teardown'; const { ServiceEnvelopeSchema } = MqttProto; const { @@ -438,12 +439,7 @@ export class MQTTManager extends EventEmitter { this.clearConnectAckTimer(); this.setStatus('connecting'); if (this.client) { - try { - this.client.removeAllListeners(); - this.client.end(true); - } catch { - // catch-no-log-ok tear down stale client before new connect - } + forceEndMqttClient(this.client); this.client = null; } const clientId = @@ -512,6 +508,33 @@ export class MQTTManager extends EventEmitter { this.client = mqtt.connect(connectOpts); } + this.client.on('error', (err: Error & { code?: string | number }) => { + // Transient network errors will trigger 'close' → our backoff handler; don't + // flip status to "error" for them — that would hide the "connecting" state. + const isTransient = isTransientNetworkError(err); + if (err.message === 'connack timeout') { + this.preferFastMqttReconnect = true; + } + if (isTransient) { + const isMsgTransient = + err.message === 'Keepalive timeout' || err.message === 'connack timeout'; + if (isMsgTransient) { + console.warn( + '[Meshtastic MQTT] Connection timeout (will reconnect):', + sanitizeLogMessage(err.message), + ); + } else { + console.warn( + '[Meshtastic MQTT] Network error (will reconnect):', + sanitizeLogMessage(err.message), + ); // log-filter-ok Meshtastic MQTT logs → App log panel + } + } else { + console.error('[Meshtastic MQTT] Fatal connection error:', sanitizeLogMessage(err.message)); // log-filter-ok Meshtastic MQTT logs → App log panel + this.setError(err.message); + } + }); + this.connectAckTimer = setTimeout(() => { this.connectAckTimer = null; if (this.status !== 'connecting' || !this.client) return; @@ -519,11 +542,8 @@ export class MQTTManager extends EventEmitter { console.error('[Meshtastic MQTT]', sanitizeLogMessage(msg)); // log-filter-ok Meshtastic MQTT logs → App log panel this.emit('error', msg); this.preferFastMqttReconnect = true; - try { - // Do not removeAllListeners — `close` must run so reconnect backoff still schedules. - this.client.end(true); - } catch { - // catch-no-log-ok forced end during stuck connect + if (this.client) { + forceEndMqttClient(this.client); } }, MESHTASTIC_MQTT_CONNECT_ACK_MS); @@ -585,33 +605,6 @@ export class MQTTManager extends EventEmitter { this.onMessage(topic, payload, packet); }); - this.client.on('error', (err: Error & { code?: string | number }) => { - // Transient network errors will trigger 'close' → our backoff handler; don't - // flip status to "error" for them — that would hide the "connecting" state. - const isTransient = isTransientNetworkError(err); - if (err.message === 'connack timeout') { - this.preferFastMqttReconnect = true; - } - if (isTransient) { - const isMsgTransient = - err.message === 'Keepalive timeout' || err.message === 'connack timeout'; - if (isMsgTransient) { - console.warn( - '[Meshtastic MQTT] Connection timeout (will reconnect):', - sanitizeLogMessage(err.message), - ); - } else { - console.warn( - '[Meshtastic MQTT] Network error (will reconnect):', - sanitizeLogMessage(err.message), - ); // log-filter-ok Meshtastic MQTT logs → App log panel - } - } else { - console.error('[Meshtastic MQTT] Fatal connection error:', sanitizeLogMessage(err.message)); // log-filter-ok Meshtastic MQTT logs → App log panel - this.setError(err.message); - } - }); - this.client.on('close', () => { this.clearConnectAckTimer(); this.clearWssPing(); @@ -1082,8 +1075,7 @@ export class MQTTManager extends EventEmitter { this.currentSettings = null; this.retryCount = 0; if (this.client) { - this.client.removeAllListeners(); - this.client.end(true); + forceEndMqttClient(this.client); this.client = null; } this.setStatus('disconnected'); @@ -1114,8 +1106,7 @@ export class MQTTManager extends EventEmitter { this.status = 'disconnected'; } if (this.client) { - this.client.removeAllListeners(); - this.client.end(true); + forceEndMqttClient(this.client); this.client = null; } if (this.status === 'connected' || this.status === 'connecting') { diff --git a/src/main/noble-ble-manager.ts b/src/main/noble-ble-manager.ts index 410ab03c2..74da79244 100644 --- a/src/main/noble-ble-manager.ts +++ b/src/main/noble-ble-manager.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'events'; import { attMtuOrDefault, maxWriteRequestPayloadBytes } from '../shared/bleAttWriteLimit'; import { withTimeout } from '../shared/withTimeout'; +import { bleCoexistenceCoordinator, type BlePeripheralOwner } from './ble-coexistence-coordinator'; import { logDeviceConnection, sanitizeLogMessage } from './log-service'; // Only load noble on Mac/Windows — Linux uses Web Bluetooth in renderer instead @@ -145,6 +146,8 @@ interface NobleBleSession { fromRadioUsedReadPumpFallback: boolean; /** Linux MeshCore early-read polling attempt count before first payload. */ meshcoreLinuxEarlyReadPollAttempts: number; + /** MAC registered with BleCoexistenceCoordinator while connected. */ + registeredMac: string | null; /** * MeshCore only: set after link-up while GATT discovery/subscribe is still running. * A second `connect(samePeripheral)` awaits this instead of calling `disconnect()` first, @@ -263,6 +266,7 @@ export class NobleBleManager extends EventEmitter { connectStartedAtMs: null, fromRadioUsedReadPumpFallback: false, meshcoreLinuxEarlyReadPollAttempts: 0, + registeredMac: null, meshcoreGattInflight: null, writeQueue: Promise.resolve(), attMtuSanitized: attMtuOrDefault(null), @@ -322,6 +326,7 @@ export class NobleBleManager extends EventEmitter { session.connectStartedAtMs = null; session.fromRadioUsedReadPumpFallback = false; session.meshcoreLinuxEarlyReadPollAttempts = 0; + session.registeredMac = null; session.writeQueue = Promise.resolve(); session.attMtuSanitized = attMtuOrDefault(null); session.attMtuSuspiciousLogged = false; @@ -535,6 +540,7 @@ export class NobleBleManager extends EventEmitter { } async startScanning(sessionId: NobleSessionId): Promise { + await bleCoexistenceCoordinator.acquireScan('noble'); // Clear known peripherals so every device is re-emitted as discovered on each new scan. // Without this, devices found in a previous scan are never re-emitted (isNew = false), // so the picker stays empty on second and subsequent scan attempts. @@ -569,6 +575,7 @@ export class NobleBleManager extends EventEmitter { this.scanRequesters.delete(sessionId); if (this.scanRequesters.size === 0) { await this.doStopScanning(); + bleCoexistenceCoordinator.releaseScan('noble'); } else { // Other sessions still want to scan; restart with updated filter. // e.g. meshcore stopped → switch from open scan back to meshtastic-only filter. @@ -578,8 +585,24 @@ export class NobleBleManager extends EventEmitter { /** Stop all scanning immediately — used for app quit and force-quit IPC. */ async stopAllScanning(): Promise { + if (this.sessions.size === 0) return; this.scanRequesters.clear(); await this.doStopScanning(); + bleCoexistenceCoordinator.releaseScan('noble'); + } + + /** Pause Noble scan for an external stack (Reticulum btleplug) without dropping GATT links. */ + async pauseScanningForExternalScan(): Promise { + if (this.scanningActive) { + await this.doStopScanning(); + } + } + + /** Resume Noble scan after external scan if mesh sessions still request discovery. */ + async resumeScanningAfterExternalScan(): Promise { + if (this.scanRequesters.size > 0 && this.adapterReady && !this.scanningActive) { + await this.doStartScanning(); + } } /** @@ -837,6 +860,8 @@ export class NobleBleManager extends EventEmitter { const session = this.getSession(sessionId); let peripheral: any = null; let connected = false; + const peripheralOwner: BlePeripheralOwner = + sessionId === 'meshcore' ? 'noble:meshcore' : 'noble:meshtastic'; console.debug( `[BLE:${sessionId}] connect start — peripheralId=${peripheralId} adapterReady=${this.adapterReady} scanRequesters=[${[...this.scanRequesters].join(',')}]`, ); @@ -910,6 +935,10 @@ export class NobleBleManager extends EventEmitter { console.debug( `[BLE:${sessionId}] peripheral info — address=${peripheral.address ?? 'unknown'} addressType=${peripheral.addressType ?? 'unknown'} rssi=${peripheral.rssi ?? 'unknown'} state=${peripheral.state} platform=${process.platform}`, ); + bleCoexistenceCoordinator.assertCanConnect( + peripheralOwner, + String(peripheral.address ?? peripheralId), + ); session.connectStartedAtMs = Date.now(); session.firstPacketLogged = false; session.fromRadioUsedReadPumpFallback = false; @@ -1251,6 +1280,9 @@ export class NobleBleManager extends EventEmitter { logDeviceConnection( `transport=ble stack=${sessionId} peripheralId=${sanitizeLogMessage(peripheralId)} mac=${sanitizeLogMessage(String(peripheral.address ?? 'unknown'))}`, ); + const registeredMac = String(peripheral.address ?? peripheralId); + bleCoexistenceCoordinator.register(registeredMac, peripheralOwner); + session.registeredMac = registeredMac; this.emit('connected', { sessionId }); } catch (err) { console.warn(`[BLE:${sessionId}] connect failed:`, err instanceof Error ? err.message : err); // log-injection-ok noble internal error @@ -1368,6 +1400,11 @@ export class NobleBleManager extends EventEmitter { const onFromNumData = session.fromNumDataHandler; if (!peripheral && !session.toRadioChar && !fromRadio && !fromNum) return; + const peripheralOwner: BlePeripheralOwner = + sessionId === 'meshcore' ? 'noble:meshcore' : 'noble:meshtastic'; + if (session.registeredMac) { + bleCoexistenceCoordinator.unregister(session.registeredMac, peripheralOwner); + } this.clearSessionState(session); try { @@ -1413,6 +1450,10 @@ export class NobleBleManager extends EventEmitter { } } + async disconnectAllSessions(): Promise { + await this.disconnectAll(); + } + async disconnectAll(): Promise { // On Linux, Noble is not initialized (Web Bluetooth is used in renderer instead) if (this.sessions.size === 0) { diff --git a/src/main/reticulum-attachment-path.test.ts b/src/main/reticulum-attachment-path.test.ts new file mode 100644 index 000000000..2d74315fc --- /dev/null +++ b/src/main/reticulum-attachment-path.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + assertReticulumAttachmentPathJailed, + getReticulumAttachmentsDir, + isReticulumAttachmentPathJailed, + sanitizeReticulumAttachmentPathForDb, +} from './reticulum-attachment-path'; + +vi.mock('electron', () => ({ + app: { + getPath: () => path.join('/tmp', 'mesh-client-test-userdata'), + }, +})); + +describe('reticulum-attachment-path', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('jails paths under userData/reticulum/attachments', () => { + const dir = getReticulumAttachmentsDir(); + const inside = path.join(dir, 'file.bin'); + expect(isReticulumAttachmentPathJailed(inside)).toBe(true); + expect(assertReticulumAttachmentPathJailed(inside)).toBe(path.resolve(inside)); + }); + + it('rejects paths outside the attachments directory', () => { + expect(isReticulumAttachmentPathJailed('/etc/passwd')).toBe(false); + expect(() => assertReticulumAttachmentPathJailed('/etc/passwd')).toThrow(/outside/); + }); + + it('sanitizeReticulumAttachmentPathForDb returns null for traversal paths', () => { + expect(sanitizeReticulumAttachmentPathForDb('/etc/passwd')).toBeNull(); + const dir = getReticulumAttachmentsDir(); + expect(sanitizeReticulumAttachmentPathForDb(path.join(dir, 'ok.bin'))).toBe( + path.resolve(path.join(dir, 'ok.bin')), + ); + }); +}); diff --git a/src/main/reticulum-attachment-path.ts b/src/main/reticulum-attachment-path.ts new file mode 100644 index 000000000..f1e3e8798 --- /dev/null +++ b/src/main/reticulum-attachment-path.ts @@ -0,0 +1,30 @@ +import { app } from 'electron'; +import path from 'path'; + +/** Canonical inbound attachment directory under Electron userData. */ +export function getReticulumAttachmentsDir(): string { + return path.join(app.getPath('userData'), 'reticulum', 'attachments'); +} + +export function isReticulumAttachmentPathJailed(filePath: string): boolean { + const resolved = path.resolve(filePath); + const dir = path.resolve(getReticulumAttachmentsDir()); + return resolved === dir || resolved.startsWith(`${dir}${path.sep}`); +} + +export function assertReticulumAttachmentPathJailed(filePath: string): string { + const resolved = path.resolve(filePath); + if (!isReticulumAttachmentPathJailed(resolved)) { + throw new Error('attachment path outside reticulum attachments directory'); + } + return resolved; +} + +export function sanitizeReticulumAttachmentPathForDb( + attachmentPath: string | null | undefined, +): string | null { + if (typeof attachmentPath !== 'string' || !attachmentPath.trim()) return null; + const trimmed = attachmentPath.trim().slice(0, 512); + if (!isReticulumAttachmentPathJailed(trimmed)) return null; + return path.resolve(trimmed); +} diff --git a/src/main/reticulum-config-paths.test.ts b/src/main/reticulum-config-paths.test.ts new file mode 100644 index 000000000..e0beb6b04 --- /dev/null +++ b/src/main/reticulum-config-paths.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultReticulumConfigPaths } from './reticulum-config-paths'; + +describe('defaultReticulumConfigPaths', () => { + it('returns platform-specific default config paths', () => { + const paths = defaultReticulumConfigPaths(); + expect(paths.length).toBeGreaterThan(0); + if (process.platform === 'win32') { + expect(paths.some((p) => p.includes('Reticulum'))).toBe(true); + } else { + expect(paths.some((p) => p.includes('.reticulum'))).toBe(true); + } + }); +}); diff --git a/src/main/reticulum-config-paths.ts b/src/main/reticulum-config-paths.ts new file mode 100644 index 000000000..59c655dfe --- /dev/null +++ b/src/main/reticulum-config-paths.ts @@ -0,0 +1,53 @@ +import { dialog } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { readUtf8FileBounded } from './reticulum-config-read'; + +/** Platform-default rnsd config file paths (first existing wins). */ +export function defaultReticulumConfigPaths(): string[] { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'); + return [path.join(appData, 'Reticulum', 'config'), path.join(appData, 'rsReticulum', 'config')]; + } + return [ + path.join(home, '.reticulum', 'config'), + path.join(home, '.config', 'rsReticulum', 'config'), + path.join(home, '.rsReticulum', 'config'), + ]; +} + +export function readFirstExistingConfig(): { path: string | null; content: string | null } { + for (const candidate of defaultReticulumConfigPaths()) { + try { + if (fs.existsSync(candidate)) { + return { path: candidate, content: readUtf8FileBounded(candidate) }; + } + } catch { + // catch-no-log-ok: try next default path + } + } + return { path: null, content: null }; +} + +export async function showReticulumConfigImportDialog(): Promise<{ + path: string | null; + content: string | null; +}> { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Reticulum config', extensions: ['config', 'ini', 'toml', 'txt', '*'] }], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null, content: null }; + } + const filePath = result.filePaths[0]; + try { + return { path: filePath, content: readUtf8FileBounded(filePath) }; + } catch { + // catch-no-log-ok: dialog file read failed; caller shows empty content + return { path: filePath, content: null }; + } +} diff --git a/src/main/reticulum-config-read.test.ts b/src/main/reticulum-config-read.test.ts new file mode 100644 index 000000000..c44a76e5c --- /dev/null +++ b/src/main/reticulum-config-read.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { readUtf8FileBounded } from './reticulum-config-read'; + +const FIXTURE_DIR = path.join(__dirname, 'fixtures/reticulum-config-read'); + +describe('readUtf8FileBounded', () => { + it('reads small UTF-8 files', () => { + const file = path.join(FIXTURE_DIR, 'small.txt'); + expect(readUtf8FileBounded(file, 1024)).toContain('interfaces:'); + }); + + it('rejects files larger than maxBytes', () => { + const file = path.join(FIXTURE_DIR, 'oversized.txt'); + expect(() => readUtf8FileBounded(file, 16)).toThrow(/exceeds 16 byte limit/); + }); +}); diff --git a/src/main/reticulum-config-read.ts b/src/main/reticulum-config-read.ts new file mode 100644 index 000000000..dff7413f6 --- /dev/null +++ b/src/main/reticulum-config-read.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; + +import { RETICULUM_CONFIG_MAX_READ_BYTES } from '../shared/reticulumProxyLimits'; + +/** Read a UTF-8 file with a byte cap to avoid main-process OOM on huge configs. */ +export function readUtf8FileBounded( + filePath: string, + maxBytes = RETICULUM_CONFIG_MAX_READ_BYTES, +): string { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(maxBytes + 1); + const bytesRead = fs.readSync(fd, buf, 0, maxBytes + 1, 0); + if (bytesRead > maxBytes) { + throw new Error(`config file exceeds ${maxBytes} byte limit`); + } + return buf.subarray(0, bytesRead).toString('utf8'); + } finally { + fs.closeSync(fd); + } +} diff --git a/src/main/reticulum-proxy-path.test.ts b/src/main/reticulum-proxy-path.test.ts new file mode 100644 index 000000000..b376357e1 --- /dev/null +++ b/src/main/reticulum-proxy-path.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { assertReticulumProxyPath, reticulumProxyGetTimeoutMs } from './reticulum-proxy-path'; + +describe('assertReticulumProxyPath', () => { + it('normalizes paths without a leading slash', () => { + expect(assertReticulumProxyPath('api/v1/status')).toBe('/api/v1/status'); + }); + + it('accepts valid API paths', () => { + expect(assertReticulumProxyPath('/api/v1/peers')).toBe('/api/v1/peers'); + expect(assertReticulumProxyPath('/api/v1/interfaces/abc/enable')).toBe( + '/api/v1/interfaces/abc/enable', + ); + }); + + it('preserves query strings on nomad page paths', () => { + expect( + assertReticulumProxyPath( + '/api/v1/nomadnetwork/page/abc?path=%2Fpage%2Findex.mu&hops=8&egress=rf', + ), + ).toBe('/api/v1/nomadnetwork/page/abc?path=%2Fpage%2Findex.mu&hops=8&egress=rf'); + }); + + it('rejects paths outside /api/v1/', () => { + expect(() => assertReticulumProxyPath('/ws')).toThrow(/must start with/); + expect(() => assertReticulumProxyPath('/api/v2/status')).toThrow(/must start with/); + }); + + it('rejects traversal segments', () => { + expect(() => assertReticulumProxyPath('/api/v1/../system/factory-reset')).toThrow( + /invalid segments/, + ); + expect(() => assertReticulumProxyPath('/api/v1/%2e%2e/system/factory-reset')).toThrow( + /invalid segments/, + ); + }); + + it('rejects paths with fragments in the path segment', () => { + expect(assertReticulumProxyPath('/api/v1/peers#frag')).toBe('/api/v1/peers#frag'); + }); +}); + +describe('reticulumProxyGetTimeoutMs', () => { + it('uses meshchat-aligned timeout for TCP nomad page fetches', () => { + expect( + reticulumProxyGetTimeoutMs( + '/api/v1/nomadnetwork/page/abc?path=%2Fpage%2Findex.mu&hops=8&egress=tcp', + ), + ).toBe(101_000); + }); + + it('uses longer RF timeout from hops and egress', () => { + expect( + reticulumProxyGetTimeoutMs( + '/api/v1/nomadnetwork/page/abc?path=%2Fpage%2Findex.mu&hops=8&egress=rf', + ), + ).toBe(101_000); + }); + + it('uses nomad timeout for file fetches', () => { + expect( + reticulumProxyGetTimeoutMs( + '/api/v1/nomadnetwork/file/abc?path=%2Ffile%2Freadme.txt&hops=8&egress=rf', + ), + ).toBe(101_000); + }); + + it('uses default timeout for other GET routes', () => { + expect(reticulumProxyGetTimeoutMs('/api/v1/nomadnetwork/nodes')).toBe(10_000); + }); + + it('uses longer timeout for transport query routes', () => { + expect(reticulumProxyGetTimeoutMs('/api/v1/peers')).toBe(30_000); + expect(reticulumProxyGetTimeoutMs('/api/v1/interfaces')).toBe(30_000); + expect(reticulumProxyGetTimeoutMs('/api/v1/topology')).toBe(30_000); + expect(reticulumProxyGetTimeoutMs('/api/v1/packets?limit=500')).toBe(30_000); + }); +}); diff --git a/src/main/reticulum-proxy-path.ts b/src/main/reticulum-proxy-path.ts new file mode 100644 index 000000000..b908d2d50 --- /dev/null +++ b/src/main/reticulum-proxy-path.ts @@ -0,0 +1,68 @@ +import { nomadPageProxyTimeoutMsFromApiPath } from '../shared/reticulumNomadTimeouts'; + +/** Allowed Reticulum sidecar HTTP paths for renderer IPC proxy. */ +const RETICULUM_PROXY_PATH_PREFIX = '/api/v1/'; + +export const RETICULUM_PROXY_GET_TIMEOUT_MS = 10_000; + +/** Routes that query the live RNS transport (path table, interface stats). */ +export const RETICULUM_TRANSPORT_QUERY_GET_TIMEOUT_MS = 30_000; + +const TRANSPORT_QUERY_GET_PATHS = [ + '/api/v1/peers', + '/api/v1/interfaces', + '/api/v1/topology', + '/api/v1/packets', +] as const; + +function isReticulumTransportQueryGetPath(normalized: string): boolean { + return TRANSPORT_QUERY_GET_PATHS.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`), + ); +} + +export function reticulumProxyGetTimeoutMs(apiPath: string): number { + const trimmed = apiPath.trim(); + const pathOnly = trimmed.split('?')[0] ?? trimmed; + const normalized = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`; + if ( + normalized.includes('/api/v1/nomadnetwork/page/') || + normalized.includes('/api/v1/nomadnetwork/file/') + ) { + return nomadPageProxyTimeoutMsFromApiPath(trimmed); + } + if (isReticulumTransportQueryGetPath(normalized)) { + return RETICULUM_TRANSPORT_QUERY_GET_TIMEOUT_MS; + } + return RETICULUM_PROXY_GET_TIMEOUT_MS; +} + +/** + * Validates a sidecar proxy path before forwarding to localhost. + * Failure point: malformed or traversal paths from a compromised renderer. + * Fallback: reject with Error (caller surfaces to UI). + */ +export function assertReticulumProxyPath(apiPath: string): string { + const trimmed = apiPath.trim(); + if (!trimmed) { + throw new Error('Reticulum proxy path is required'); + } + if (trimmed.length > 2048) { + throw new Error('Reticulum proxy path is too long'); + } + const withoutFragment = trimmed.split('#')[0] ?? trimmed; + const pathOnly = withoutFragment.split('?')[0] ?? withoutFragment; + let normalized = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`; + try { + normalized = decodeURIComponent(normalized); + } catch { + throw new Error('Reticulum proxy path contains invalid encoding'); + } + if (!normalized.startsWith(RETICULUM_PROXY_PATH_PREFIX)) { + throw new Error(`Reticulum proxy path must start with ${RETICULUM_PROXY_PATH_PREFIX}`); + } + if (normalized.includes('..') || normalized.includes('\\') || normalized.includes('\0')) { + throw new Error('Reticulum proxy path contains invalid segments'); + } + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts new file mode 100644 index 000000000..68183cd48 --- /dev/null +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from 'events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const spawnMock = vi.fn(); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/mesh-client-test', + getAppPath: () => '/tmp/mesh-client-test', + }, +})); + +vi.mock('./log-service', () => ({ + sanitizeLogMessage: (s: string) => s, +})); + +vi.mock('./reticulum-sidecar-path', () => ({ + ensureDevSidecarBinary: vi.fn().mockResolvedValue(undefined), + resolveSidecarBinaryPath: () => '/tmp/mesh-client-test/mesh-client-reticulum', +})); + +vi.mock('ws', () => ({ + default: class MockWebSocket { + on = vi.fn(); + close = vi.fn(); + }, +})); + +import fs from 'fs'; + +import { ReticulumSidecarManager } from './reticulum-sidecar-manager'; + +function mockSidecarProc( + pid = 4242, +): EventEmitter & { pid: number; kill: ReturnType } { + const proc = new EventEmitter() as EventEmitter & { + pid: number; + kill: ReturnType; + stdout: EventEmitter; + stderr: EventEmitter; + }; + proc.pid = pid; + proc.kill = vi.fn(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + return proc; +} + +describe('ReticulumSidecarManager', () => { + beforeEach(() => { + spawnMock.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + status: 'ok', + version: '0.1.0', + rns_ready: false, + lxmf_ready: false, + }), + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports idle status before start', () => { + const manager = new ReticulumSidecarManager(); + expect(manager.getStatus()).toEqual({ running: false, port: 0, pid: null }); + }); + + it('resolveBinaryPath returns dev target when bundled binary missing', () => { + const manager = new ReticulumSidecarManager(); + const resolved = manager.resolveBinaryPath(); + expect(resolved).toContain('mesh-client-reticulum'); + }); + + it('stop emits status when proc already null', async () => { + const manager = new ReticulumSidecarManager(); + const statusListener = vi.fn(); + manager.on('status', statusListener); + + // Simulate stale running state after process exited without coordinated stop(). + ( + manager as unknown as { _status: { running: boolean; port: number; pid: number | null } } + )._status = { + running: true, + port: 59477, + pid: null, + }; + + await manager.stop(); + + expect(manager.getStatus()).toEqual({ running: false, port: 0, pid: null }); + expect(statusListener).toHaveBeenCalledWith({ running: false, port: 0, pid: null }); + }); + + it('stop emits idle status even when already idle', async () => { + const manager = new ReticulumSidecarManager(); + const statusListener = vi.fn(); + manager.on('status', statusListener); + + await manager.stop(); + + expect(manager.getStatus()).toEqual({ running: false, port: 0, pid: null }); + expect(statusListener).toHaveBeenCalledWith({ running: false, port: 0, pid: null }); + }); + + it('coalesces concurrent start() into a single spawn', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + + const proc = mockSidecarProc(); + proc.kill.mockImplementation(() => { + proc.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc); + + const manager = new ReticulumSidecarManager(); + const [first, second] = await Promise.all([manager.start(), manager.start()]); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(first.running).toBe(true); + expect(first.port).toBeGreaterThan(0); + expect(first.pid).toBe(4242); + + await manager.stop(); + + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); +}); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts new file mode 100644 index 000000000..958fc26ef --- /dev/null +++ b/src/main/reticulum-sidecar-manager.ts @@ -0,0 +1,388 @@ +import { type ChildProcess, spawn } from 'child_process'; +import { app } from 'electron'; +import { EventEmitter } from 'events'; +import fs from 'fs'; +import net from 'net'; +import path from 'path'; +import WebSocket from 'ws'; + +import type { + ReticulumSidecarStartOptions, + ReticulumSidecarStatus, + ReticulumStatusResponse, +} from '../shared/reticulum-types'; +import { RETICULUM_PROXY_MAX_BODY_BYTES } from '../shared/reticulumProxyLimits'; +import { MS_PER_SECOND } from '../shared/timeConstants'; +import { sanitizeLogMessage } from './log-service'; +import { assertReticulumProxyPath, reticulumProxyGetTimeoutMs } from './reticulum-proxy-path'; +import { ensureDevSidecarBinary, resolveSidecarBinaryPath } from './reticulum-sidecar-path'; + +const HEALTH_POLL_INTERVAL_MS = 250; +const HEALTH_POLL_TIMEOUT_MS = 30 * MS_PER_SECOND; +const STOP_GRACE_MS = 5 * MS_PER_SECOND; + +function sidecarChildEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + USER: process.env.USER, + TMPDIR: process.env.TMPDIR, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + }; + if (process.platform === 'win32') { + env.APPDATA = process.env.APPDATA; + env.USERPROFILE = process.env.USERPROFILE; + env.LOCALAPPDATA = process.env.LOCALAPPDATA; + } + return env; +} + +function assertProxyBodySize(body: unknown): void { + const json = JSON.stringify(body ?? {}); + if (json.length > RETICULUM_PROXY_MAX_BODY_BYTES) { + throw new Error('Reticulum proxy body too large'); + } +} + +async function findFreePort(host = '127.0.0.1'): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, host, () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + server.on('error', reject); + }); +} + +async function pollSidecarHealth(port: number): Promise { + const url = `http://127.0.0.1:${port}/api/v1/status`; + const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS; + let lastError = 'health poll timeout'; + + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (!res.ok) { + lastError = `status ${res.status}`; + } else { + const body = (await res.json()) as ReticulumStatusResponse; + if (body.status === 'ok') return body; + lastError = `unexpected status field: ${body.status}`; + } + } catch (err) { + // catch-no-log-ok: health poll retries until deadline; lastError surfaces on timeout + lastError = err instanceof Error ? err.message : String(err); + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + throw new Error(lastError); +} + +export class ReticulumSidecarManager extends EventEmitter { + private proc: ChildProcess | null = null; + private ws: { close: () => void } | null = null; + private startPromise: Promise | null = null; + private _status: ReticulumSidecarStatus = { + running: false, + port: 0, + pid: null, + }; + + resolveBinaryPath(): string { + return resolveSidecarBinaryPath(); + } + + getStatus(): ReticulumSidecarStatus { + return { ...this._status }; + } + + private reticulumUserDir(...segments: string[]): string { + return path.join(app.getPath('userData'), 'reticulum', ...segments); + } + + async start(opts: ReticulumSidecarStartOptions = {}): Promise { + if (this.startPromise) { + return this.startPromise; + } + this.startPromise = this.startOnce(opts).finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + private async startOnce( + opts: ReticulumSidecarStartOptions = {}, + ): Promise { + if (opts.reuseIfRunning && this._status.running && this.proc) { + try { + await pollSidecarHealth(this._status.port); + return this.getStatus(); + } catch { + // catch-no-log-ok: reuseIfRunning health failed — stop stale process and start fresh + await this.stopProc(); + } + } + + if (this.proc) { + await this.stopProc(); + } + + const configDir = this.reticulumUserDir('config'); + const storageDir = this.reticulumUserDir('storage'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(storageDir, { recursive: true }); + + const port = await findFreePort(); + const binary = this.resolveBinaryPath(); + try { + await ensureDevSidecarBinary(binary); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this._status = { running: false, port: 0, pid: null, lastError: msg }; + throw new Error(msg); + } + if (!fs.existsSync(binary)) { + const msg = `Reticulum sidecar binary not found: ${binary}. Run \`pnpm run reticulum:sidecar:build\` from the repo root (requires Rust).`; + this._status = { running: false, port: 0, pid: null, lastError: msg }; + throw new Error(msg); + } + + const args = [ + '--headless', + '--host', + '127.0.0.1', + '--port', + String(port), + '--reticulum-config-dir', + configDir, + '--storage-dir', + storageDir, + ]; + + const proc = spawn(binary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: sidecarChildEnv(), + }); + this.proc = proc; + + proc.stdout?.on('data', (chunk: Buffer) => { + console.debug('[ReticulumSidecar]', sanitizeLogMessage(chunk.toString('utf8').trim())); + }); + proc.stderr?.on('data', (chunk: Buffer) => { + const text = sanitizeLogMessage(chunk.toString('utf8').trim()); + console.warn('[ReticulumSidecar]', text); + }); + proc.on('exit', (code, signal) => { + console.debug(`[ReticulumSidecar] exited code=${code ?? 'null'} signal=${signal ?? 'null'}`); + this.teardownWs(); + this.proc = null; + this._status = { + running: false, + port: this._status.port, + pid: null, + lastError: code != null && code !== 0 ? `exit ${code}` : undefined, + }; + this.emit('status', this.getStatus()); + }); + + try { + await pollSidecarHealth(port); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await this.stopProc(); + this._status = { running: false, port: 0, pid: null, lastError: msg }; + throw new Error(msg); + } + + this._status = { + running: true, + port, + pid: proc.pid ?? null, + }; + this.connectWs(port); + this.emit('status', this.getStatus()); + return this.getStatus(); + } + + async stop(): Promise { + if (this.startPromise) { + await this.startPromise.catch(() => { + // catch-no-log-ok: in-flight start may fail; explicit stop still runs afterward + }); + } + await this.stopProc(); + } + + private async stopProc(): Promise { + this.teardownWs(); + const proc = this.proc; + this.proc = null; + if (!proc) { + this._status = { running: false, port: 0, pid: null }; + this.emit('status', this.getStatus()); + return; + } + + await new Promise((resolve) => { + const killTimer = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + // catch-no-log-ok: process may already be gone during forced shutdown + } + resolve(); + }, STOP_GRACE_MS); + + proc.once('exit', () => { + clearTimeout(killTimer); + resolve(); + }); + + try { + proc.kill('SIGTERM'); + } catch { + // catch-no-log-ok: process may already be gone when sending SIGTERM + clearTimeout(killTimer); + resolve(); + } + }); + + this._status = { running: false, port: 0, pid: null }; + this.emit('status', this.getStatus()); + } + + async proxyGet(apiPath: string): Promise { + const status = this.getStatus(); + if (!status.running || status.port <= 0) { + throw new Error('Reticulum sidecar is not running'); + } + const normalized = assertReticulumProxyPath(apiPath); + const res = await fetch(`http://127.0.0.1:${status.port}${normalized}`, { + signal: AbortSignal.timeout(reticulumProxyGetTimeoutMs(apiPath)), + }); + if (!res.ok) { + throw new Error(`sidecar GET ${normalized} failed: ${res.status}`); + } + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + const text = await res.text(); + if (!text) return { ok: true }; + try { + return JSON.parse(text) as unknown; + } catch { + // catch-no-log-ok non-JSON GET body returned as plain text wrapper + return { ok: true, body: text }; + } + } + return res.json(); + } + + async proxyPost(apiPath: string, body: unknown): Promise { + const status = this.getStatus(); + if (!status.running || status.port <= 0) { + throw new Error('Reticulum sidecar is not running'); + } + const normalized = assertReticulumProxyPath(apiPath); + assertProxyBodySize(body); + const res = await fetch(`http://127.0.0.1:${status.port}${normalized}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + throw new Error(`sidecar POST ${normalized} failed: ${res.status}`); + } + return res.json(); + } + + async proxyPut(apiPath: string, body: unknown): Promise { + const status = this.getStatus(); + if (!status.running || status.port <= 0) { + throw new Error('Reticulum sidecar is not running'); + } + const normalized = assertReticulumProxyPath(apiPath); + assertProxyBodySize(body); + const res = await fetch(`http://127.0.0.1:${status.port}${normalized}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + throw new Error(`sidecar PUT ${normalized} failed: ${res.status}`); + } + return res.json(); + } + + async proxyDelete(apiPath: string): Promise { + const status = this.getStatus(); + if (!status.running || status.port <= 0) { + throw new Error('Reticulum sidecar is not running'); + } + const normalized = assertReticulumProxyPath(apiPath); + const res = await fetch(`http://127.0.0.1:${status.port}${normalized}`, { + method: 'DELETE', + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + throw new Error(`sidecar DELETE ${normalized} failed: ${res.status}`); + } + const text = await res.text(); + if (!text) return { ok: true }; + try { + return JSON.parse(text) as unknown; + } catch { + // catch-no-log-ok: empty or non-JSON DELETE body is treated as success + return { ok: true }; + } + } + + private connectWs(port: number): void { + this.teardownWs(); + try { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + socket.on('message', (data: Buffer) => { + const text = data.toString('utf8'); + try { + const parsed = JSON.parse(text) as { type?: string; payload?: unknown }; + this.emit('event', { + type: parsed.type ?? 'message', + payload: parsed.payload ?? parsed, + }); + } catch { + // catch-no-log-ok: non-JSON ws payloads are forwarded as raw text events + this.emit('event', { type: 'message', payload: text }); + } + }); + socket.on('error', (err: Error) => { + console.warn('[ReticulumSidecar] ws error:', sanitizeLogMessage(err.message)); + }); + this.ws = { + close: () => { + try { + socket.close(); + } catch { + // catch-no-log-ok: socket may already be closed + } + }, + }; + } catch (err) { + console.warn( + '[ReticulumSidecar] ws bridge unavailable:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + } + } + + private teardownWs(): void { + this.ws?.close(); + this.ws = null; + } +} diff --git a/src/main/reticulum-sidecar-path.test.ts b/src/main/reticulum-sidecar-path.test.ts new file mode 100644 index 000000000..f84a312ac --- /dev/null +++ b/src/main/reticulum-sidecar-path.test.ts @@ -0,0 +1,125 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getAppPath: () => '/virtual/app', + isPackaged: false, + getPath: () => '/tmp/mesh-client-test', + }, +})); + +vi.mock('./log-service', () => ({ + sanitizeLogMessage: (s: string) => s, +})); + +const spawnMock = vi.fn(); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); + +import { + findReticulumSidecarProjectDir, + hasRnsStackSiblings, + newestReticulumSidecarSourceMtimeMs, + resolveSidecarBinaryPath, + sidecarBinaryIsStale, + sidecarBinaryLacksRnsBle, + sidecarBinaryLacksRnsStack, + sidecarBinaryName, + sidecarCargoBuildArgs, +} from './reticulum-sidecar-path'; + +describe('reticulum-sidecar-path', () => { + let tmpDir: string; + + afterEach(() => { + spawnMock.mockReset(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = ''; + } + }); + + it('findReticulumSidecarProjectDir locates Cargo.toml under extra roots', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-')); + const projectDir = path.join(tmpDir, 'reticulum-sidecar'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'Cargo.toml'), '[package]\nname = "test"\n'); + + expect(findReticulumSidecarProjectDir([tmpDir])).toBe(projectDir); + }); + + it('resolveSidecarBinaryPath prefers debug build under project dir', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-')); + const projectDir = path.join(tmpDir, 'reticulum-sidecar'); + const binary = path.join(projectDir, 'target', 'debug', sidecarBinaryName()); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'Cargo.toml'), '[package]\nname = "test"\n'); + fs.writeFileSync(binary, ''); + + expect(resolveSidecarBinaryPath([tmpDir])).toBe(binary); + }); + + it('sidecarBinaryIsStale returns true when Rust source is newer than binary', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-stale-')); + const projectDir = path.join(tmpDir, 'reticulum-sidecar'); + const srcDir = path.join(projectDir, 'src'); + const binary = path.join(projectDir, 'target', 'debug', sidecarBinaryName()); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'Cargo.toml'), '[package]\nname = "test"\n'); + fs.writeFileSync(binary, ''); + fs.writeFileSync(path.join(srcDir, 'main.rs'), 'fn main() {}'); + + const past = Date.now() - 60_000; + fs.utimesSync(binary, past / 1000, past / 1000); + const future = Date.now() + 60_000; + fs.utimesSync(path.join(srcDir, 'main.rs'), future / 1000, future / 1000); + + expect(sidecarBinaryIsStale(binary, projectDir)).toBe(true); + expect(newestReticulumSidecarSourceMtimeMs(projectDir)).toBeGreaterThan( + fs.statSync(binary).mtimeMs, + ); + }); + + it('sidecarCargoBuildArgs uses rns-stack when Ratspeak siblings exist', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-siblings-')); + const meshRoot = path.join(tmpDir, 'mesh-client'); + const projectDir = path.join(meshRoot, 'reticulum-sidecar'); + fs.mkdirSync(path.join(tmpDir, 'rsReticulum', 'crates', 'rns-runtime'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'rsLXMF', 'crates', 'lxmf-core'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'rsReticulum/crates/rns-runtime/Cargo.toml'), '[package]\n'); + fs.writeFileSync(path.join(tmpDir, 'rsLXMF/crates/lxmf-core/Cargo.toml'), '[package]\n'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'Cargo.toml'), '[package]\nname = "test"\n'); + + expect(hasRnsStackSiblings(projectDir)).toBe(true); + expect(sidecarCargoBuildArgs(projectDir)).toEqual([ + 'build', + '--features', + 'rns-stack,rns-ble,rns-rnode-tcp', + ]); + }); + + it('sidecarBinaryLacksRnsBle detects sidecars built without rns-ble', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-ble-')); + const binary = path.join(tmpDir, sidecarBinaryName()); + fs.writeFileSync(binary, 'rns-ble feature not enabled in this build'); + expect(sidecarBinaryLacksRnsBle(binary)).toBe(true); + fs.writeFileSync(binary, 'ble_peer runtime linked'); + expect(sidecarBinaryLacksRnsBle(binary)).toBe(false); + }); + + it('sidecarBinaryLacksRnsStack detects stub-only binaries', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-stub-')); + const binary = path.join(tmpDir, sidecarBinaryName()); + fs.writeFileSync(binary, 'stub-sidecar-no-network-stack'); + expect(sidecarBinaryLacksRnsStack(binary)).toBe(true); + fs.writeFileSync(binary, 'stub-sidecar-with-rns_runtime-linked'); + expect(sidecarBinaryLacksRnsStack(binary)).toBe(false); + }); +}); diff --git a/src/main/reticulum-sidecar-path.ts b/src/main/reticulum-sidecar-path.ts new file mode 100644 index 000000000..aa5967842 --- /dev/null +++ b/src/main/reticulum-sidecar-path.ts @@ -0,0 +1,218 @@ +import { spawn } from 'child_process'; +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +import { sanitizeLogMessage } from './log-service'; + +export function sidecarBinaryName(): string { + return process.platform === 'win32' ? 'mesh-client-reticulum.exe' : 'mesh-client-reticulum'; +} + +/** Locate `reticulum-sidecar/Cargo.toml` from dev / packaged search roots. */ +export function findReticulumSidecarProjectDir(extraRoots: string[] = []): string | null { + const searchRoots = new Set(extraRoots); + try { + searchRoots.add(app.getAppPath()); + } catch { + // catch-no-log-ok app path unavailable in unit tests without electron ready + } + searchRoots.add(process.cwd()); + searchRoots.add(path.resolve(__dirname, '../..')); + searchRoots.add(path.resolve(__dirname, '../../..')); + + for (const root of searchRoots) { + const projectDir = path.join(root, 'reticulum-sidecar'); + if (fs.existsSync(path.join(projectDir, 'Cargo.toml'))) { + return projectDir; + } + } + return null; +} + +export function resolveSidecarBinaryPath(extraRoots: string[] = []): string { + const name = sidecarBinaryName(); + + if (process.resourcesPath) { + const bundled = path.join(process.resourcesPath, 'reticulum-sidecar', name); + if (fs.existsSync(bundled)) return bundled; + } + + const projectDir = findReticulumSidecarProjectDir(extraRoots); + if (projectDir) { + for (const profile of ['debug', 'release'] as const) { + const candidate = path.join(projectDir, 'target', profile, name); + if (fs.existsSync(candidate)) return candidate; + } + return path.join(projectDir, 'target', 'debug', name); + } + + return path.join(app.getAppPath(), 'reticulum-sidecar', 'target', 'debug', name); +} + +export function hasRnsStackSiblings(projectDir: string): boolean { + const rnsRuntime = path.normalize( + path.join(projectDir, '../../rsReticulum/crates/rns-runtime/Cargo.toml'), + ); + const lxmfCore = path.normalize( + path.join(projectDir, '../../rsLXMF/crates/lxmf-core/Cargo.toml'), + ); + return fs.existsSync(rnsRuntime) && fs.existsSync(lxmfCore); +} + +/** Cargo build args: full RNS stack (+ BLE) when Ratspeak siblings are present. */ +export function sidecarCargoBuildArgs(projectDir: string): string[] { + if (hasRnsStackSiblings(projectDir)) { + return ['build', '--features', 'rns-stack,rns-ble,rns-rnode-tcp']; + } + return ['build']; +} + +/** Stub sidecar builds omit rsReticulum symbols needed for live path-table peers. */ +export function sidecarBinaryLacksRnsStack(binaryPath: string): boolean { + try { + const bytes = fs.readFileSync(binaryPath); + return !bytes.includes(Buffer.from('rns_runtime')); + } catch { + // catch-no-log-ok binary missing or unreadable — treat as stub build + return true; + } +} + +/** Sidecars built without `rns-ble` embed this availability stub string. */ +export function sidecarBinaryLacksRnsBle(binaryPath: string): boolean { + try { + const bytes = fs.readFileSync(binaryPath); + return bytes.includes(Buffer.from('rns-ble feature not enabled in this build')); + } catch { + // catch-no-log-ok binary missing or unreadable — treat as no BLE support + return true; + } +} + +let devBuildInFlight: Promise | null = null; + +function runCargoBuild(projectDir: string): Promise { + const cargoArgs = sidecarCargoBuildArgs(projectDir); + return new Promise((resolve, reject) => { + const proc = spawn('cargo', cargoArgs, { + cwd: projectDir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + proc.stdout?.on('data', (chunk: Buffer) => { + console.debug('[ReticulumSidecar] cargo:', sanitizeLogMessage(chunk.toString('utf8').trim())); + }); + proc.stderr?.on('data', (chunk: Buffer) => { + const line = chunk.toString('utf8'); + stderr += line; + console.debug('[ReticulumSidecar] cargo:', sanitizeLogMessage(line.trim())); + }); + proc.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + reject( + new Error( + 'RETICULUM_CARGO_MISSING: Rust toolchain (cargo) not found. Install from https://rustup.rs then run `pnpm run reticulum:sidecar:build`.', + ), + ); + return; + } + reject(err); + }); + proc.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `RETICULUM_CARGO_BUILD_FAILED: cargo build exited ${code ?? 'null'}: ${stderr.trim().slice(-400)}`, + ), + ); + }); + }); +} + +/** Newest mtime among Cargo.toml and reticulum-sidecar/src Rust sources. */ +export function newestReticulumSidecarSourceMtimeMs(projectDir: string): number { + let newest = 0; + const cargoToml = path.join(projectDir, 'Cargo.toml'); + if (fs.existsSync(cargoToml)) { + newest = Math.max(newest, fs.statSync(cargoToml).mtimeMs); + } + const srcDir = path.join(projectDir, 'src'); + if (!fs.existsSync(srcDir)) return newest; + + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.rs')) { + newest = Math.max(newest, fs.statSync(full).mtimeMs); + } + } + }; + walk(srcDir); + return newest; +} + +/** True when Rust sources are newer than the built sidecar binary. */ +export function sidecarBinaryIsStale(binaryPath: string, projectDir: string): boolean { + if (!fs.existsSync(binaryPath)) return true; + const binaryMtime = fs.statSync(binaryPath).mtimeMs; + return newestReticulumSidecarSourceMtimeMs(projectDir) > binaryMtime; +} + +async function runDevSidecarCargoBuild(projectDir: string, reason: string): Promise { + if (!devBuildInFlight) { + console.debug(`[ReticulumSidecar] ${reason}; running cargo build…`); + devBuildInFlight = runCargoBuild(projectDir).finally(() => { + devBuildInFlight = null; + }); + } + await devBuildInFlight; +} + +/** Dev-only: compile the sidecar when the debug binary is missing or stale. */ +export async function ensureDevSidecarBinary(binaryPath: string): Promise { + if (app.isPackaged) return; + + const projectDir = findReticulumSidecarProjectDir(); + if (!projectDir) { + throw new Error( + 'RETICULUM_SIDECAR_PROJECT_MISSING: reticulum-sidecar/ not found. Run `pnpm run reticulum:sidecar:build` from the mesh-client repo root.', + ); + } + + const missing = !fs.existsSync(binaryPath); + const stale = !missing && sidecarBinaryIsStale(binaryPath, projectDir); + const lacksRnsStack = + !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsStack(binaryPath); + const lacksRnsBle = + !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsBle(binaryPath); + if (missing) { + await runDevSidecarCargoBuild(projectDir, 'debug binary missing'); + } else if (stale) { + await runDevSidecarCargoBuild(projectDir, 'sidecar sources newer than binary'); + } else if (lacksRnsStack) { + await runDevSidecarCargoBuild( + projectDir, + 'debug binary is stub-only; rebuilding with rns-stack for live peers', + ); + } else if (lacksRnsBle) { + await runDevSidecarCargoBuild( + projectDir, + 'debug binary lacks rns-ble; rebuilding with BLE interface support', + ); + } else { + return; + } + + if (!fs.existsSync(binaryPath)) { + throw new Error( + `RETICULUM_SIDECAR_BINARY_MISSING: expected ${binaryPath} after cargo build. Run \`pnpm run reticulum:sidecar:build\` manually.`, + ); + } +} diff --git a/src/main/validate-ipc-sender.ts b/src/main/validate-ipc-sender.ts new file mode 100644 index 000000000..dd9c7b9c0 --- /dev/null +++ b/src/main/validate-ipc-sender.ts @@ -0,0 +1,30 @@ +import { app, type IpcMainInvokeEvent } from 'electron'; + +/** Validate IPC sender origin to prevent untrusted renderers from invoking privileged handlers. */ +export function validateIpcSender(event: IpcMainInvokeEvent): boolean { + const frame = event.senderFrame; + if (!frame) return false; + try { + const url = new URL(frame.url); + const isDev = !app.isPackaged; + if (isDev) { + return ( + url.protocol === 'file:' || + url.protocol === 'mesh-client:' || + (url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) || + url.protocol === 'https:' + ); + } + return url.protocol === 'file:' || url.protocol === 'mesh-client:'; + } catch { + // catch-no-log-ok invalid URL in frame is expected; treat as untrusted + return false; + } +} + +export function assertIpcSender(event: IpcMainInvokeEvent, channel: string): void { + if (!validateIpcSender(event)) { + throw new Error(`${channel}: unauthorized sender`); + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 94b7ac1d7..8bf6160d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { + BlePeripheralOwner, + BleScanOwner, ElectronAPI, MeshNode, MeshProtocol, @@ -16,6 +18,11 @@ import type { SpellcheckReplacePayload, UpdateCheckingPayload, } from '../shared/electron-api.types'; +import type { + ReticulumSidecarEvent, + ReticulumSidecarStartOptions, + ReticulumSidecarStatus, +} from '../shared/reticulum-types'; import type { TAKClientInfo, TAKServerStatus, TAKSettings } from '../shared/tak-types'; export type { NobleBleDevice, NobleBleSessionId, SerialPort }; @@ -104,6 +111,58 @@ contextBridge.exposeInMainWorld('electronAPI', { rx_hops?: number | null; room_server_id?: number | null; }) => ipcRenderer.invoke('db:saveMeshcoreMessage', message), + getReticulumMessages: (identityId: string, limit?: number) => + ipcRenderer.invoke('db:getReticulumMessages', identityId, limit), + searchReticulumMessages: (identityId: string, query: string, limit?: number) => + ipcRenderer.invoke('db:searchReticulumMessages', identityId, query, limit), + deleteReticulumMessage: (identityId: string, messageHash: string) => + ipcRenderer.invoke('db:deleteReticulumMessage', identityId, messageHash), + clearReticulumMessages: (identityId: string) => + ipcRenderer.invoke('db:clearReticulumMessages', identityId), + saveReticulumMessage: (message: { + identity_id: string; + sender_id: string; + sender_name?: string | null; + payload: string; + timestamp: number; + to_hash?: string | null; + reply_to_hash?: string | null; + message_hash?: string | null; + received_via?: string | null; + delivery_status?: string | null; + delivery_attempts?: number | null; + next_delivery_attempt_at?: number | null; + attachment_path?: string | null; + }) => ipcRenderer.invoke('db:saveReticulumMessage', message), + markStaleReticulumOutbound: (identityId: string, staleAfterMs?: number) => + ipcRenderer.invoke('db:markStaleReticulumOutbound', identityId, staleAfterMs), + vacuumReticulumTables: () => ipcRenderer.invoke('db:vacuumReticulumTables'), + getReticulumDestinations: () => ipcRenderer.invoke('db:getReticulumDestinations'), + deleteReticulumDestination: (destinationHash: string) => + ipcRenderer.invoke('db:deleteReticulumDestination', destinationHash), + upsertReticulumDestination: (row: { + destination_hash: string; + display_name?: string | null; + last_heard?: number | null; + favorited?: boolean | number | null; + icon_name?: string | null; + icon_color?: string | null; + }) => ipcRenderer.invoke('db:upsertReticulumDestination', row), + getBlockedContacts: (protocol: string, identityId: string) => + ipcRenderer.invoke('db:getBlockedContacts', protocol, identityId), + blockContact: (protocol: string, identityId: string, blockedHash: string) => + ipcRenderer.invoke('db:blockContact', protocol, identityId, blockedHash), + unblockContact: (protocol: string, identityId: string, blockedHash: string) => + ipcRenderer.invoke('db:unblockContact', protocol, identityId, blockedHash), + getReticulumIdentityActivity: (destinationHash: string) => + ipcRenderer.invoke('db:getReticulumIdentityActivity', destinationHash), + upsertReticulumIdentityActivity: (row: { + destination_hash: string; + aspect: string; + identity_hash?: string | null; + last_seen: number; + hops?: number | null; + }) => ipcRenderer.invoke('db:upsertReticulumIdentityActivity', row), saveMeshcoreContact: (contact: { node_id: number; public_key: string; @@ -485,6 +544,19 @@ contextBridge.exposeInMainWorld('electronAPI', { }, }, + bleCoexistence: { + register: (mac: string, owner: BlePeripheralOwner) => + ipcRenderer.invoke('bleCoexistence:register', mac, owner), + unregister: (mac: string, owner: BlePeripheralOwner) => + ipcRenderer.invoke('bleCoexistence:unregister', mac, owner), + assertCanConnect: (owner: BlePeripheralOwner, mac: string) => + ipcRenderer.invoke('bleCoexistence:assertCanConnect', owner, mac), + getState: () => ipcRenderer.invoke('bleCoexistence:getState'), + acquireScan: (owner: BleScanOwner) => ipcRenderer.invoke('bleCoexistence:acquireScan', owner), + releaseScan: (owner: BleScanOwner) => ipcRenderer.invoke('bleCoexistence:releaseScan', owner), + pauseNobleScan: () => ipcRenderer.invoke('bleCoexistence:pauseNobleScan'), + }, + // ─── Noble BLE ────────────────────────────────────────────────── onNobleBleAdapterState: (cb: (state: string) => void) => { const handler = (_: unknown, state: string) => { @@ -856,10 +928,65 @@ contextBridge.exposeInMainWorld('electronAPI', { }, }, + // ─── Reticulum sidecar ─────────────────────────────────────────── + reticulum: { + start: (opts?: ReticulumSidecarStartOptions): Promise => + ipcRenderer.invoke('reticulum:start', opts), + stop: (): Promise => ipcRenderer.invoke('reticulum:stop'), + getStatus: (): Promise => ipcRenderer.invoke('reticulum:getStatus'), + proxyGet: (apiPath: string): Promise => + ipcRenderer.invoke('reticulum:proxyGet', apiPath), + proxyPost: (apiPath: string, body: unknown): Promise => + ipcRenderer.invoke('reticulum:proxyPost', apiPath, body), + proxyPut: (apiPath: string, body: unknown): Promise => + ipcRenderer.invoke('reticulum:proxyPut', apiPath, body), + proxyDelete: (apiPath: string): Promise => + ipcRenderer.invoke('reticulum:proxyDelete', apiPath), + readDefaultConfigFile: (): Promise<{ path: string | null; content: string | null }> => + ipcRenderer.invoke('reticulum:readDefaultConfigFile'), + showConfigImportDialog: (): Promise<{ path: string | null; content: string | null }> => + ipcRenderer.invoke('reticulum:showConfigImportDialog'), + onEvent: (cb: (event: ReticulumSidecarEvent) => void): (() => void) => { + const handler = (_: unknown, event: ReticulumSidecarEvent) => { + cb(event); + }; + ipcRenderer.on('reticulum:event', handler); + return () => ipcRenderer.off('reticulum:event', handler); + }, + onStatus: (cb: (status: ReticulumSidecarStatus) => void): (() => void) => { + const handler = (_: unknown, status: ReticulumSidecarStatus) => { + cb(status); + }; + ipcRenderer.on('reticulum:status', handler); + return () => ipcRenderer.off('reticulum:status', handler); + }, + }, + + // ─── Reticulum identity vault ──────────────────────────────────── + vault: { + setPasscode: (passcode: string, secret: string) => + ipcRenderer.invoke('vault:setPasscode', passcode, secret), + unlock: (passcode: string) => ipcRenderer.invoke('vault:unlock', passcode), + lock: () => ipcRenderer.invoke('vault:lock'), + status: () => ipcRenderer.invoke('vault:status'), + }, + // ─── Chat export ───────────────────────────────────────────────── chat: { export: (messages: unknown[]) => ipcRenderer.invoke('chat:export', messages) as Promise<{ success: boolean; path?: string }>, + saveReticulumAttachment: (opts: { + fileName: string; + mimeType?: string; + dataBase64: string; + promptSave?: boolean; + }) => + ipcRenderer.invoke('chat:saveReticulumAttachment', opts) as Promise<{ + success: boolean; + path?: string; + }>, + showItemInFolder: (filePath: string) => + ipcRenderer.invoke('chat:showItemInFolder', filePath) as Promise<{ ok: boolean }>, linkPreview: { fetch: (url: string) => ipcRenderer.invoke('chat:fetchLinkPreview', url) as Promise<{ @@ -873,8 +1000,21 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:outbox:list', protocol) as Promise, add: (entry: OutboxEntryInput) => ipcRenderer.invoke('chat:outbox:add', entry) as Promise, - updateStatus: (id: number, status: OutboxStatus, error?: string, nextRetryAt?: number) => - ipcRenderer.invoke('chat:outbox:updateStatus', id, status, error, nextRetryAt), + updateStatus: ( + id: number, + status: OutboxStatus, + error?: string, + nextRetryAt?: number, + attemptCount?: number, + ) => + ipcRenderer.invoke( + 'chat:outbox:updateStatus', + id, + status, + error, + nextRetryAt, + attemptCount, + ), remove: (id: number) => ipcRenderer.invoke('chat:outbox:remove', id), }, }, diff --git a/src/renderer/App.test.tsx b/src/renderer/App.test.tsx index 6d28f1e6a..70c68aa13 100644 --- a/src/renderer/App.test.tsx +++ b/src/renderer/App.test.tsx @@ -631,8 +631,10 @@ describe('App accessibility', () => { renderApp(); const mqttLabel = await screen.findByLabelText('MQTT error'); - expect(mqttLabel.querySelector('span.lg\\:inline')).toHaveClass('animate-pulse'); - expect(mqttLabel.querySelector('span.lg\\:inline')).toHaveClass('text-red-400'); + const mqttText = mqttLabel.querySelector('span.lg\\:inline'); + expect(mqttText).toHaveClass('text-red-400'); + expect(mqttText).not.toHaveClass('animate-pulse'); + expect(mqttLabel.querySelector('svg')).toHaveClass('animate-pulse'); }); it('shows pulsing red device status when reconnecting after loss', async () => { @@ -656,8 +658,10 @@ describe('App accessibility', () => { renderApp(); const deviceLabel = await screen.findByLabelText('Reconnecting (BLE)'); - expect(deviceLabel.querySelector('span.lg\\:inline')).toHaveClass('animate-pulse'); - expect(deviceLabel.querySelector('span.lg\\:inline')).toHaveClass('text-red-400'); + const deviceText = deviceLabel.querySelector('span.lg\\:inline'); + expect(deviceText).toHaveClass('text-red-400'); + expect(deviceText).not.toHaveClass('animate-pulse'); + expect(deviceLabel.parentElement?.querySelector('.rounded-full')).toHaveClass('animate-pulse'); }); it('renders the queue badge in meshcore mode when queueStatus is available', async () => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c3162b4d9..66f9771d5 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -23,19 +23,26 @@ import { clearPersistedLastReadForProtocol, clearPersistedRoomsLastRead, ensureMeshcoreChatLastReadSanitized, + ensureReticulumChatLastReadSanitized, getSanitizedMeshcoreChatLastRead, getSanitizedMeshcoreRoomsLastRead, getSanitizedMeshtasticChatLastRead, + getSanitizedReticulumChatLastRead, loadMutedViews, removePersistedLastReadForChannel, subscribeMutedViewsChanged, subscribePersistedLastRead, subscribePersistedRoomsLastRead, } from '@/renderer/lib/chatPanelProtocolStorage'; -import { type ChatUnreadDmOptions, totalUnreadCount } from '@/renderer/lib/chatUnreadCounts'; +import { + type ChatUnreadDmOptions, + computeReticulumChatUnread, + totalUnreadCount, +} from '@/renderer/lib/chatUnreadCounts'; import { setDebugSnapshotUiContext } from '@/renderer/lib/debugSnapshotUiContext'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import type { MessageClearRefreshOptions } from '@/renderer/lib/hydrateIdentityStoresFromDb'; +import { ConnectIcon } from '@/renderer/lib/icons/connectIcon'; import { MqttGlobeIcon } from '@/renderer/lib/icons/connectionIcons'; import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; @@ -57,6 +64,7 @@ import ChannelUtilizationChart from './components/ChannelUtilizationChart'; import ConfigureNodeSelector from './components/ConfigureNodeSelector'; import ErrorBoundary from './components/ErrorBoundary'; import { FirmwareUpdateNotifier } from './components/FirmwareUpdateNotifier'; +import { GlobalInstantTooltip } from './components/GlobalInstantTooltip'; import { HelpTooltip } from './components/HelpTooltip'; import { InactiveProtocolNotifier } from './components/InactiveProtocolNotifier'; import LanguageSelector from './components/LanguageSelector'; @@ -86,23 +94,29 @@ import { useProtocolDisconnect, } from './hooks/useProtocolConnection'; import { useProtocolFacade } from './hooks/useProtocolFacade'; +import type { useReticulumPanelActions } from './hooks/useReticulumPanelActions'; import { useSendMessage } from './hooks/useSendMessage'; import { useSerialServiceListeners } from './hooks/useSerialServiceListeners'; import { useSpellcheckReplaceSync } from './hooks/useSpellcheckReplaceSync'; import { useTakServer } from './hooks/useTakServer'; import { ChatPanel, ConnectionPanel, LogPanel, NodeListPanel } from './lazyAppPanels'; -import { ContactGroupsModal, NodeDetailModal } from './lazyModals'; +import { ContactGroupsModal, NodeDetailModal, ReticulumPeerDetailModal } from './lazyModals'; import { AdminPanel, AppPanel, DiagnosticsPanel, MapPanel, ModulePanel, + NomadNetworkPanel, PacketDistributionPanel, PeerGraphPanel, RadioPanel, RawPacketLogPanel, RepeatersPanel, + ReticulumAdminPanel, + ReticulumNetworkPanel, + ReticulumPeerListPanel, + ReticulumTopologyPanel, RFHistogramsPanel, RoomsPanel, SecurityPanel, @@ -115,10 +129,16 @@ import { computeTabMappings, findFilteredTabIndexForPanel, MAP_TAB_PANEL_INDEX, + MODULES_PANEL_INDEX, + NODES_PANEL_INDEX, + NOMAD_NETWORK_PANEL_INDEX, RADIO_TAB_PANEL_INDEX, resolveSavedTabOnProtocolSwitch, ROOMS_PANEL_INDEX, + SECURITY_PANEL_INDEX, + TOPOLOGY_PANEL_INDEX, } from './lib/appTabMappings'; +import { dedupeChannelPillsByIndex } from './lib/channelListDedupe'; import { playMessageNotification } from './lib/chatNotifications'; import { deviceHeaderVariant, @@ -142,6 +162,7 @@ import { meshcoreRoomServerIdsFromNodes, repairMeshcoreHydratedMessages, } from './lib/meshcoreDbCacheHydration'; +import { initNobleBleDualRadioStartup } from './lib/meshcoreDualNobleBleInit'; import { syncMeshcoreDisplayReplyRepairs } from './lib/meshcoreStoreDedup'; import { pubkeyToNodeId } from './lib/meshcoreUtils'; import { meshNodeStubForDetailModal } from './lib/meshNodeStubForDetail'; @@ -149,12 +170,13 @@ import { shouldAutoLaunchMeshtasticMqtt, shouldMaintainMeshtasticMqttConnection, } from './lib/meshtasticMqttLiveIngest'; -import { tryAutoLaunchMqtt } from './lib/mqttAutoLaunch'; +import { shouldAutoLaunchMeshcoreMqttAtStartup, tryAutoLaunchMqtt } from './lib/mqttAutoLaunch'; import { nodeLabelForRawPacket } from './lib/nodeLongNameOrHex'; import { ensureOfflineProtocolIdentities } from './lib/offlineProtocolIdentities'; import { parseStoredJson } from './lib/parseStoredJson'; import { protocolHeaderBorderClass } from './lib/protocolTheme'; import { useRadioProvider } from './lib/radio/providerFactory'; +import type { ReticulumRawPacketEntry } from './lib/rawPacketLogConstants'; import { repairMeshtasticReplyPreviews } from './lib/replyPreview'; import { logRfReconnectFailure, reconnectRfFromLastConnection } from './lib/rfReconnectHelper'; import { getStoredMeshProtocol, MESH_PROTOCOL_STORAGE_KEY } from './lib/storedMeshProtocol'; @@ -181,6 +203,7 @@ import { import type { MeshcoreRuntime, MeshtasticRuntime } from './runtime/runtimeTypes'; import { useMeshcoreRuntime } from './runtime/useMeshcoreRuntime'; import { useMeshtasticRuntime } from './runtime/useMeshtasticRuntime'; +import { useReticulumRuntime } from './runtime/useReticulumRuntime'; import { useDiagnosticsStore } from './stores/diagnosticsStore'; import { useIdentityStore } from './stores/identityStore'; import { useMapLayerStore } from './stores/mapLayerStore'; @@ -188,6 +211,7 @@ import { useMapViewportStore } from './stores/mapViewportStore'; import { useNodeStore } from './stores/nodeStore'; import { usePathHistoryStore } from './stores/pathHistoryStore'; import { usePositionHistoryStore } from './stores/positionHistoryStore'; +import { useReticulumPeerStore } from './stores/reticulumPeerStore'; // Tabs capability filtering lives in appTabMappings.ts (computeTabMappings). @@ -388,13 +412,15 @@ function ColoradoMeshWatermarkMark() { export default function App() { const meshtasticRuntime = useMeshtasticRuntime(); const meshcoreRuntime = useMeshcoreRuntime(); + const reticulumRuntime = useReticulumRuntime(); const runtimeMap = useMemo( () => ({ meshtastic: meshtasticRuntime, meshcore: meshcoreRuntime, + reticulum: reticulumRuntime, }) as unknown as RuntimeMap, - [meshtasticRuntime, meshcoreRuntime], + [meshtasticRuntime, meshcoreRuntime, reticulumRuntime], ); return ( @@ -408,6 +434,7 @@ function AppContent() { const runtimes = useAllRuntimes(); const meshtasticRuntime = runtimes.meshtastic as unknown as MeshtasticRuntime; const meshcoreRuntime = runtimes.meshcore as unknown as MeshcoreRuntime; + const reticulumRuntime = runtimes.reticulum; const [activeTab, setActiveTab] = useState(0); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem('mesh-client:sidebarCollapsed') === 'true'; @@ -476,6 +503,7 @@ function AppContent() { }, [sidebarCollapsed]); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedPeerHash, setSelectedPeerHash] = useState(null); // Stable array ref from Map.get — safe for React 19 useSyncExternalStore (not latestPositionHistoryPoint). const selectedNodeHistoryPoints = usePositionHistoryStore( useCallback( @@ -503,7 +531,11 @@ function AppContent() { }); const [pendingDmTarget, setPendingDmTarget] = useState(null); const [pendingRoomTarget, setPendingRoomTarget] = useState(null); - const [lastReadRevision, setLastReadRevision] = useState({ meshtastic: 0, meshcore: 0 }); + const [lastReadRevision, setLastReadRevision] = useState({ + meshtastic: 0, + meshcore: 0, + reticulum: 0, + }); const [roomsLastReadRevision, setRoomsLastReadRevision] = useState(0); const [meshcoreMutedViewsRevision, setMeshcoreMutedViewsRevision] = useState(0); const [logPanelVisible, setLogPanelVisible] = useState(readLogPanelVisible); @@ -620,12 +652,17 @@ function AppContent() { ensureOfflineProtocolIdentities(); }, []); + useLayoutEffect(() => { + initNobleBleDualRadioStartup(); + }, []); + const [protocol, setProtocol] = useState(() => getStoredMeshProtocol()); const protocolConnect = useProtocolConnect(); const protocolDisconnect = useProtocolDisconnect(); const meshtasticConnection = useProtocolConnectionActions('meshtastic'); const meshcoreConnection = useProtocolConnectionActions('meshcore'); + const reticulumConnection = useProtocolConnectionActions('reticulum'); usePowerRecovery({ callbacksByProtocol: { @@ -637,6 +674,10 @@ function AppContent() { onPowerSuspend: meshcoreRuntime.onPowerSuspend, onPowerResume: meshcoreRuntime.onPowerResume, }, + reticulum: { + onPowerSuspend: reticulumRuntime.onPowerSuspend, + onPowerResume: reticulumRuntime.onPowerResume, + }, }, }); useSerialServiceListeners(); @@ -645,6 +686,7 @@ function AppContent() { const allPanelActions = useAllProtocolPanelActions({ meshtastic: meshtasticRuntime, meshcore: meshcoreRuntime, + reticulum: reticulumRuntime, }); const meshtasticPanelActions = allPanelActions.meshtastic as ReturnType< typeof useMeshtasticPanelActions @@ -652,10 +694,10 @@ function AppContent() { const meshcorePanelActions = allPanelActions.meshcore as ReturnType< typeof useMeshcorePanelActions >; - const activeFacade = useProtocolFacade(protocol, { - meshtastic: meshtasticPanelActions, - meshcore: meshcorePanelActions, - }); + const reticulumPanelActions = allPanelActions.reticulum as ReturnType< + typeof useReticulumPanelActions + >; + const activeFacade = useProtocolFacade(protocol, allPanelActions); const panelActions = allPanelActions[protocol]; const { identityIdByProtocol, @@ -664,6 +706,7 @@ function AppContent() { } = useActiveMeshIdentity(protocol); const meshtasticIdentityId = identityIdByProtocol.meshtastic; const meshcoreIdentityId = identityIdByProtocol.meshcore; + const reticulumIdentityId = identityIdByProtocol.reticulum; const meshtasticNodesById = useNodeStore((s) => meshtasticIdentityId ? s.nodes[meshtasticIdentityId] : undefined, ); @@ -672,6 +715,7 @@ function AppContent() { ); const meshtasticStoreMessages = useMessages(meshtasticIdentityId); const meshcoreStoreMessages = useMessages(meshcoreIdentityId); + const reticulumStoreMessages = useMessages(reticulumIdentityId); const meshtasticUiMessages = useMemo( () => repairMeshtasticReplyPreviews(messageRecordsToChatMessages(meshtasticStoreMessages)), [meshtasticStoreMessages], @@ -708,9 +752,22 @@ function AppContent() { if (!meshcoreNodesById) return new Map(); return nodeRecordsToMeshNodeMap(Object.values(meshcoreNodesById)); }, [meshcoreNodesById]); + const reticulumUiMessages = useMemo( + () => messageRecordsToChatMessages(reticulumStoreMessages), + [reticulumStoreMessages], + ); + const reticulumNodesById = useNodeStore((s) => + reticulumIdentityId ? s.nodes[reticulumIdentityId] : undefined, + ); + const reticulumUiNodes = useMemo(() => { + if (!reticulumNodesById) return new Map(); + return nodeRecordsToMeshNodeMap(Object.values(reticulumNodesById)); + }, [reticulumNodesById]); + const reticulumPathPeerCount = useReticulumPeerStore((s) => s.peers.size); const meshtasticDbRefresh = useProtocolDbRefresh('meshtastic', meshtasticIdentityId); const meshcoreDbRefresh = useProtocolDbRefresh('meshcore', meshcoreIdentityId); + const reticulumDbRefresh = useProtocolDbRefresh('reticulum', reticulumIdentityId); const { refreshAllFromDb: refreshMeshtasticAllFromDb } = meshtasticDbRefresh; const { refreshAllFromDb: refreshMeshcoreAllFromDb } = meshcoreDbRefresh; @@ -724,6 +781,11 @@ function AppContent() { void refreshMeshcoreAllFromDb(); }, [meshcoreIdentityId, refreshMeshcoreAllFromDb]); + useEffect(() => { + if (!reticulumIdentityId) return; + void reticulumDbRefresh.refreshAllFromDb(); + }, [reticulumIdentityId, reticulumDbRefresh]); + useEffect(() => { if (!meshcoreIdentityId) return; const selfNum = useIdentityStore.getState().identities[meshcoreIdentityId]?.selfNodeNum; @@ -734,44 +796,90 @@ function AppContent() { const sendMessage = useSendMessage(focusedIdentityId); const meshtasticConnectionView = useConnectionView(meshtasticIdentityId); const meshcoreConnectionView = useConnectionView(meshcoreIdentityId); + const reticulumConnectionView = useConnectionView(reticulumIdentityId); const meshtasticCapabilities = useRadioProvider('meshtastic'); const meshcoreCapabilities = useRadioProvider('meshcore'); + const reticulumCapabilities = useRadioProvider('reticulum'); const capabilitiesByProtocol = useMemo( - () => protocolRecord(meshtasticCapabilities, meshcoreCapabilities), - [meshtasticCapabilities, meshcoreCapabilities], + () => protocolRecord(meshtasticCapabilities, meshcoreCapabilities, reticulumCapabilities), + [meshtasticCapabilities, meshcoreCapabilities, reticulumCapabilities], ); const tabsByProtocol = useMemo( () => protocolRecord( computeTabMappings(t, 'meshtastic', meshtasticCapabilities), computeTabMappings(t, 'meshcore', meshcoreCapabilities), + computeTabMappings(t, 'reticulum', reticulumCapabilities), ), - [t, meshtasticCapabilities, meshcoreCapabilities], + [t, meshtasticCapabilities, meshcoreCapabilities, reticulumCapabilities], ); const uiNodesByProtocol = useMemo( - () => protocolRecord(meshtasticUiNodes, meshcoreUiNodes), - [meshtasticUiNodes, meshcoreUiNodes], + () => protocolRecord(meshtasticUiNodes, meshcoreUiNodes, reticulumUiNodes), + [meshtasticUiNodes, meshcoreUiNodes, reticulumUiNodes], ); const uiMessagesByProtocol = useMemo( - () => protocolRecord(meshtasticUiMessages, meshcoreUiMessages), - [meshtasticUiMessages, meshcoreUiMessages], + () => protocolRecord(meshtasticUiMessages, meshcoreUiMessages, reticulumUiMessages), + [meshtasticUiMessages, meshcoreUiMessages, reticulumUiMessages], ); const connectionViewByProtocol = useMemo( - () => protocolRecord(meshtasticConnectionView, meshcoreConnectionView), - [meshtasticConnectionView, meshcoreConnectionView], + () => protocolRecord(meshtasticConnectionView, meshcoreConnectionView, reticulumConnectionView), + [meshtasticConnectionView, meshcoreConnectionView, reticulumConnectionView], ); const connectionActionsByProtocol = useMemo( - () => protocolRecord(meshtasticConnection, meshcoreConnection), - [meshtasticConnection, meshcoreConnection], + () => protocolRecord(meshtasticConnection, meshcoreConnection, reticulumConnection), + [meshtasticConnection, meshcoreConnection, reticulumConnection], ); const panelActionsByProtocol = useMemo( - () => protocolRecord(meshtasticPanelActions, meshcorePanelActions), - [meshtasticPanelActions, meshcorePanelActions], + () => protocolRecord(meshtasticPanelActions, meshcorePanelActions, reticulumPanelActions), + [meshtasticPanelActions, meshcorePanelActions, reticulumPanelActions], ); const deviceStateByProtocol = useMemo( - () => protocolRecord(meshtasticRuntime.state, meshcoreRuntime.state), - [meshtasticRuntime.state, meshcoreRuntime.state], + () => protocolRecord(meshtasticRuntime.state, meshcoreRuntime.state, reticulumRuntime.state), + [meshtasticRuntime.state, meshcoreRuntime.state, reticulumRuntime.state], + ); + const selfNodeIdByProtocol = useMemo( + () => protocolRecord(meshtasticRuntime.selfNodeId, meshcoreRuntime.selfNodeId, null), + [meshtasticRuntime.selfNodeId, meshcoreRuntime.selfNodeId], + ); + const securityLocalNodeNumByProtocol = useMemo( + () => protocolRecord(meshtasticConnectionView.state.myNodeNum, undefined as number | undefined), + [meshtasticConnectionView.state.myNodeNum], + ); + const securityLocalNodeLabelByProtocol = useMemo( + () => + protocolRecord( + meshtasticUiNodes.get(meshtasticConnectionView.state.myNodeNum)?.long_name ?? undefined, + meshcoreRuntime.selfInfo?.name, + ), + [meshtasticUiNodes, meshtasticConnectionView.state.myNodeNum, meshcoreRuntime.selfInfo?.name], + ); + const securityMeshcoreNodeIdByProtocol = useMemo( + () => protocolRecord(undefined as number | undefined, meshcoreConnectionView.state.myNodeNum), + [meshcoreConnectionView.state.myNodeNum], + ); + const normalizedMeshtasticDeviceLogs = useMemo( + () => + meshtasticRuntime.deviceLogs.map((d) => ({ + ts: d.time, + level: + d.level >= 40 + ? 'error' + : d.level >= 30 + ? 'warn' + : d.level >= 10 + ? 'log' + : d.level > 0 + ? 'debug' + : 'log', + source: d.source, + message: d.message, + })), + [meshtasticRuntime.deviceLogs], + ); + const deviceLogsByProtocol = useMemo( + () => protocolRecord(normalizedMeshtasticDeviceLogs, meshcoreRuntime.deviceLogs, []), + [normalizedMeshtasticDeviceLogs, meshcoreRuntime.deviceLogs], ); const nodesForUi = selectByProtocol(uiNodesByProtocol, protocol); const activeUiMessages = selectByProtocol(uiMessagesByProtocol, protocol); @@ -814,8 +922,10 @@ function AppContent() { ); }, [hasMeshtasticSendingRow, nowMs, activeFacade.messages, myNodeNumForQueue, sendingWindowMs]); const handleSend = useCallback( - (text: string, channel: number, destination?: number, replyId?: number) => { - sendMessage(text, channel, destination, replyId != null ? String(replyId) : undefined); + (text: string, channel: number, destination?: number, replyRef?: number | string) => { + const replyTo = + replyRef == null ? undefined : typeof replyRef === 'string' ? replyRef : String(replyRef); + sendMessage(text, channel, destination, replyTo); }, [sendMessage], ); @@ -872,6 +982,20 @@ function AppContent() { setLastReadRevision((prev) => ({ ...prev, meshcore: prev.meshcore + 1 })); }, [meshcoreIdentityId, meshcoreUiMessages]); + const reticulumLastReadSanitizedRef = useRef(false); + useEffect(() => { + if (!reticulumIdentityId || reticulumLastReadSanitizedRef.current) return; + if (localStorage.getItem('mesh-client:lastReadSanitized:reticulum') === '1') { + reticulumLastReadSanitizedRef.current = true; + return; + } + if (reticulumUiMessages.length === 0) return; + ensureReticulumChatLastReadSanitized(reticulumUiMessages); + reticulumLastReadSanitizedRef.current = true; + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time migration bumps last-read revision after sanitize + setLastReadRevision((prev) => ({ ...prev, reticulum: prev.reticulum + 1 })); + }, [reticulumIdentityId, reticulumUiMessages]); + const meshtasticOwnNodeIdSet = useMemo(() => { const ids = meshtasticMqttOwnNodeIds( meshtasticRuntime.selfNodeId, @@ -963,6 +1087,16 @@ function AppContent() { meshcoreUiMessages, ]); + const reticulumChatUnread = useMemo(() => { + void lastReadRevision.reticulum; + const lastRead = getSanitizedReticulumChatLastRead(reticulumUiMessages); + return computeReticulumChatUnread( + reticulumUiMessages, + reticulumConnectionView.state.status, + lastRead, + ); + }, [lastReadRevision.reticulum, reticulumUiMessages, reticulumConnectionView.state.status]); + const meshcoreRoomsUnread = useMemo(() => { void roomsLastReadRevision; void meshcoreMutedViewsRevision; @@ -984,14 +1118,17 @@ function AppContent() { meshcoreRuntime.state.status, ]); - /** Meshtastic + MeshCore nodes for Diagnostics (foreign MeshCore sender labels/links). */ + /** Protocol-scoped nodes for Diagnostics (Meshtastic merges MeshCore for foreign-LoRa labels). */ const nodesForDiagnostics = useMemo(() => { - const merged = new Map(meshtasticUiNodes); - for (const [id, node] of meshcoreUiNodes) { - merged.set(id, node); + if (protocol === 'meshtastic') { + const merged = new Map(meshtasticUiNodes); + for (const [id, node] of meshcoreUiNodes) { + merged.set(id, node); + } + return merged; } - return merged; - }, [meshtasticUiNodes, meshcoreUiNodes]); + return nodesForUi; + }, [protocol, meshtasticUiNodes, meshcoreUiNodes, nodesForUi]); const rawPacketGetNodeLabel = useCallback( (id: number) => nodeLabelForRawPacket(nodesForUi.get(id), id, protocol), [nodesForUi, protocol], @@ -1026,23 +1163,34 @@ function AppContent() { const capabilities = activeProtocolCapabilities; const nodeCountLabel = capabilities.nodeListTabUsesContactsLabel ? t('common.contacts') - : t('common.nodes'); + : capabilities.nodeListTabUsesPeersLabel + ? t('common.peers') + : t('common.nodes'); + const footerNodeCount = + protocol === 'reticulum' && reticulumPathPeerCount > 0 + ? reticulumPathPeerCount + : nodesForUi.size; useNodeStatusNotifier(nodesForUi, capabilities); const chatUnreadByProtocol = useMemo( - () => protocolRecord(meshtasticChatUnread, meshcoreChatUnread), - [meshtasticChatUnread, meshcoreChatUnread], + () => protocolRecord(meshtasticChatUnread, meshcoreChatUnread, reticulumChatUnread), + [meshtasticChatUnread, meshcoreChatUnread, reticulumChatUnread], ); const roomsUnreadByProtocol = useMemo( - () => protocolRecord(0, meshcoreRoomsUnread), + () => protocolRecord(0, meshcoreRoomsUnread, 0), [meshcoreRoomsUnread], ); const chatUnread = selectByProtocol(chatUnreadByProtocol, protocol); const roomsUnread = selectByProtocol(roomsUnreadByProtocol, protocol); const storeMessageCountByProtocol = useMemo( - () => protocolRecord(meshtasticStoreMessages.length, meshcoreStoreMessages.length), - [meshtasticStoreMessages.length, meshcoreStoreMessages.length], + () => + protocolRecord( + meshtasticStoreMessages.length, + meshcoreStoreMessages.length, + reticulumStoreMessages.length, + ), + [meshtasticStoreMessages.length, meshcoreStoreMessages.length, reticulumStoreMessages.length], ); const meshtasticOwnNodeIdsForChat = useMemo( () => @@ -1053,13 +1201,29 @@ function AppContent() { ), [activeRuntime.selfNodeId, meshtasticRuntime.virtualNodeId, meshtasticRuntime.lastRfSelfNodeId], ); + const reticulumOwnNodeIdsForChat = useMemo(() => { + const selfId = + typeof reticulumRuntime.selfNodeId === 'number' + ? reticulumRuntime.selfNodeId + : reticulumRuntime.state.myNodeNum; + return selfId > 0 ? [selfId >>> 0] : []; + }, [reticulumRuntime.selfNodeId, reticulumRuntime.state.myNodeNum]); const headerSelfNodeLabel = capabilities.prefersDeviceOwnerLongNameInHeader ? meshcoreRuntime.deviceOwner?.longName?.trim() || panelActions.getPickerStyleNodeLabel(activeConnectionView.state.myNodeNum) : panelActions.getPickerStyleNodeLabel(activeConnectionView.state.myNodeNum); const sendReactionByProtocol = useMemo( - () => protocolRecord(meshtasticPanelActions.sendReaction, meshcoreRuntime.sendReaction), - [meshtasticPanelActions.sendReaction, meshcoreRuntime.sendReaction], + () => + protocolRecord( + meshtasticPanelActions.sendReaction, + meshcoreRuntime.sendReaction, + reticulumPanelActions.sendReaction, + ), + [ + meshtasticPanelActions.sendReaction, + meshcoreRuntime.sendReaction, + reticulumPanelActions.sendReaction, + ], ); const activePanelIndex = tabIndexToPanelIndex[activeTab] ?? 0; @@ -1175,6 +1339,20 @@ function AppContent() { [protocol, tabsByProtocol], ); + const handleNavigateToReticulumConnection = useCallback(() => { + const connectionTabIndex = findFilteredTabIndexForPanel( + selectByProtocol(tabsByProtocol, 'reticulum'), + 0, + ); + if (connectionTabIndex >= 0) { + setActiveTab(connectionTabIndex); + } + }, [tabsByProtocol]); + + const handleRefreshReticulumDiagnostics = useCallback(() => { + void reticulumRuntime.syncDiagnostics?.(); + }, [reticulumRuntime]); + const runReanalysis = useDiagnosticsStore((s) => s.runReanalysis); const ignoreMqttEnabled = useDiagnosticsStore((s) => s.ignoreMqttEnabled); const envMode = useDiagnosticsStore((s) => s.envMode); @@ -1237,28 +1415,28 @@ function AppContent() { }; }, [isRemoteConfigureTarget, meshtasticRuntime, meshtasticPanelActions]); const effectiveChannelConfigs = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.channelConfigs ?? []) - : activeRuntime.channelConfigs; + ? (meshtasticRuntime.remoteConfigSnapshot?.channelConfigs ?? []) + : meshtasticRuntime.channelConfigs; const effectiveLoraConfig = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.loraConfig ?? null) - : activeRuntime.loraConfig; + ? (meshtasticRuntime.remoteConfigSnapshot?.loraConfig ?? null) + : meshtasticRuntime.loraConfig; const effectiveModuleConfigs = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.moduleConfigs ?? {}) - : activeRuntime.moduleConfigs; + ? (meshtasticRuntime.remoteConfigSnapshot?.moduleConfigs ?? {}) + : meshtasticRuntime.moduleConfigs; const effectiveMeshtasticConfigSlices = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.configSlices ?? {}) - : activeRuntime.meshtasticConfigSlices; + ? (meshtasticRuntime.remoteConfigSnapshot?.configSlices ?? {}) + : meshtasticRuntime.meshtasticConfigSlices; const effectiveSecurityConfig = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.securityConfig ?? null) - : activeRuntime.securityConfig; + ? (meshtasticRuntime.remoteConfigSnapshot?.securityConfig ?? null) + : meshtasticRuntime.securityConfig; const effectiveDeviceOwner = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.deviceOwner ?? null) - : activeRuntime.deviceOwner; + ? (meshtasticRuntime.remoteConfigSnapshot?.deviceOwner ?? null) + : meshtasticRuntime.deviceOwner; const effectiveDeviceFixedPosition = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.deviceFixedPosition ?? null) - : activeRuntime.deviceFixedPosition; + ? (meshtasticRuntime.remoteConfigSnapshot?.deviceFixedPosition ?? null) + : meshtasticRuntime.deviceFixedPosition; const effectiveRemoteChannelFailedIndices = isRemoteConfigureTarget - ? (activeRuntime.remoteConfigSnapshot?.failedChannelIndices ?? []) + ? (meshtasticRuntime.remoteConfigSnapshot?.failedChannelIndices ?? []) : undefined; const handleRetryRemoteChannelsTail = useCallback(() => { if (meshtasticRuntime.configureTargetNodeNum == null) return; @@ -1312,9 +1490,9 @@ function AppContent() { useEffect(() => { if (!isRemoteConfigureTarget || configureTargetNodeNum == null) return; if (!hasLocalMeshtasticRadio) return; - if (activePanelIndex === 5) { + if (activePanelIndex === MODULES_PANEL_INDEX) { void refreshRemoteConfigSnapshot(configureTargetNodeNum, 'modules'); - } else if (activePanelIndex === 9) { + } else if (activePanelIndex === SECURITY_PANEL_INDEX) { void refreshRemoteConfigSnapshot(configureTargetNodeNum, 'security'); } }, [ @@ -1382,12 +1560,9 @@ function AppContent() { const handleResend = useCallback( (msg: ChatMessage) => { - sendMessage( - msg.payload, - msg.channel, - msg.to ?? undefined, - msg.replyId != null ? String(msg.replyId) : undefined, - ); + const replyTo = + msg.reticulum_reply_to_hash ?? (msg.replyId != null ? String(msg.replyId) : undefined); + sendMessage(msg.payload, msg.channel, msg.to ?? undefined, replyTo); }, [sendMessage], ); @@ -1412,9 +1587,16 @@ function AppContent() { /** MeshCore chat: only show configured channels (key !== all zeros). */ const chatChannels = useMemo(() => { - if (!capabilities.nodeListTabUsesContactsLabel) return activeRuntime.channels; - return meshcoreConfiguredChatChannels(activeRuntime.channels); - }, [capabilities.nodeListTabUsesContactsLabel, activeRuntime.channels]); + if (capabilities.hasReticulumInterfaceConfig) return []; + if (capabilities.hasCompanionContactManagementConfig) { + return meshcoreConfiguredChatChannels(activeRuntime.channels); + } + return dedupeChannelPillsByIndex(activeRuntime.channels); + }, [ + capabilities.hasReticulumInterfaceConfig, + capabilities.hasCompanionContactManagementConfig, + activeRuntime.channels, + ]); const [chatTabVisited, setChatTabVisited] = useState(false); const [roomsTabVisited, setRoomsTabVisited] = useState(false); @@ -1623,6 +1805,12 @@ function AppContent() { } continue; } + if (prot === 'meshcore' && !shouldAutoLaunchMeshcoreMqttAtStartup()) { + console.debug( + '[App] MeshCore MQTT auto-launch deferred: JWT identity not ready (will retry after RF connect)', + ); + continue; + } void tryAutoLaunchMqtt(prot).catch((e: unknown) => { console.warn('[App] MQTT auto-launch connect failed ' + errLikeToLogString(e)); }); @@ -1772,7 +1960,12 @@ function AppContent() { prevMeshcoreMsgCountRef.current = count; }, [meshcoreUiMessages.length, capabilitiesByProtocol]); - useAppTrayUnreadSync(meshtasticChatUnread, meshcoreChatUnread, meshcoreRoomsUnread); + useAppTrayUnreadSync( + meshtasticChatUnread, + meshcoreChatUnread, + meshcoreRoomsUnread, + reticulumChatUnread, + ); // ─── Auto flood advert (MeshCore) ──────────────────────────────── const advertSentRef = useRef(false); @@ -1958,6 +2151,7 @@ function AppContent() { return ( + {/* Global assertive live region for critical announcements */}
{/* Passive notifications for inactive protocol activity */} @@ -1992,7 +2186,7 @@ function AppContent() { role="banner" className={`bg-deep-black relative grid w-full grid-cols-[auto_minmax(0,1fr)] items-center border-b py-2 pr-4 ${protocolHeaderBorderClass(protocol, isConfigured)}`} > -

Mesh Client

+

{t('app.title')}

{/* Sidebar-area branding — top-left cell, matches sidebar width */}
- Colorado Mesh + {t('app.brandName')}
) : ( @@ -2019,9 +2213,7 @@ function AppContent() { type="button" aria-busy={meshTubePhase !== 'idle'} aria-pressed={meshTubeLit} - aria-label={ - meshTubeLit ? 'Turn off Colorado Mesh sign' : 'Turn on Colorado Mesh sign' - } + aria-label={meshTubeLit ? t('app.meshTubeSignOff') : t('app.meshTubeSignOn')} className={[ 'cm-watermark cm-watermark-expanded cm-watermark-mesh-tube', meshTubePhase === 'flicker-on' && 'cm-watermark-mesh-tube--flicker-on', @@ -2033,7 +2225,7 @@ function AppContent() { onClick={handleMeshTubeToggle} > - Colorado Mesh + {t('app.brandName')} )}
@@ -2041,10 +2233,7 @@ function AppContent() {
@@ -2066,21 +2255,31 @@ function AppContent() { )} -
- - -
+ + + + )}
+ {activeConnectionView.state.status === 'connecting' && ( +
@@ -1698,11 +1778,10 @@ export default function AppPanel({ aria-label={t('appPanel.clearPositionHistory')} onClick={() => { executeWithConfirmation({ - name: 'Clear Position History', - title: 'Clear Position History', - message: - 'This will permanently delete all stored position history from the database. Movement trails will no longer be shown for past sessions. Continue?', - confirmLabel: 'Clear Position History', + actionId: 'clearPositionHistory', + title: t('appPanel.clearPositionHistory'), + message: t('appPanel.clearPositionHistoryConfirm'), + confirmLabel: t('appPanel.clearPositionHistory'), danger: true, action: async () => { await Promise.resolve(); @@ -1712,7 +1791,7 @@ export default function AppPanel({ }} className="w-full rounded-lg border border-red-800 bg-red-900/50 px-4 py-2.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900/70" > - Clear Position History + {t('appPanel.clearPositionHistory')} @@ -1733,7 +1812,7 @@ export default function AppPanel({ onChange={(e) => { setDeleteAgeDays(Math.max(1, parseInt(e.target.value) || 1)); }} - aria-label={`Delete nodes last heard more than ${deleteAgeDays} days`} + aria-label={t('appPanel.deleteNodesOlderThanAria', { days: deleteAgeDays })} className="bg-deep-black w-20 rounded border border-red-800/60 px-2 py-1 text-right text-sm text-gray-200 focus:border-red-500 focus:outline-none" /> {t('common.days')} @@ -1742,10 +1821,13 @@ export default function AppPanel({ aria-label={t('appPanel.deleteOldNodes')} onClick={() => { executeWithConfirmation({ - name: 'Delete Old Nodes', - title: 'Delete Old Nodes', - message: `This will permanently delete all nodes that haven't been heard in the last ${deleteAgeDays} day${deleteAgeDays !== 1 ? 's' : ''}. They will be re-discovered when they broadcast again.`, - confirmLabel: 'Delete Old Nodes', + actionId: 'deleteOldNodes', + title: t('appPanel.deleteOldNodes'), + message: t('appPanel.deleteOldNodesConfirm', { + days: deleteAgeDays, + count: deleteAgeDays, + }), + confirmLabel: t('appPanel.deleteOldNodes'), danger: true, action: async () => { await window.electronAPI.db.deleteNodesByAge(deleteAgeDays); @@ -1754,7 +1836,7 @@ export default function AppPanel({ }} className="rounded border border-red-800 bg-red-900/50 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-red-300 transition-colors hover:bg-red-900/70" > - Delete Old Nodes + {t('appPanel.deleteOldNodes')} {/* MeshCore contacts cleanup */} @@ -1940,11 +2036,10 @@ export default function AppPanel({ aria-label={t('appPanel.deleteNodesWithoutPubkeys')} onClick={() => { executeWithConfirmation({ - name: 'Delete Contacts Without Pubkeys', - title: 'Delete Contacts Without Pubkeys', - message: - 'This will permanently delete all MeshCore contacts from the database that have no public key. Chat stub nodes (created from messages) will be excluded. This cannot be undone.', - confirmLabel: 'Delete', + actionId: 'deleteContactsNoPubkeys', + title: t('appPanel.deleteContactsNoPubkeysTitle'), + message: t('appPanel.deleteContactsNoPubkeysConfirm'), + confirmLabel: t('appPanel.deleteContactsNoPubkeysConfirmButton'), danger: true, action: async () => { const result = @@ -1974,42 +2069,74 @@ export default function AppPanel({
{t('appPanel.messagesSection')}
-
- - { + setClearChannelTarget(parseInt(e.target.value, 10)); + }} + aria-label={t('common.channel')} + className="bg-deep-black flex-1 rounded-lg border border-red-800/60 px-3 py-1.5 text-sm text-gray-200 focus:border-red-500 focus:outline-none" + > + - ))} - -
+ {msgChannels.map((ch) => ( + + ))} + + + )} @@ -2045,25 +2172,24 @@ export default function AppPanel({ {onClearMeshcoreRepeaters && (
- MeshCore + {t('appPanel.dangerZoneMeshcoreHeading')}
)} @@ -2071,18 +2197,17 @@ export default function AppPanel({ {/* Everything */}
- Everything + {t('appPanel.dangerZoneEverythingHeading')}
diff --git a/src/renderer/components/BootSequence.test.tsx b/src/renderer/components/BootSequence.test.tsx index 4714fd725..fff60c1e4 100644 --- a/src/renderer/components/BootSequence.test.tsx +++ b/src/renderer/components/BootSequence.test.tsx @@ -466,6 +466,15 @@ describe('BootSequence component', () => { expect(container.querySelector('canvas')).toBeInTheDocument(); }); + it('renders with Reticulum protocol', () => { + const { container } = renderBootSequence({ + protocol: 'reticulum', + phraseSeed: 12, + identityId: null, + }); + expect(container.querySelector('canvas')).toBeInTheDocument(); + }); + it('renders with Meshtastic identityId', () => { const { container } = renderBootSequence({ protocol: 'meshtastic', diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 2f89a40b6..5d30b503f 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/refs */ import 'emoji-picker-element'; -import { CornerUpLeft } from 'lucide-react-motion'; +import { CornerUpLeft, Mic } from 'lucide-react-motion'; import { type RefObject, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -40,6 +40,8 @@ declare global { export interface ChatComposerSendOpts { replyId?: number; + /** Reticulum ratspeak.chat.v2 reply target (LXMF message hash). */ + replyHash?: string; chunkIndex?: number; } @@ -73,8 +75,12 @@ export interface ChatComposerProps { /** When provided, used instead of an internal outbox hook (ChatPanel shares one instance for message list). */ queueOutbox?: (entry: OutboxEntryInput) => Promise; onSendChunk: (text: string, opts?: ChatComposerSendOpts) => Promise; + /** Reticulum LXMF file/image attachment send (requires DM destination). */ + onSendAttachment?: (file: File, destination: number) => Promise; /** Called after a successful send (e.g. clear unread divider). */ onSendSuccess?: () => void; + /** Use LXMF message hash for reply threading (Reticulum). */ + lxmfReplyHashReplies?: boolean; textareaRef?: RefObject; className?: string; } @@ -101,18 +107,26 @@ export function ChatComposer({ outboxDestination, queueOutbox: queueOutboxProp, onSendChunk, + onSendAttachment, onSendSuccess, + lxmfReplyHashReplies = false, textareaRef, className, }: ChatComposerProps) { const { t } = useTranslation(); const iconTrigger = useIconTrigger(); const isLinux = useMemo(() => window.electronAPI.getPlatform() === 'linux', []); + const maxVoiceRecordMs = 60_000; const limitHintId = useId(); const counterLiveId = useId(); + const fileInputRef = useRef(null); + const mediaRecorderRef = useRef(null); + const recordChunksRef = useRef([]); + const recordTimerRef = useRef | null>(null); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); + const [isRecordingVoice, setIsRecordingVoice] = useState(false); const [chatActionError, setChatActionError] = useState<{ message: string; viewKey: string; @@ -131,6 +145,106 @@ export function ChatComposer({ inputValueRef.current = input; const prevViewKeyRef = useRef(null); + useEffect(() => { + return () => { + if (recordTimerRef.current) clearTimeout(recordTimerRef.current); + mediaRecorderRef.current?.stop(); + }; + }, []); + + const stopVoiceRecording = useCallback( + (send: boolean) => { + if (recordTimerRef.current) { + clearTimeout(recordTimerRef.current); + recordTimerRef.current = null; + } + const recorder = mediaRecorderRef.current; + if (!recorder) return; + recorder.onstop = () => { + const blob = new Blob(recordChunksRef.current, { + type: recorder.mimeType || 'audio/webm', + }); + recordChunksRef.current = []; + mediaRecorderRef.current = null; + setIsRecordingVoice(false); + if (!send || !onSendAttachment || outboxDestination == null) return; + const ext = blob.type.includes('ogg') ? 'ogg' : 'webm'; + const file = new File([blob], `voice-${Date.now()}.${ext}`, { + type: blob.type || 'audio/webm', + }); + void (async () => { + setSending(true); + try { + await onSendAttachment(file, outboxDestination); + onSendSuccess?.(); + } catch (err) { + console.error( + '[ChatComposer] Voice attachment send failed: ' + errLikeToLogString(err), + ); + setChatActionError({ + message: errLikeToLogString(err), + viewKey, + }); + } finally { + setSending(false); + } + })(); + }; + if (recorder.state !== 'inactive') recorder.stop(); + }, + [onSendAttachment, onSendSuccess, outboxDestination, viewKey], + ); + + const startVoiceRecording = useCallback(async () => { + if (!onSendAttachment || outboxDestination == null || disabled || !isConnected) return; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') + ? 'audio/ogg;codecs=opus' + : ''; + const recorder = mimeType + ? new MediaRecorder(stream, { mimeType }) + : new MediaRecorder(stream); + recordChunksRef.current = []; + recorder.ondataavailable = (e) => { + if (e.data.size > 0) recordChunksRef.current.push(e.data); + }; + recorder.onerror = () => { + stream.getTracks().forEach((track) => { + track.stop(); + }); + setIsRecordingVoice(false); + }; + recorder.start(); + mediaRecorderRef.current = recorder; + setIsRecordingVoice(true); + recordTimerRef.current = setTimeout(() => { + setChatActionError({ + message: t('chatPanel.voiceRecordingTooLong'), + viewKey, + }); + stopVoiceRecording(true); + }, maxVoiceRecordMs); + } catch (err) { + console.error('[ChatComposer] Voice recording failed: ' + errLikeToLogString(err)); + setChatActionError({ + message: errLikeToLogString(err), + viewKey, + }); + } + }, [ + disabled, + isConnected, + maxVoiceRecordMs, + onSendAttachment, + outboxDestination, + stopVoiceRecording, + t, + viewKey, + ]); + const replyToSenderName = replyTo?.sender_name; const meshcoreOpenWireCompat = protocol === 'meshcore' ? isMeshcoreOpenWireCompatEnabled() : false; @@ -139,7 +253,13 @@ export function ChatComposer({ ? undefined : protocol === 'meshtastic' ? replyTo.packetId - : (replyTo.packetId ?? replyTo.timestamp); + : lxmfReplyHashReplies + ? undefined + : (replyTo.packetId ?? replyTo.timestamp); + const reticulumReplyHash = + lxmfReplyHashReplies && replyTo?.reticulum_message_hash + ? replyTo.reticulum_message_hash + : undefined; const limitStatus = useMemo( () => @@ -294,7 +414,7 @@ export function ChatComposer({ channel: outboxChannel, toNode: outboxDestination ?? null, payload: textsToSend[i], - replyId: i === 0 ? (replyKey ?? null) : null, + replyId: i === 0 && typeof replyKey === 'number' ? replyKey : null, status: 'queued', error: null, nextRetryAt: null, @@ -323,7 +443,8 @@ export function ChatComposer({ try { for (let i = 0; i < textsToSend.length; i++) { await onSendChunk(textsToSend[i], { - replyId: i === 0 ? replyKey : undefined, + replyId: i === 0 && typeof replyKey === 'number' ? replyKey : undefined, + replyHash: i === 0 ? reticulumReplyHash : undefined, chunkIndex: i, }); } @@ -334,8 +455,33 @@ export function ChatComposer({ } catch (err) { console.error('[ChatComposer] Send failed: ' + errLikeToLogString(err)); const fallback = variant === 'room' ? t('roomsPanel.postFailed') : t('chatPanel.sendFailed'); + const errMsg = err instanceof Error ? err.message : fallback; + if (allowOutbox && queueOutbox) { + const groupId = textsToSend.length > 1 ? crypto.randomUUID() : null; + for (let i = 0; i < textsToSend.length; i++) { + await queueOutbox({ + protocol, + viewKey, + channel: outboxChannel, + toNode: outboxDestination ?? null, + payload: textsToSend[i], + replyId: i === 0 && typeof replyKey === 'number' ? replyKey : null, + status: 'queued', + error: null, + nextRetryAt: null, + groupId, + groupIndex: groupId ? i : null, + groupTotal: groupId ? textsToSend.length : null, + }); + } + clearSentDraft(draftSnapshot); + setMentionQuery(null); + onReplyClear?.(); + onSendSuccess?.(); + return; + } setChatActionError({ - message: err instanceof Error ? err.message : fallback, + message: errMsg, viewKey, }); } finally { @@ -359,6 +505,7 @@ export function ChatComposer({ queueOutbox, replyTo, replyKey, + reticulumReplyHash, sending, t, variant, @@ -759,6 +906,74 @@ export function ChatComposer({ 😊 + {onSendAttachment ? ( + <> + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || disabled || !isConnected) return; + if (outboxDestination == null) { + setChatActionError({ + message: t('chatPanel.attachDmOnly'), + viewKey, + }); + return; + } + void (async () => { + setSending(true); + try { + await onSendAttachment(file, outboxDestination); + onSendSuccess?.(); + } catch (err) { + // catch-no-log-ok: attachment failure shown inline in composer + setChatActionError({ + message: errLikeToLogString(err), + viewKey, + }); + } finally { + setSending(false); + } + })(); + }} + /> + + + + + + + + ) : null} {showMeshcoreGifButton && ( - ); - })} + {!dmOnlyChat && ( + <> + + {t('chatPanel.channels')} + + {channels.map((ch, chIdx) => { + const unread = unreadCounts.get(ch.index) ?? 0; + const channelUnreadSuffix = + unread > 0 && !(viewMode === 'channels' && channel === ch.index) + ? ` ${unread > 99 ? '99+' : unread}` + : ''; + return ( + + ); + })} + + )}
@@ -1639,7 +1769,7 @@ function ChatPanel({ : 'text-muted hover:text-gray-300' }`} onClick={() => { - setViewMode((v) => (v === 'starred' ? 'channels' : 'starred')); + setViewMode((v) => (v === 'starred' ? (dmOnlyChat ? 'dm' : 'channels') : 'starred')); }} > )} + {protocol === 'reticulum' && reticulumPropagationSync.active && ( +
+ {t('chatPanel.reticulumPropagationSyncActive')} + {reticulumPropagationSync.progress > 0 ? ( + + {Math.min(100, Math.round(reticulumPropagationSync.progress))}% + + ) : null} +
+ )} + {/* Row 2 — DM tabs */}
- DMs + {t('chatPanel.dms')} {visibleDmTabs.length === 0 ? ( - No conversations + + {t(dmOnlyChat ? 'chatPanel.noDmConversationsReticulum' : 'chatPanel.noDmConversations')} + ) : ( visibleDmTabs.map((nodeNum) => { const dmUnread = dmUnreadCounts.get(nodeNum) ?? 0; @@ -1684,7 +1830,7 @@ function ChatPanel({ dmUnread > 0 && !(viewMode === 'dm' && activeDmNode === nodeNum); return (
× @@ -1855,7 +2001,7 @@ function ChatPanel({ {/* Disconnected overlay */} {!isConnected && (
-

Not connected — messages are read-only

+

{t('chatPanel.readOnlyDisconnected')}

)} @@ -1881,7 +2027,11 @@ function ChatPanel({ .sort((a, b) => b.starredAt - a.starredAt) .map((s) => { const sourceLabel = - s.to != null ? `DM: ${s.sender_name || String(s.sender_id)}` : `ch${s.channel}`; + s.to != null + ? t('chatPanel.starredDm', { + name: s.sender_name || String(s.sender_id), + }) + : t('chatPanel.starredChannel', { channel: s.channel }); return (
{searchQuery ? t('chatPanel.emptyNoSearchMatches') - : isDmMode - ? t('chatPanel.emptyNoDmMessages', { name: dmNodeName }) - : isConnected - ? t('chatPanel.emptyNoMessagesYet') - : t('chatPanel.emptyConnectFirst')} + : dmOnlyChat && activeDmNode == null + ? t('chatPanel.emptySelectDm') + : isDmMode + ? t('chatPanel.emptyNoDmMessages', { name: dmNodeName }) + : isConnected + ? t('chatPanel.emptyNoMessagesYet') + : t('chatPanel.emptyConnectFirst')}
) : (
= filteredMessages.length - 3; @@ -2240,14 +2392,22 @@ function ChatPanel({ {/* Message text with optional search highlight (div: ChatPayloadText may render block link previews) */}
- { - scheduleMessageRowRemeasure(i); - }} - /> + {showLxmfAttachmentLine && + parseReticulumAttachmentPayload(msg.payload) ? ( + + ) : ( + { + scheduleMessageRowRemeasure(i); + }} + /> + )}
{/* Transport + RF hop count (incoming) */} @@ -2274,25 +2434,40 @@ function ChatPanel({ {/* Delivery status for own messages */} {isOwn && (msg.status || msg.mqttStatus) && (
- {isOwn && msg.status === 'failed' && ( - - )} - {msg.mqttStatus ? ( + {isOwn && + (msg.status === 'failed' || + (protocol === 'reticulum' && msg.status === 'sending')) && ( + + )} + {showLxmfDeliveryStatus && msg.status ? ( + + ) : msg.mqttStatus ? ( <> {msg.status && ( @@ -2552,6 +2727,7 @@ function ChatPanel({ connectionType={connectionType} isMqttOnly={isMqttOnly} isDmMode={isDmMode} + disabled={dmOnlyChat && activeDmNode == null} composerContext={viewMode === 'dm' ? 'dm' : 'channel'} senderDisplayName={composerSelfDisplayName} placeholder={composePlaceholder} @@ -2564,6 +2740,9 @@ function ChatPanel({ outboxDestination={viewMode === 'dm' && activeDmNode != null ? activeDmNode : undefined} queueOutbox={queueOutbox} onSendChunk={handleSendChunk} + onSendAttachment={onSendAttachment} + payloadLimit={composerPayloadLimit} + lxmfReplyHashReplies={lxmfReplyHashReplies} onSendSuccess={() => { setUnreadDividerTimestamp(0); }} diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index 5c159e2fc..bd1464a4c 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -7,6 +7,7 @@ import { axe } from 'vitest-axe'; import type { SerialPort } from '@/shared/electron-api.types'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import type { FirmwareCheckResult } from '../lib/firmwareCheck'; import { MESHCORE_IDENTITY_STORAGE_KEY } from '../lib/letsMeshJwt'; import type { DeviceState } from '../lib/types'; @@ -124,6 +125,23 @@ describe('ConnectionPanel accessibility', () => { protocol="meshtastic" />, ); + hydrateAxeThemeColors(container); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no axe violations for MeshCore disconnected state', async () => { + const { container } = render( + , + ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -551,6 +569,7 @@ describe('ConnectionPanel firmware status indicator', () => { { phase: 'update-available', latestVersion: '2.5.4' }, vi.fn(), ); + hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); }); @@ -982,6 +1001,8 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { const mcConnKey = 'mesh-client:lastConnection:meshcore'; const mtConnKey = 'mesh-client:lastConnection:meshtastic'; localStorage.setItem(protocolKey, 'meshcore'); + localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); + localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); localStorage.setItem( mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), @@ -992,7 +1013,9 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { ); const onAutoConnect = vi.fn().mockResolvedValue(undefined); const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - const settleSpy = vi.spyOn(dualNoble, 'awaitNobleBleProtocolSettle'); + dualNoble.resetNobleBleConnectMutexForTests(); + dualNoble.initNobleBleDualRadioStartup(); + const settleSpy = vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled'); try { render( @@ -1018,15 +1041,20 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { } finally { localStorage.removeItem(mcConnKey); localStorage.removeItem(mtConnKey); + localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); + localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); + dualNoble.resetNobleBleConnectMutexForTests(); restore(); } }); - it('defers inactive meshtastic auto-connect until active meshcore settles', async () => { + it('defers inactive meshtastic auto-connect until active meshcore primary settles', async () => { const { restore } = mockMacNoblePlatform(); const mcConnKey = 'mesh-client:lastConnection:meshcore'; const mtConnKey = 'mesh-client:lastConnection:meshtastic'; localStorage.setItem(protocolKey, 'meshcore'); + localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); + localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); localStorage.setItem( mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), @@ -1037,13 +1065,17 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { ); const onAutoConnect = vi.fn().mockResolvedValue(undefined); const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); + dualNoble.resetNobleBleConnectMutexForTests(); + dualNoble.initNobleBleDualRadioStartup(); let releaseSettle!: () => void; - const settleSpy = vi.spyOn(dualNoble, 'awaitNobleBleProtocolSettle').mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), - ); + const settleSpy = vi + .spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled') + .mockImplementation( + () => + new Promise((resolve) => { + releaseSettle = resolve; + }), + ); try { render( @@ -1058,7 +1090,7 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { ); await Promise.resolve(); - expect(settleSpy).toHaveBeenCalledWith('meshcore', expect.any(Number)); + expect(settleSpy).toHaveBeenCalled(); expect(onAutoConnect).not.toHaveBeenCalled(); releaseSettle(); @@ -1073,15 +1105,20 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { } finally { localStorage.removeItem(mcConnKey); localStorage.removeItem(mtConnKey); + localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); + localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); + dualNoble.resetNobleBleConnectMutexForTests(); restore(); } }); - it('defers inactive meshcore auto-connect until active meshtastic settles', async () => { + it('defers inactive meshcore auto-connect until active meshtastic primary settles', async () => { const { restore } = mockMacNoblePlatform(); const mcConnKey = 'mesh-client:lastConnection:meshcore'; const mtConnKey = 'mesh-client:lastConnection:meshtastic'; localStorage.setItem(protocolKey, 'meshtastic'); + localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); + localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); localStorage.setItem( mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), @@ -1092,13 +1129,17 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { ); const onAutoConnect = vi.fn().mockResolvedValue(undefined); const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); + dualNoble.resetNobleBleConnectMutexForTests(); + dualNoble.initNobleBleDualRadioStartup(); let releaseSettle!: () => void; - const settleSpy = vi.spyOn(dualNoble, 'awaitNobleBleProtocolSettle').mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), - ); + const settleSpy = vi + .spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled') + .mockImplementation( + () => + new Promise((resolve) => { + releaseSettle = resolve; + }), + ); try { render( @@ -1113,7 +1154,7 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { ); await Promise.resolve(); - expect(settleSpy).toHaveBeenCalledWith('meshtastic', expect.any(Number)); + expect(settleSpy).toHaveBeenCalled(); expect(onAutoConnect).not.toHaveBeenCalled(); releaseSettle(); @@ -1128,6 +1169,9 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { } finally { localStorage.removeItem(mcConnKey); localStorage.removeItem(mtConnKey); + localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); + localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); + dualNoble.resetNobleBleConnectMutexForTests(); restore(); } }); @@ -1569,3 +1613,37 @@ describe('ConnectionPanel LetsMesh username sync', () => { } }); }); + +describe('ConnectionPanel Reticulum', () => { + it('shows Reticulum stack panel instead of BLE spinner while sidecar is connecting', async () => { + const lastConnKey = 'mesh-client:lastConnection:reticulum'; + localStorage.setItem( + lastConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'saved-reticulum-ble' }), + ); + const onAutoConnect = vi.fn().mockResolvedValue(undefined); + + try { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Reticulum stack')).toBeInTheDocument(); + }); + expect(screen.queryByText(/Scanning for Bluetooth devices/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Auto-connecting/i)).not.toBeInTheDocument(); + expect(onAutoConnect).not.toHaveBeenCalled(); + } finally { + localStorage.removeItem(lastConnKey); + } + }); +}); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index c51cdfada..d88159be2 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1,20 +1,26 @@ /* eslint-disable react-hooks/set-state-in-effect, react-hooks/refs */ import { PARENT_HOVER_ATTR } from 'lucide-react-motion'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { ConnectionIcon, MqttGlobeIcon } from '@/renderer/lib/icons/connectionIcons'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { SpinnerIcon, SpinnerIconLg } from '@/renderer/lib/icons/spinnerIcon'; import { - awaitNobleBleProtocolSettle, + awaitNobleBlePrimaryAutoConnectSettled, + dualNobleBleBothRadiosConfigured, + getNobleBleDualRadioPrimaryProtocol, + initNobleBleDualRadioStartup, + isNobleBleDualRadioSecondary, isRendererNobleBlePlatform, meshcoreTargetsSharedMeshtasticBlePeripheral, + notifyNobleBlePrimaryAutoConnectSettled, } from '@/renderer/lib/meshcoreDualNobleBleInit'; import { markMqttUserDisconnect } from '@/renderer/lib/mqttDisconnectIntent'; import { mqttUsesTls } from '@/renderer/lib/mqttTls'; import { parseTcpAddress } from '@/renderer/lib/parseTcpAddress'; +import { useRadioProvider } from '@/renderer/lib/radio/providerFactory'; import type { RfConnectAutomaticFn, RfConnectFn } from '@/renderer/lib/rfConnectionTypes'; import { isPairingRelatedError } from '@/shared/blePairingError'; import { @@ -24,15 +30,16 @@ import { import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { clampTcpPort, parseTcpPortFromString } from '@/shared/tcpPort'; +import { useNobleBleConnectMutexWait } from '../hooks/useNobleBleConnectMutexWait'; import { reconnectBleWithScan } from '../lib/bleReconnectHelper'; import { humanizeBleError, humanizeHttpError, + humanizeReticulumSidecarError, humanizeSerialError, } from '../lib/connectionPanelErrorHumanize'; import { runConnectionPanelStorageMigrations } from '../lib/connectionPanelStorageMigrations'; import type { FirmwareCheckResult } from '../lib/firmwareCheck'; -import { resolveLastBlePeripheralId } from '../lib/lastConnectionStorage'; import { letsMeshPresetConfigurationDeviation, validateLetsMeshManualCredentials, @@ -50,6 +57,7 @@ import { readMeshcoreIdentity, readMeshcoreIdentityAsync, } from '../lib/letsMeshJwt'; +import { translateMeshcoreUserMessage } from '../lib/meshcore/meshcoreMessageI18n'; import { readMeshcoreMqttSettingsFromStorage } from '../lib/meshcoreMqttSettingsStorage'; import { meshcoreMqttUserFacingHint } from '../lib/meshcoreMqttUserHint'; import { @@ -69,6 +77,7 @@ import { meshtasticMqttErrorUserHint, } from '../lib/meshtasticMqttTlsMigration'; import { parseStoredJson } from '../lib/parseStoredJson'; +import { getSerialPortNodeName } from '../lib/serialPortNodeNames'; import { LAST_SERIAL_PORT_KEY } from '../lib/serialPortSignature'; import { getStoredMeshProtocol } from '../lib/storedMeshProtocol'; import { POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS } from '../lib/timeConstants'; @@ -84,6 +93,7 @@ import type { import ConnectionBatteryGauge from './ConnectionBatteryGauge'; import FirmwareStatusIndicator from './FirmwareStatusIndicator'; import { HelpTooltip } from './HelpTooltip'; +import { ReticulumStackPanel } from './ReticulumStackPanel'; // ─── Last Connection (localStorage) ─────────────────────────────── interface LastConnection { type: ConnectionType; @@ -139,6 +149,39 @@ function parseBluetoothctlPairedState(info: string): 'yes' | 'no' | 'unknown' { } const STAGE_LINUX_UNPAIRED = 'connectionPanel.stageLinuxUnpaired'; +const STAGE_WAITING_NOBLE_BLE_MESHTASTIC = 'connectionPanel.stageWaitingNobleBleMeshtastic'; +const STAGE_WAITING_NOBLE_BLE_MESHCORE = 'connectionPanel.stageWaitingNobleBleMeshcore'; + +function resolveBleAutoConnectLabel( + bleId: string, + lc?: LastConnection | null, + fallbackName?: string | null, +): string { + return lc?.bleDeviceName ?? getBleDeviceName(bleId) ?? fallbackName ?? bleId; +} + +function resolveConnectionStageText( + stage: string, + autoConnectTarget: string | null, + t: (key: string, opts?: Record) => string, +): string { + if (!stage) return ''; + if (autoConnectTarget) { + if (stage === STAGE_WAITING_NOBLE_BLE_MESHCORE) { + return t('connectionPanel.stageWaitingNobleBleMeshcore', { deviceName: autoConnectTarget }); + } + if (stage === STAGE_WAITING_NOBLE_BLE_MESHTASTIC) { + return t('connectionPanel.stageWaitingNobleBleMeshtastic', { deviceName: autoConnectTarget }); + } + if ( + stage === 'connectionPanel.stageConnecting' || + stage === 'connectionPanel.stageConnectingLast' + ) { + return t('connectionPanel.stageAutoConnectingBle', { deviceName: autoConnectTarget }); + } + } + return t(stage); +} function shouldForgetGrantedWebBluetoothDevice( device: BluetoothDevice, @@ -229,15 +272,6 @@ function getBleDeviceName(deviceId: string): string | null { return cache[deviceId] ?? null; } -function getSerialPortNodeName(portId: string): string | null { - const cache = - parseStoredJson>( - localStorage.getItem('mesh-client:serialPortNodeNames'), - 'ConnectionPanel serialPortNodeNames', - ) ?? {}; - return cache[portId] ?? null; -} - function MqttGlobeStatusIcon({ status }: { status: MQTTStatus }) { const color = status === 'connected' @@ -294,6 +328,8 @@ interface Props { onOpenFirmwareReleases?: () => void; /** MeshCore: export private key from connected radio when MQTT identity cache is incomplete. */ ensureMeshcoreMqttIdentity?: () => Promise; + /** Reticulum: start or restart the AGPL sidecar stack. */ + onStartReticulumStack?: () => Promise; } export default function ConnectionPanel({ @@ -309,10 +345,13 @@ export default function ConnectionPanel({ firmwareCheckState, onOpenFirmwareReleases, ensureMeshcoreMqttIdentity, + onStartReticulumStack, }: Props) { const { t } = useTranslation(); + const capabilities = useRadioProvider(protocol); const parentIconTrigger = useParentIconTrigger(); const letsMeshUsernameSyncTimerRef = useRef | null>(null); + const [reticulumStackError, setReticulumStackError] = useState(null); useEffect(() => { runConnectionPanelStorageMigrations(); @@ -341,6 +380,7 @@ export default function ConnectionPanel({ const [error, setError] = useState(null); const [connecting, setConnecting] = useState(false); const [connectionStage, setConnectionStage] = useState(''); + const nobleBleMutexWait = useNobleBleConnectMutexWait(protocol); const [showRePairButton, setShowRePairButton] = useState(false); const [showPinPrompt, setShowPinPrompt] = useState(false); const showPinPromptRef = useRef(false); @@ -464,17 +504,21 @@ export default function ConnectionPanel({ if (mqttProtocol !== protocol) return; setMqttError( protocol === 'meshcore' - ? meshcoreMqttUserFacingHint(error) + ? translateMeshcoreUserMessage(t, meshcoreMqttUserFacingHint(error)) : meshtasticMqttErrorUserHint(error), ); }); - }, [protocol]); + }, [protocol, t]); useEffect(() => { return window.electronAPI.mqtt.onWarning(({ warning, protocol: mqttProtocol }) => { if (mqttProtocol !== protocol) return; - setMqttWarning(protocol === 'meshcore' ? meshcoreMqttUserFacingHint(warning) : warning); + setMqttWarning( + protocol === 'meshcore' + ? translateMeshcoreUserMessage(t, meshcoreMqttUserFacingHint(warning)) + : warning, + ); }); - }, [protocol]); + }, [protocol, t]); // Clear MQTT error on successful connect; leave it visible on disconnect so the user can read it. useEffect(() => { @@ -600,6 +644,7 @@ export default function ConnectionPanel({ const autoConnectTimeoutRef = useRef | null>(null); const isAutoConnectingRef = useRef(false); const [isAutoConnecting, setIsAutoConnecting] = useState(false); + const [autoConnectBleTarget, setAutoConnectBleTarget] = useState(null); const [sharedBleNotice, setSharedBleNotice] = useState(false); // Tracks BLE device name at selection time, used when saving LastConnection const lastSelectedBleNameRef = useRef(null); @@ -672,6 +717,7 @@ export default function ConnectionPanel({ setConnecting(false); isAutoConnectingRef.current = false; setIsAutoConnecting(false); + setAutoConnectBleTarget(null); if (autoConnectTimeoutRef.current) { clearTimeout(autoConnectTimeoutRef.current); autoConnectTimeoutRef.current = null; @@ -751,26 +797,12 @@ export default function ConnectionPanel({ return; } if (lastId && device.deviceId === lastId) { - if (autoConnectTimeoutRef.current) { - clearTimeout(autoConnectTimeoutRef.current); - autoConnectTimeoutRef.current = null; - } - void window.electronAPI.stopNobleBleScanning(protocol); - saveLastBleDevice(protocol, device.deviceId); - lastSelectedBleNameRef.current = device.deviceName ?? null; - setConnectionStage('connectionPanel.stageConnecting'); - onConnect('ble', undefined, device.deviceId).catch((err: unknown) => { - isAutoConnectingRef.current = false; - setIsAutoConnecting(false); - const bleErrMsg = humanizeBleError(err, t); - if (bleErrMsg) setError(bleErrMsg); - setConnecting(false); - setConnectionStage(''); - }); + // reconnectBleWithScan + connectAutomatic already owns Noble auto-connect; a second + // onConnect here races prepareRfConnect / Noble IPC (dual-protocol startup). return; } } - if (connectionTypeRef.current === 'ble') { + if (connectionTypeRef.current === 'ble' && !isAutoConnectingRef.current) { setShowBlePicker(true); setConnectionStage('connectionPanel.stageScanning'); } @@ -1268,10 +1300,32 @@ export default function ConnectionPanel({ // Serial and BLE use gesture-free reconnect when the platform remembers the device. // HTTP still uses the one-click reconnect card (no autoconnect on mount). useEffect(() => { + initNobleBleDualRadioStartup(); + + const notifyPrimaryAutoConnectSettledIfNeeded = () => { + if ( + dualNobleBleBothRadiosConfigured() && + getNobleBleDualRadioPrimaryProtocol() === protocol + ) { + notifyNobleBlePrimaryAutoConnectSettled(); + } + }; + + if (capabilities.hasReticulumInterfaceConfig) { + notifyPrimaryAutoConnectSettledIfNeeded(); + return; + } + if (autoConnectFiredRef.current) return; - if (deviceStateRef.current.status !== 'disconnected') return; + if (deviceStateRef.current.status !== 'disconnected') { + notifyPrimaryAutoConnectSettledIfNeeded(); + return; + } const lc = lastConnectionRef.current; - if (!lc) return; + if (!lc) { + notifyPrimaryAutoConnectSettledIfNeeded(); + return; + } autoConnectFiredRef.current = true; @@ -1283,6 +1337,7 @@ export default function ConnectionPanel({ console.warn('[ConnectionPanel] auto-connect timed out after 30s'); isAutoConnectingRef.current = false; setIsAutoConnecting(false); + setAutoConnectBleTarget(null); setError(t('connectionPanel.error.autoConnectTimeout')); setConnecting(false); setConnectionStage(''); @@ -1296,6 +1351,7 @@ export default function ConnectionPanel({ } isAutoConnectingRef.current = false; setIsAutoConnecting(false); + setAutoConnectBleTarget(null); const errMsg = err instanceof Error ? transport === 'serial' @@ -1307,24 +1363,42 @@ export default function ConnectionPanel({ setConnectionStage(''); }; + const maybeNotifyPrimaryBleAutoConnectSettled = notifyPrimaryAutoConnectSettledIfNeeded; + const startBleNobleAutoConnect = (): boolean => { - if (!lastBleId || isLinux) return false; - setConnectionType('ble'); - isAutoConnectingRef.current = true; - setIsAutoConnecting(true); - setConnecting(true); - setConnectionStage('connectionPanel.stageConnecting'); - // reconnectBleWithScan owns failure timing (wait + scan); do not use the 30s serial timeout here. - void reconnectBleWithScan(protocol, lastBleId, () => - onAutoConnectRef.current('ble', undefined, undefined, lastBleId), - ) - .then(() => { - isAutoConnectingRef.current = false; - setIsAutoConnecting(false); - setConnecting(false); - setConnectionStage(''); - }) - .catch(onAutoConnectFailed); + if (!lastBleId || isLinux) { + maybeNotifyPrimaryBleAutoConnectSettled(); + return false; + } + void (async () => { + const bleTargetLabel = resolveBleAutoConnectLabel( + lastBleId, + lc, + lastConnectionBleDeviceNameFallbackRef.current, + ); + setAutoConnectBleTarget(bleTargetLabel); + setConnectionType('ble'); + isAutoConnectingRef.current = true; + setIsAutoConnecting(true); + setConnecting(true); + setShowBlePicker(false); + setConnectionStage('connectionPanel.stageConnecting'); + // Primary: notify secondary after the first connect attempt (not after scan fallback). + await reconnectBleWithScan(protocol, lastBleId, () => { + const attempt = onAutoConnectRef.current('ble', undefined, undefined, lastBleId); + if ( + dualNobleBleBothRadiosConfigured() && + getNobleBleDualRadioPrimaryProtocol() === protocol + ) { + void attempt.finally(maybeNotifyPrimaryBleAutoConnectSettled); + } + return attempt; + }); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); + })().catch(onAutoConnectFailed); return true; }; @@ -1343,27 +1417,67 @@ export default function ConnectionPanel({ setIsAutoConnecting(false); setConnecting(false); setConnectionStage(''); + maybeNotifyPrimaryBleAutoConnectSettled(); if (getStoredMeshProtocol() === 'meshcore') { setSharedBleNotice(true); } return true; }; + const runSecondaryBleAutoConnect = async (bleId: string) => { + const primary = getNobleBleDualRadioPrimaryProtocol(); + const bleTargetLabel = resolveBleAutoConnectLabel( + bleId, + lc, + lastConnectionBleDeviceNameFallbackRef.current, + ); + setAutoConnectBleTarget(bleTargetLabel); + setConnectionType('ble'); + isAutoConnectingRef.current = true; + setIsAutoConnecting(true); + setConnecting(true); + setShowBlePicker(false); + setConnectionStage( + primary === 'meshtastic' + ? STAGE_WAITING_NOBLE_BLE_MESHTASTIC + : STAGE_WAITING_NOBLE_BLE_MESHCORE, + ); + await awaitNobleBlePrimaryAutoConnectSettled(POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS); + setConnectionStage('connectionPanel.stageConnecting'); + void reconnectBleWithScan(protocol, bleId, () => + onAutoConnectRef.current('ble', undefined, undefined, bleId), + ) + .then(() => { + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); + }) + .catch(onAutoConnectFailed); + }; + const runBleAutoConnectWithDefer = async () => { if (skipMeshcoreSharedMeshtasticBleAutoConnect()) return; - const activeProtocol = getStoredMeshProtocol(); - const otherProtocol = protocol === 'meshtastic' ? 'meshcore' : 'meshtastic'; - const otherHasNobleBle = - isRendererNobleBlePlatform() && - loadLastConnection(otherProtocol)?.type === 'ble' && - Boolean(resolveLastBlePeripheralId(otherProtocol)); - if (protocol !== activeProtocol && otherHasNobleBle) { - await awaitNobleBleProtocolSettle( - activeProtocol, - POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS, + const bleId = lastBleId; + const secondary = isNobleBleDualRadioSecondary(protocol); + if (secondary) { + if (!bleId) { + console.warn(`[ConnectionPanel] ${protocol} dual-radio auto-connect skipped — no BLE id`); + return; + } + await runSecondaryBleAutoConnect(bleId); + return; + } + const started = startBleNobleAutoConnect(); + if (!started) { + console.warn( + `[ConnectionPanel] BLE auto-connect skipped for ${protocol} — no remembered device`, ); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); } - startBleNobleAutoConnect(); }; const migrateLastConnectionToBle = (bleDeviceId: string) => { @@ -1394,14 +1508,19 @@ export default function ConnectionPanel({ return; } onAutoConnectFailed(err, 'serial'); + maybeNotifyPrimaryBleAutoConnectSettled(); }); } else if (lc.type === 'ble') { if (lastBleId && !isLinux) { void runBleAutoConnectWithDefer(); + } else { + maybeNotifyPrimaryBleAutoConnectSettled(); } + } else { + maybeNotifyPrimaryBleAutoConnectSettled(); } // HTTP: do not auto-trigger — show one-click reconnect card instead - }, [protocol, isLinux, t]); + }, [protocol, isLinux, t, capabilities.hasReticulumInterfaceConfig]); // Cleanup timeout on unmount useEffect( @@ -1416,7 +1535,8 @@ export default function ConnectionPanel({ setError(null); if (lastConnection.type === 'ble') { - if (lastConnection.bleDeviceId) { + void (async () => { + if (!lastConnection.bleDeviceId) return; setConnectionType('ble'); setBleDevices([]); setShowBlePicker(false); @@ -1431,7 +1551,14 @@ export default function ConnectionPanel({ setConnectionStage('connectionPanel.stageReconnecting'); // Same-tick IPC: discovery may run before setConnectionType('ble') commits; picker gating uses connectionTypeRef. connectionTypeRef.current = 'ble'; - void onConnect('ble', undefined).catch((err: unknown) => { + try { + await onConnect('ble', undefined); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); + } catch (err: unknown) { + // catch-no-log-ok reconnect errors surfaced via setError/humanizeBleError isAutoConnectingRef.current = false; setIsAutoConnecting(false); const bleErrMsg = humanizeBleError(err, t); @@ -1451,29 +1578,29 @@ export default function ConnectionPanel({ setManualPairingFallback(true); setPinInputValue(''); } - }); + } } else { const bleDeviceId = lastConnection.bleDeviceId; setConnectionStage('connectionPanel.stageConnecting'); - void reconnectBleWithScan(protocol, bleDeviceId, () => - onConnect('ble', undefined, bleDeviceId), - ) - .then(() => { - isAutoConnectingRef.current = false; - setIsAutoConnecting(false); - setConnecting(false); - setConnectionStage(''); - }) - .catch((err: unknown) => { - isAutoConnectingRef.current = false; - setIsAutoConnecting(false); - const bleErrMsg = humanizeBleError(err, t); - if (bleErrMsg) setError(bleErrMsg); - setConnecting(false); - setConnectionStage(''); - }); + try { + await reconnectBleWithScan(protocol, bleDeviceId, () => + onConnect('ble', undefined, bleDeviceId), + ); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); + } catch (err: unknown) { + // catch-no-log-ok reconnect errors surfaced via setError/humanizeBleError + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + const bleErrMsg = humanizeBleError(err, t); + if (bleErrMsg) setError(bleErrMsg); + setConnecting(false); + setConnectionStage(''); + } } - } + })(); } else if (lastConnection.type === 'http') { const fallbackAddress = protocol === 'meshcore' ? tcpHost : httpAddress; const addr = lastConnection.httpAddress ?? fallbackAddress; @@ -1518,6 +1645,42 @@ export default function ConnectionPanel({ state.status === 'stale' || state.status === 'reconnecting'; + useEffect(() => { + const rfBusy = + connecting || + isAutoConnecting || + state.status === 'connecting' || + state.status === 'reconnecting'; + if (!rfBusy || !isRendererNobleBlePlatform()) return; + + if (nobleBleMutexWait.waitingOnNobleBlePeer) { + const primary = nobleBleMutexWait.primaryProtocol; + if (primary === 'meshtastic') { + setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHTASTIC); + } else if (primary === 'meshcore') { + setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHCORE); + } + return; + } + + if ( + nobleBleMutexWait.active === protocol && + (connectionStage === STAGE_WAITING_NOBLE_BLE_MESHTASTIC || + connectionStage === STAGE_WAITING_NOBLE_BLE_MESHCORE) + ) { + setConnectionStage('connectionPanel.stageConnecting'); + } + }, [ + connecting, + isAutoConnecting, + state.status, + protocol, + nobleBleMutexWait.waitingOnNobleBlePeer, + nobleBleMutexWait.active, + nobleBleMutexWait.primaryProtocol, + connectionStage, + ]); + const handleExitApp = useCallback( async (variant: 'connected' | 'idle' | 'connecting') => { if (variant === 'connecting') { @@ -1558,10 +1721,40 @@ export default function ConnectionPanel({ }; // ─── Connecting Progress View ─────────────────────────────────── - if (connecting && !isConnected) { - return ( + const rfSessionPending = + connecting || + isAutoConnecting || + state.status === 'connecting' || + state.status === 'reconnecting'; + const showNobleBleWaitNotice = + nobleBleMutexWait.waitingOnNobleBlePeer && rfSessionPending && isRendererNobleBlePlatform(); + + const showAutoReconnectBanner = + isAutoConnecting || + state.status === 'reconnecting' || + connecting || + nobleBleMutexWait.waitingForPrimaryAutoConnect; + + const renderAutoReconnectBanner = (): ReactNode => + showAutoReconnectBanner ? ( +
+ {t('connectionPanel.autoReconnectInProgress')} +
+ ) : null; + + let connectingProgressView: ReactNode = null; + if ( + !capabilities.hasReticulumInterfaceConfig && + ((connecting && !isConnected) || (showNobleBleWaitNotice && state.status !== 'configured')) + ) { + connectingProgressView = (
{renderExitActions('connecting')}
+ {renderAutoReconnectBanner()}

@@ -1569,9 +1762,11 @@ export default function ConnectionPanel({ ? t('connectionPanel.pairWithDevice') : showBlePicker ? t('connectionPanel.scanningBluetooth') - : isAutoConnecting - ? t('connectionPanel.autoConnecting') - : t('connectionPanel.connecting')} + : isAutoConnecting && autoConnectBleTarget + ? t('connectionPanel.autoConnectingTo', { deviceName: autoConnectBleTarget }) + : isAutoConnecting + ? t('connectionPanel.autoConnecting') + : t('connectionPanel.connecting')}

- {connectionStage ? t(connectionStage) : ''} + {resolveConnectionStageText(connectionStage, autoConnectBleTarget, t)}

{t('connectionPanel.stayOnTab')}

@@ -1650,15 +1845,15 @@ export default function ConnectionPanel({
{bleDevices.some((d) => d.deviceName === 'AdaDFU') && (

- On macOS, if a device shows as "AdaDFU", pair it first in System Settings - → Bluetooth to see its Meshtastic name. + {t('connectionPanel.hintAdaDfuBle')}

)} {protocol === 'meshcore' && (

- Pair your MeshCore device in system Bluetooth settings before - connecting. Use a PIN code if prompted — if your system does not ask for a PIN, the - connection will fail and you may need to remove the pairing and re-pair with a PIN. + }} + />

)}
@@ -1806,7 +2001,7 @@ export default function ConnectionPanel({ ? 'animate-pulse text-yellow-400' : mqttStatus === 'error' ? 'text-red-400' - : 'text-gray-500' + : 'text-gray-300' }`} aria-live="polite" > @@ -1937,11 +2132,14 @@ export default function ConnectionPanel({ > {( [ - { id: 'official-plain' as const, label: 'MQTT :1883' }, - { id: 'liam' as const, label: "Liam's" }, - { id: 'custom' as const, label: 'Custom' }, + { + id: 'official-plain' as const, + labelKey: 'connectionPanel.meshtasticPreset.officialPlain', + }, + { id: 'liam' as const, labelKey: 'connectionPanel.meshtasticPreset.liam' }, + { id: 'custom' as const, labelKey: 'connectionPanel.meshtasticPreset.custom' }, ] as const - ).map(({ id, label }) => ( + ).map(({ id, labelKey }) => ( ))}
@@ -1988,13 +2186,13 @@ export default function ConnectionPanel({ > {( [ - { id: 'letsmesh', label: 'LetsMesh' }, - { id: 'coloradomesh', label: 'Colorado Mesh' }, - { id: 'meshmapper', label: 'MeshMapper' }, - { id: 'ripple', label: 'Ripple Networks' }, - { id: 'custom', label: 'Custom' }, + { id: 'letsmesh', labelKey: 'connectionPanel.meshcorePreset.letsmesh' }, + { id: 'coloradomesh', labelKey: 'connectionPanel.meshcorePreset.coloradomesh' }, + { id: 'meshmapper', labelKey: 'connectionPanel.meshcorePreset.meshmapper' }, + { id: 'ripple', labelKey: 'connectionPanel.meshcorePreset.ripple' }, + { id: 'custom', labelKey: 'connectionPanel.meshcorePreset.custom' }, ] as const - ).map(({ id, label }) => ( + ).map(({ id, labelKey }) => ( ))}
@@ -2088,7 +2286,7 @@ export default function ConnectionPanel({ className={`rounded border px-2 py-1 text-xs font-medium transition-colors ${ meshcoreMqttSettings.server === LETSMESH_HOST_US ? 'bg-brand-green/20 border-brand-green text-brand-green' - : 'bg-secondary-dark border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200' + : 'bg-secondary-dark border-gray-600 text-gray-300 hover:border-gray-400 hover:text-gray-100' }`} > US @@ -2109,7 +2307,7 @@ export default function ConnectionPanel({ className={`rounded border px-2 py-1 text-xs font-medium transition-colors ${ meshcoreMqttSettings.server === LETSMESH_HOST_EU ? 'bg-brand-green/20 border-brand-green text-brand-green' - : 'bg-secondary-dark border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200' + : 'bg-secondary-dark border-gray-600 text-gray-300 hover:border-gray-400 hover:text-gray-100' }`} > EU @@ -2206,10 +2404,10 @@ export default function ConnectionPanel({ letsMeshPresetConfigurationDeviation(meshcoreMqttSettings) && (
{meshcorePreset === 'letsmesh' - ? 'LetsMesh needs WebSocket on port 443 and server mqtt-us-v1.letsmesh.net or mqtt-eu-v1.letsmesh.net. Use Region (US/EU), or switch to Custom for other brokers.' + ? t('connectionPanel.meshcorePresetDeviation.letsmesh') : meshcorePreset === 'coloradomesh' - ? 'Colorado Mesh needs WebSocket on port 1883 with TLS enabled, server mqtt.meshcore.coloradomesh.org, path /ws. Reset the preset or switch to Custom for other brokers.' - : 'MeshMapper needs WebSocket on port 443 and server mqtt.meshmapper.cc. Reset the preset or switch to Custom for other brokers.'} + ? t('connectionPanel.meshcorePresetDeviation.coloradomesh') + : t('connectionPanel.meshcorePresetDeviation.meshmapper')}
)} {protocol === 'meshcore' && @@ -2224,8 +2422,8 @@ export default function ConnectionPanel({ }`} > {hasPrivateKey && readMeshcoreIdentity()?.public_key - ? 'Auth token (meshcore-decoder format) will be generated when you connect. Username is v1_ plus your 64-character public key (hex). JWT audience matches the Server hostname.' - : 'No full identity — import your MeshCore config in the Radio panel (public and private keys), or paste username (v1_) and token manually. JWT audience in the token must match the Server hostname.'} + ? t('connectionPanel.meshcoreMqttIdentity.hasPrivateKey') + : t('connectionPanel.meshcoreMqttIdentity.noPrivateKey')}
)} {protocol === 'meshcore' && ( @@ -2240,10 +2438,9 @@ export default function ConnectionPanel({ className="accent-brand-green mt-0.5 shrink-0" />
)} @@ -2303,10 +2500,10 @@ export default function ConnectionPanel({
@@ -2318,7 +2515,7 @@ export default function ConnectionPanel({ updateMqtt('topicPrefix', e.target.value, false); }} className="bg-secondary-dark focus:border-brand-green w-full rounded border border-gray-600 px-2 py-1.5 text-sm text-gray-200 focus:outline-none" - placeholder="msh/US/" + placeholder={t('connectionPanel.topicPrefixPlaceholder')} />
@@ -2329,8 +2526,10 @@ export default function ConnectionPanel({
@@ -2462,7 +2661,7 @@ export default function ConnectionPanel({ settings.tokenExpiresAt = expiresAt; } catch (e) { const msg = e instanceof Error ? e.message : String(e); - setMqttError(`Auth token generation failed: ${msg}`); + setMqttError(t('connectionPanel.authTokenFailed', { message: msg })); console.warn( '[ConnectionPanel] LetsMesh auth token generation failed ' + errLikeToLogString(e), @@ -2472,10 +2671,10 @@ export default function ConnectionPanel({ } else if (!settings.password) { setMqttError( identity?.private_key && !identity?.public_key - ? 'Public key missing from identity. Import your MeshCore config JSON in the Radio panel (must include public and private keys), or paste a broker token in the password field.' + ? t('connectionPanel.meshcoreMqttIdentity.publicKeyMissing') : identity - ? 'Could not build LetsMesh username. Import your MeshCore config JSON in the Radio panel, or paste username (v1_) and token manually.' - : 'No device identity found. Import your MeshCore config JSON in the Radio panel, or paste username and token manually.', + ? t('connectionPanel.meshcoreMqttIdentity.usernameBuildFailed') + : t('connectionPanel.meshcoreMqttIdentity.noIdentity'), ); return; } @@ -2487,7 +2686,7 @@ export default function ConnectionPanel({ }); }} disabled={mqttStatus === 'connecting'} - className={`rounded-lg bg-green-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-400 disabled:opacity-40 ${mqttStatus === 'connecting' ? 'flex-1' : 'w-full'}`} + className={`bg-readable-green hover:bg-readable-green/90 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-40 ${mqttStatus === 'connecting' ? 'flex-1' : 'w-full'}`} > {t('connectionPanel.connectMqtt')} @@ -2496,11 +2695,51 @@ export default function ConnectionPanel({ ); + if (connectingProgressView) { + return ( +
+ {connectingProgressView} + {mqttSection} +
+ ); + } + + if (capabilities.hasReticulumInterfaceConfig) { + const exitVariant = isConnected + ? 'connected' + : state.status === 'connecting' + ? 'connecting' + : 'idle'; + return ( +
+ {renderExitActions(exitVariant)} + { + setReticulumStackError(null); + try { + await onStartReticulumStack?.(); + } catch (err: unknown) { + setReticulumStackError(humanizeReticulumSidecarError(err, t)); + throw err; + } + }} + onStopStack={async () => { + setReticulumStackError(null); + await onDisconnect(); + }} + /> +
+ ); + } + // ─── Connected View ──────────────────────────────────────────── if (isConnected) { return (
{renderExitActions('connected')} + {renderAutoReconnectBanner()}
@@ -2515,7 +2754,7 @@ export default function ConnectionPanel({ href="https://github.com/Colorado-Mesh/mesh-client/blob/main/docs/troubleshooting.md" target="_blank" rel="noreferrer" - className="text-muted hover:text-brand-green text-xs transition-colors" + className="hover:text-brand-green text-xs text-gray-300 transition-colors" > Docs ↗ @@ -2623,6 +2862,7 @@ export default function ConnectionPanel({ return (
{renderExitActions('idle')} + {renderAutoReconnectBanner()} {/* Last Connection — one-click reconnect card */} {lastConnection && !connecting && ( @@ -2644,7 +2884,7 @@ export default function ConnectionPanel({ @@ -2655,7 +2895,7 @@ export default function ConnectionPanel({ clearLastConnection(protocol); setLastConnection(null); }} - className="text-xs text-gray-600 transition-colors hover:text-gray-400" + className="text-xs text-gray-400 transition-colors hover:text-gray-200" > {t('connectionPanel.forgetDevice')} @@ -2677,11 +2917,11 @@ export default function ConnectionPanel({ href="https://github.com/Colorado-Mesh/mesh-client/blob/main/docs/troubleshooting.md" target="_blank" rel="noreferrer" - className="text-muted hover:text-brand-green text-xs transition-colors" + className="hover:text-brand-green text-xs text-gray-300 transition-colors" > Docs ↗ - + {t('connectionPanel.disconnected')}
@@ -2702,6 +2942,11 @@ export default function ConnectionPanel({ {error}
)} + {showAutoReconnectBanner && ( +
+ {t('connectionPanel.autoReconnectInProgress')} +
+ )} {showRePairButton && isLinux && connectionType === 'ble' && (
@@ -2785,7 +3030,7 @@ export default function ConnectionPanel({ }} className={`flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-medium transition-all ${ connectionType === type - ? 'ring-bright-green bg-green-500 text-white ring-2' + ? 'ring-bright-green bg-readable-green text-white ring-2' : 'bg-secondary-dark text-gray-300 hover:bg-gray-600' }`} > @@ -2841,7 +3086,7 @@ export default function ConnectionPanel({ onChange={(e) => { setHttpAddress(e.target.value); }} - placeholder="meshtastic.local or 192.168.1.x" + placeholder={t('connectionPanel.deviceAddressPlaceholder')} className="bg-secondary-dark focus:border-brand-green w-full rounded border border-gray-600 px-2 py-1.5 text-sm text-gray-200 focus:outline-none" autoComplete="off" /> @@ -2865,7 +3110,7 @@ export default function ConnectionPanel({ onChange={(e) => { setTcpHost(e.target.value); }} - placeholder="localhost or 192.168.1.x" + placeholder={t('connectionPanel.meshcoreHostPlaceholder')} className="bg-secondary-dark w-full rounded border border-gray-600 px-2 py-1.5 text-sm text-gray-200 focus:border-purple-500 focus:outline-none" autoComplete="off" aria-label={t('connectionPanel.meshcoreHost')} @@ -2894,7 +3139,7 @@ export default function ConnectionPanel({ )} {/* Connection hints */} -
+
{connectionType === 'ble' && protocol === 'meshtastic' && ( <>

{t('connectionPanel.hintMeshtasticBle1')}

@@ -2940,7 +3185,7 @@ export default function ConnectionPanel({ state.status === 'connecting' || (connectionType === 'http' && !activeHostAddress.trim()) } - className="w-full rounded-lg bg-green-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-400 disabled:cursor-not-allowed disabled:opacity-50" + className="bg-readable-green hover:bg-readable-green/90 w-full rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50" > {t('connectionPanel.connectButton')} diff --git a/src/renderer/components/ContactGroupsModal.tsx b/src/renderer/components/ContactGroupsModal.tsx index 869025f0d..d08578dde 100644 --- a/src/renderer/components/ContactGroupsModal.tsx +++ b/src/renderer/components/ContactGroupsModal.tsx @@ -249,10 +249,12 @@ export default function ContactGroupsModal({ /* Member management view */ <>

- {memberIds.size} member{memberIds.size !== 1 ? 's' : ''} +

+ {t('contactGroupsModal.memberCount', { count: memberIds.size })} +

{sortedContacts.length === 0 ? ( -

No contacts yet.

+

{t('contactGroupsModal.noContactsYet')}

) : (
    {sortedContacts.map((contact) => ( @@ -265,7 +267,9 @@ export default function ContactGroupsModal({ disabled={busy} className="accent-brand-green" /> - {contact.long_name} + + {contact.long_name || t('common.unknown')} + ))} @@ -303,7 +307,7 @@ export default function ContactGroupsModal({ {/* Group list */} {groups.length === 0 ? ( -

    No groups yet. Create one above.

    +

    {t('contactGroupsModal.noGroupsYet')}

    ) : (
      {groups.map((group) => ( diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index c74af68bf..e3e67b1e4 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -5,6 +5,7 @@ import { axe } from 'vitest-axe'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { setMeshtasticConnectedMyNodeNum } from '../lib/meshtasticConnectedNodeRef'; +import { RETICULUM_CAPABILITIES } from '../lib/radio/BaseRadioProvider'; import type { DiagnosticRow, MeshNode, RoutingDiagnosticRow } from '../lib/types'; import type { ForeignLoraDetection } from '../stores/diagnosticsStore'; import DiagnosticsPanel from './DiagnosticsPanel'; @@ -470,3 +471,72 @@ describe('DiagnosticsPanel cross-protocol RF', () => { ).not.toBeInTheDocument(); }); }); + +describe('DiagnosticsPanel reticulum scope', () => { + it('does not surface Meshtastic routing rows on the Reticulum tab', () => { + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'routing', + id: 'routing:99', + nodeId: 0x12345678, + type: 'hop_goblin', + severity: 'error', + description: 'Ghost hop from Meshtastic', + detectedAt: Date.now(), + } satisfies RoutingDiagnosticRow, + ]; + + render( + , + ); + + expect(screen.queryByText('Ghost hop from Meshtastic')).not.toBeInTheDocument(); + expect(screen.getByText(/no diagnostics detected/i)).toBeInTheDocument(); + expect(screen.queryByText(/network health/i)).not.toBeInTheDocument(); + }); + + it('shows Reticulum config diagnostics rows on the Reticulum tab', () => { + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'rf', + id: 'rf:1:reticulum/audit/ghost_interface/tcp-1', + nodeId: 1, + condition: 'reticulum/audit/ghost_interface', + cause: 'Interface "Dublin" enabled in config but not loaded by RNS', + severity: 'error', + detectedAt: Date.now(), + causeI18n: { + key: 'diagnosticsPanel.reticulum.audit.ghost_interface', + params: { name: 'Dublin', message: 'ghost' }, + }, + reticulumInterfaceId: 'tcp-1', + reticulumRepairKind: 'repair_config', + }, + ]; + + render( + , + ); + + expect(screen.getByText('Reticulum interface config')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Repair config' })).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 362deadde..772b6f9cf 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -47,13 +47,16 @@ import { getRecommendedAction, getRecommendedActionForRfCondition, } from '../lib/diagnostics/RemediationEngine'; +import { isReticulumDiagnosticRow } from '../lib/diagnostics/ReticulumDiagnosticEngine'; import { hasLocalStatsData } from '../lib/diagnostics/RFDiagnosticEngine'; import type { OurPosition } from '../lib/gpsSource'; import { startNetworkDiscovery } from '../lib/networkDiscovery'; import type { ProtocolCapabilities } from '../lib/radio/BaseRadioProvider'; import type { DiagnosticRow, MeshNode, MeshProtocol } from '../lib/types'; import { routingRowToNodeAnomaly } from '../lib/types'; +import DiagnosticsPingPanel from './DiagnosticsPingPanel'; import MeshCongestionAttributionBlock from './MeshCongestionAttributionBlock'; +import { ReticulumDiagnosticsSection } from './ReticulumDiagnosticsSection'; function foreignLoraListFromBySender( bySender: Map | undefined, @@ -145,6 +148,10 @@ interface Props { meshtasticListenerNodeId?: number; /** MeshCore contacts only — used for heard-by-Meshtastic links (not merged Meshtastic nodes). */ meshcoreNodes?: Map; + /** Reticulum: switch to Connection tab for interface edit actions. */ + onNavigateToReticulumConnection?: () => void; + /** Reticulum: refresh config audit rows after repair/disable. */ + onRefreshReticulumDiagnostics?: () => void; } function AlertTriangleIcon({ className }: { className?: string }) { @@ -170,6 +177,8 @@ export default function DiagnosticsPanel({ protocol, meshtasticListenerNodeId = 0, meshcoreNodes = new Map(), + onNavigateToReticulumConnection, + onRefreshReticulumDiagnostics, }: Props) { const { t } = useTranslation(); const formatRowTime = useCallback( @@ -180,12 +189,21 @@ export default function DiagnosticsPanel({ [t], ); const showMqttControls = capabilities?.hasMqttHybrid !== false; + const showLoRaMeshDiagnostics = capabilities?.hasHopCount !== false; const diagnosticRows = useDiagnosticsStore((s) => s.diagnosticRows); const diagnosticRowsRestoredAt = useDiagnosticsStore((s) => s.diagnosticRowsRestoredAt); const clearDiagnosticRowsSnapshot = useDiagnosticsStore((s) => s.clearDiagnosticRowsSnapshot); + const visibleDiagnosticRows = useMemo(() => { + if (!showLoRaMeshDiagnostics) { + return diagnosticRows.filter( + (r) => isReticulumDiagnosticRow(r) || nodes.has(r.nodeId) || r.nodeId === myNodeNum, + ); + } + return diagnosticRows; + }, [diagnosticRows, nodes, showLoRaMeshDiagnostics, myNodeNum]); const routingAnomaliesMap = useMemo( - () => diagnosticRowsToRoutingMap(diagnosticRows), - [diagnosticRows], + () => diagnosticRowsToRoutingMap(visibleDiagnosticRows), + [visibleDiagnosticRows], ); const packetStats = useDiagnosticsStore((s) => s.packetStats); const packetCache = useDiagnosticsStore((s) => s.packetCache); @@ -292,7 +310,12 @@ export default function DiagnosticsPanel({ onTraceRouteRef.current = onTraceRoute; useEffect(() => { - if (!autoTracerouteEnabled || !isConnected) { + if ( + !showLoRaMeshDiagnostics || + !capabilities?.hasTraceRoute || + !autoTracerouteEnabled || + !isConnected + ) { stopDiscoveryRef.current?.(); stopDiscoveryRef.current = null; return; @@ -315,7 +338,7 @@ export default function DiagnosticsPanel({ stop(); stopDiscoveryRef.current = null; }; - }, [autoTracerouteEnabled, isConnected]); + }, [showLoRaMeshDiagnostics, capabilities?.hasTraceRoute, autoTracerouteEnabled, isConnected]); /** * Match Node List Ch.Util / Air Tx columns: count nodes where at least one is non-null @@ -337,12 +360,12 @@ export default function DiagnosticsPanel({ /** Mesh-wide status from absolute counts only (no node-percentage; scales to large meshes). */ const meshHealth = useMemo(() => { - const errors = diagnosticRows.filter( + const errors = visibleDiagnosticRows.filter( (r) => r.kind === 'routing' && r.severity === 'error', ).length; const warnings = - diagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'warning').length + - diagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'warning').length; + visibleDiagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'warning').length + + visibleDiagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'warning').length; if (errors >= DEGRADED_ERROR_THRESHOLD) { return { status: 'degraded' as const, @@ -365,11 +388,13 @@ export default function DiagnosticsPanel({ textColor: 'text-brand-green', bg: 'bg-brand-green/10 border-brand-green/30', }; - }, [diagnosticRows, t]); + }, [visibleDiagnosticRows, t]); /** Connected node only — same threshold as mesh so small error counts stay attention/orange. */ const connectedHealth = useMemo(() => { - const rows = diagnosticRows.filter((r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r)); + const rows = visibleDiagnosticRows.filter( + (r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r), + ); const errors = rows.filter((r) => r.kind === 'routing' && r.severity === 'error').length; const warnings = rows.filter((r) => r.kind === 'routing' && r.severity === 'warning').length + @@ -405,7 +430,7 @@ export default function DiagnosticsPanel({ warnings, infos, }; - }, [diagnosticRows, myNodeNum, t]); + }, [visibleDiagnosticRows, myNodeNum, t]); const matchesSearchRow = (row: DiagnosticRow) => { if (!search.trim()) return true; @@ -427,11 +452,11 @@ export default function DiagnosticsPanel({ ); }; - const showRoutingAnomalyBanner = meshHasRoutingAnomaliesFromRows(diagnosticRows); + const showRoutingAnomalyBanner = meshHasRoutingAnomaliesFromRows(visibleDiagnosticRows); const meshCongestionBlock = useMemo(() => { if (!homeNode) return null; - const hasMeshCongestionRow = diagnosticRows.some( + const hasMeshCongestionRow = visibleDiagnosticRows.some( (r) => r.kind === 'rf' && r.nodeId === homeNode.node_id && r.condition === 'Mesh Congestion', ); if (!hasMeshCongestionRow) return null; @@ -442,9 +467,9 @@ export default function DiagnosticsPanel({ const originators = packetCache.size > 0 ? summarizeRfDuplicateOriginators(packetCache) : []; if (lines.length === 0 && originators.length === 0) return null; return { lines, originators }; - }, [homeNode, packetCache, routingAnomaliesMap, diagnosticRows]); + }, [homeNode, packetCache, routingAnomaliesMap, visibleDiagnosticRows]); - const anomalyList = diagnosticRows.filter(matchesSearchRow).sort((a, b) => { + const anomalyList = visibleDiagnosticRows.filter(matchesSearchRow).sort((a, b) => { const order = (s: string) => (s === 'error' ? 0 : s === 'warning' ? 1 : 2); const sevA = a.kind === 'routing' ? a.severity : a.severity; const sevB = b.kind === 'routing' ? b.severity : b.severity; @@ -462,15 +487,15 @@ export default function DiagnosticsPanel({ ); const meshRows = anomalyList.filter((r) => r.nodeId !== myNodeNum); - const errorCount = diagnosticRows.filter( + const errorCount = visibleDiagnosticRows.filter( (r) => r.kind === 'routing' && r.severity === 'error', ).length; const warningCount = - diagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'warning').length + - diagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'warning').length; + visibleDiagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'warning').length + + visibleDiagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'warning').length; const infoCount = - diagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'info').length + - diagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'info').length; + visibleDiagnosticRows.filter((r) => r.kind === 'routing' && r.severity === 'info').length + + visibleDiagnosticRows.filter((r) => r.kind === 'rf' && r.severity === 'info').length; const handleTraceRoute = async (nodeId: number) => { // Clear any prior failure for this node @@ -798,148 +823,174 @@ export default function DiagnosticsPanel({
- {diagnosticRowsRestoredAt != null && diagnosticRows.length > 0 && ( -
- - {t('diagnosticsPanel.restoredSessionBanner', { - time: formatIsoDateTime(diagnosticRowsRestoredAt), - })} - - + {capabilities?.hasReticulumNativeDiagnostics ? : null} + + {capabilities?.hasReticulumNativeDiagnostics ? ( +
+

+ {t('diagnosticsPanel.reticulum.sectionTitle')} +

+
- )} + ) : null} - {/* Network health: single band + counts; nodes count = telemetry only; tooltip for notes */} -
0 - ? t('diagnosticsPanel.heuristicNotesTooltip', { count: infoCount }) - : undefined - } - > -
-
- {t('diagnosticsPanel.networkHealthLabel')} - - {meshHealth.label} + {diagnosticRowsRestoredAt != null && + showLoRaMeshDiagnostics && + visibleDiagnosticRows.length > 0 && ( +
+ + {t('diagnosticsPanel.restoredSessionBanner', { + time: formatIsoDateTime(diagnosticRowsRestoredAt), + })} +
-
- - {t('diagnosticsPanel.nodesWithTelemetry', { count: nodesWithTelemetryCount })} - - {errorCount > 0 && ( - <> - · - - {t('diagnosticsPanel.errorCount', { count: errorCount })} + )} + + {showLoRaMeshDiagnostics && ( + <> + {/* Network health: single band + counts; nodes count = telemetry only; tooltip for notes */} +
0 + ? t('diagnosticsPanel.heuristicNotesTooltip', { count: infoCount }) + : undefined + } + > +
+
+ + {t('diagnosticsPanel.networkHealthLabel')} - - )} - {warningCount > 0 && ( - <> - · - - {t('diagnosticsPanel.warningCount', { count: warningCount })} + + {meshHealth.label} - - )} - {errorCount === 0 && warningCount === 0 && diagnosticRows.length === 0 && ( - <> - · - {t('diagnosticsPanel.noIssues')} - - )} -
- {(connectedHealth.errors > 0 || connectedHealth.warnings > 0) && - (connectedHealth.errors !== errorCount || - connectedHealth.warnings !== warningCount) && ( -
- {t('diagnosticsPanel.thisNodePrefix')}{' '} - {connectedHealth.errors > 0 && ( - - {t('diagnosticsPanel.errorCount', { count: connectedHealth.errors })} - +
+
+ + {t('diagnosticsPanel.nodesWithTelemetry', { count: nodesWithTelemetryCount })} + + {errorCount > 0 && ( + <> + · + + {t('diagnosticsPanel.errorCount', { count: errorCount })} + + )} - {connectedHealth.errors > 0 && connectedHealth.warnings > 0 && ( - , + {warningCount > 0 && ( + <> + · + + {t('diagnosticsPanel.warningCount', { count: warningCount })} + + )} - {connectedHealth.warnings > 0 && ( - - {t('diagnosticsPanel.warningCount', { count: connectedHealth.warnings })} - + {errorCount === 0 && warningCount === 0 && visibleDiagnosticRows.length === 0 && ( + <> + · + {t('diagnosticsPanel.noIssues')} + )}
- )} -
-
- - {/* Channel utilization 24h timeline — connected node only */} - {isConnected && - (() => { - const samples = cuHistory.get(myNodeNum) ?? []; - if (samples.length < 2) return null; - const now = Date.now(); - const cutoff = now - 24 * 60 * 60 * 1000; - const chartData = samples - .filter((s) => s.t >= cutoff) - .map((s) => ({ - time: new Date(s.t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - cu: Math.round(s.cu * 10) / 10, - })); - if (chartData.length < 2) return null; - return ( -
-

- {t('diagnosticsPanel.cuHistoryHeading')} -

- - - - - - [`${v}%`, t('diagnosticsPanel.cuHistoryTooltipLabel')]} - labelStyle={{ color: '#9ca3af' }} - /> - - - + {(connectedHealth.errors > 0 || connectedHealth.warnings > 0) && + (connectedHealth.errors !== errorCount || + connectedHealth.warnings !== warningCount) && ( +
+ {t('diagnosticsPanel.thisNodePrefix')}{' '} + {connectedHealth.errors > 0 && ( + + {t('diagnosticsPanel.errorCount', { count: connectedHealth.errors })} + + )} + {connectedHealth.errors > 0 && connectedHealth.warnings > 0 && ( + , + )} + {connectedHealth.warnings > 0 && ( + + {t('diagnosticsPanel.warningCount', { count: connectedHealth.warnings })} + + )} +
+ )}
- ); - })()} +
+ + {/* Channel utilization 24h timeline — connected node only */} + {isConnected && + (() => { + const samples = cuHistory.get(myNodeNum) ?? []; + if (samples.length < 2) return null; + const now = Date.now(); + const cutoff = now - 24 * 60 * 60 * 1000; + const chartData = samples + .filter((s) => s.t >= cutoff) + .map((s) => ({ + time: new Date(s.t).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + cu: Math.round(s.cu * 10) / 10, + })); + if (chartData.length < 2) return null; + return ( +
+

+ {t('diagnosticsPanel.cuHistoryHeading')} +

+ + + + + + [`${v}%`, t('diagnosticsPanel.cuHistoryTooltipLabel')]} + labelStyle={{ color: '#9ca3af' }} + /> + + + +
+ ); + })()} + + )} {/* MeshCore nodes heard by Meshtastic radio (per transmitter) */} {showMeshtasticForeignLora && meshcoreHeardList.length > 0 && ( @@ -970,7 +1021,7 @@ export default function DiagnosticsPanel({ {t('diagnosticsPanel.foreignCountColumn')} - Signal + {t('diagnosticsPanel.signalColumn')} @@ -1007,8 +1058,12 @@ export default function DiagnosticsPanel({ {d.count}× - {d.rssi !== undefined ? `RSSI ${d.rssi}` : '—'} - {d.snr !== undefined ? ` / SNR ${d.snr.toFixed(1)}` : ''} + {d.rssi !== undefined + ? t('diagnosticsPanel.rssiShort', { rssi: d.rssi }) + : '—'} + {d.snr !== undefined + ? ` / ${t('diagnosticsPanel.snrDb', { snr: d.snr.toFixed(1) })}` + : ''} ); @@ -1070,17 +1125,21 @@ export default function DiagnosticsPanel({
{d.count}×
{(d.rssi !== undefined || d.snr !== undefined) && ( <> -
Signal
+
{t('diagnosticsPanel.signalColumn')}
- {d.rssi !== undefined ? `RSSI ${d.rssi} dBm` : ''} + {d.rssi !== undefined + ? t('diagnosticsPanel.rssiDbm', { rssi: d.rssi }) + : ''} {d.rssi !== undefined && d.snr !== undefined ? ', ' : ''} - {d.snr !== undefined ? `SNR ${d.snr.toFixed(1)} dB` : ''} + {d.snr !== undefined + ? t('diagnosticsPanel.snrDb', { snr: d.snr.toFixed(1) }) + : ''}
)} {d.lastSenderId != null && ( <> -
Sender
+
{t('diagnosticsPanel.senderLabel')}
{formatMeshtasticNodeId(d.lastSenderId)} {senderName ? ` (${senderName})` : ''} @@ -1095,187 +1154,193 @@ export default function DiagnosticsPanel({ )} {/* Settings */} -
-

- {t('diagnosticsPanel.displaySettings')} -

-
-
- { - setCongestionHalosEnabled(e.target.checked); - }} - className="accent-brand-green" - /> - -
-
- { - setAnomalyHalosEnabled(e.target.checked); - }} - className="accent-brand-green" - /> - -
- {showMqttControls && ( -
- { - setIgnoreMqttEnabled(e.target.checked); - }} - className="accent-brand-green" - /> - - - Gray out MQTT-only nodes and exclude them from diagnostics - -
- )} -
- { - setAutoTracerouteEnabled(protocol, e.target.checked); - }} - className="accent-brand-green" - /> - - - {t('diagnosticsPanel.autoTracerouteHelp')} - {lastDiscoveryTs !== null && <> · last: {formatRowTime(lastDiscoveryTs)}} - -
-
-
{t('diagnosticsPanel.environmentProfile')}
-
- {( - [ - { mode: 'standard', label: t('diagnosticsPanel.environmentStandard') }, - { mode: 'city', label: t('diagnosticsPanel.environmentCity') }, - { mode: 'canyon', label: t('diagnosticsPanel.environmentCanyon') }, - ] as const - ).map(({ mode, label }, i) => ( - - ))} + className="accent-brand-green" + /> + +
+
+ { + setAnomalyHalosEnabled(e.target.checked); + }} + className="accent-brand-green" + /> + +
+ {showMqttControls && ( +
+ { + setIgnoreMqttEnabled(e.target.checked); + }} + className="accent-brand-green" + /> + + + Gray out MQTT-only nodes and exclude them from diagnostics + +
+ )} +
+ { + setAutoTracerouteEnabled(protocol, e.target.checked); + }} + className="accent-brand-green" + /> + + + {t('diagnosticsPanel.autoTracerouteHelp')} + {lastDiscoveryTs !== null && <> · last: {formatRowTime(lastDiscoveryTs)}} + +
+
+
+ {t('diagnosticsPanel.environmentProfile')} +
+
+ {( + [ + { mode: 'standard', label: t('diagnosticsPanel.environmentStandard') }, + { mode: 'city', label: t('diagnosticsPanel.environmentCity') }, + { mode: 'canyon', label: t('diagnosticsPanel.environmentCanyon') }, + ] as const + ).map(({ mode, label }, i) => ( + + ))} +
+ + {envMode === 'standard' && t('diagnosticsPanel.environmentStandardHint')} + {envMode === 'city' && t('diagnosticsPanel.environmentCityHint')} + {envMode === 'canyon' && t('diagnosticsPanel.environmentCanyonHint')} + +
+
+
+ {t('diagnosticsPanel.staleRoutingDiagnostics')} +
+
+ + { + const v = parseInt(e.target.value, 10); + if (Number.isFinite(v)) setDiagnosticRowsMaxAgeHours(v); + }} + aria-label={t('diagnosticsPanel.dropRoutingRows', { + hours: diagnosticRowsMaxAgeHours, + })} + className="bg-deep-black focus:border-brand-green w-16 rounded border border-gray-600 px-2 py-1 text-right text-sm text-gray-200 focus:outline-none" + /> + {t('diagnosticsPanel.hoursRange')} +
+ {t('diagnosticsPanel.staleRoutingHelp')} +
- - {envMode === 'standard' && t('diagnosticsPanel.environmentStandardHint')} - {envMode === 'city' && t('diagnosticsPanel.environmentCityHint')} - {envMode === 'canyon' && t('diagnosticsPanel.environmentCanyonHint')} -
-
-
- {t('diagnosticsPanel.staleRoutingDiagnostics')} -
-
- - { - const v = parseInt(e.target.value, 10); - if (Number.isFinite(v)) setDiagnosticRowsMaxAgeHours(v); - }} - aria-label={t('diagnosticsPanel.dropRoutingRows', { - hours: diagnosticRowsMaxAgeHours, + + {/* Per-Node MQTT Filters */} + {showMqttControls && mqttIgnoredNodes.size > 0 && ( +
+

+ {t('diagnosticsPanel.perNodeMqttFilters')} +

+
+ {[...mqttIgnoredNodes].map((nodeId) => { + const n = nodes.get(nodeId); + const label = n?.short_name || n?.long_name || formatMeshtasticNodeId(nodeId); + return ( + + {label} + + + ); })} - className="bg-deep-black focus:border-brand-green w-16 rounded border border-gray-600 px-2 py-1 text-right text-sm text-gray-200 focus:outline-none" - /> - {t('diagnosticsPanel.hoursRange')} +
- {t('diagnosticsPanel.staleRoutingHelp')} -
-
-
- - {/* Per-Node MQTT Filters */} - {showMqttControls && mqttIgnoredNodes.size > 0 && ( -
-

- {t('diagnosticsPanel.perNodeMqttFilters')} -

-
- {[...mqttIgnoredNodes].map((nodeId) => { - const n = nodes.get(nodeId); - const label = n?.short_name || n?.long_name || formatMeshtasticNodeId(nodeId); - return ( - - {label} - - - ); - })} -
-
- )} + )} - {/* Mesh-wide routing stress (independent of packet path mix samples) */} - {showRoutingAnomalyBanner && ( -
- - {t('meshCongestion.routingAnomalies')} -
- )} + {/* Mesh-wide routing stress (independent of packet path mix samples) */} + {showRoutingAnomalyBanner && ( +
+ + {t('meshCongestion.routingAnomalies')} +
+ )} - {/* Duplicate-traffic attribution heard at this client (same logic as home node detail) */} - {meshCongestionBlock && ( - + {/* Duplicate-traffic attribution heard at this client (same logic as home node detail) */} + {meshCongestionBlock && ( + + )} + )} {/* IP Geolocation Accuracy Warning */} @@ -1290,7 +1355,7 @@ export default function DiagnosticsPanel({

- {t('diagnosticsPanel.diagnosticsHeading', { count: diagnosticRows.length })} + {t('diagnosticsPanel.diagnosticsHeading', { count: visibleDiagnosticRows.length })}

- {diagnosticRows.length === 0 + {visibleDiagnosticRows.length === 0 ? t('diagnosticsPanel.noDiagnosticsHealthy') : t('diagnosticsPanel.noAnomaliesMatchSearch')}
diff --git a/src/renderer/components/DiagnosticsPingPanel.tsx b/src/renderer/components/DiagnosticsPingPanel.tsx new file mode 100644 index 000000000..8646a89eb --- /dev/null +++ b/src/renderer/components/DiagnosticsPingPanel.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { + isReticulumSidecarRunning, + pingReticulumDestination, + type ReticulumPingProbeResult, +} from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { MS_PER_SECOND } from '@/shared/timeConstants'; + +const DEFAULT_INTERVAL_SEC = 5; +const MAX_RESULTS = 50; + +export interface PingResultRow { + seq: number; + at: number; + result: ReticulumPingProbeResult; +} + +/** Reticulum destination ping loop — mount only when {@link ProtocolCapabilities.hasReticulumNativeDiagnostics}. */ +export default function DiagnosticsPingPanel() { + const { t } = useTranslation(); + const [hash, setHash] = useState(''); + const [intervalSec, setIntervalSec] = useState(DEFAULT_INTERVAL_SEC); + const [running, setRunning] = useState(false); + const [rows, setRows] = useState([]); + const seqRef = useRef(0); + const runningRef = useRef(false); + const hashRef = useRef(''); + + useEffect(() => { + runningRef.current = running; + }, [running]); + + useEffect(() => { + hashRef.current = hash.trim(); + }, [hash]); + + const runOnce = useCallback(async () => { + const target = hashRef.current; + if (!target) return; + seqRef.current += 1; + const seq = seqRef.current; + const result = await pingReticulumDestination(target); + setRows((prev) => { + const next = [{ seq, at: Date.now(), result }, ...prev]; + return next.slice(0, MAX_RESULTS); + }); + }, []); + + useEffect(() => { + if (!running) return; + let cancelled = false; + const tick = async () => { + if (cancelled || !runningRef.current) return; + if (!(await isReticulumSidecarRunning())) return; + await runOnce(); + }; + void tick(); + const ms = Math.max(1, intervalSec) * MS_PER_SECOND; + const id = window.setInterval(() => { + void tick(); + }, ms); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [running, intervalSec, runOnce]); + + const start = () => { + if (!hash.trim()) return; + seqRef.current = 0; + setRows([]); + setRunning(true); + }; + + const stop = () => { + setRunning(false); + }; + + return ( +
+

{t('diagnosticsPing.title')}

+

{t('diagnosticsPing.reticulumHint')}

+
+ + + {running ? ( + + ) : ( + + )} + +
+ {rows.length > 0 ? ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
{t('diagnosticsPing.colSeq')}{t('diagnosticsPing.colRtt')}{t('diagnosticsPing.colHops')}{t('diagnosticsPing.colStatus')}
{row.seq} + {row.result.rttMs != null + ? t('diagnosticsPing.rttMs', { ms: row.result.rttMs }) + : t('common.emDash')} + {row.result.hops ?? t('common.emDash')} + {row.result.ok + ? t('diagnosticsPing.statusOk') + : t('diagnosticsPing.statusFailed', { + error: row.result.error ?? t('common.error'), + })} +
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/components/GlobalInstantTooltip.test.tsx b/src/renderer/components/GlobalInstantTooltip.test.tsx new file mode 100644 index 000000000..99a9dccfd --- /dev/null +++ b/src/renderer/components/GlobalInstantTooltip.test.tsx @@ -0,0 +1,62 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { GlobalInstantTooltip } from './GlobalInstantTooltip'; +import { HelpTooltip } from './HelpTooltip'; + +describe('GlobalInstantTooltip', () => { + it('shows an instant tooltip for elements with a native title on hover', async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + const button = screen.getByRole('button', { name: 'Refresh' }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + await user.hover(button); + expect(screen.getByRole('tooltip')).toHaveTextContent('Refresh data'); + expect(button.getAttribute('title')).toBeNull(); + await user.unhover(button); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(button.getAttribute('title')).toBe('Refresh data'); + }); + + it('shows tooltip on keyboard focus', () => { + render( + <> + + + , + ); + const button = screen.getByRole('button', { name: 'Action' }); + act(() => { + fireEvent.focus(button); + }); + expect(screen.getByRole('tooltip')).toHaveTextContent('Keyboard tip'); + act(() => { + fireEvent.blur(button); + }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('does not intercept HelpTooltip-managed triggers', async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + const trigger = document.querySelector('.cursor-help')!; + await user.hover(trigger); + expect(screen.getByRole('tooltip')).toHaveTextContent('Managed help'); + expect(screen.getAllByRole('tooltip')).toHaveLength(1); + }); +}); diff --git a/src/renderer/components/GlobalInstantTooltip.tsx b/src/renderer/components/GlobalInstantTooltip.tsx new file mode 100644 index 000000000..dc611bb5d --- /dev/null +++ b/src/renderer/components/GlobalInstantTooltip.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { attachGlobalInstantTooltipListeners } from '@/renderer/lib/globalInstantTooltip'; +import { + computeInstantTooltipPosition, + type InstantTooltipPosition, +} from '@/renderer/lib/instantTooltipPosition'; + +import { InstantTooltipBubble } from './InstantTooltipBubble'; + +/** + * App-wide instant tooltips for native `title` attributes (Electron delays native titles). + * Mount once near the app root; HelpTooltip uses its own handler and opts out via + * `data-instant-tooltip-managed`. + */ +export function GlobalInstantTooltip() { + const [state, setState] = useState<{ text: string; pos: InstantTooltipPosition } | null>(null); + + useEffect(() => { + return attachGlobalInstantTooltipListeners({ + onShow: (host, text) => { + setState({ text, pos: computeInstantTooltipPosition(host.getBoundingClientRect()) }); + }, + onHide: () => { + setState(null); + }, + onReposition: (host) => { + setState((prev) => + prev + ? { ...prev, pos: computeInstantTooltipPosition(host.getBoundingClientRect()) } + : null, + ); + }, + }); + }, []); + + if (!state) return null; + return createPortal(, document.body); +} diff --git a/src/renderer/components/HelpTooltip.tsx b/src/renderer/components/HelpTooltip.tsx index a70333b09..989f998ca 100644 --- a/src/renderer/components/HelpTooltip.tsx +++ b/src/renderer/components/HelpTooltip.tsx @@ -1,36 +1,39 @@ import type { ReactNode } from 'react'; import { useRef, useState } from 'react'; -const TOOLTIP_WIDTH = 256; // w-64 -const TOOLTIP_MARGIN = 8; +import { + computeInstantTooltipPosition, + type InstantTooltipPosition, +} from '@/renderer/lib/instantTooltipPosition'; + +import { InstantTooltipBubble } from './InstantTooltipBubble'; export function HelpTooltip({ text, children, className, + ariaLabel, }: { text: string; children?: ReactNode; /** Extra classes on the wrapper (e.g. `shrink-0` in toolbars). */ className?: string; + /** Accessible name when custom children replace the default ⓘ trigger. */ + ariaLabel?: string; }) { - const [pos, setPos] = useState<{ top: number; left: number; below: boolean } | null>(null); + const [pos, setPos] = useState(null); const ref = useRef(null); const updatePosition = () => { const r = ref.current?.getBoundingClientRect(); if (!r) return; - const centeredLeft = r.left + r.width / 2; - const clampedLeft = Math.max( - TOOLTIP_WIDTH / 2 + TOOLTIP_MARGIN, - Math.min(window.innerWidth - TOOLTIP_WIDTH / 2 - TOOLTIP_MARGIN, centeredLeft), - ); - const below = r.top < 80; - setPos({ top: below ? r.bottom + 4 : r.top - 8, left: clampedLeft, below }); + setPos(computeInstantTooltipPosition(r)); }; return ( {children ?? } - {pos && ( - - {text} - - )} + {pos && } ); } diff --git a/src/renderer/components/IdentityVaultPanel.tsx b/src/renderer/components/IdentityVaultPanel.tsx new file mode 100644 index 000000000..545b4d3c1 --- /dev/null +++ b/src/renderer/components/IdentityVaultPanel.tsx @@ -0,0 +1,216 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import type { IdentityVaultStatus } from '@/shared/electron-api.types'; + +export interface IdentityVaultPanelProps { + disabled?: boolean; + /** Optional identity backup JSON to encrypt when enabling the vault. */ + secret?: string | null; +} + +export function IdentityVaultPanel({ disabled = false, secret = null }: IdentityVaultPanelProps) { + const { t } = useTranslation(); + const [status, setStatus] = useState({ configured: false, unlocked: false }); + const [passcode, setPasscode] = useState(''); + const [confirmPasscode, setConfirmPasscode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const refreshStatus = useCallback(async () => { + try { + setStatus(await window.electronAPI.vault.status()); + } catch (e) { + console.warn('[IdentityVaultPanel] status ' + errLikeToLogString(e)); + } + }, []); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + const handleSetPasscode = async () => { + if (passcode.length < 4) { + setError(t('identityVault.passcodeTooShort')); + return; + } + if (passcode !== confirmPasscode) { + setError(t('identityVault.passcodeMismatch')); + return; + } + const vaultSecret = secret?.trim() || 'mesh-client-reticulum-vault'; + setBusy(true); + setError(null); + setMessage(null); + try { + const res = await window.electronAPI.vault.setPasscode(passcode, vaultSecret); + if (!res.ok) { + setError(res.error ?? t('identityVault.setFailed')); + return; + } + setPasscode(''); + setConfirmPasscode(''); + setMessage(t('identityVault.setSuccess')); + await refreshStatus(); + } catch (e) { + // catch-no-log-ok surfaced inline via setError + setError(errLikeToLogString(e)); + } finally { + setBusy(false); + } + }; + + const handleUnlock = async () => { + setBusy(true); + setError(null); + setMessage(null); + try { + const res = await window.electronAPI.vault.unlock(passcode); + if (!res.ok) { + setError(res.error ?? t('identityVault.unlockFailed')); + return; + } + setPasscode(''); + setMessage(t('identityVault.unlockSuccess')); + await refreshStatus(); + } catch (e) { + // catch-no-log-ok surfaced inline via setError + setError(errLikeToLogString(e)); + } finally { + setBusy(false); + } + }; + + const handleLock = async () => { + setBusy(true); + setError(null); + setMessage(null); + try { + await window.electronAPI.vault.lock(); + setMessage(t('identityVault.lockSuccess')); + await refreshStatus(); + } catch (e) { + // catch-no-log-ok surfaced inline via setError + setError(errLikeToLogString(e)); + } finally { + setBusy(false); + } + }; + + const statusLabel = status.unlocked + ? t('identityVault.statusUnlocked') + : status.configured + ? t('identityVault.statusLocked') + : t('identityVault.statusNotConfigured'); + + return ( +
+
+

{t('identityVault.title')}

+ + {statusLabel} + +
+

{t('identityVault.hint')}

+ + {!status.configured ? ( +
+ + + +
+ ) : status.unlocked ? ( + + ) : ( +
+ + +
+ )} + + {error ? ( +

+ {error} +

+ ) : null} + {message ?

{message}

: null} +
+ ); +} + +export default IdentityVaultPanel; diff --git a/src/renderer/components/InactiveProtocolNotifier.tsx b/src/renderer/components/InactiveProtocolNotifier.tsx index b488808a0..c306aba9f 100644 --- a/src/renderer/components/InactiveProtocolNotifier.tsx +++ b/src/renderer/components/InactiveProtocolNotifier.tsx @@ -6,11 +6,6 @@ import { REGISTERED_MESH_PROTOCOLS } from '@/renderer/lib/types'; import { useToast } from './Toast'; -const PROTOCOL_DISPLAY_NAME: Record = { - meshtastic: 'Meshtastic', - meshcore: 'MeshCore', -}; - export interface InactiveProtocolNotifierProps { activeProtocol: MeshProtocol; messagesByProtocol: Record; @@ -59,7 +54,7 @@ export function InactiveProtocolNotifier({ if (realNew.length > 0) { addToast( t('toasts.newMessages', { - protocol: PROTOCOL_DISPLAY_NAME[inactiveProtocol], + protocol: t(`connectionPanel.bleOwner.${inactiveProtocol}`), count: realNew.length, }), 'info', diff --git a/src/renderer/components/InstantTooltipBubble.tsx b/src/renderer/components/InstantTooltipBubble.tsx new file mode 100644 index 000000000..c9c327e82 --- /dev/null +++ b/src/renderer/components/InstantTooltipBubble.tsx @@ -0,0 +1,19 @@ +import type { InstantTooltipPosition } from '@/renderer/lib/instantTooltipPosition'; + +export function InstantTooltipBubble({ text, pos }: { text: string; pos: InstantTooltipPosition }) { + return ( + + {text} + + ); +} diff --git a/src/renderer/components/LogPanel.filtering.test.ts b/src/renderer/components/LogPanel.filtering.test.ts index e1ff7a58d..75b24777b 100644 --- a/src/renderer/components/LogPanel.filtering.test.ts +++ b/src/renderer/components/LogPanel.filtering.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import path from 'path'; import { describe, expect, it } from 'vitest'; -import { isDeviceEntry } from './LogPanel'; +import { isAppLogEntry, isDeviceEntry, isOwnedByOtherProtocol } from './LogPanel'; function entry(source: string, message: string, level = 'log') { return { ts: Date.now(), level, source, message }; @@ -182,6 +182,53 @@ describe('isDeviceEntry — MeshCore protocol', () => { }); }); +describe('isDeviceEntry — Reticulum protocol', () => { + it('classifies [ReticulumSidecar] message as Reticulum device entry', () => { + expect( + isDeviceEntry( + entry('main', '[ReticulumSidecar] sidecar listening on 127.0.0.1:19437'), + 'reticulum', + ), + ).toBe(true); + }); + + it('classifies [useReticulumRuntime] message as Reticulum device entry', () => { + expect( + isDeviceEntry(entry('main', '[useReticulumRuntime] connect failed timeout'), 'reticulum'), + ).toBe(true); + }); + + it('classifies [ReticulumNetworkPanel] message as Reticulum device entry', () => { + expect( + isDeviceEntry( + entry('main', '[ReticulumNetworkPanel] identity status network error'), + 'reticulum', + ), + ).toBe(true); + }); + + it('classifies [ReticulumIPC] message as Reticulum device entry', () => { + expect(isDeviceEntry(entry('main', '[ReticulumIPC] start'), 'reticulum')).toBe(true); + }); + + it('classifies [Reticulum] BLE coexistence messages as Reticulum device entry', () => { + expect( + isDeviceEntry( + entry('renderer', '[Reticulum] bleCoexistence acquireScan failed'), + 'reticulum', + ), + ).toBe(true); + }); + + it('does NOT classify Meshtastic SDK source as Reticulum device entry', () => { + expect(isDeviceEntry(entry('meshtastic-sdk', 'packet decoded'), 'reticulum')).toBe(false); + }); + + it('does NOT classify [useMeshcoreRuntime] message as Reticulum device entry', () => { + expect(isDeviceEntry(entry('main', '[useMeshcoreRuntime] connected'), 'reticulum')).toBe(false); + }); +}); + describe('isDeviceEntry — no protocol (fallback)', () => { it('classifies meshtastic source as device entry', () => { expect(isDeviceEntry(entry('meshtastic', 'msg'))).toBe(true); @@ -211,6 +258,14 @@ describe('isDeviceEntry — no protocol (fallback)', () => { expect(isDeviceEntry(entry('main', '[useMeshcoreRuntime] something'))).toBe(true); }); + it('classifies [useReticulumRuntime] message as device entry', () => { + expect(isDeviceEntry(entry('main', '[useReticulumRuntime] something'))).toBe(true); + }); + + it('classifies [ReticulumSidecar] message as device entry', () => { + expect(isDeviceEntry(entry('main', '[ReticulumSidecar] stack ready'))).toBe(true); + }); + it('does NOT classify generic app-only message as device entry', () => { expect(isDeviceEntry(entry('main', 'App started successfully'))).toBe(false); expect(isDeviceEntry(entry('renderer', 'React mounted'))).toBe(false); @@ -220,38 +275,79 @@ describe('isDeviceEntry — no protocol (fallback)', () => { describe('dual-mode appEntries guard', () => { it('Meshtastic [Meshtastic MQTT] entry appears in app view (not treated as device log)', () => { const mqttEntry = entry('main', '[Meshtastic MQTT] ServiceEnvelope decode failed'); - const isApp = !isDeviceEntry(mqttEntry, 'meshtastic') && !isDeviceEntry(mqttEntry, 'meshcore'); - expect(isApp).toBe(true); + expect(isAppLogEntry(mqttEntry, 'meshtastic')).toBe(true); }); it('MeshCore [MeshCore MQTT] entry is excluded from app view when Meshtastic is active', () => { const mqttEntry = entry('main', '[MeshCore MQTT] status: connected'); - const isApp = !isDeviceEntry(mqttEntry, 'meshtastic') && !isDeviceEntry(mqttEntry, 'meshcore'); - expect(isApp).toBe(false); + expect(isAppLogEntry(mqttEntry, 'meshtastic')).toBe(false); + }); + + it('Reticulum sidecar entry is excluded from app view', () => { + const rtEntry = entry('main', '[ReticulumSidecar] listening on 127.0.0.1:19437'); + expect(isAppLogEntry(rtEntry, 'meshtastic')).toBe(false); + expect(isAppLogEntry(rtEntry, 'meshcore')).toBe(false); }); it('Meshtastic SDK entry is excluded from app view', () => { const sdkEntry = entry('meshtastic-sdk', 'packet decoded'); - const isApp = !isDeviceEntry(sdkEntry, 'meshtastic') && !isDeviceEntry(sdkEntry, 'meshcore'); - expect(isApp).toBe(false); + expect(isAppLogEntry(sdkEntry, 'meshtastic')).toBe(false); }); it('MeshCore source entry is excluded from app view', () => { const meshcoreEntry = entry('meshcore', 'rx rssi=-90'); - const isApp = - !isDeviceEntry(meshcoreEntry, 'meshtastic') && !isDeviceEntry(meshcoreEntry, 'meshcore'); - expect(isApp).toBe(false); + expect(isAppLogEntry(meshcoreEntry, 'meshtastic')).toBe(false); }); it('[NobleBleManager] entry is excluded from app view', () => { const bleEntry = entry('main', '[NobleBleManager] startScanning error: peripheral lost'); - const isApp = !isDeviceEntry(bleEntry, 'meshtastic') && !isDeviceEntry(bleEntry, 'meshcore'); - expect(isApp).toBe(false); + expect(isAppLogEntry(bleEntry, 'meshtastic')).toBe(false); }); it('generic app entry passes through to app view', () => { const appEntry = entry('main', 'Window created'); - const isApp = !isDeviceEntry(appEntry, 'meshtastic') && !isDeviceEntry(appEntry, 'meshcore'); - expect(isApp).toBe(true); + expect(isAppLogEntry(appEntry, 'meshtastic')).toBe(true); + }); +}); + +describe('protocol-scoped appEntries — Reticulum tab', () => { + it('excludes Meshtastic MQTT from Reticulum app view', () => { + const mqttEntry = entry('main', '[Meshtastic MQTT] CONNACK received'); + expect(isAppLogEntry(mqttEntry, 'reticulum')).toBe(false); + expect(isOwnedByOtherProtocol(mqttEntry, 'reticulum')).toBe(true); + }); + + it('excludes MeshCore MQTT from Reticulum app view', () => { + const mqttEntry = entry('main', '[MeshCore MQTT] connect start'); + expect(isAppLogEntry(mqttEntry, 'reticulum')).toBe(false); + }); + + it('excludes MeshCore runtime from Reticulum device view', () => { + const mcEntry = entry('main', '[useMeshcoreRuntime] connected'); + expect(isDeviceEntry(mcEntry, 'reticulum')).toBe(false); + expect(isAppLogEntry(mcEntry, 'reticulum')).toBe(false); + }); + + it('includes generic app lines in Reticulum app view', () => { + expect(isAppLogEntry(entry('main', 'Window created'), 'reticulum')).toBe(true); + }); + + it('includes Reticulum sidecar lines in Reticulum device view only', () => { + const rtEntry = entry('main', '[ReticulumSidecar] stack ready'); + expect(isDeviceEntry(rtEntry, 'reticulum')).toBe(true); + expect(isAppLogEntry(rtEntry, 'reticulum')).toBe(false); + }); +}); + +describe('protocol-scoped appEntries — MeshCore tab', () => { + it('excludes Meshtastic MQTT from MeshCore app view', () => { + const mqttEntry = entry('main', '[Meshtastic MQTT] subscribe callback OK'); + expect(isAppLogEntry(mqttEntry, 'meshcore')).toBe(false); + }); + + it('routes MeshCore MQTT to device view', () => { + const mqttEntry = entry('main', '[MeshCore MQTT] PINGRESP received'); + expect(isDeviceEntry(mqttEntry, 'meshcore')).toBe(true); + expect(isAppLogEntry(mqttEntry, 'meshcore')).toBe(false); }); }); diff --git a/src/renderer/components/LogPanel.tsx b/src/renderer/components/LogPanel.tsx index 2e54e6c8f..4dba154ad 100644 --- a/src/renderer/components/LogPanel.tsx +++ b/src/renderer/components/LogPanel.tsx @@ -101,16 +101,38 @@ export function isDeviceEntry(entry: LogEntry, protocol?: MeshProtocol): boolean entry.message.includes('[IpcNobleConnection:meshcore]') ); } + if (protocol === 'reticulum') { + return ( + entry.source.includes('reticulum') || + entry.message.includes('[ReticulumSidecar]') || + entry.message.includes('[ReticulumNetworkPanel]') || + entry.message.includes('[useReticulumRuntime]') || + entry.message.includes('[useReticulumSidecarApi]') || + entry.message.includes('[ReticulumIPC]') || + entry.message.includes('[Reticulum]') || + entry.message.includes('[reticulumSidecarReads]') || + entry.message.includes('[IPC] reticulum') + ); + } // No protocol: show all device entries (fallback) return ( entry.source === 'sdk' || entry.source.includes('meshtastic') || entry.source.includes('meshcore') || + entry.source.includes('reticulum') || entry.message.includes('[useMeshtasticRuntime]') || entry.message.includes('[iMeshDevice]') || entry.message.includes('[useMeshcoreRuntime]') || + entry.message.includes('[useReticulumRuntime]') || entry.message.includes('[TransportNobleIpc]') || entry.message.includes('[MeshCore MQTT]') || + entry.message.includes('[ReticulumSidecar]') || + entry.message.includes('[ReticulumNetworkPanel]') || + entry.message.includes('[ReticulumIPC]') || + entry.message.includes('[Reticulum]') || + entry.message.includes('[reticulumSidecarReads]') || + entry.message.includes('[useReticulumSidecarApi]') || + entry.message.includes('[IPC] reticulum') || entry.message.includes('[NobleBleManager]') || entry.message.includes('[BLE:') || entry.message.includes('[BLE:meshcore]') || @@ -118,6 +140,33 @@ export function isDeviceEntry(entry: LogEntry, protocol?: MeshProtocol): boolean ); } +/** App-panel MQTT/infrastructure tags scoped to one protocol tab (not device/SDK traffic). */ +export function isProtocolExclusiveAppEntry(entry: LogEntry, protocol: MeshProtocol): boolean { + return protocol === 'meshtastic' && entry.message.includes('[Meshtastic MQTT]'); +} + +/** True when the line belongs to a protocol other than the active tab. */ +export function isOwnedByOtherProtocol(entry: LogEntry, activeProtocol: MeshProtocol): boolean { + for (const p of ['meshtastic', 'meshcore', 'reticulum'] as MeshProtocol[]) { + if (p === activeProtocol) continue; + if (isDeviceEntry(entry, p)) return true; + if (isProtocolExclusiveAppEntry(entry, p)) return true; + } + return false; +} + +/** App log lines for the active protocol tab (or dual/triple fallback when unset). */ +export function isAppLogEntry(entry: LogEntry, protocol?: MeshProtocol): boolean { + if (protocol) { + return !isDeviceEntry(entry, protocol) && !isOwnedByOtherProtocol(entry, protocol); + } + return ( + !isDeviceEntry(entry, 'meshtastic') && + !isDeviceEntry(entry, 'meshcore') && + !isDeviceEntry(entry, 'reticulum') + ); +} + function formatEntry(entry: LogEntry): string { const ts = formatLogTimeOfDay(entry.ts); return `${ts} [${entry.level}] ${entry.message}`; @@ -167,6 +216,12 @@ export default function LogPanel({ const dragStartX = useRef(0); const dragStartWidth = useRef(0); + useEffect(() => { + if (protocol === 'reticulum') { + setLogSource('device'); + } + }, [protocol]); + useEffect(() => { let off: (() => void) | null = null; let cancelled = false; @@ -234,22 +289,25 @@ export default function LogPanel({ setEntries([]); } catch (e) { console.warn('[LogPanel] clear log failed ' + errLikeToLogString(e)); - setLogClearError(e instanceof Error ? e.message : 'Could not clear log'); + setLogClearError(e instanceof Error ? e.message : t('logPanel.clearFailed')); } - }, []); + }, [t]); const libraryEntries = useMemo( () => entries.filter((e) => isDeviceEntry(e, protocol)), [entries, protocol], ); - // Dual-mode: exclude device entries from BOTH protocols so neither leaks into the app view. const appEntries = useMemo( - () => entries.filter((e) => !isDeviceEntry(e, 'meshtastic') && !isDeviceEntry(e, 'meshcore')), - [entries], + () => entries.filter((e) => isAppLogEntry(e, protocol)), + [entries, protocol], + ); + const scopedDeviceLogs = useMemo( + () => (deviceLogs ?? []).filter((e) => !protocol || isDeviceEntry(e, protocol)), + [deviceLogs, protocol], ); const allDeviceLogs: LogEntry[] = useMemo( - () => [...(deviceLogs ?? []), ...libraryEntries].sort((a, b) => a.ts - b.ts), - [deviceLogs, libraryEntries], + () => [...scopedDeviceLogs, ...libraryEntries].sort((a, b) => a.ts - b.ts), + [scopedDeviceLogs, libraryEntries], ); const visibleLines: LogEntry[] = useMemo( diff --git a/src/renderer/components/MapPanel.tsx b/src/renderer/components/MapPanel.tsx index 46c57afeb..e85c9fde5 100644 --- a/src/renderer/components/MapPanel.tsx +++ b/src/renderer/components/MapPanel.tsx @@ -1278,7 +1278,9 @@ export default function MapPanel({
-
{wp.name || 'Waypoint'}
+
+ {wp.name || t('mapPanel.waypointDefaultName')} +
{wp.description &&
{wp.description}
}
{formatCoordPair(wp.latitude, wp.longitude, coordinateFormat)} @@ -1288,7 +1290,7 @@ export default function MapPanel({ onClick={() => onDeleteWaypoint(wp.id)} className="mt-1 w-full rounded border border-red-800/50 bg-red-900/40 px-2 py-1 text-xs text-red-300 transition-colors hover:bg-red-900/60" > - Delete Waypoint + {t('mapPanel.waypointDelete')} )}
@@ -1300,7 +1302,7 @@ export default function MapPanel({ {nodesToRender.length === 0 && (
- No nodes with GPS positions yet + {t('mapPanel.noGpsNodes')}
)} diff --git a/src/renderer/components/MentionAutocomplete.tsx b/src/renderer/components/MentionAutocomplete.tsx index 229913405..40b79fadd 100644 --- a/src/renderer/components/MentionAutocomplete.tsx +++ b/src/renderer/components/MentionAutocomplete.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { nodeDisplayName } from '../lib/nodeLongNameOrHex'; import type { MeshNode, MeshProtocol } from '../lib/types'; @@ -19,13 +21,14 @@ export default function MentionAutocomplete({ onSelect, onSetSelectedIdx, }: Props) { + const { t } = useTranslation(); if (candidates.length === 0) return null; return (
{candidates.map((c, i) => (
@@ -2114,23 +2139,7 @@ export default function ModulePanel({ onChange={setTakTeam} disabled={disabled} description={t('modulePanel.fields.takTeamDesc')} - options={[ - { value: 0, label: t('modulePanel.fields.takTeamUnspecified') }, - { value: 1, label: 'White' }, - { value: 2, label: 'Yellow' }, - { value: 3, label: 'Orange' }, - { value: 4, label: 'Magenta' }, - { value: 5, label: 'Red' }, - { value: 6, label: 'Maroon' }, - { value: 7, label: 'Purple' }, - { value: 8, label: 'Dark Blue' }, - { value: 9, label: 'Blue' }, - { value: 10, label: 'Cyan' }, - { value: 11, label: 'Teal' }, - { value: 12, label: 'Green' }, - { value: 13, label: 'Dark Green' }, - { value: 14, label: 'Brown' }, - ]} + options={takTeamColorOptions} /> )} diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 864abda04..e030629d0 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; +import { markDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; import { meshcoreApplyRepeaterSessionAuthSkip, @@ -433,4 +434,60 @@ describe('NodeDetailModal MeshCore actions', () => { expect(onDeleteNode).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); }); + + it('shows translated MQTT delete failure when active identity is connected', async () => { + const user = userEvent.setup(); + const onDeleteNode = vi + .fn() + .mockRejectedValue( + markDeleteActiveMqttIdentityError( + 'Cannot delete active MQTT identity while MQTT is connected', + ), + ); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Delete Node' })); + await user.click(screen.getByRole('button', { name: 'Confirm Delete' })); + + const status = screen.getByText('Delete failed. Disconnect MQTT and try again.'); + expect(status).toBeInTheDocument(); + expect(status).toHaveClass('text-red-300'); + }); + + it('renders Pax Counter section when data is present', () => { + render( + , + ); + + expect(screen.getByText('Pax Counter')).toBeInTheDocument(); + expect(screen.getByText('Detected Count')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 42c7027c5..3de387fae 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -10,7 +10,9 @@ import { isValidMeshtasticAdminKeyBase64, normalizeMeshtasticAdminKeyInput, } from '@/renderer/lib/meshtasticRemoteAdminKeyStorage'; +import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolIdentities'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; +import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { useMeshcoreRepeaterRemoteAuth } from '../hooks/useMeshcoreRepeaterRemoteAuth'; @@ -37,6 +39,7 @@ import { getNodeStatus } from '../lib/nodeStatus'; import { useRadioProvider } from '../lib/radio/providerFactory'; import { MESHCORE_TRACE_PING_TOTAL_TIMEOUT_MS } from '../lib/timeConstants'; import type { MeshCoreLocalStats, MeshNode, MeshProtocol, NeighborInfoRecord } from '../lib/types'; +import { blockHashForNodeNum, useBlockStore } from '../stores/blockStore'; import { useCoordFormatStore } from '../stores/coordFormatStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useNodeStore } from '../stores/nodeStore'; @@ -53,10 +56,10 @@ interface NodeDetailModalProps { nodes?: Map; node: MeshNode | null; onClose: () => void; - onRequestPosition: (nodeNum: number) => Promise; - onTraceRoute: (nodeNum: number) => Promise; + onRequestPosition?: (nodeNum: number) => Promise; + onTraceRoute?: (nodeNum: number) => Promise; traceRouteHops?: string[]; - onDeleteNode: (nodeNum: number) => Promise; + onDeleteNode?: (nodeNum: number) => Promise; onMessageNode?: (nodeNum: number) => void; /** MeshCore room server: open Rooms tab for BBS posts (not DM). */ onOpenRoom?: (nodeNum: number) => void; @@ -118,6 +121,48 @@ function meshcorePublicKeyToHex(publicKey: Uint8Array): string { .join(''); } +function NodeBlockButton({ + protocol, + node, + publicKeyHex, +}: { + protocol: MeshProtocol | undefined; + node: MeshNode; + publicKeyHex?: string; +}) { + const { t } = useTranslation(); + const identityId = + protocol && protocol !== 'reticulum' + ? (getIdentityIdForProtocol(protocol) ?? getOfflineIdentityIdForProtocol(protocol)) + : null; + const blockedHash = + protocol === 'meshcore' && publicKeyHex + ? publicKeyHex + : protocol && protocol !== 'reticulum' + ? blockHashForNodeNum(node.node_id) + : ''; + const isBlocked = useBlockStore((s) => (blockedHash ? s.isBlocked(blockedHash) : false)); + const block = useBlockStore((s) => s.block); + const unblock = useBlockStore((s) => s.unblock); + if (!protocol || protocol === 'reticulum' || !identityId || !blockedHash) return null; + return ( + + ); +} + function WatchToggleButton({ nodeId }: { nodeId: number }) { const { t } = useTranslation(); const isWatched = useWatchedNodesStore((s) => s.watchedNodeIds.has(nodeId)); @@ -192,6 +237,7 @@ export default function NodeDetailModal({ const coordinateFormat = useCoordFormatStore((s) => s.coordinateFormat); const [actionStatus, setActionStatus] = useState(null); + const [actionStatusIsDeleteMqttError, setActionStatusIsDeleteMqttError] = useState(false); const [adminKeyStatus, setAdminKeyStatus] = useState(null); const [adminKeyDraft, setAdminKeyDraft] = useState(''); const [adminKeyError, setAdminKeyError] = useState(null); @@ -275,6 +321,7 @@ export default function NodeDetailModal({ // Reset all state when node changes useEffect(() => { setActionStatus(null); + setActionStatusIsDeleteMqttError(false); setAdminKeyStatus(null); setPositionRequestedAt(null); setTraceRoutePending(false); @@ -483,7 +530,7 @@ export default function NodeDetailModal({ setPositionRequestedAt(Date.now()); setActionStatus(t('nodeDetailModal.requestingPosition')); try { - await onRequestPosition(node.node_id); + await onRequestPosition?.(node.node_id); } catch (e) { console.warn('[NodeDetailModal] request position failed ' + errLikeToLogString(e)); setPositionRequestedAt(null); @@ -495,7 +542,7 @@ export default function NodeDetailModal({ setTraceRoutePending(true); setActionStatus(t('nodeDetailModal.traceRouteRequested')); try { - await onTraceRoute(node.node_id); + await onTraceRoute?.(node.node_id); } finally { setTraceRoutePending(false); } @@ -622,6 +669,15 @@ export default function NodeDetailModal({
+ +
+ + {showStartStackBanner ? ( +

+ {t('connectionPanel.reticulumIdentity.startStackFirst')} +

+ ) : null} + + {sidecarRunning && !nomadApiAvailable ? ( +

+ {t('nomadNetwork.unavailable')} +

+ ) : null} + +
+
+
+ + +
+ + { + setSearchQuery(e.target.value); + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="mb-3 w-full rounded border border-gray-600 bg-slate-900 px-3 py-2 text-sm text-gray-200" + /> + + {filteredRows.length === 0 ? ( +

{t(emptyKey)}

+ ) : ( +
    + {filteredRows.map((node) => { + const isSelected = + selectedHash?.toLowerCase() === node.destination_hash.toLowerCase(); + const label = node.display_name ?? node.destination_hash.slice(0, 16); + return ( +
  • +
    + + +
    +
  • + ); + })} +
+ )} +
+ +
+ {!selectedNode ? ( +

+ {t('nomadNetwork.selectNode')} +

+ ) : ( + <> +
+ + {selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16)} + + {selectedNode.hops != null ? ( + + {t('nomadNetwork.hopsAway', { count: selectedNode.hops })} + + ) : null} +
+ + {isNomadMicronPage(pageContentType, pagePath) && pageContent != null ? ( + + ) : null} + + +
+
+ +
{ + e.preventDefault(); + void loadNodePage(selectedNode.destination_hash, pagePath); + }} + > + { + setPagePath(e.target.value); + }} + aria-label={t('nomadNetwork.pagePathAria')} + placeholder={t('nomadNetwork.pagePath')} + className="min-w-0 flex-1 rounded border border-gray-600 bg-slate-900 px-2 py-1 font-mono text-xs text-gray-200" + /> +
+ +
+ {fileDownloading ? ( +

{t('nomadNetwork.fileDownloading')}

+ ) : null} + {fileDownloadError ? ( +

+ {t('nomadNetwork.fileDownloadFailed', { error: fileDownloadError })} +

+ ) : null} + {pageLoading ? ( +

{t('nomadNetwork.pageLoading')}

+ ) : pageError ? ( +

+ {t('nomadNetwork.pageFailed', { error: pageError })} +

+ ) : pageContent != null ? ( + isNomadMicronPage(pageContentType, pagePath) && !showPageSource ? ( + { + void loadNodePage(hash, path); + }} + onDownloadFile={(hash, path) => { + void downloadNodeFile(hash, path); + }} + /> + ) : ( +
+                      {pageContent}
+                    
+ ) + ) : null} +
+ + )} +
+
+
+ ); +} diff --git a/src/renderer/components/PacketDistributionPanel.tsx b/src/renderer/components/PacketDistributionPanel.tsx index 257bcf36f..61210eba4 100644 --- a/src/renderer/components/PacketDistributionPanel.tsx +++ b/src/renderer/components/PacketDistributionPanel.tsx @@ -4,11 +4,15 @@ import { useTranslation } from 'react-i18next'; import { Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import type { RxPacketEntry } from '../lib/meshcore/meshcoreHookTypes'; -import type { MeshtasticRawPacketEntry } from '../lib/rawPacketLogConstants'; +import type { + MeshtasticRawPacketEntry, + ReticulumRawPacketEntry, +} from '../lib/rawPacketLogConstants'; +import { formatReticulumWireEnumLabel } from '../lib/reticulum/reticulumRawPacketLog'; // ── Types ───────────────────────────────────────────────────────────────────── -type Variant = 'meshtastic' | 'meshcore'; +type Variant = 'meshtastic' | 'meshcore' | 'reticulum'; type PacketDistributionPanelProps = | { @@ -20,6 +24,11 @@ type PacketDistributionPanelProps = variant: 'meshcore'; packets: RxPacketEntry[]; getNodeLabel: (id: number) => string; + } + | { + variant: 'reticulum'; + packets: ReticulumRawPacketEntry[]; + getNodeLabel: (id: number) => string; }; type MainView = 'overall' | 'by-type'; @@ -32,6 +41,7 @@ interface NormalizedPacket { packetType: string; viaMqtt: boolean; isLocal?: boolean; + groupKey?: string; } interface SliceData { @@ -69,8 +79,17 @@ const TOOLTIP_STYLE = { function normalize( variant: Variant, - packets: MeshtasticRawPacketEntry[] | RxPacketEntry[], + packets: MeshtasticRawPacketEntry[] | RxPacketEntry[] | ReticulumRawPacketEntry[], ): NormalizedPacket[] { + if (variant === 'reticulum') { + return (packets as ReticulumRawPacketEntry[]).map((p) => ({ + ts: p.ts, + fromNodeId: null, + packetType: formatReticulumWireEnumLabel(p.packetType) || 'UNKNOWN', + viaMqtt: false, + groupKey: p.interfaceName || tFallbackInterface(p.interfaceId), + })); + } if (variant === 'meshtastic') { return (packets as MeshtasticRawPacketEntry[]).map((p) => ({ ts: p.ts, @@ -88,6 +107,10 @@ function normalize( })); } +function tFallbackInterface(id: number): string { + return `iface_${id}`; +} + function applyTimeFilter(packets: NormalizedPacket[], filter: TimeFilter): NormalizedPacket[] { if (filter === 'all') return packets; const cutoff = filter === 'hour' ? Date.now() - 3_600_000 : Date.now() - 86_400_000; @@ -99,7 +122,7 @@ function applySourceFilter( filter: SourceFilter, variant: Variant, ): NormalizedPacket[] { - if (variant === 'meshcore') return packets; + if (variant === 'meshcore' || variant === 'reticulum') return packets; const nonLocal = packets.filter((p) => !p.isLocal); if (filter === 'all') return nonLocal; if (filter === 'rf') return nonLocal.filter((p) => !p.viaMqtt); @@ -149,6 +172,7 @@ function resolveNodeKey( variant: Variant, t: TFunction, ): string { + if (variant === 'reticulum') return k; const id = parseInt(k, 10); if (k === 'null' || isNaN(id)) { return variant === 'meshcore' ? t('packetDistribution.noSenderId') : t('common.unknown'); @@ -263,7 +287,8 @@ export default function PacketDistributionPanel({ // ── Overall Distribution data ───────────────────────────────────────────── const deviceSlices = useMemo(() => { - const counts = countBy(filtered, 'fromNodeId'); + const groupField: keyof NormalizedPacket = variant === 'reticulum' ? 'groupKey' : 'fromNodeId'; + const counts = countBy(filtered, groupField); const sorted = [...counts.entries()] .map(([k, count]) => ({ key: k, count })) .sort((a, b) => b.count - a.count); @@ -299,7 +324,8 @@ export default function PacketDistributionPanel({ const typeDeviceSlices = useMemo(() => { if (!effectiveType) return []; const subset = filtered.filter((p) => p.packetType === effectiveType); - const counts = countBy(subset, 'fromNodeId'); + const groupField: keyof NormalizedPacket = variant === 'reticulum' ? 'groupKey' : 'fromNodeId'; + const counts = countBy(subset, groupField); const sorted = [...counts.entries()] .map(([k, count]) => ({ key: k, count })) .sort((a, b) => b.count - a.count); diff --git a/src/renderer/components/PeerGraphPanel.tsx b/src/renderer/components/PeerGraphPanel.tsx index 26cf5e3e6..237c73ab2 100644 --- a/src/renderer/components/PeerGraphPanel.tsx +++ b/src/renderer/components/PeerGraphPanel.tsx @@ -1,8 +1,19 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { nodeHealthScore, nodeHealthTier } from '../lib/nodeHealthScore'; -import type { MeshNode } from '../lib/types'; +import { + buildMeshPeerTopologyGraph, + type MeshPeerTopologyGraph, + type MeshPeerTopologyGraphNode, +} from '@/renderer/lib/buildMeshPeerTopologyGraph'; +import { + FORCE_GRAPH_DEFAULTS, + type ForceEdge, + type SimNodeState, + startForceSimulationLoop, +} from '@/renderer/lib/forceDirectedGraphLayout'; +import type { MeshNode } from '@/renderer/lib/types'; +import { useSvgPanZoom } from '@/renderer/lib/useSvgPanZoom'; interface PeerGraphPanelProps { nodes: Map; @@ -10,197 +21,177 @@ interface PeerGraphPanelProps { onNodeClick?: (nodeId: number) => void; } -type HealthTier = ReturnType; - -interface GraphNode { - id: number; - label: string; - tier: HealthTier; -} - -interface GraphEdge { - source: number; - target: number; - /** 0 = direct link, 1 = one-hop link */ - hops: number; -} - -interface SimNode extends GraphNode { +interface RenderNode extends MeshPeerTopologyGraphNode { x: number; y: number; - vx: number; - vy: number; } interface RenderSnapshot { - nodes: (GraphNode & { x: number; y: number })[]; - edges: GraphEdge[]; + nodes: RenderNode[]; + edges: ForceEdge[]; + hiddenCount: number; + totalNodeCount: number; + relayCount: number; + demotedDirectCount: number; } -const TIER_FILL: Record = { +const TIER_FILL: Record = { good: '#22c55e', warn: '#eab308', poor: '#ef4444', }; -/** - * Build edges for both protocols using hops_away. - * hops_away === 0 → direct link to my node (thick solid edge) - * hops_away === 1 → one-hop link to my node (thin dashed edge) - * Higher hops_away → no edge drawn (node still shown) - */ -function buildEdges(myNodeId: number, nodes: Map): GraphEdge[] { - const edges: GraphEdge[] = []; - for (const node of nodes.values()) { - if (node.node_id === myNodeId) continue; - const h = node.hops_away ?? null; - if (h === 0 || h === 1) { - edges.push({ source: myNodeId, target: node.node_id, hops: h }); - } +function relaySpokeColor(online: boolean): string { + return online ? '#22c55e' : '#ef4444'; +} + +function graphSizes(nodeCount: number): { + centerR: number; + relayR: number; + peerR: number; + nodePadding: number; +} { + if (nodeCount > 36) { + return { centerR: 18, relayR: 12, peerR: 7, nodePadding: 12 }; + } + if (nodeCount > 20) { + return { centerR: 20, relayR: 14, peerR: 9, nodePadding: 14 }; } - return edges; + return { centerR: 22, relayR: 18, peerR: 12, nodePadding: FORCE_GRAPH_DEFAULTS.nodePadding }; } -const NODE_RADIUS = 18; -const REPULSION = 8000; -const SPRING_LEN_DIRECT = 140; -const SPRING_LEN_HOP = 220; -const SPRING_K = 0.06; -const DAMPING = 0.6; -const MAX_V = 8; -const RENDER_EVERY = 2; +function stopPanZoomPointer(e: React.PointerEvent): void { + e.stopPropagation(); +} export default function PeerGraphPanel({ nodes, myNodeId, onNodeClick }: PeerGraphPanelProps) { const { t } = useTranslation(); const svgRef = useRef(null); - const simRef = useRef([]); - const edgesRef = useRef([]); - const frameRef = useRef(0); - const animRef = useRef(null); - const [snapshot, setSnapshot] = useState({ nodes: [], edges: [] }); + const simRef = useRef([]); + const metaRef = useRef>(new Map()); + const edgesRef = useRef([]); + const statsRef = useRef({ + hiddenCount: 0, + totalNodeCount: 0, + relayCount: 0, + demotedDirectCount: 0, + }); + const [snapshot, setSnapshot] = useState({ + nodes: [], + edges: [], + hiddenCount: 0, + totalNodeCount: 0, + relayCount: 0, + demotedDirectCount: 0, + }); + const [includeDistantPeers, setIncludeDistantPeers] = useState(false); + const [maxHops, setMaxHops] = useState(2); + const { transform, resetView, bindSvgRef, onPointerDown, onPointerMove, onPointerUp } = + useSvgPanZoom(); + + const setSvgRef = useCallback( + (el: SVGSVGElement | null) => { + svgRef.current = el; + bindSvgRef(el); + }, + [bindSvgRef], + ); - const rebuild = useCallback(() => { - const width = svgRef.current?.clientWidth ?? 600; - const height = svgRef.current?.clientHeight ?? 400; - const cx = width / 2; - const cy = height / 2; + const publishSnapshotFromSim = useCallback(() => { + const renderNodes = simRef.current + .map((sn) => { + const meta = metaRef.current.get(sn.id); + if (!meta) return null; + return { ...meta, x: sn.x, y: sn.y }; + }) + .filter((n): n is RenderNode => n != null); + setSnapshot({ + nodes: renderNodes, + edges: [...edgesRef.current], + hiddenCount: statsRef.current.hiddenCount, + totalNodeCount: statsRef.current.totalNodeCount, + relayCount: statsRef.current.relayCount, + demotedDirectCount: statsRef.current.demotedDirectCount, + }); + }, []); - // Only include nodes with a known direct or relay connection, plus my own node. - // Nodes with hops_away >= 2 or null have no edge data and would just add O(n²) cost. - const connectedEdges = buildEdges(myNodeId, nodes); - const connectedIds = new Set([myNodeId]); - for (const e of connectedEdges) { - connectedIds.add(e.source); - connectedIds.add(e.target); - } - const ids = [...connectedIds].filter((id) => nodes.has(id)); + const applyGraph = useCallback( + (graph: MeshPeerTopologyGraph) => { + const width = svgRef.current?.clientWidth ?? 800; + const height = svgRef.current?.clientHeight ?? 600; + const cx = width / 2; + const cy = height / 2; - const existingById = new Map(simRef.current.map((n) => [n.id, n])); - simRef.current = ids.map((id, i) => { - const node = nodes.get(id)!; - const angle = (2 * Math.PI * i) / Math.max(1, ids.length); - const r = Math.min(cx, cy) * 0.55; - const existing = existingById.get(id); - return { - id, - label: node.short_name || `!${id.toString(16).slice(-4)}`, - tier: nodeHealthTier(nodeHealthScore(node).total), - x: existing?.x ?? cx + r * Math.cos(angle), - y: existing?.y ?? cy + r * Math.sin(angle), - vx: 0, - vy: 0, + const existingById = new Map(simRef.current.map((n) => [n.id, n])); + const meta = new Map(); + + simRef.current = graph.nodes.map((node) => { + meta.set(node.id, node); + const existing = existingById.get(node.id); + const seedX = node.kind === 'self' ? cx : node.seedX * (width / 800); + const seedY = node.kind === 'self' ? cy : node.seedY * (height / 600); + return { + id: node.id, + x: existing?.x ?? seedX, + y: existing?.y ?? seedY, + vx: 0, + vy: 0, + }; + }); + + metaRef.current = meta; + edgesRef.current = graph.edges; + statsRef.current = { + hiddenCount: graph.hiddenCount, + totalNodeCount: graph.totalNodeCount, + relayCount: graph.relayCount, + demotedDirectCount: graph.demotedDirectCount, }; - }); + publishSnapshotFromSim(); + }, + [publishSnapshotFromSim], + ); - edgesRef.current = connectedEdges; - }, [nodes, myNodeId]); + const rebuildGraph = useCallback(() => { + const selfNode = nodes.get(myNodeId); + const selfLabel = + selfNode?.short_name?.trim() || selfNode?.long_name?.trim() || t('peerGraph.meShort'); + const width = svgRef.current?.clientWidth ?? 800; + const height = svgRef.current?.clientHeight ?? 600; + const graph = buildMeshPeerTopologyGraph(nodes, { + myNodeId, + selfLabel, + cx: width / 2, + cy: height / 2, + filter: { includeDistantPeers, maxHops }, + }); + applyGraph(graph); + }, [applyGraph, includeDistantPeers, maxHops, myNodeId, nodes, t]); useEffect(() => { - rebuild(); - }, [rebuild]); + rebuildGraph(); + }, [rebuildGraph]); useEffect(() => { - let running = true; - frameRef.current = 0; - - function step() { - if (!running) return; - const ns = simRef.current; - const es = edgesRef.current; - const width = svgRef.current?.clientWidth ?? 600; - const height = svgRef.current?.clientHeight ?? 400; - - if (ns.length === 0) { - animRef.current = requestAnimationFrame(step); - return; - } + resetView(); + }, [includeDistantPeers, maxHops, resetView]); - const fx = new Float64Array(ns.length); - const fy = new Float64Array(ns.length); + const sizes = useMemo(() => graphSizes(snapshot.nodes.length), [snapshot.nodes.length]); - // Repulsion between all node pairs - for (let i = 0; i < ns.length; i++) { - for (let j = i + 1; j < ns.length; j++) { - const dx = ns[j].x - ns[i].x || 0.01; - const dy = ns[j].y - ns[i].y || 0.01; - const distSq = Math.max(1, dx * dx + dy * dy); - const dist = Math.sqrt(distSq); - const force = REPULSION / distSq; - fx[i] -= (force * dx) / dist; - fy[i] -= (force * dy) / dist; - fx[j] += (force * dx) / dist; - fy[j] += (force * dy) / dist; - } - } - - // Spring attraction along edges - for (const edge of es) { - const si = ns.findIndex((n) => n.id === edge.source); - const ti = ns.findIndex((n) => n.id === edge.target); - if (si < 0 || ti < 0) continue; - const dx = ns[ti].x - ns[si].x; - const dy = ns[ti].y - ns[si].y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const targetLen = edge.hops === 0 ? SPRING_LEN_DIRECT : SPRING_LEN_HOP; - const force = SPRING_K * (dist - targetLen); - fx[si] += (force * dx) / dist; - fy[si] += (force * dy) / dist; - fx[ti] -= (force * dx) / dist; - fy[ti] -= (force * dy) / dist; - } - - // Gentle centering pull - const cx = width / 2; - const cy = height / 2; - for (let i = 0; i < ns.length; i++) { - fx[i] += (cx - ns[i].x) * 0.003; - fy[i] += (cy - ns[i].y) * 0.003; - } - - // Integrate - for (let i = 0; i < ns.length; i++) { - ns[i].vx = Math.max(-MAX_V, Math.min(MAX_V, (ns[i].vx + fx[i]) * DAMPING)); - ns[i].vy = Math.max(-MAX_V, Math.min(MAX_V, (ns[i].vy + fy[i]) * DAMPING)); - ns[i].x = Math.max(NODE_RADIUS, Math.min(width - NODE_RADIUS, ns[i].x + ns[i].vx)); - ns[i].y = Math.max(NODE_RADIUS, Math.min(height - NODE_RADIUS, ns[i].y + ns[i].vy)); - } - - frameRef.current++; - if (frameRef.current % RENDER_EVERY === 0) { - setSnapshot({ - nodes: ns.map(({ id, label, tier, x, y }) => ({ id, label, tier, x, y })), - edges: [...es], - }); - } - animRef.current = requestAnimationFrame(step); - } - - animRef.current = requestAnimationFrame(step); - return () => { - running = false; - if (animRef.current !== null) cancelAnimationFrame(animRef.current); - }; - }, []); + useEffect(() => { + return startForceSimulationLoop({ + getSimNodes: () => simRef.current, + getEdges: () => edgesRef.current, + getDimensions: () => ({ + width: svgRef.current?.clientWidth ?? 800, + height: svgRef.current?.clientHeight ?? 600, + }), + onFrame: () => { + publishSnapshotFromSim(); + }, + nodePadding: sizes.nodePadding, + }); + }, [publishSnapshotFromSim, sizes.nodePadding]); const totalNodes = nodes.size; @@ -212,100 +203,279 @@ export default function PeerGraphPanel({ nodes, myNodeId, onNodeClick }: PeerGra ); } - const { nodes: renderNodes, edges: renderEdges } = snapshot; + const { + nodes: renderNodes, + edges: renderEdges, + hiddenCount, + totalNodeCount, + relayCount, + demotedDirectCount, + } = snapshot; + const nodeById = new Map(renderNodes.map((n) => [n.id, n])); + const hasGraph = renderNodes.length > 0; return (
-
+
{t('peerGraph.title')} + + + + {hasGraph && relayCount > 0 ? ( + {t('peerGraph.relayCount', { count: relayCount })} + ) : null} + {hasGraph && demotedDirectCount > 0 ? ( + + {t('peerGraph.compactLeafCount', { count: demotedDirectCount })} + + ) : null} - {renderNodes.length < totalNodes && ( + {hiddenCount > 0 && ( - {t('peerGraph.hiddenCount', { shown: renderNodes.length, total: totalNodes })} + {t('peerGraph.hiddenCount', { + shown: renderNodes.length, + total: totalNodeCount, + })} )} - {t('peerGraph.nodeCount', { count: renderNodes.length })} - {' · '} - {t('peerGraph.edgeCount', { count: renderEdges.length })} + {hasGraph && ( + <> + {t('peerGraph.nodeCount', { count: renderNodes.length })} + {' · '} + {t('peerGraph.edgeCount', { count: renderEdges.length })} + + )}
- - - - - - - - - {/* Edges */} - {renderEdges.map((edge, i) => { - const src = renderNodes.find((n) => n.id === edge.source); - const tgt = renderNodes.find((n) => n.id === edge.target); - if (!src || !tgt) return null; - return ( - - ); - })} - - {/* Nodes */} - {renderNodes.map((node) => { - const isSelf = node.id === myNodeId; - const fill = isSelf ? '#8b5cf6' : TIER_FILL[node.tier]; - const r = isSelf ? NODE_RADIUS + 4 : NODE_RADIUS; - return ( - onNodeClick?.(node.id)} - style={{ cursor: onNodeClick ? 'pointer' : undefined }} - role={onNodeClick ? 'button' : undefined} - aria-label={node.label} - > - {isSelf && ( - - )} - - - {node.label} - + {!hasGraph ? ( +
+ {t('peerGraph.noConnectedNodes')} +
+ ) : ( +
+ + + + + + + + + + {renderEdges.map((edge, i) => { + const a = nodeById.get(edge.source); + const b = nodeById.get(edge.target); + if (!a || !b) return null; + const isDirectSpoke = a.kind === 'self' && b.kind === 'relay'; + const stroke = isDirectSpoke ? relaySpokeColor(b.online) : '#94a3b8'; + const strokeWidth = isDirectSpoke ? 3 : 1; + return ( + + ); + })} + + {renderNodes.map((node) => { + if (node.kind === 'self') { + const fill = TIER_FILL[node.tier]; + return ( + onNodeClick?.(node.nodeId)} + style={{ cursor: onNodeClick ? 'pointer' : undefined }} + role={onNodeClick ? 'button' : undefined} + tabIndex={onNodeClick ? 0 : undefined} + aria-label={node.label} + > + + + + {t('peerGraph.meShort')} + + + {node.label} + + + ); + } + + if (node.kind === 'relay') { + const r = sizes.relayR; + const fill = TIER_FILL[node.tier]; + return ( + onNodeClick?.(node.nodeId)} + style={{ cursor: onNodeClick ? 'pointer' : undefined }} + role={onNodeClick ? 'button' : undefined} + tabIndex={0} + aria-label={node.label} + > + + + {node.label} + + + ); + } + + const r = sizes.peerR; + const fill = node.online ? TIER_FILL[node.tier] : '#475569'; + return ( + onNodeClick?.(node.nodeId)} + style={{ cursor: onNodeClick ? 'pointer' : undefined }} + role={onNodeClick ? 'button' : undefined} + tabIndex={0} + aria-label={node.label} + > + + + + {node.label} + + {node.hops != null && node.hops > 1 ? ( + + {t('peerGraph.hopBadge', { count: node.hops })} + + ) : null} + + ); + })} - ); - })} - -
+ +
+ )} +
{t('peerGraph.me')} + + + {t('peerGraph.relayHub')} + {t('peerGraph.good')} @@ -319,19 +489,19 @@ export default function PeerGraphPanel({ nodes, myNodeId, onNodeClick }: PeerGra {t('peerGraph.poor')} - - + + {t('peerGraph.directLink')} - + diff --git a/src/renderer/components/ProtocolSwitcher.test.tsx b/src/renderer/components/ProtocolSwitcher.test.tsx index 71a0b3007..8db090e33 100644 --- a/src/renderer/components/ProtocolSwitcher.test.tsx +++ b/src/renderer/components/ProtocolSwitcher.test.tsx @@ -15,7 +15,7 @@ describe('ProtocolSwitcher', () => { render( , ); @@ -28,16 +28,20 @@ describe('ProtocolSwitcher', () => { 'aria-pressed', 'false', ); + expect(screen.getByRole('button', { name: /Reticulum/i })).toHaveAttribute( + 'aria-pressed', + 'false', + ); await user.click(screen.getByRole('button', { name: /MeshCore/i })); expect(onProtocolChange).toHaveBeenCalledWith('meshcore'); }); - it('has no serious axe violations', async () => { + it('has no serious axe violations with three protocol pills', async () => { const { container } = render( {}} />, ); diff --git a/src/renderer/components/RadioPanel.tsx b/src/renderer/components/RadioPanel.tsx index e5f27fcdd..8ef873e39 100644 --- a/src/renderer/components/RadioPanel.tsx +++ b/src/renderer/components/RadioPanel.tsx @@ -159,37 +159,9 @@ interface Props { onRetryRemoteChannelsTail?: () => void; } -const REGIONS = [ - { value: 0, label: 'Unset' }, - { value: 1, label: 'US' }, - { value: 2, label: 'EU_433' }, - { value: 3, label: 'EU_868' }, - { value: 4, label: 'CN' }, - { value: 5, label: 'JP' }, - { value: 6, label: 'ANZ' }, - { value: 7, label: 'KR' }, - { value: 8, label: 'TW' }, - { value: 9, label: 'RU' }, - { value: 10, label: 'IN' }, - { value: 11, label: 'NZ_865' }, - { value: 12, label: 'TH' }, - { value: 13, label: 'UA_433' }, - { value: 14, label: 'UA_868' }, - { value: 15, label: 'MY_433' }, - { value: 16, label: 'MY_919' }, - { value: 17, label: 'SG_923' }, - { value: 18, label: 'LORA_24' }, -]; +const REGION_VALUES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] as const; -const MODEM_PRESETS = [ - { value: 0, label: 'Long Fast' }, - { value: 1, label: 'Long Slow' }, - { value: 2, label: 'Long Moderate' }, - { value: 3, label: 'Short Fast' }, - { value: 4, label: 'Short Slow' }, - { value: 5, label: 'Medium Fast' }, - { value: 6, label: 'Medium Slow' }, -]; +const MODEM_PRESET_VALUES = [0, 1, 2, 3, 4, 5, 6] as const; const DEVICE_ROLES = [ { value: 0, label: 'Client', description: 'Normal client mode' }, @@ -494,6 +466,7 @@ function ConfigSection({ disabled: boolean; hideApply?: boolean; }) { + const { t } = useTranslation(); return (
@@ -508,7 +481,9 @@ function ConfigSection({ disabled={disabled || applying} className="bg-readable-green hover:bg-readable-green/90 disabled:text-muted w-full rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:bg-gray-600" > - {applying ? 'Applying...' : `Apply ${title}`} + {applying + ? t('modulePanel.applyingButton') + : t('modulePanel.applySection', { section: title })} )}
@@ -913,6 +888,22 @@ export default function RadioPanel({ })), [t], ); + const regionOptions = useMemo( + () => + REGION_VALUES.map((value) => ({ + value, + label: t(`radioPanel.regions.${value}.label`), + })), + [t], + ); + const modemPresetOptions = useMemo( + () => + MODEM_PRESET_VALUES.map((value) => ({ + value, + label: t(`radioPanel.modemPresets.${value}.label`), + })), + [t], + ); const oledTypeOptions = useMemo( () => @@ -1231,7 +1222,7 @@ export default function RadioPanel({ console.error('[RadioPanel] config import error: ' + errLikeToLogString(err)); addToast( t('radioPanel.configParseFailed', { - message: err instanceof Error ? err.message : 'Invalid JSON', + message: err instanceof Error ? err.message : t('common.unknown'), }), 'error', ); @@ -1587,7 +1578,7 @@ export default function RadioPanel({ @@ -1602,7 +1593,7 @@ export default function RadioPanel({ @@ -2138,10 +2129,10 @@ export default function RadioPanel({ addToast( capabilities?.hasCompanionContactManagementConfig ? t('radioPanel.meshcoreGpsFailed', { - message: err instanceof Error ? err.message : 'unknown', + message: err instanceof Error ? err.message : t('common.unknown'), }) : t('radioPanel.actionFailed', { - message: err instanceof Error ? err.message : 'Unknown error', + message: err instanceof Error ? err.message : t('common.unknown'), }), 'error', ); @@ -3110,10 +3101,14 @@ function ChannelSection({ await onClearChannel(selectedIndex); } await onCommit(); - setStatus(`Channel ${selectedIndex} reset!`); + setStatus(t('radioPanel.channelResetStatus', { index: selectedIndex })); } catch (err) { console.warn('[RadioPanel] reset channel failed ' + errLikeToLogString(err)); - setStatus(`Failed: ${err instanceof Error ? err.message : 'Unknown'}`); + setStatus( + t('radioPanel.channelSaveFailed', { + message: err instanceof Error ? err.message : t('common.unknown'), + }), + ); } finally { setSaving(false); } @@ -3237,13 +3232,15 @@ function ChannelSection({ {/* ── Edit Form ── */} {selectedIndex !== null && (
-

Edit Channel {selectedIndex}

+

+ {t('radioPanel.editChannelTitle', { index: selectedIndex })} +

{/* Name */}
{editName.length}/11
@@ -3269,7 +3266,7 @@ function ChannelSection({ {selectedIndex !== 0 && (
{editKeyHex.length > 0 && !isValidHex && ( -

Must be exactly 32 hex characters.

+

{t('radioPanel.meshcoreChannel.invalidHex')}

)}
@@ -3779,15 +3780,16 @@ function MeshcoreChannelSection({ disabled={disabled} className="text-muted w-full rounded border border-dashed border-gray-600 px-3 py-1.5 text-xs transition-colors hover:border-gray-400 hover:text-gray-300 disabled:opacity-50" > - + Add Channel + {t('radioPanel.meshcoreChannel.addButton')} )}

- Keys are 128-bit (16 bytes), shown as 32 hex characters. Channel names up to{' '} - {MESHCORE_CHANNEL_NAME_MAX_LEN} characters. Up to {MESHCORE_CHANNEL_INDEX_MAX + 1}{' '} - channels (indices 0–{MESHCORE_CHANNEL_INDEX_MAX}). For #channels, use "Derive from - name" (SHA-256 of the name with a leading #). + {t('radioPanel.meshcoreChannel.footerHelp', { + nameMax: MESHCORE_CHANNEL_NAME_MAX_LEN, + channelCount: MESHCORE_CHANNEL_INDEX_MAX + 1, + indexMax: MESHCORE_CHANNEL_INDEX_MAX, + })}

diff --git a/src/renderer/components/RawPacketLogPanel.test.tsx b/src/renderer/components/RawPacketLogPanel.test.tsx index a92225eb2..a292bec9f 100644 --- a/src/renderer/components/RawPacketLogPanel.test.tsx +++ b/src/renderer/components/RawPacketLogPanel.test.tsx @@ -1,21 +1,63 @@ import { create, toBinary } from '@bufbuild/protobuf'; import { Mesh, Portnums } from '@meshtastic/protobufs'; import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { VIRTUALIZER_SCROLL_END_THRESHOLD } from '../lib/chatScrollUtils'; import type { RxPacketEntry } from '../lib/meshcore/meshcoreHookTypes'; -import type { MeshtasticRawPacketEntry } from '../lib/rawPacketLogConstants'; +import type { + MeshtasticRawPacketEntry, + ReticulumRawPacketEntry, +} from '../lib/rawPacketLogConstants'; import RawPacketLogPanel from './RawPacketLogPanel'; +let mockIsAtEnd = true; +const mockScrollToEnd = vi.fn(); +const mockScrollToIndex = vi.fn(); +let lastVirtualizerOptions: Record | undefined; +let lastVirtualizerInstance: Record | undefined; + vi.mock('@tanstack/react-virtual', () => ({ - useVirtualizer: (opts: { count: number }) => ({ - getVirtualItems: () => - Array.from({ length: opts.count }, (_, index) => ({ index, start: index * 36 })), - getTotalSize: () => opts.count * 36, - measureElement: () => {}, - }), + useVirtualizer: (opts: Record & { count: number }) => { + lastVirtualizerOptions = opts; + const count = opts.count; + const getItemKey = opts.getItemKey as ((index: number) => string | number) | undefined; + const instance = { + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: getItemKey?.(index) ?? index, + start: index * 36, + })), + getTotalSize: () => count * 36, + measureElement: () => {}, + isAtEnd: () => mockIsAtEnd, + scrollToEnd: mockScrollToEnd, + scrollToIndex: mockScrollToIndex, + get scrollDirection() { + return 'forward' as const; + }, + shouldAdjustScrollPositionOnItemSizeChange: undefined as + | (( + item: { index: number }, + delta: number, + inst: { scrollDirection: string | null; isAtEnd: () => boolean }, + ) => boolean) + | undefined, + }; + lastVirtualizerInstance = instance; + return instance; + }, })); +beforeEach(() => { + mockIsAtEnd = true; + mockScrollToEnd.mockClear(); + mockScrollToIndex.mockClear(); + lastVirtualizerOptions = undefined; + lastVirtualizerInstance = undefined; +}); + function hexToU8(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { @@ -45,6 +87,301 @@ function meshcorePacket(rawHex: string, payloadTypeString: string): RxPacketEntr }; } +function reticulumPacket(ts: number, interfaceName = 'RNode'): ReticulumRawPacketEntry { + return { + ts, + direction: 'rx', + interfaceId: 1, + interfaceName, + raw: new Uint8Array([0x01, 0x02, ts & 0xff]), + packetType: 'DATA', + headerType: 'SINGLE', + }; +} + +describe('RawPacketLogPanel scroll pinning', () => { + it('configures TanStack Virtual with sniffer scroll contract', () => { + render( + 'node'} + />, + ); + expect(lastVirtualizerOptions?.anchorTo).toBe('end'); + expect(lastVirtualizerOptions?.followOnAppend).toBe(true); + expect(lastVirtualizerOptions?.scrollEndThreshold).toBe(VIRTUALIZER_SCROLL_END_THRESHOLD); + const adjust = lastVirtualizerInstance?.shouldAdjustScrollPositionOnItemSizeChange as ( + item: { index: number }, + delta: number, + instance: { + scrollDirection: 'forward' | 'backward' | null; + isAtEnd: () => boolean; + }, + ) => boolean; + expect(adjust).toBeTypeOf('function'); + expect(adjust({ index: 0 }, 0, { scrollDirection: 'forward', isAtEnd: () => true })).toBe(true); + expect(adjust({ index: 0 }, 0, { scrollDirection: 'forward', isAtEnd: () => false })).toBe( + false, + ); + }); + + it('scrolls to end when pinned and new packets arrive', () => { + mockIsAtEnd = true; + const initial = reticulumPacket(1_710_000_000_000); + const { rerender } = render( + 'node'} + />, + ); + + mockScrollToEnd.mockClear(); + const newer = reticulumPacket(1_710_000_001_000, 'TCP'); + rerender( + 'node'} + />, + ); + + expect(mockScrollToEnd).toHaveBeenCalled(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('preserves scroll anchor when not pinned and new packets arrive', () => { + mockIsAtEnd = false; + const packets = [reticulumPacket(1_710_000_000_000), reticulumPacket(1_710_000_001_000)]; + const { container, rerender } = render( + 'node'} + />, + ); + + const scrollContainer = container.querySelector('[role="log"]')!; + fireEvent.scroll(scrollContainer); + + mockScrollToEnd.mockClear(); + mockScrollToIndex.mockClear(); + + rerender( + 'node'} + />, + ); + + expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToIndex).toHaveBeenCalledWith(0, { align: 'start' }); + }); +}); + +describe('RawPacketLogPanel pause capture', () => { + it('freezes visible packet count while paused even when parent packets grow', () => { + const packets = [reticulumPacket(1_710_000_000_000), reticulumPacket(1_710_000_001_000)]; + const { rerender } = render( + 'node'} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + + rerender( + 'node'} + />, + ); + + expect(lastVirtualizerOptions?.count).toBe(2); + expect(screen.getByText('1 new while paused')).toBeTruthy(); + }); + + it('freezes snapshot when ring buffer is at capacity and parent rotates entries', () => { + const cap = 3; + const packets = [ + reticulumPacket(1_710_000_000_000), + reticulumPacket(1_710_000_001_000), + reticulumPacket(1_710_000_002_000), + ]; + const { rerender } = render( + 'node'} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + + const rotated = [...packets.slice(1), reticulumPacket(1_710_000_003_000)].slice(-cap); + rerender( + 'node'} + />, + ); + + expect(lastVirtualizerOptions?.count).toBe(cap); + expect(screen.getByText('1 new while paused')).toBeTruthy(); + }); + + it('disables followOnAppend while paused', () => { + render( + 'node'} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + + expect(lastVirtualizerOptions?.followOnAppend).toBe(false); + }); + + it('does not follow new packets while paused at bottom', () => { + mockIsAtEnd = true; + const packets = [reticulumPacket(1_710_000_000_000)]; + const { rerender } = render( + 'node'} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + mockScrollToEnd.mockClear(); + mockScrollToIndex.mockClear(); + + rerender( + 'node'} + />, + ); + + expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('resumes live capture and scrolls to end', () => { + const packets = [reticulumPacket(1_710_000_000_000)]; + const { rerender } = render( + 'node'} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + rerender( + 'node'} + />, + ); + + mockScrollToEnd.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Resume' })); + + expect(lastVirtualizerOptions?.count).toBe(2); + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + + it('does not follow new packets while a row is expanded', () => { + mockIsAtEnd = true; + const packets = [reticulumPacket(1_710_000_000_000)]; + const { container, rerender } = render( + 'node'} + />, + ); + + const row = container.querySelector('[data-index="0"] .cursor-pointer'); + expect(row).toBeTruthy(); + fireEvent.click(row!); + + mockScrollToEnd.mockClear(); + mockScrollToIndex.mockClear(); + + rerender( + 'node'} + />, + ); + + expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); +}); + +describe('RawPacketLogPanel duplicate row keys', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('does not warn when two identical captures share content but differ by index', () => { + const packet = meshcorePacket( + '150107819d28f7a0d427af7cbd3c6057b29763736b3878eb027514687b110abe33456565ca1117316f81033b1de05496a57ab1c44335f53749008b593a19cd9c9e340d34f076', + 'GRP_TXT', + ); + render( + 'node'} + />, + ); + + const duplicateKeyWarnings = consoleErrorSpy.mock.calls.filter((args: unknown[]) => + String(args[0]).includes('Encountered two children with the same key'), + ); + expect(duplicateKeyWarnings).toHaveLength(0); + }); +}); + describe('RawPacketLogPanel meshcore expanded details', () => { it('shows GRP_TXT channel hash plus MAC/ciphertext info', () => { const packets = [ @@ -115,7 +452,7 @@ function meshtasticPacket( }) as never, ); return { - ts: 1_710_000_000_000, + ts: overrides.ts ?? 1_710_000_000_000, snr: 2.5, rssi: -90, raw, @@ -146,6 +483,33 @@ describe('RawPacketLogPanel meshtastic expanded details', () => { expect(screen.getByText(/payload=decoded/)).toBeInTheDocument(); }); + it('keeps expanded row open when new packets arrive', () => { + const initial = meshtasticPacket({ portLabel: 'TEXT_MESSAGE_APP' }); + const { rerender } = render( + 'TestNode'} + />, + ); + + fireEvent.click(screen.getByText('TEXT_MESSAGE_APP')); + expect(screen.getByText(/id=0x12345678/)).toBeInTheDocument(); + + const newer = meshtasticPacket({ portLabel: 'POSITION_APP', ts: 1_710_000_001_000 }); + rerender( + 'TestNode'} + />, + ); + + expect(screen.getByText(/id=0x12345678/)).toBeInTheDocument(); + }); + it('node name click opens details without expanding raw hex', () => { const onNodeClick = vi.fn(); const packets = [meshtasticPacket({ portLabel: 'TEXT_MESSAGE_APP' })]; @@ -168,7 +532,7 @@ describe('RawPacketLogPanel meshtastic expanded details', () => { it('collapses expanded row when filter excludes it', () => { const packets = [ meshtasticPacket({ portLabel: 'TEXT_MESSAGE_APP' }), - meshtasticPacket({ portLabel: 'POSITION_APP' }), + meshtasticPacket({ portLabel: 'POSITION_APP', ts: 1_710_000_001_000 }), ]; render( = { FLOOD: 'FLOOD', @@ -37,18 +52,20 @@ const ROUTE_LABEL: Record = { /** Sender column: Meshtastic long names can be ~36 chars; flex so the row shares space without a 120px cap. */ const RAW_PACKET_NAME_COL = 'min-w-0 flex-1 max-w-[min(28rem,50vw)]'; +function formatReticulumDestinationLabel( + destinationHash: string | null | undefined, + getNodeLabel: (nodeId: number) => string, +): string | null { + if (typeof reticulumDestinationColumnText !== 'function') return null; + return reticulumDestinationColumnText(destinationHash, getNodeLabel, reticulumHashToNodeId); +} + function toHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } -/** Stable virtualizer row key — ts alone can collide within the same millisecond. */ -function rawPacketRowKey(ts: number, raw: Uint8Array): string { - const prefix = raw.length > 0 ? toHex(raw.subarray(0, Math.min(4, raw.length))) : 'empty'; - return `${ts}-${raw.length}-${prefix}`; -} - function formatTs(ts: number): string { return formatLogTimeOfDay(ts); } @@ -312,6 +329,82 @@ function MeshtasticExpandedDetails({ p }: { p: MeshtasticRawPacketEntry }) { ); } +function ReticulumExpandedDetails({ + p, + getNodeLabel, +}: { + p: ReticulumRawPacketEntry; + getNodeLabel: (nodeId: number) => string; +}) { + const { t } = useTranslation(); + const destinationLabel = formatReticulumDestinationLabel(p.destinationHash, getNodeLabel); + return ( +
+

+ {t('rawPacketLog.reticulum.direction')}:{' '} + {p.direction.toUpperCase()} + {' · '} + {t('rawPacketLog.reticulum.interface')}:{' '} + {p.interfaceName} +

+

+ {t('rawPacketLog.reticulum.packetType')}:{' '} + {formatReticulumWireEnumLabel(p.packetType)} + {' · '} + {t('rawPacketLog.reticulum.headerType')}:{' '} + {formatReticulumWireEnumLabel(p.headerType)} +

+ {p.destinationHash ? ( +

+ {t('rawPacketLog.reticulum.destination')}:{' '} + {destinationLabel ?? p.destinationHash.slice(0, 16)} +

+ ) : null} + {(p.rssi != null || p.snr != null) && ( +

+ {p.rssi != null ? `RSSI ${p.rssi.toFixed(1)}` : null} + {p.rssi != null && p.snr != null ? ' · ' : null} + {p.snr != null ? `SNR ${p.snr.toFixed(1)}` : null} +

+ )} +
+ ); +} + +function ReticulumRow({ + p, + getNodeLabel, +}: { + p: ReticulumRawPacketEntry; + getNodeLabel: (nodeId: number) => string; +}) { + const { t } = useTranslation(); + const typeLabel = formatReticulumWireEnumLabel(p.packetType); + const dirLabel = + p.direction === 'tx' ? t('rawPacketLog.reticulum.tx') : t('rawPacketLog.reticulum.rx'); + const destinationLabel = formatReticulumDestinationLabel(p.destinationHash, getNodeLabel); + return ( + <> + + {formatTs(p.ts)} + + + {dirLabel} + + + {typeLabel} · {p.interfaceName} + {destinationLabel ? ` · ${destinationLabel}` : ''} + + + ); +} + interface MeshcoreProps { variant: 'meshcore'; packets: RxPacketEntry[]; @@ -330,23 +423,60 @@ interface MeshtasticProps { onNodeClick?: (nodeId: number) => void; } -type Props = MeshcoreProps | MeshtasticProps; +interface ReticulumProps { + variant: 'reticulum'; + packets: ReticulumRawPacketEntry[]; + onClear: () => void; + getNodeLabel: (nodeId: number) => string; +} + +type Props = MeshcoreProps | MeshtasticProps | ReticulumProps; export default function RawPacketLogPanel(props: Props) { - const { variant, packets, onClear, getNodeLabel, onNodeClick } = props; + const { variant, packets, onClear, getNodeLabel } = props; + const onNodeClick = variant === 'reticulum' ? undefined : props.onNodeClick; const floodScopeHashtag = variant === 'meshcore' ? props.floodScopeHashtag : undefined; const { t } = useTranslation(); const [filter, setFilter] = useState(''); - const [expandedIdx, setExpandedIdx] = useState(null); + const [expandedRowKey, setExpandedRowKey] = useState(null); + const [isPaused, setIsPaused] = useState(false); + /** Snapshot taken at pause time so ring-buffer eviction does not mutate the frozen view. */ + const [pausedPackets, setPausedPackets] = useState(null); + const [showScrollButton, setShowScrollButton] = useState(false); const scrollRef = useRef(null); - const atBottomRef = useRef(true); + /** Sticky intent: user is reading latest packets and wants auto-follow on new traffic. */ + const isPinnedToBottomRef = useRef(true); + const isPausedRef = useRef(false); + isPausedRef.current = isPaused; + const unreadStartIndexRef = useRef(-1); + const anchorIndexRef = useRef(0); + const prevFilteredLengthRef = useRef(0); + const expandedRowKeyRef = useRef(null); + expandedRowKeyRef.current = expandedRowKey; + + const pendingWhilePaused = useMemo(() => { + if (!isPaused || pausedPackets == null) return 0; + if (packets.length > pausedPackets.length) { + return packets.length - pausedPackets.length; + } + const snapLastTs = pausedPackets[pausedPackets.length - 1]?.ts; + if (snapLastTs == null) return 0; + let count = 0; + for (let i = packets.length - 1; i >= 0; i--) { + if (packets[i].ts <= snapLastTs) break; + count++; + } + return count; + }, [isPaused, pausedPackets, packets]); const filtered = useMemo(() => { - if (!filter.trim()) return packets; const q = filter.trim().toUpperCase(); const f = filter.trim().toLowerCase(); + if (variant === 'meshcore') { - return packets.filter( + const list = isPaused && pausedPackets != null ? (pausedPackets as RxPacketEntry[]) : packets; + if (!filter.trim()) return list; + return list.filter( (p) => (p.routeTypeString ?? '').includes(q) || (p.payloadTypeString ?? '').includes(q) || @@ -361,7 +491,32 @@ export default function RawPacketLogPanel(props: Props) { .includes(q)), ); } - return packets.filter( + if (variant === 'reticulum') { + const list = + isPaused && pausedPackets != null ? (pausedPackets as ReticulumRawPacketEntry[]) : packets; + if (!filter.trim()) return list; + return list.filter((p) => { + const destinationLabel = formatReticulumDestinationLabel(p.destinationHash, getNodeLabel); + if (p.destinationHash) { + registerReticulumDestinationHash( + reticulumHashToNodeId(p.destinationHash), + p.destinationHash, + ); + } + return ( + p.interfaceName.toLowerCase().includes(f) || + (p.packetType ?? '').toLowerCase().includes(f) || + (p.destinationHash ?? '').toLowerCase().includes(f) || + (destinationLabel ?? '').toLowerCase().includes(f) || + p.direction.includes(f) || + toHex(p.raw).includes(f) + ); + }); + } + const list = + isPaused && pausedPackets != null ? (pausedPackets as MeshtasticRawPacketEntry[]) : packets; + if (!filter.trim()) return list; + return list.filter( (p) => (p.portLabel ?? '').includes(q) || toHex(p.raw).includes(f) || @@ -369,39 +524,137 @@ export default function RawPacketLogPanel(props: Props) { (p.isLocal && 'local'.includes(f)) || (p.fromNodeId != null && getNodeLabel(p.fromNodeId).toUpperCase().includes(q)), ); - }, [packets, filter, variant, getNodeLabel]); + }, [packets, pausedPackets, isPaused, filter, variant, getNodeLabel]); - useEffect(() => { - setExpandedIdx(null); - }, [packets, filter]); + const expandedIndex = useMemo(() => { + if (!expandedRowKey) return -1; + return filtered.findIndex( + (row, index) => rawPacketVirtualizerKey(row.ts, row.raw, index) === expandedRowKey, + ); + }, [filtered, expandedRowKey]); const getScrollElement = useCallback(() => scrollRef.current, []); - const estimateSize = useCallback(() => 36, []); + const estimateSize = useCallback( + (index: number) => (index === expandedIndex ? 200 : 36), + [expandedIndex], + ); + const measureElement = useMemo( + () => createStableChatMeasureElement(estimateSize), + [estimateSize], + ); const virtualizer = useVirtualizer({ count: filtered.length, getScrollElement, estimateSize, + measureElement, overscan: 12, + anchorTo: 'end', + followOnAppend: !isPaused, + scrollEndThreshold: VIRTUALIZER_SCROLL_END_THRESHOLD, + getItemKey: (index) => { + const row = filtered[index]; + if (!row) return `raw-slot-${index}`; + return rawPacketVirtualizerKey(row.ts, row.raw, index); + }, }); + const virtualizerRef = useRef(virtualizer); + virtualizerRef.current = virtualizer; + + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => { + if (isPausedRef.current || expandedRowKeyRef.current) return false; + return createChatScrollAdjustPredicate({ + unreadStartIndexRef, + isPinnedToBottomRef, + })(item, delta, instance); + }; + const onScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48; + const items = virtualizerRef.current.getVirtualItems(); + if (items.length > 0) { + anchorIndexRef.current = items[0].index; + } + const atEnd = virtualizerRef.current.isAtEnd(VIRTUALIZER_SCROLL_END_THRESHOLD); + if (!isPaused) { + isPinnedToBottomRef.current = atEnd; + } + setShowScrollButton(!atEnd && !isPaused); + }, [isPaused]); + + const unpinScroll = useCallback(() => { + isPinnedToBottomRef.current = false; + setShowScrollButton(true); + }, []); + + const scrollToLatest = useCallback(() => { + isPinnedToBottomRef.current = true; + virtualizerRef.current.scrollToEnd(); + setShowScrollButton(false); }, []); + const togglePause = useCallback(() => { + if (isPaused) { + setIsPaused(false); + setPausedPackets(null); + isPinnedToBottomRef.current = true; + requestAnimationFrame(() => { + virtualizerRef.current.scrollToEnd(); + setShowScrollButton(false); + }); + return; + } + setPausedPackets(packets.slice()); + setIsPaused(true); + isPinnedToBottomRef.current = false; + requestAnimationFrame(() => { + virtualizerRef.current.scrollToIndex(anchorIndexRef.current, { align: 'start' }); + }); + }, [isPaused, packets]); + + useLayoutEffect(() => { + requestAnimationFrame(() => { + if (!scrollRef.current) return; + const atEnd = virtualizerRef.current.isAtEnd(VIRTUALIZER_SCROLL_END_THRESHOLD); + if (!isPaused) { + isPinnedToBottomRef.current = atEnd; + } + setShowScrollButton(!atEnd && !isPaused); + }); + }, [isPaused]); + + // Follow latest when pinned; preserve anchor row when user scrolled up to inspect history. + useEffect(() => { + if (isPaused || expandedRowKey) return; + const prevLen = prevFilteredLengthRef.current; + prevFilteredLengthRef.current = filtered.length; + if (filtered.length <= prevLen) return; + + if (isPinnedToBottomRef.current) { + virtualizerRef.current.scrollToEnd(); + return; + } + + virtualizerRef.current.scrollToIndex(anchorIndexRef.current, { align: 'start' }); + }, [filtered.length, isPaused, expandedRowKey]); + const handleClear = useCallback(() => { - setExpandedIdx(null); + setExpandedRowKey(null); + setIsPaused(false); + setPausedPackets(null); onClear(); }, [onClear]); return ( -
+
{variant === 'meshcore' ? (

{t('rawPacketLog.transportLegendHint')}

+ ) : variant === 'reticulum' ? ( +

+ {t('rawPacketLog.reticulum.legendHint')} +

) : (

{t('rawPacketLog.meshtasticLegendHint')} @@ -414,12 +667,35 @@ export default function RawPacketLogPanel(props: Props) { value={filter} onChange={(e) => { setFilter(e.target.value); - setExpandedIdx(null); + setExpandedRowKey(null); }} aria-label={t('rawPacketLog.filterPackets')} className="min-w-0 flex-1 rounded border border-gray-600 bg-slate-800 px-2 py-1 font-mono text-xs text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" /> {filtered.length} + {isPaused && pendingWhilePaused > 0 ? ( + + {t('rawPacketLog.pausedPending', { count: pendingWhilePaused })} + + ) : null} + + ) : null}

)}
diff --git a/src/renderer/components/RepeatersPanel.tsx b/src/renderer/components/RepeatersPanel.tsx index c639ffb64..58a5c64e6 100644 --- a/src/renderer/components/RepeatersPanel.tsx +++ b/src/renderer/components/RepeatersPanel.tsx @@ -15,6 +15,7 @@ import type { MeshCoreNodeTelemetry, MeshCoreRepeaterStatus, } from '../lib/meshcore/meshcoreHookTypes'; +import { translateMeshcoreUserMessage } from '../lib/meshcore/meshcoreMessageI18n'; import { meshcoreClearRepeaterRemoteSessionAuth, meshcoreIsRepeaterRemoteAuthTouched, @@ -915,8 +916,10 @@ export default function RepeatersPanel({
- Path: - ● Me + + {t('repeatersPanel.pathLabel')} + + {t('repeatersPanel.hopMe')} {(Array.isArray(traceResult.pathSnrs) ? traceResult.pathSnrs : [] @@ -927,7 +930,9 @@ export default function RepeatersPanel({ {hop > 0 ? '+' : ''} {hop.toFixed(2)} dB - ● Hop {i + 1} + + {t('repeatersPanel.hopN', { n: i + 1 })} + ))} @@ -951,7 +956,9 @@ export default function RepeatersPanel({ })}

{neighborData.neighbours.length === 0 ? ( -

No neighbors reported

+

+ {t('repeatersPanel.noNeighborsReported')} +

) : (
{neighborData.neighbours.map((nb, i) => { @@ -983,40 +990,53 @@ export default function RepeatersPanel({ {isTelemetryLoading ? ( -

Fetching telemetry…

+

+ {t('repeatersPanel.fetchingTelemetry')} +

) : telemetryData ? (
{telemetryData.voltage != null && ( - Battery: {telemetryData.voltage.toFixed(2)}V + {t('repeatersPanel.telemetryBattery', { + voltage: telemetryData.voltage.toFixed(2), + })} )} {telemetryData.temperature != null && ( - Temp: {telemetryData.temperature.toFixed(1)}°C + {t('repeatersPanel.telemetryTemp', { + temp: telemetryData.temperature.toFixed(1), + })} )} {telemetryData.relativeHumidity != null && ( - Humidity: {telemetryData.relativeHumidity.toFixed(0)}% + {t('repeatersPanel.telemetryHumidity', { + humidity: telemetryData.relativeHumidity.toFixed(0), + })} )} {telemetryData.barometricPressure != null && ( - Pressure: {telemetryData.barometricPressure.toFixed(1)} hPa + {t('repeatersPanel.telemetryPressure', { + pressure: telemetryData.barometricPressure.toFixed(1), + })} )} {telemetryData.gps && ( - GPS:{' '} - {formatCoordPair( - telemetryData.gps.latitude, - telemetryData.gps.longitude, - coordinateFormat, - )} - {telemetryData.gps.altitude - ? ` alt ${telemetryData.gps.altitude}m` - : ''} + {t('repeatersPanel.telemetryGps', { + coords: formatCoordPair( + telemetryData.gps.latitude, + telemetryData.gps.longitude, + coordinateFormat, + ), + alt: telemetryData.gps.altitude + ? t('repeatersPanel.telemetryGpsAlt', { + altitude: telemetryData.gps.altitude, + }) + : '', + })} )} {telemetryData.voltage == null && @@ -1035,7 +1055,11 @@ export default function RepeatersPanel({ ) : (
{telemetryError ? ( -

{telemetryError}

+

+ {t('nodeDetailModal.telemetryFailed', { + message: translateMeshcoreUserMessage(t, telemetryError), + })} +

) : (

{t('repeatersPanel.noTelemetryResponse')} @@ -1099,12 +1123,14 @@ export default function RepeatersPanel({ {isCliLoading ? ( ) : ( - 'Send' + t('repeatersPanel.cliSend') )}

- Quick: + + {t('repeatersPanel.cliQuick')} + {[ 'name', 'radio', diff --git a/src/renderer/components/ReticulumAdminPanel.test.tsx b/src/renderer/components/ReticulumAdminPanel.test.tsx new file mode 100644 index 000000000..20cf03145 --- /dev/null +++ b/src/renderer/components/ReticulumAdminPanel.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/renderer/lib/radio/providerFactory', () => ({ + useRadioProvider: () => ({ + hasRNodeFlasher: true, + }), +})); + +const refreshIdentity = vi.fn(); +vi.mock('@/renderer/lib/reticulum/useReticulumSidecarApi', () => ({ + useReticulumSidecarApi: vi.fn(() => ({ + sidecarUiRunning: true, + sidecarApiReady: true, + refreshIdentity, + })), +})); + +vi.mock('./flasher/RNodeFlasherSection', () => ({ + RNodeFlasherSection: () =>
, +})); + +import { useReticulumSidecarApi } from '@/renderer/lib/reticulum/useReticulumSidecarApi'; + +import { ReticulumAdminPanel } from './ReticulumAdminPanel'; +import { ToastProvider } from './Toast'; + +describe('ReticulumAdminPanel', () => { + beforeEach(() => { + refreshIdentity.mockReset(); + window.electronAPI.reticulum.proxyGet = vi.fn().mockResolvedValue({ interfaces: [] }); + window.electronAPI.reticulum.proxyPost = vi.fn().mockResolvedValue({ ok: true }); + }); + + it('renders flasher and factory reset danger zone', () => { + render( + + {}} /> + , + ); + + expect(screen.getByText('tabs.admin')).toBeInTheDocument(); + expect(screen.getByTestId('flasher-mock')).toBeInTheDocument(); + expect(screen.getByText('radioPanel.dangerZone')).toBeInTheDocument(); + expect(screen.getByText('adminPanel.reticulumFactoryReset.button')).toBeInTheDocument(); + }); + + it('passes portBlocked to flasher when enabled RNode interface is active', async () => { + window.electronAPI.reticulum.proxyGet = vi.fn().mockResolvedValue({ + interfaces: [{ id: '1', type: 'RNode', enabled: true }], + }); + + render( + + {}} /> + , + ); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyGet).toHaveBeenCalledWith('/api/v1/interfaces'); + }); + }); + + it('factory reset confirms and calls sidecar API', async () => { + const user = userEvent.setup(); + render( + + {}} /> + , + ); + + await user.click(screen.getByText('adminPanel.reticulumFactoryReset.button')); + await user.click( + screen.getByRole('button', { name: 'adminPanel.reticulumFactoryReset.confirm' }), + ); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith( + '/api/v1/system/factory-reset', + {}, + ); + }); + expect(refreshIdentity).toHaveBeenCalled(); + }); + + it('shows flasher hint when stack is stopped', () => { + vi.mocked(useReticulumSidecarApi).mockReturnValue({ + sidecarUiRunning: false, + sidecarApiReady: false, + refreshIdentity, + } as unknown as ReturnType); + + render( + + {}} /> + , + ); + + expect(screen.getByText('flasher.stackStoppedHint')).toBeInTheDocument(); + expect( + screen.queryByText('connectionPanel.reticulumIdentity.startStackFirst'), + ).not.toBeInTheDocument(); + }); + + it('has no serious axe violations', async () => { + const { container } = render( + + {}} /> + , + ); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/renderer/components/ReticulumAdminPanel.tsx b/src/renderer/components/ReticulumAdminPanel.tsx new file mode 100644 index 000000000..0024e2a42 --- /dev/null +++ b/src/renderer/components/ReticulumAdminPanel.tsx @@ -0,0 +1,144 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { useRadioProvider } from '@/renderer/lib/radio/providerFactory'; +import { useReticulumSidecarApi } from '@/renderer/lib/reticulum/useReticulumSidecarApi'; + +import { ConfirmModal } from './ConfirmModal'; +import { RNodeFlasherSection } from './flasher/RNodeFlasherSection'; +import { useToast } from './Toast'; + +interface ReticulumInterfaceRow { + id: string; + type: string; + enabled: boolean; +} + +export interface ReticulumAdminPanelProps { + connecting: boolean; + onStartStack: () => Promise; +} + +/** Administration tab: RNode flasher and stack factory reset (danger zone). */ +export function ReticulumAdminPanel({ connecting, onStartStack }: ReticulumAdminPanelProps) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const capabilities = useRadioProvider('reticulum'); + const { sidecarUiRunning, sidecarApiReady, refreshIdentity } = useReticulumSidecarApi({ + connecting, + onStartStack, + }); + + const [interfaces, setInterfaces] = useState([]); + const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false); + const [resetInFlight, setResetInFlight] = useState(false); + + const refreshInterfaces = useCallback(async () => { + if (!sidecarApiReady) return; + try { + const body = (await window.electronAPI.reticulum.proxyGet('/api/v1/interfaces')) as { + interfaces?: ReticulumInterfaceRow[]; + }; + setInterfaces(body.interfaces ?? []); + } catch (e) { + console.debug('[ReticulumAdminPanel] interfaces ' + errLikeToLogString(e)); + } + }, [sidecarApiReady]); + + useEffect(() => { + if (!sidecarApiReady) { + setInterfaces([]); + return; + } + void refreshInterfaces(); + }, [sidecarApiReady, refreshInterfaces]); + + const rnodeInterfaceActive = + sidecarApiReady && + interfaces.some((iface) => iface.enabled && iface.type.toLowerCase().includes('rnode')); + const flasherPortBlocked = rnodeInterfaceActive; + + const handleFactoryReset = async () => { + setResetInFlight(true); + try { + await window.electronAPI.reticulum.proxyPost('/api/v1/system/factory-reset', {}); + setShowFactoryResetConfirm(false); + await refreshIdentity(); + await refreshInterfaces(); + addToast( + t('radioPanel.actionCompleted', { name: t('adminPanel.reticulumFactoryReset.title') }), + 'success', + ); + } catch (e) { + addToast(t('radioPanel.actionFailed', { message: errLikeToLogString(e) }), 'error'); + console.warn('[ReticulumAdminPanel] factory reset ' + errLikeToLogString(e)); + } finally { + setResetInFlight(false); + } + }; + + return ( +
+

{t('tabs.admin')}

+ + {!sidecarUiRunning && capabilities.hasRNodeFlasher ? ( +
+ {t('flasher.stackStoppedHint')} +
+ ) : null} + + {!sidecarUiRunning && !capabilities.hasRNodeFlasher ? ( +
+ {t('connectionPanel.reticulumIdentity.startStackFirst')} +
+ ) : null} + + {capabilities.hasRNodeFlasher ? ( +
+

{t('flasher.title')}

+
+ +
+
+ ) : null} + +
+

{t('radioPanel.dangerZone')}

+
+

{t('adminPanel.reticulumFactoryReset.hint')}

+

{t('radioPanel.dangerZonePermanent')}

+ +
+
+ + {showFactoryResetConfirm ? ( + { + void handleFactoryReset(); + }} + onCancel={() => { + if (resetInFlight) return; + setShowFactoryResetConfirm(false); + }} + /> + ) : null} +
+ ); +} + +export default ReticulumAdminPanel; diff --git a/src/renderer/components/ReticulumAnnounceControls.test.tsx b/src/renderer/components/ReticulumAnnounceControls.test.tsx new file mode 100644 index 000000000..7c3e0beb2 --- /dev/null +++ b/src/renderer/components/ReticulumAnnounceControls.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ToastProvider } from './Toast'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts?.error) return `${key}:${opts.error}`; + return key; + }, + }), +})); + +const isReticulumSidecarRunning = vi.fn(); +vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ + isReticulumSidecarRunning: () => isReticulumSidecarRunning(), +})); + +import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; + +describe('ReticulumAnnounceControls', () => { + beforeEach(() => { + isReticulumSidecarRunning.mockResolvedValue(true); + window.electronAPI.reticulum.proxyGet = vi.fn().mockResolvedValue({ + enable_transport: false, + share_instance: true, + loglevel: 4, + announce_interval_sec: 0, + }); + window.electronAPI.reticulum.proxyPut = vi.fn().mockResolvedValue({ ok: true }); + window.electronAPI.reticulum.proxyDelete = vi.fn().mockResolvedValue({ ok: true }); + }); + + it('saves announce interval and shows status when sidecar is running', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = await screen.findByLabelText('reticulumIdentity.announceIntervalSec'); + await user.clear(input); + await user.type(input, '300'); + await user.click(screen.getByRole('button', { name: 'common.save' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith('/api/v1/stack/settings', { + enable_transport: false, + share_instance: true, + loglevel: 4, + announce_interval_sec: 300, + }); + }); + expect(await screen.findByRole('status')).toHaveTextContent('reticulumIdentity.announceSaved'); + }); + + it('clamps announce interval to 86400 on save', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = await screen.findByLabelText('reticulumIdentity.announceIntervalSec'); + await user.clear(input); + await user.type(input, '999999'); + await user.click(screen.getByRole('button', { name: 'common.save' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/stack/settings', + expect.objectContaining({ announce_interval_sec: 86400 }), + ); + }); + }); + + it('shows error when sidecar is stopped on save', async () => { + isReticulumSidecarRunning.mockResolvedValue(false); + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'common.save' })); + expect(await screen.findByRole('status')).toHaveTextContent( + 'reticulumIdentity.announceSaveSidecarStopped', + ); + expect(window.electronAPI.reticulum.proxyPut).not.toHaveBeenCalled(); + }); + + it('clears announces when sidecar is running', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'reticulumIdentity.clearAnnounces' })); + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyDelete).toHaveBeenCalledWith('/api/v1/announces'); + }); + }); + + it('surfaces proxyPut failure', async () => { + window.electronAPI.reticulum.proxyPut = vi.fn().mockResolvedValue({ ok: false, error: 'nope' }); + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'common.save' })); + expect(await screen.findByRole('status')).toHaveTextContent( + 'reticulumIdentity.announceSaveFailed:nope', + ); + }); + + it('remains clickable when parent only gates on sidecar readiness (not connecting)', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'common.save' })).not.toBeDisabled(); + }); +}); diff --git a/src/renderer/components/ReticulumAnnounceControls.tsx b/src/renderer/components/ReticulumAnnounceControls.tsx new file mode 100644 index 000000000..90ffc9939 --- /dev/null +++ b/src/renderer/components/ReticulumAnnounceControls.tsx @@ -0,0 +1,179 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; + +import { useToast } from './Toast'; + +export interface ReticulumAnnounceControlsProps { + disabled?: boolean; + /** When true, omit top border/margin for embedding in App panel. */ + embedded?: boolean; +} + +interface StackSettingsPayload { + enable_transport: boolean; + share_instance: boolean; + loglevel: number; + announce_interval_sec: number; +} + +function parseStackSettings(raw: Record): StackSettingsPayload { + return { + enable_transport: Boolean(raw.enable_transport), + share_instance: raw.share_instance !== false, + loglevel: typeof raw.loglevel === 'number' ? raw.loglevel : Number(raw.loglevel) || 4, + announce_interval_sec: + typeof raw.announce_interval_sec === 'number' + ? raw.announce_interval_sec + : Number(raw.announce_interval_sec) || 0, + }; +} + +function clampAnnounceIntervalSec(value: number): number { + return Math.min(86400, Math.max(0, Math.trunc(value) || 0)); +} + +/** Announce interval and clear-announces controls for the Reticulum stack. */ +export function ReticulumAnnounceControls({ + disabled = false, + embedded = false, +}: ReticulumAnnounceControlsProps) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const [announceInterval, setAnnounceInterval] = useState(0); + const [busy, setBusy] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + + const load = useCallback(async () => { + if (!(await isReticulumSidecarRunning())) return; + try { + const settings = (await window.electronAPI.reticulum.proxyGet('/api/v1/stack/settings')) as { + announce_interval_sec?: number; + }; + setAnnounceInterval(settings.announce_interval_sec ?? 0); + } catch (e) { + console.warn('[ReticulumAnnounceControls] load ' + errLikeToLogString(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const saveAnnounceInterval = async () => { + setBusy(true); + setStatusMessage(null); + try { + if (!(await isReticulumSidecarRunning())) { + setStatusMessage(t('reticulumIdentity.announceSaveSidecarStopped')); + addToast(t('reticulumIdentity.announceSaveSidecarStopped'), 'error'); + return; + } + const current = parseStackSettings( + (await window.electronAPI.reticulum.proxyGet('/api/v1/stack/settings')) as Record< + string, + unknown + >, + ); + const res = (await window.electronAPI.reticulum.proxyPut('/api/v1/stack/settings', { + ...current, + announce_interval_sec: clampAnnounceIntervalSec(announceInterval), + })) as { ok?: boolean; error?: string }; + if (res?.ok === false) { + const message = t('reticulumIdentity.announceSaveFailed', { + error: res.error ?? t('common.error'), + }); + setStatusMessage(message); + addToast(message, 'error'); + return; + } + const savedMessage = t('reticulumIdentity.announceSaved'); + setStatusMessage(savedMessage); + addToast(savedMessage, 'success'); + await load(); + } catch (e) { + const message = t('reticulumIdentity.announceSaveFailed', { + error: errLikeToLogString(e), + }); + console.warn('[ReticulumAnnounceControls] announce interval ' + errLikeToLogString(e)); + setStatusMessage(message); + addToast(message, 'error'); + } finally { + setBusy(false); + } + }; + + const clearAnnounces = async () => { + setBusy(true); + setStatusMessage(null); + try { + if (!(await isReticulumSidecarRunning())) { + setStatusMessage(t('reticulumIdentity.announceSaveSidecarStopped')); + return; + } + await window.electronAPI.reticulum.proxyDelete('/api/v1/announces'); + addToast(t('reticulumIdentity.clearAnnouncesDone'), 'success'); + } catch (e) { + console.warn('[ReticulumAnnounceControls] clear announces ' + errLikeToLogString(e)); + addToast(t('reticulumIdentity.clearAnnouncesFailed'), 'error'); + } finally { + setBusy(false); + } + }; + + const controlsDisabled = disabled || busy; + + return ( +
+
+ + { + setAnnounceInterval(clampAnnounceIntervalSec(Number(e.target.value))); + setStatusMessage(null); + }} + /> + + +
+ {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} +
+ ); +} diff --git a/src/renderer/components/ReticulumAppPanelSection.tsx b/src/renderer/components/ReticulumAppPanelSection.tsx new file mode 100644 index 000000000..932030979 --- /dev/null +++ b/src/renderer/components/ReticulumAppPanelSection.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; + +import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; +import { ReticulumPropagationControls } from './ReticulumPropagationControls'; + +export interface ReticulumAppPanelSectionProps { + sidecarReady?: boolean; + disabled?: boolean; +} + +export function ReticulumAppPanelSection({ + sidecarReady = false, + disabled = false, +}: ReticulumAppPanelSectionProps) { + const { t } = useTranslation(); + + return ( +
+

{t('appPanel.reticulumSection')}

+
+

{t('appPanel.reticulumAnnounceHelp')}

+ + +
+
+ ); +} diff --git a/src/renderer/components/ReticulumAttachmentLine.tsx b/src/renderer/components/ReticulumAttachmentLine.tsx new file mode 100644 index 000000000..ef011ab44 --- /dev/null +++ b/src/renderer/components/ReticulumAttachmentLine.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { + isReticulumAudioAttachment, + isReticulumImageAttachment, + parseReticulumAttachmentPayload, +} from '@/renderer/lib/reticulum/parseReticulumAttachmentPayload'; + +export interface ReticulumAttachmentLineProps { + payload: string; + attachmentPath?: string | null; + /** Base64 payload when file is not yet saved locally. */ + attachmentDataBase64?: string | null; + attachmentMimeType?: string | null; +} + +export function ReticulumAttachmentLine({ + payload, + attachmentPath, + attachmentDataBase64, + attachmentMimeType, +}: ReticulumAttachmentLineProps) { + const { t } = useTranslation(); + const [audioSrc, setAudioSrc] = useState(null); + const parsed = parseReticulumAttachmentPayload(payload); + if (!parsed) return null; + + const mimeType = attachmentMimeType ?? parsed.mimeType; + const isAudio = isReticulumAudioAttachment(mimeType); + + const showInFolder = () => { + if (!attachmentPath) return; + void window.electronAPI.chat.showItemInFolder(attachmentPath); + }; + + const saveAttachment = async () => { + if (!attachmentDataBase64) return; + try { + const res = await window.electronAPI.chat.saveReticulumAttachment({ + fileName: parsed.fileName, + mimeType, + dataBase64: attachmentDataBase64, + promptSave: true, + }); + if (res.success && res.path) { + void window.electronAPI.chat.showItemInFolder(res.path); + } + } catch (e) { + console.warn('[ReticulumAttachmentLine] save ' + errLikeToLogString(e)); + } + }; + + const ensureAudioSrc = () => { + if (audioSrc) return; + if (attachmentPath) { + setAudioSrc(`file://${attachmentPath}`); + return; + } + if (attachmentDataBase64) { + setAudioSrc(`data:${mimeType};base64,${attachmentDataBase64}`); + } + }; + + return ( +
+
+ + {isReticulumImageAttachment(mimeType) + ? t('chatPanel.reticulumImageAttachment', { name: parsed.fileName }) + : isAudio + ? t('chatPanel.reticulumAudioAttachment', { name: parsed.fileName }) + : t('chatPanel.reticulumFileAttachment', { name: parsed.fileName })} + + {attachmentPath ? ( + + ) : attachmentDataBase64 ? ( + + ) : null} +
+ {isAudio ? ( + // User-recorded voice clips have no transcript; aria-label on
+ ); +} diff --git a/src/renderer/components/ReticulumDiagnosticsSection.test.tsx b/src/renderer/components/ReticulumDiagnosticsSection.test.tsx new file mode 100644 index 000000000..73b78deae --- /dev/null +++ b/src/renderer/components/ReticulumDiagnosticsSection.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import type { RfDiagnosticRow } from '@/renderer/lib/types'; + +import { ReticulumDiagnosticsSection } from './ReticulumDiagnosticsSection'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => + params && Object.keys(params).length > 0 ? `${key}:${JSON.stringify(params)}` : key, + }), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + useToast: () => ({ addToast: vi.fn() }), +})); + +vi.mock('@/renderer/lib/sessions/reticulumSession', () => ({ + tryGetReticulumSession: () => ({ restartStack: vi.fn() }), +})); + +const reticulumRow: RfDiagnosticRow = { + kind: 'rf', + id: 'rf:1:reticulum/audit/ghost_interface/tcp-1', + nodeId: 1, + condition: 'reticulum/audit/ghost_interface', + cause: 'ghost', + severity: 'error', + detectedAt: Date.now(), + causeI18n: { + key: 'diagnosticsPanel.reticulum.audit.ghost_interface', + params: { name: 'Dublin', message: 'ghost' }, + }, + reticulumInterfaceId: 'tcp-1', + reticulumRepairKind: 'repair_config', +}; + +describe('ReticulumDiagnosticsSection', () => { + it('renders runtime row via translateReticulumDiagnosticCause', () => { + const runtimeRow: RfDiagnosticRow = { + kind: 'rf', + id: 'rf:1:reticulum/rns-not-ready', + nodeId: 1, + condition: 'reticulum/rns-not-ready', + cause: 'RNS stack is not ready', + severity: 'warning', + detectedAt: Date.now(), + causeI18n: { key: 'diagnosticsPanel.reticulum.runtime.rnsNotReady' }, + }; + render(); + expect(screen.getByText('diagnosticsPanel.reticulum.runtime.rnsNotReady')).toBeInTheDocument(); + }); + + it('renders audit rows with repair action', () => { + render(); + expect(screen.getByText('diagnosticsPanel.reticulum.action.repair_config')).toBeInTheDocument(); + expect( + screen.getByText( + 'diagnosticsPanel.reticulum.audit.ghost_interface:{"name":"Dublin","message":"ghost"}', + ), + ).toBeInTheDocument(); + }); + + it('has no serious axe violations', async () => { + const { container } = render(); + hydrateAxeThemeColors(container); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/renderer/components/ReticulumDiagnosticsSection.tsx b/src/renderer/components/ReticulumDiagnosticsSection.tsx new file mode 100644 index 000000000..78b4b5fc6 --- /dev/null +++ b/src/renderer/components/ReticulumDiagnosticsSection.tsx @@ -0,0 +1,282 @@ +import { Info, TriangleAlert } from 'lucide-react-motion'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useToast } from '@/renderer/components/Toast'; +import { + DIAGNOSTICS_CATEGORY_STYLES, + DIAGNOSTICS_SEVERITY_HEADER, + DIAGNOSTICS_SEVERITY_TEXT, + reticulumMeshHealthBand, +} from '@/renderer/lib/diagnostics/diagnosticsPanelStyles'; +import { isReticulumDiagnosticRow } from '@/renderer/lib/diagnostics/ReticulumDiagnosticEngine'; +import { translateReticulumDiagnosticCause } from '@/renderer/lib/diagnostics/reticulumDiagnosticLabels'; +import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; +import { + repairReticulumConfig, + type ReticulumConfigRepairKind, +} from '@/renderer/lib/reticulum/reticulumConfigAudit'; +import { tryGetReticulumSession } from '@/renderer/lib/sessions/reticulumSession'; +import type { DiagnosticRow, RfDiagnosticRow } from '@/renderer/lib/types'; +import { useReticulumUiStore } from '@/renderer/stores/reticulumUiStore'; + +export interface ReticulumDiagnosticsSectionProps { + rows: DiagnosticRow[]; + onNavigateToConnection?: () => void; + onRefreshDiagnostics?: () => void; +} + +function SeverityIcon({ severity }: { severity: string }) { + const trigger = useIconTrigger(); + if (severity === 'info') { + return ; + } + return ; +} + +function severityHeaderKey(severity: 'error' | 'warning' | 'info', count: number): string { + if (severity === 'info') { + return count === 1 + ? 'diagnosticsPanel.severityNotes_one' + : 'diagnosticsPanel.severityNotes_other'; + } + if (severity === 'warning') { + return count === 1 + ? 'diagnosticsPanel.severityWarnings_one' + : 'diagnosticsPanel.severityWarnings_other'; + } + return count === 1 + ? 'diagnosticsPanel.severityErrors_one' + : 'diagnosticsPanel.severityErrors_other'; +} + +function remedyCategoryForRow(row: RfDiagnosticRow): keyof typeof DIAGNOSTICS_CATEGORY_STYLES { + if (row.reticulumRepairKind === 'repair_config' || row.reticulumRepairKind === 'add_auto') { + return 'Configuration'; + } + if (row.reticulumRepairKind === 'restart_stack') { + return 'Software'; + } + if (row.reticulumRepairKind === 'apply_preset') { + return 'Hardware'; + } + return 'Configuration'; +} + +export function ReticulumDiagnosticsSection({ + rows, + onNavigateToConnection, + onRefreshDiagnostics, +}: ReticulumDiagnosticsSectionProps) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const requestInterfaceEdit = useReticulumUiStore((s) => s.requestInterfaceEdit); + const [busyKey, setBusyKey] = useState(null); + + const reticulumRows = useMemo( + () => rows.filter((r): r is RfDiagnosticRow => r.kind === 'rf' && isReticulumDiagnosticRow(r)), + [rows], + ); + + const errorCount = reticulumRows.filter((r) => r.severity === 'error').length; + const warningCount = reticulumRows.filter((r) => r.severity === 'warning').length; + const meshHealth = reticulumMeshHealthBand(errorCount, warningCount); + + const runRepair = useCallback( + async (kinds: ReticulumConfigRepairKind[], rowKey: string) => { + setBusyKey(rowKey); + try { + const res = await repairReticulumConfig(kinds); + if (!res.ok) { + addToast(res.error ?? t('diagnosticsPanel.reticulum.repairFailed'), 'error'); + return; + } + if (!res.repaired?.length) { + addToast(t('diagnosticsPanel.reticulum.repairNoChanges'), 'warning'); + onRefreshDiagnostics?.(); + return; + } + addToast(t('diagnosticsPanel.reticulum.repairSuccess'), 'success'); + if (res.restart_required) { + const session = tryGetReticulumSession(); + if (session?.restartStack) { + await session.restartStack(); + onRefreshDiagnostics?.(); + return; + } + } + onRefreshDiagnostics?.(); + } catch (e) { + addToast(t('diagnosticsPanel.reticulum.repairFailed'), 'error'); + console.debug('[ReticulumDiagnosticsSection] repair', e); + } finally { + setBusyKey(null); + } + }, + [onRefreshDiagnostics, t, addToast], + ); + + const runDisable = useCallback( + async (interfaceId: string, rowKey: string) => { + setBusyKey(rowKey); + try { + await window.electronAPI.reticulum.proxyPost( + `/api/v1/interfaces/${interfaceId}/disable`, + {}, + ); + addToast(t('diagnosticsPanel.reticulum.disableSuccess'), 'success'); + onRefreshDiagnostics?.(); + } catch (e) { + addToast(t('diagnosticsPanel.reticulum.disableFailed'), 'error'); + console.debug('[ReticulumDiagnosticsSection] disable', e); + } finally { + setBusyKey(null); + } + }, + [onRefreshDiagnostics, t, addToast], + ); + + const runAction = useCallback( + async (row: RfDiagnosticRow) => { + const kind = row.reticulumRepairKind; + if (!kind) return; + if (kind === 'edit') { + if (row.reticulumInterfaceId) { + requestInterfaceEdit(row.reticulumInterfaceId); + } + onNavigateToConnection?.(); + return; + } + if (kind === 'restart_stack') { + setBusyKey(row.id); + try { + const session = tryGetReticulumSession(); + if (session?.restartStack) { + await session.restartStack(); + } + onRefreshDiagnostics?.(); + } finally { + setBusyKey(null); + } + return; + } + if (kind === 'disable' && row.reticulumInterfaceId) { + await runDisable(row.reticulumInterfaceId, row.id); + return; + } + if (kind === 'repair_config') { + await runRepair(['repair_config'], row.id); + return; + } + if (kind === 'add_auto') { + await runRepair(['add_auto'], row.id); + return; + } + if (kind === 'apply_preset') { + await runRepair(['apply_preset'], row.id); + } + }, + [onNavigateToConnection, onRefreshDiagnostics, requestInterfaceEdit, runDisable, runRepair], + ); + + if (reticulumRows.length === 0) { + return ( +
+

{t(meshHealth.labelKey)}

+

{t('diagnosticsPanel.reticulum.noIssues')}

+
+ ); + } + + const grouped = (['error', 'warning', 'info'] as const).map((severity) => ({ + severity, + rows: reticulumRows.filter((r) => r.severity === severity), + })); + + return ( +
+
+

{t(meshHealth.labelKey)}

+

+ {t('diagnosticsPanel.reticulum.summary', { + errors: errorCount, + warnings: warningCount, + })} +

+
+ +
+ + + + + + + + + + + {grouped.map(({ severity, rows: groupRows }) => + groupRows.length > 0 ? ( + + + + + {groupRows.map((row) => { + const category = remedyCategoryForRow(row); + const repairKind = row.reticulumRepairKind; + return ( + + + + + + + ); + })} + + ) : null, + )} + +
+ {t('diagnosticsPanel.reticulum.colInterface')} + {t('diagnosticsPanel.reticulum.colIssue')}{t('diagnosticsPanel.reticulum.colFix')}{t('diagnosticsPanel.reticulum.colAction')}
+ {' '} + {t(severityHeaderKey(severity, groupRows.length), { + count: groupRows.length, + })} +
+ {row.reticulumInterfaceId + ? (/"([^"]+)"/.exec(row.cause)?.[1] ?? t('common.emDash')) + : t('diagnosticsPanel.reticulum.stackScope')} + + {row.causeI18n ? translateReticulumDiagnosticCause(t, row) : row.cause} + + + {t(`diagnosticsPanel.reticulum.remedy.${category.toLowerCase()}`)} + + + {repairKind ? ( + + ) : ( + {t('common.emDash')} + )} +
+
+
+ ); +} diff --git a/src/renderer/components/ReticulumIdentitySwitcher.tsx b/src/renderer/components/ReticulumIdentitySwitcher.tsx new file mode 100644 index 000000000..d52695ed7 --- /dev/null +++ b/src/renderer/components/ReticulumIdentitySwitcher.tsx @@ -0,0 +1,96 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { + listReticulumIdentities, + type ReticulumSidecarIdentityRow, + switchReticulumIdentity, +} from '@/renderer/lib/reticulum/reticulumSidecarReads'; + +export interface ReticulumIdentitySwitcherProps { + disabled?: boolean; + onSwitched?: () => void; +} + +export function ReticulumIdentitySwitcher({ + disabled = false, + onSwitched, +}: ReticulumIdentitySwitcherProps) { + const { t } = useTranslation(); + const [identities, setIdentities] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setIdentities(await listReticulumIdentities()); + } catch (e) { + console.warn('[ReticulumIdentitySwitcher] list ' + errLikeToLogString(e)); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const active = identities.find((i) => i.active) ?? identities[0]; + + const handleSwitch = async (id: string) => { + if (id === active?.id) return; + setBusy(true); + setError(null); + try { + await switchReticulumIdentity(id); + await refresh(); + onSwitched?.(); + } catch (e) { + // catch-no-log-ok surfaced inline via setError + setError(errLikeToLogString(e)); + } finally { + setBusy(false); + } + }; + + if (identities.length <= 1) { + if (!active) return null; + return ( +

+ {t('reticulumIdentitySwitcher.active', { + name: active.display_name ?? active.id, + })} +

+ ); + } + + return ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.tsx b/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.tsx new file mode 100644 index 000000000..c17c10b33 --- /dev/null +++ b/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.tsx @@ -0,0 +1,110 @@ +import { useTranslation } from 'react-i18next'; + +import type { ReticulumLocalInterfaceAlert } from '@/renderer/lib/reticulum/reticulumLocalInterfaceHealth'; +import { reticulumLocalOfflineDisplayKind } from '@/renderer/lib/reticulum/reticulumLocalInterfaceHealth'; + +function alertNeedsSerialPortContext(alert: ReticulumLocalInterfaceAlert): boolean { + if (alert.reason === 'stale_port') { + return true; + } + return ( + alert.reason === 'enabled_down' && reticulumLocalOfflineDisplayKind(alert.iface) === 'serial' + ); +} + +export interface ReticulumLocalInterfaceAlertsBlockProps { + alerts: ReticulumLocalInterfaceAlert[]; + availablePorts: string[]; + onRefreshPorts?: () => void; + onRestartStack?: () => void; + compact?: boolean; +} + +/** User-visible summary when local/USB Reticulum interfaces need attention. */ +export function ReticulumLocalInterfaceAlertsBlock({ + alerts, + availablePorts, + onRefreshPorts, + onRestartStack, + compact = false, +}: ReticulumLocalInterfaceAlertsBlockProps) { + const { t } = useTranslation(); + + if (alerts.length === 0) { + return null; + } + + const showSerialPortContext = alerts.some(alertNeedsSerialPortContext); + + return ( +
+

+ {t('connectionPanel.reticulumLocalInterfaces.needsAttention', { count: alerts.length })} +

+
    + {alerts.map((alert) => ( +
  • +

    + {alert.reason === 'stale_port' + ? t('connectionPanel.reticulumLocalInterfaces.stalePort', { + name: alert.iface.name, + port: alert.iface.serial_port ?? '', + }) + : t('connectionPanel.reticulumLocalInterfaces.offline', { + name: alert.iface.name, + })} +

    + {!compact ? ( +

    + {alert.reason === 'stale_port' + ? t('connectionPanel.reticulumLocalInterfaces.stalePortHint') + : (() => { + const kind = reticulumLocalOfflineDisplayKind(alert.iface); + if (kind === 'ble') { + return t('connectionPanel.reticulumLocalInterfaces.offlineHintBle'); + } + if (kind === 'wifi') { + return t('connectionPanel.reticulumLocalInterfaces.offlineHintWifi'); + } + return t('connectionPanel.reticulumLocalInterfaces.offlineHint'); + })()} +

    + ) : null} +
  • + ))} +
+ {showSerialPortContext && availablePorts.length > 0 ? ( +

+ {t('connectionPanel.reticulumLocalInterfaces.availablePorts', { + ports: availablePorts.join(', '), + })} +

+ ) : null} +
+ {onRestartStack ? ( + + ) : null} + {onRefreshPorts && showSerialPortContext ? ( + + ) : null} +
+
+ ); +} diff --git a/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.test.tsx b/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.test.tsx new file mode 100644 index 000000000..06b40b26d --- /dev/null +++ b/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.test.tsx @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts && 'count' in opts) return `${key}:${opts.count}`; + if (opts && 'name' in opts) return `${key}:${opts.name}`; + return key; + }, + }), +})); + +import { ReticulumLocalInterfaceConnectingBlock } from './ReticulumLocalInterfaceConnectingBlock'; + +describe('ReticulumLocalInterfaceConnectingBlock', () => { + it('renders cyan connecting status for BLE interfaces', () => { + render( + , + ); + + expect( + screen.getByText('connectionPanel.reticulumLocalInterfaces.connectingHeading:1'), + ).toBeInTheDocument(); + expect( + screen.getByText('connectionPanel.reticulumLocalInterfaces.connectingRow:NV0N2'), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('border-cyan-700/45'); + }); +}); diff --git a/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.tsx b/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.tsx new file mode 100644 index 000000000..7e7fa92c1 --- /dev/null +++ b/src/renderer/components/ReticulumLocalInterfaceConnectingBlock.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next'; + +import { SpinnerIcon } from '@/renderer/lib/icons/spinnerIcon'; +import type { ReticulumLocalInterfaceInput } from '@/renderer/lib/reticulum/reticulumLocalInterfaceHealth'; + +export interface ReticulumLocalInterfaceConnectingBlockProps { + interfaces: readonly ReticulumLocalInterfaceInput[]; +} + +/** In-progress BLE RNode link after stack start (not an error). */ +export function ReticulumLocalInterfaceConnectingBlock({ + interfaces, +}: ReticulumLocalInterfaceConnectingBlockProps) { + const { t } = useTranslation(); + + if (interfaces.length === 0) { + return null; + } + + return ( +
+
+ +
+

+ {t('connectionPanel.reticulumLocalInterfaces.connectingHeading', { + count: interfaces.length, + })} +

+
    + {interfaces.map((iface) => ( +
  • + {t('connectionPanel.reticulumLocalInterfaces.connectingRow', { + name: iface.name, + })} +
  • + ))} +
+

+ {t('connectionPanel.reticulumLocalInterfaces.connectingHintBle')} +

+
+
+
+ ); +} diff --git a/src/renderer/components/ReticulumMessageStatusBadge.tsx b/src/renderer/components/ReticulumMessageStatusBadge.tsx new file mode 100644 index 000000000..e22189b1c --- /dev/null +++ b/src/renderer/components/ReticulumMessageStatusBadge.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next'; + +import { HelpTooltip } from '@/renderer/components/HelpTooltip'; +import type { MessageRecord, MessageTransport } from '@/renderer/stores/messageStore'; + +export interface ReticulumMessageStatusBadgeProps { + status: 'sending' | 'acked' | 'failed'; + via: Extract; + deliveryMethod?: MessageRecord['reticulumDeliveryMethod']; + error?: string; +} + +export function ReticulumMessageStatusBadge({ + status, + via, + deliveryMethod, + error, +}: ReticulumMessageStatusBadgeProps) { + const { t } = useTranslation(); + const icon = status === 'sending' ? '\u23F3' : status === 'acked' ? '\u2713' : '\u2717'; + const colorClass = + status === 'sending' ? 'text-muted' : status === 'acked' ? 'text-bright-green' : 'text-red-400'; + const label = + deliveryMethod === 'propagated' ? 'PN' : via === 'rf' ? 'RF' : via === 'tcp' ? 'TCP' : 'NET'; + const tooltipKey = + deliveryMethod === 'propagated' + ? 'chatPanel.sentViaPropagation' + : via === 'rf' + ? 'chatPanel.sentViaRf' + : via === 'tcp' + ? 'chatPanel.sentViaTcp' + : 'chatPanel.sentViaNetwork'; + const statusLabel = + status === 'sending' + ? deliveryMethod === 'propagated' + ? t('chatPanel.reticulumSendPropagated') + : t('chatPanel.reticulumSendSending') + : status === 'acked' + ? t('chatPanel.reticulumSendDelivered') + : (error ?? t('chatPanel.reticulumSendFailed')); + const tooltip = `${t(tooltipKey)}: ${statusLabel}`; + return ( + + + {label} {icon} + + + ); +} diff --git a/src/renderer/components/ReticulumNetworkPanel.test.tsx b/src/renderer/components/ReticulumNetworkPanel.test.tsx new file mode 100644 index 000000000..963b13f70 --- /dev/null +++ b/src/renderer/components/ReticulumNetworkPanel.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumSidecarApi', () => ({ + useReticulumSidecarApi: () => ({ + sidecarApiReady: true, + identity: { configured: true, identity_hash: 'abc', lxmf_hash: 'def' }, + statsSummary: null, + appInfo: null, + refreshIdentity: vi.fn(), + }), +})); + +vi.mock('../stores/reticulumPeerStore', () => ({ + refreshReticulumPeersFromSidecar: vi.fn().mockResolvedValue([]), + useReticulumPeerStore: (selector: (s: { peers: Map }) => unknown) => + selector({ peers: new Map([['a', {}]]) }), +})); + +import { ReticulumNetworkPanel } from './ReticulumNetworkPanel'; + +describe('ReticulumNetworkPanel', () => { + beforeEach(() => { + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/stack/settings') { + return Promise.resolve({ + enable_transport: true, + share_instance: true, + loglevel: 3, + announce_interval_sec: 600, + }); + } + return Promise.resolve({}); + }); + window.electronAPI.reticulum.proxyPut = vi.fn().mockResolvedValue({ ok: true }); + }); + + it('does not render flasher or factory reset sections', () => { + render( {}} />); + + expect(screen.queryByText('flasher.title')).not.toBeInTheDocument(); + expect(screen.queryByText('adminPanel.reticulumFactoryReset.title')).not.toBeInTheDocument(); + }); + + it('preserves announce_interval_sec when saving stack settings', async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByText('networkPanel.reticulumStackSettings.save')); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith('/api/v1/stack/settings', { + enable_transport: true, + share_instance: true, + loglevel: 3, + announce_interval_sec: 600, + }); + }); + }); +}); diff --git a/src/renderer/components/ReticulumNetworkPanel.tsx b/src/renderer/components/ReticulumNetworkPanel.tsx new file mode 100644 index 000000000..a4377a375 --- /dev/null +++ b/src/renderer/components/ReticulumNetworkPanel.tsx @@ -0,0 +1,632 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; +import { invalidateReticulumInterfacesCache } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { + type ReticulumIdentityStatus, + useReticulumSidecarApi, +} from '@/renderer/lib/reticulum/useReticulumSidecarApi'; +import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; + +import { refreshReticulumPeersFromSidecar } from '../stores/reticulumPeerStore'; +import { ConfirmModal } from './ConfirmModal'; +import { IdentityVaultPanel } from './IdentityVaultPanel'; +import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; +import { ReticulumIdentitySwitcher } from './ReticulumIdentitySwitcher'; +import ReticulumPropagationSection from './ReticulumPropagationSection'; + +function ReticulumCollapsibleSection({ + title, + children, + defaultOpen = false, + danger = false, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + danger?: boolean; +}) { + return ( +
+ + {title} + + +
{children}
+
+ ); +} + +export interface ReticulumNetworkPanelProps { + connecting: boolean; + onStartStack: () => Promise; +} + +/** Network tab: identity, stack settings, propagation, config import. */ +export function ReticulumNetworkPanel({ connecting, onStartStack }: ReticulumNetworkPanelProps) { + const { t } = useTranslation(); + const sidecarEventRef = useRef<(evt: ReticulumSidecarEvent) => void>(() => {}); + + const { sidecarApiReady, identity, statsSummary, appInfo, refreshIdentity } = + useReticulumSidecarApi({ + connecting, + onStartStack, + onEvent: (evt) => { + sidecarEventRef.current(evt); + }, + }); + + const [mnemonic, setMnemonic] = useState(null); + const [importPhrase, setImportPhrase] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [identityError, setIdentityError] = useState(null); + const [confirmSaved, setConfirmSaved] = useState(false); + const [exportJson, setExportJson] = useState(null); + const [exportPassphrase, setExportPassphrase] = useState(''); + const [configPaste, setConfigPaste] = useState(''); + const [importWarnings, setImportWarnings] = useState([]); + const [showImportConfirm, setShowImportConfirm] = useState(false); + const [pendingImportMode, setPendingImportMode] = useState<'merge' | 'replace'>('merge'); + const [stackSettings, setStackSettings] = useState({ + enable_transport: false, + share_instance: true, + loglevel: 4, + }); + + const refreshStackSettings = useCallback(async () => { + if (!sidecarApiReady) return; + try { + const body = (await window.electronAPI.reticulum.proxyGet( + '/api/v1/stack/settings', + )) as typeof stackSettings; + setStackSettings({ + enable_transport: body.enable_transport, + share_instance: body.share_instance, + loglevel: typeof body.loglevel === 'number' ? body.loglevel : 4, + }); + } catch (e) { + console.debug('[ReticulumNetworkPanel] stack settings ' + errLikeToLogString(e)); + } + }, [sidecarApiReady]); + + const refreshPeers = useCallback(async () => { + if (!sidecarApiReady) return; + try { + await refreshReticulumPeersFromSidecar(); + } catch (e) { + console.debug('[ReticulumNetworkPanel] peers ' + errLikeToLogString(e)); + } + }, [sidecarApiReady]); + + useEffect(() => { + sidecarEventRef.current = (evt: ReticulumSidecarEvent) => { + if (evt.type === 'interface.state' || evt.type === 'stats_update') { + invalidateReticulumInterfacesCache(); + } + if ( + evt.type === 'peers_updated' || + evt.type === 'stats_update' || + evt.type === 'announce.received' + ) { + void refreshPeers(); + } + }; + }, [refreshPeers]); + + useEffect(() => { + if (!sidecarApiReady) return; + void refreshStackSettings(); + void refreshPeers(); + }, [sidecarApiReady, refreshStackSettings, refreshPeers]); + + const handleExportIdentity = async () => { + const passphrase = exportPassphrase.trim(); + if (!passphrase) { + setIdentityError(t('connectionPanel.reticulumIdentity.exportPassphraseRequired')); + return; + } + setIdentityError(null); + try { + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/identity/export', { + passphrase, + })) as { ok?: boolean; backup?: unknown; error?: string }; + if (!res.ok) { + setIdentityError(res.error ?? t('connectionPanel.reticulumIdentity.failed')); + return; + } + setExportJson( + typeof res.backup === 'string' ? res.backup : JSON.stringify(res.backup, null, 2), + ); + } catch (e) { + // catch-no-log-ok: export failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const handleGenerate = async () => { + if (!sidecarApiReady) return; + setIdentityError(null); + try { + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/identity/generate', { + display_name: displayName.trim() || null, + })) as { + ok?: boolean; + mnemonic?: string; + error?: string; + }; + if (!res.ok) { + setIdentityError(res.error ?? t('connectionPanel.reticulumIdentity.failed')); + return; + } + setMnemonic(res.mnemonic ?? null); + setConfirmSaved(false); + await refreshIdentity(); + } catch (e) { + // catch-no-log-ok: export failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const handleImportIdentity = async () => { + if (!sidecarApiReady) return; + setIdentityError(null); + try { + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/identity/import', { + mnemonic: importPhrase.trim(), + display_name: displayName.trim() || null, + })) as { ok?: boolean; error?: string }; + if (!res.ok) { + setIdentityError(res.error ?? t('connectionPanel.reticulumIdentity.failed')); + return; + } + setImportPhrase(''); + setMnemonic(null); + await refreshIdentity(); + } catch (e) { + // catch-no-log-ok: export failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const runConfigImport = async (mode: 'merge' | 'replace', content: string) => { + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/config/import', { + content, + mode, + })) as { ok?: boolean; warnings?: string[]; error?: string }; + if (!res.ok) { + setIdentityError(res.error ?? t('networkPanel.reticulumConfigImportFailed')); + return; + } + setImportWarnings(res.warnings ?? []); + setConfigPaste(''); + invalidateReticulumInterfacesCache(); + await refreshStackSettings(); + }; + + const handleImportConfig = (mode: 'merge' | 'replace') => { + const content = configPaste.trim(); + if (!content) return; + setPendingImportMode(mode); + setShowImportConfirm(true); + }; + + const handleImportFromSystem = async () => { + try { + const result = await window.electronAPI.reticulum.readDefaultConfigFile(); + if (!result.content) { + setIdentityError(t('networkPanel.reticulumConfigNotFound')); + return; + } + setConfigPaste(result.content); + setPendingImportMode('merge'); + setShowImportConfirm(true); + } catch (e) { + // catch-no-log-ok: export failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const handleImportFromFile = async () => { + try { + const result = await window.electronAPI.reticulum.showConfigImportDialog(); + if (!result.content) return; + setConfigPaste(result.content); + setPendingImportMode('merge'); + setShowImportConfirm(true); + } catch (e) { + // catch-no-log-ok: export failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const saveStackSettings = async () => { + try { + const current = (await window.electronAPI.reticulum.proxyGet( + '/api/v1/stack/settings', + )) as Record; + const announceInterval = + typeof current.announce_interval_sec === 'number' + ? current.announce_interval_sec + : Number(current.announce_interval_sec) || 0; + const res = (await window.electronAPI.reticulum.proxyPut('/api/v1/stack/settings', { + ...stackSettings, + announce_interval_sec: announceInterval, + })) as { ok?: boolean; error?: string }; + if (res?.ok === false) { + setIdentityError(res.error ?? t('networkPanel.reticulumStackSettings.saveFailed')); + return; + } + await refreshStackSettings(); + } catch (e) { + // catch-no-log-ok: stack settings save failure shown via setIdentityError + setIdentityError(errLikeToLogString(e)); + } + }; + + const identityReady = identity?.configured === true; + const identityActionsDisabled = !sidecarApiReady || connecting; + + return ( +
+ {!sidecarApiReady ? ( +

+ {t('connectionPanel.reticulumIdentity.startStackFirst')} +

+ ) : null} + + +
+ + + + +
+
+ + +

{t('connectionPanel.reticulumIdentity.hint')}

+ {identityError ? ( +

+ {identityError} +

+ ) : null} + {identityReady ? ( + { + void handleExportIdentity(); + }} + /> + ) : ( + { + void handleGenerate(); + }} + onImport={() => { + void handleImportIdentity(); + }} + /> + )} + {identityReady && sidecarApiReady ? ( + { + void refreshIdentity(); + }} + /> + ) : null} + {identityReady ? ( + + ) : null} + {identityReady && sidecarApiReady ? ( + + ) : null} +
+ + {sidecarApiReady ? ( + <> + +

{t('networkPanel.reticulumConfigImport.hint')}

+