diff --git a/AGENTS.md b/AGENTS.md index 181e6a9bb..42d91891b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `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` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `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` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** @@ -165,7 +165,8 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Retention:** App defaults Reticulum destination age/count pruning to 30 days / 10,000 destinations (favorites preserved; count max 50,000); Reticulum message retention independently enabled at 4,000. RRC room history retention independently enabled by default at **10,000** messages (30-day age prune) via `rrcMessageRetention*` settings and `db:pruneRrcMessagesByCount` / `db:pruneRrcMessagesByAge`. - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` -- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` +- **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + `reticulum:onVoiceAudio` (`/ws/voice`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). +- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` - **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start - **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` - **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) @@ -258,7 +259,7 @@ Do **not** reintroduce Meshtastic-only startup gates, child-before-parent init, ### MQTT -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. ### UI @@ -281,7 +282,7 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. -- **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200). +- **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). - **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). - **Meshtastic dedup:** `meshtasticMessageDedup.ts` — merges delayed RF/MQTT duplicates (**10-minute** content window) in `useMeshtasticRuntime` ingest. - **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. @@ -339,6 +340,8 @@ Do not change behavior guarded by `meshcoreZeroHopRepeaterWorkingState.test.ts` | Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](docs/troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | | Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](docs/troubleshooting.md#reticulum-interface-addeditdelete-fails) | | Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](docs/troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | +| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](docs/troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | +| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](docs/troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | | Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](docs/troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | | Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](docs/troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | | Chat export fails | `chat:export` handler in `src/main/index.ts` | diff --git a/README.md b/README.md index 269365094..aff12e6a0 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco ### Reticulum Features -Reticulum is the third protocol tab (**amber** pill). The stack runs in 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 LXMF contacts persist in SQLite. Primary interop target: [Ratspeak](https://github.com/ratspeak/Ratspeak) peers on rsReticulum/rsLXMF; Nomad page hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad). Architecture and API details: [docs/reticulum.md](docs/reticulum.md). +Reticulum is the third protocol tab (**amber** pill). The stack runs in 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 LXMF contacts persist in SQLite. Primary interop target: [Ratspeak](https://github.com/ratspeak/Ratspeak) peers on rsReticulum/rsLXMF; Nomad page hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad); LXST voice uses sibling [rsLXST](https://github.com/ratspeak/rsLXST). Architecture and API details: [docs/reticulum.md](docs/reticulum.md). **Stack & interfaces (Connection tab)** @@ -321,14 +321,14 @@ Reticulum is the third protocol tab (**amber** pill). The stack runs in an **AGP **Messaging (Chat + RRC)** -- **Chat tab:** **DM-only** LXMF text and reactions (peer file transfer via Remote rncp; historic LXMF attachment labels still render; **cached raster images** on disk under `reticulum/attachments/` also display inline) +- **Chat tab:** **DM-only** LXMF text and reactions (peer file transfer via Remote rncp; historic LXMF attachment labels still render; **cached raster images** on disk under `reticulum/attachments/` also display inline); **LXST voice Call** on DM headers (live telephony — not an LXMF voice-note clip) - **RRC tab:** multi-hub relay chat (rooms, nicklists, slash commands, favourites, auto-join, reconnect; up to 8 hubs) - **Remote tab:** **rnsh** interactive shell sessions and **rncp** file transfer (send / receive / fetch) over Reticulum, with saved addresses and inbound-policy controls; also available from Chat DMs as send-file (distinct from Meshtastic remote admin) - **Direct** delivery when the destination is in the path table (then **one-shot remote PN fallback** on Direct fail when a preferred remote PN is set); **propagated (PN)** when offline — Completes show **Stored at propagation node**, not recipient Delivered **Peers, topology, Nomad Network** -- **Peers** tab: RNS path-table peers, messaged **History**, saved **Contacts**, and **Favorites** (sub-tabs); LXMFace avatars; virtualized large lists, path probe, and peer detail modal (Save as contact is manual — messaging alone does not add Contacts) +- **Peers** tab: RNS path-table peers, messaged **History**, saved **Contacts**, and **Favorites** (sub-tabs); LXMFace avatars; virtualized large lists, path probe, **LXST Call** on peer rows, and peer detail modal (Save as contact is manual — messaging alone does not add Contacts) - **Map** tab: local RMAP v4 discovery map (Leaflet + OSM basemaps; heard opt-in interfaces with GPS; interface-type filters; reachable vs heard-only sidebar list; publish via Network + Connection); **Global map** link to [rmap.world](https://rmap.world/) — no position trails or waypoints (contrast with Meshtastic/MeshCore Map) - **Topology** tab: best-effort graph from the RNS path table (next-hop edges, force layout) - **Nomad Network** tab: collapsible favourites/announces list (default **Favourites** sub-tab); **My Pages** hosts a static Nomad site (rsNomad `nomad-core`) — **Choose folder** for a watched site root (e.g. sibling `nomad-page` with `pages/*.mu`) or pages directory, set a display name, Start serving (off by default; auto-restores when the stack comes back up); edit pages on disk (read-only list in the UI); fit-width pages by default with an open-width toggle; panel lazy-mounts after first visit and keeps browse state across tab switches (`mesh-client:nomadNodeListCollapsed` for sidebar width) @@ -512,23 +512,23 @@ Enter your broker URL, topic, and optional credentials in the MQTT section of th | Windows | Yes | Yes | Yes | Yes | Yes | | Linux | Yes | Yes | Yes | Yes | Yes | -Sidecar dev build: `pnpm run reticulum:sidecar:build` ([Rust](https://rustup.rs/) required). Full stack needs sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad` — see [docs/reticulum.md](docs/reticulum.md#building-the-sidecar). +Sidecar dev build: `pnpm run reticulum:sidecar:build` ([Rust](https://rustup.rs/) required). Full stack needs sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST` — see [docs/reticulum.md](docs/reticulum.md#building-the-sidecar). ### Tech Stack -| Component | Technology | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Desktop | Electron | -| UI | React 19 + TypeScript 6 + Zustand | -| Styling | Tailwind CSS v4 | -| Localization | i18next + react-i18next; 16 languages; static JSON bundles | -| Meshtastic | @meshtastic/core + transport-http, transport-web-serial (JSR); BLE via @stoprocent/noble (macOS/Windows) and Web Bluetooth (Linux) | -| MeshCore | @liamcottle/meshcore.js (BLE, Web Serial, TCP via main-process IPC) | -| Reticulum | `mesh-client-reticulum` sidecar (Rust, rsReticulum/rsLXMF/rsNomad); `reticulum:*` IPC proxy; LXMF in SQLite (`reticulum_messages`, `reticulum_destinations`) | -| Maps | Leaflet + OpenStreetMap (Meshtastic/MeshCore node positions; Reticulum **Map** = local RMAP v4 discovery + link to rmap.world; **Topology** = RNS path graph) | -| Charts | Recharts | -| Database | SQLite (node:sqlite built-in, via db-compat.ts shim) | -| Build | esbuild + Vite + electron-builder + Flatpak (freedesktop 24.08, Electron2 BaseApp) + optional `cargo` sidecar | +| Component | Technology | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop | Electron | +| UI | React 19 + TypeScript 6 + Zustand | +| Styling | Tailwind CSS v4 | +| Localization | i18next + react-i18next; 16 languages; static JSON bundles | +| Meshtastic | @meshtastic/core + transport-http, transport-web-serial (JSR); BLE via @stoprocent/noble (macOS/Windows) and Web Bluetooth (Linux) | +| MeshCore | @liamcottle/meshcore.js (BLE, Web Serial, TCP via main-process IPC) | +| Reticulum | `mesh-client-reticulum` sidecar (Rust, rsReticulum/rsLXMF/rsNomad/rsLXST); `reticulum:*` IPC proxy; LXMF in SQLite (`reticulum_messages`, `reticulum_destinations`); LXST voice | +| Maps | Leaflet + OpenStreetMap (Meshtastic/MeshCore node positions; Reticulum **Map** = local RMAP v4 discovery + link to rmap.world; **Topology** = RNS path graph) | +| Charts | Recharts | +| Database | SQLite (node:sqlite built-in, via db-compat.ts shim) | +| Build | esbuild + Vite + electron-builder + Flatpak (freedesktop 24.08, Electron2 BaseApp) + optional `cargo` sidecar | ### Architecture diff --git a/docs/development-environment.md b/docs/development-environment.md index 9bcbc6560..bd0963fb9 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -119,7 +119,7 @@ pnpm run reticulum:sidecar:build This writes `reticulum-sidecar/target/debug/mesh-client-reticulum` (macOS/Linux) or `.exe` on Windows. -**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to a SHA or ref before running the clone script. +**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` to a SHA or ref before running the clone script. When those siblings already exist, `pnpm run reticulum:sidecar:build` applies required overlays via `scripts/ensure-rsReticulum-patches.sh` before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index b1b020dc3..2df5d5798 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -38,7 +38,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio | MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | | Chat composer | `ChatComposer.tsx` in `ChatPanel` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | | Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | -| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override; `app_settings` reapply on connect | **App** (MeshCore v8+ transport keys) | +| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override **remembered per channel view**; `app_settings` reapply on connect. Community region/scope guide: [RegionMesh MeshCore region configuration](https://www.regionmesh.com/meshcore-region-configuration/) | **App** (MeshCore v8+ transport keys) | | Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | | Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | | PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | @@ -62,6 +62,17 @@ Room servers (`hw_model === 'Room'`, contact type 3) are BBS nodes on the mesh. **Dedup:** [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) merges duplicate RF/MQTT and tapback echoes for chat and rooms (cross-transport and channel RF **5 min**; room/tapback **60 s**). +## MeshCore: regional flood scope + +MeshCore **regions** (on repeaters) and **scopes** (on outbound flood messages) limit how far flooded traffic is forwarded. Community naming, exact matching, hierarchy (`region put child parent`), and the wildcard `*` for unscoped traffic are documented by [RegionMesh — MeshCore region configuration](https://www.regionmesh.com/meshcore-region-configuration/). + +**In mesh-client:** + +- **Radio → Regional flood scope** sets the companion radio-wide default (`meshcoreFloodScopeHashtag` in `app_settings`; reapplied on connect). +- **Chat** split-Send override chooses Default (follow Radio), **Unscoped** (mesh-wide / clear scope for that channel’s sends), or a named hashtag (e.g. `#metro`, `#us-co`). Default and Unscoped stay distinct choices. +- Chat remembers the override **per channel view** (`mesh-client:floodScopeOverrides:meshcore`, keyed like drafts by `ch:N`) so a mesh-wide Public channel and a scoped shared-key channel (e.g. metro containment) do not bleed into each other when switching pills. +- Channel PSK still controls who can **decode**; region/scope controls which repeaters **forward**. Hierarchy and `region allowf` live on repeater CLI, not in Chat. + ## MeshCore: identity-scoped UI stores Live Chat and Nodes read **identity-scoped** `nodeStore` / `messageStore` (keyed by `identityId`) via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`). DM/trace pubkeys live in `meshcorePubKeyRegistry` (mirrored into runtime maps for send/RPC). Hydration: [`hydrateIdentityStoresFromDb.ts`](../src/renderer/lib/hydrateIdentityStoresFromDb.ts). **Chat-driven `last_heard`** (`meshcoreIngest`, `ensureMeshcoreChatSenderInNodeStore`) updates node freshness on text traffic, not only adverts. diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 15213652a..7031a6f18 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -221,16 +221,22 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi ### System -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | -| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | -| GET | `/api/v1/voice/status` | | LXST stub status | -| GET | `/api/v1/games/status` | | LRGP stub status | -| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | -| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | -| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | -| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | +| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | +| GET | `/api/v1/voice/status` | | LXST telephony status (`available`, `enabled`, `running`, `microphone_muted`, `active_call`) | +| POST | `/api/v1/voice/call` | `{ identity_hash }` | Place Opus call (`QualityHigh`, ~15s discovery). `identity_hash` is 32-hex identity (not LXMF dest). | +| POST | `/api/v1/voice/answer` | | Answer incoming call | +| POST | `/api/v1/voice/reject` | | Reject ringing call | +| POST | `/api/v1/voice/hangup` | | End active call | +| POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | +| GET | `/api/v1/games/status` | | LRGP stub status | +| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | +| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | +| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | +| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | ## WebSocket @@ -240,14 +246,16 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`. +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`. **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. +**`GET /ws/voice`:** dedicated high-rate stream for LXST PCM receive frames (`voice.audio` with `link_id`, `profile`, `channels`, `samples_b64`). Same starvation rationale as packet tap — do not put realtime audio on shared `/ws`. Electron main bridges this to `reticulum:voiceAudio` (not `reticulum:event`). + - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. -- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. +- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. @@ -273,7 +281,10 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand | `reticulum:rncpSend` / `rncpFetch` / `setRncpListener` | Picker-gated rncp send/fetch/listener (path must match `reticulum-remote-paths` allowlist) | | `reticulum:showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` | Native pickers that seed the rncp send-file / save-dir+fetch-jail allowlists | | `reticulum:revealInFolder` | Reveal a path in the OS file manager when it matches an rncp picker allowlist | -| `reticulum:onEvent` / `onStatus` | WS events and sidecar status | +| `reticulum:onEvent` / `onStatus` | Shared `/ws` events and sidecar status | +| `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | +| `reticulum:onVoiceAudio` | Dedicated `/ws/voice` → `reticulum:voiceAudio` PCM frames (`voice.audio`) | +| `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds, **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`). Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. diff --git a/docs/reticulum.md b/docs/reticulum.md index d92a75efa..a3c5c65f3 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -29,11 +29,11 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Stack lifecycle | Start / stop / auto-start; disconnect & quit | | Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | | Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | -| LXMF chat | DM-only text and reactions (outbound LXMF file/voice attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files) | +| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | | Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | | RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges, toasts when the RRC tab is inactive, automatic reconnect with backoff | | Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set. Path/transport badges (RF/BLE/TCP/NET, multi, PN) are egress evidence — UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe and peer detail modal (Save as contact is manual) | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** on rows; peer detail modal (Save as contact is manual) | | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | | Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | | Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | @@ -290,6 +290,8 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions. - Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. +- **`[whispers]`:** `/msg NICK text` opens the synthetic whispers room and pins the reply peer. Plain text in `[whispers]` sends a NOTICE to that peer (no `/msg` prefix). Inbound whispers update the reply target only when the user has not pinned a peer via `/msg` or a prior plain reply. With no target yet, send shows `rrc.whisperNoTarget`. +- Chat virtualization pins to the bottom while reading live traffic; **Jump to latest** appears when scrolled up; leaving/re-entering RRC restores the prior scroll pin when possible (`RrcChatView` + TanStack Virtual). - Unintended link drops enter **reconnecting** (backoff 2–30 s), preserve desired rooms (including join keys), and rejoin after WELCOME. Explicit **Disconnect** / **Cancel** clears that hub (`will_reconnect: false`). - **Involuntary PART:** hub/self `PARTED` while the room is still desired queues a silent re-JOIN; UI banner uses neutral `rrc.moderation.hubParted` (not kick/ban wording). Member-fanout `PARTED` (another peer left) updates the nicklist only — must not be treated as self-leave. - @mention unread badges and inactive-tab toasts; muted views use the shared Chat mute storage keyed as `rrc::`. @@ -313,6 +315,8 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before preferred-PN fallback. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. +**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → preferred PN. It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). + **Network → stack settings → Prefer path medium** sets the global bias: | Preference | Behavior | @@ -397,7 +401,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, and `rsNomad` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. +`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, `rsNomad`, and `rsLXST` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. @@ -407,7 +411,7 @@ End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and pnpm run reticulum:sidecar:build ``` -When sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). +When sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). **Electron dev:** **Start stack** auto-runs `cargo build` when the debug binary is missing, when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the binary, or when a stub binary is present but full-stack siblings exist. First compile can take several minutes — pre-build with the command above. @@ -468,7 +472,8 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination - **Clear announces** — path table may refill from the live network on the next refresh - **Topology** — next-hop only; not a full end-to-end trace - **AGPL sidecar** — separate process and license from the MIT Electron shell -- **LXST voice calls** and **LRGP games** — not integrated (status endpoints may exist in sidecar; no UI) +- **LRGP games** — not integrated (status endpoint may exist in sidecar; no UI) +- **LXST voice calls** — integrated via rsLXST `TelephonyService` in the sidecar (`/api/v1/voice/*` + WS `voice.*`). Renderer owns mic/speaker (`getUserMedia` / Web Audio); Call controls live on Peers rows and Chat DM (no separate Voice tab). Live interop with Ratspeak / Python LXST should be verified manually on a real mesh. - **Hardware identity (YubiKey/PIV)** — not wired - **In-app firmware download** — local `.zip` pick only @@ -481,6 +486,7 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination | Sidecar 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) | | `register_packet_tap` / cargo build failed | [troubleshooting.md#reticulum-sidecar-cargo-build-fails](troubleshooting.md#reticulum-sidecar-cargo-build-fails-register_packet_tap--reticulum_cargo_build_failed) | | AutoInterface utun log spam (macOS VPN) | [troubleshooting.md#reticulum-autointerface-log-spam-on-macos](troubleshooting.md#reticulum-autointerface-log-spam-on-macos-vpn-utun--enobufs) | +| Local DMs hang (Auto + private TCP hub) | [troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | | Interface add/edit/delete fails | [troubleshooting.md#reticulum-interface-addeditdelete-fails](troubleshooting.md#reticulum-interface-addeditdelete-fails) | | Nomad / topology 404 | [troubleshooting.md#reticulum-nomad-network-or-topology-api-returns-404](troubleshooting.md#reticulum-nomad-network-or-topology-api-returns-404) | | RNode Wi‑Fi won't connect | [troubleshooting.md#rnode-wi-fi-interface-offline-or-wont-connect](troubleshooting.md#rnode-wi-fi-interface-offline-or-wont-connect) | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 11891f8a9..c3cfbc7e6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -208,14 +208,24 @@ pnpm run trace-deprecation ### Permission messages in the console -The session allowlist grants **serial**, **geolocation**, and **media** (camera for QR ingest; microphone reserved for future live audio). Other permissions such as `web-app-installation` remain denied and may appear as `[permissions] … → denied` in the log. +The session allowlist grants **serial**, **geolocation**, and **media** (camera for QR ingest; microphone for Reticulum LXST voice calls). Other permissions such as `web-app-installation` remain denied and may appear as `[permissions] … → denied` in the log. -If microphone permission is denied when a future live-audio feature requests it: +If microphone permission is denied when placing or answering an LXST voice call: - **macOS:** System Settings → Privacy & Security → Microphone — allow Mesh-client (or Electron when running `pnpm run dev`). Packaged builds include `NSMicrophoneUsageDescription`. - **Windows:** Settings → Privacy & security → Microphone — allow desktop apps / Mesh-client. The app opens this page when OS status is `denied`. - **Linux:** Ensure PulseAudio or PipeWire can capture; Flatpak builds already include `--socket=pulseaudio`. AppImage/deb use the host audio stack. +### Reticulum LXST voice call fails or is silent + +- **Stack not running:** Call needs a live Reticulum sidecar (`available` + `enabled` + `running` from `/api/v1/voice/status`). Start the stack from Connection. +- **Peer identity unknown:** Dial uses a 32-hex **identity** hash (not only the LXMF destination). Wait for an announce or Probe the peer from Peers / Chat DM, then try Call again. +- **Busy / rejected / no answer:** Line-busy and reject play distinct tones; discovery/ring timeouts surface as no-answer toasts. Only one local call at a time — Hang up before dialing again. +- **Call progress tones (outbound):** Expect **dial tone → peer-derived DTMF burst → UK double-ring** while connecting. **Busy / no-answer** uses a short busy cadence; **connect-fail / unexpected drop** uses a fast reorder tone (not the same as busy). Hearing dial/ring without two-way audio is often still connecting — wait for Established before assuming failure. +- **One-way or silent audio:** Confirm microphone permission (above). **Answer** only warms `AudioContext` on the click; **microphone capture/TX begins after `voice.update: established`** (Call click warms contexts before dial). If capture still fails after Established, check OS privacy and that another app is not exclusive-locking the mic. TX drops increment `localTxDrops` under IPC pressure — hang up and retry on a quieter link. +- **Inbound accept fails (Columba / Python LXST):** mesh-client→peer may work while peer→mesh-client fails on Answer. Current builds defer mic/TX until Established and soft-drop pre-establish PCM (older packages could fatal-error lxst with `active call is not established` on Answer). Rebuild sidecar + app, then retry. If it still fails, check developer-bundle logs for `[ReticulumSidecar]` `call start role=incoming`, `call failed` / `call terminated`, and renderer `[reticulumVoice] voice.error message=…` / `answer failed`. Generic UI toast **Voice call failed** hides the raw rsLXST reason — the log line is definitive. +- **Interop:** Peer must run LXST telephony (Sideband, Ratspeak, Columba, or mesh-client with rsLXST). This is not an LXMF voice-note clip. + If QR camera scanning fails with camera permission denied: - **macOS:** System Settings → Privacy & Security → Camera — allow Mesh-client (packaged builds include `NSCameraUsageDescription`). `media:ensureCameraAccess` opens the privacy pane when denied. @@ -983,9 +993,25 @@ Quit mesh-client fully, reopen, and click **Start stack** again. Or apply individual overlays (`./scripts/apply-rsReticulum-packet-tap.sh`, `./scripts/apply-rsReticulum-auto-beacon-utun.sh`, `./scripts/apply-rsReticulum-link-client-nomad.sh`, …) then `pnpm run reticulum:sidecar:build`. 3. **Workaround on old builds**: disable **AutoInterface** under Connection → Interfaces if LAN discovery is not needed (TCP/RNode paths still work). 4. **Physical NIC failures** (`en0`, `wlan0`, …): restart the stack; check firewall/multicast permissions — that indicates real LAN discovery failure, not VPN noise. +5. **Local DMs hang with Auto + LAN TCP hub** — see [Reticulum local DMs hang with AutoInterface + private TCP hub](#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). Log path: `~/Library/Application Support/mesh-client/mesh-client.log` (macOS). +### Reticulum local DMs hang with AutoInterface + private TCP hub + +**Symptoms**: LXMF Direct to a LAN peer stalls on **Sending** (or takes minutes) while **AutoInterface** is enabled and a **private** TCP/UDP hub is also up (e.g. local transport at `192.168.x.x`). Disabling Auto and restarting the stack makes the same DMs work over the hub (`received_via` / path interface shows TCP). Announce flood is not required to reproduce. + +**Cause**: AutoInterface peers are normal Reticulum **0-hop** neighbors. Transport prefers fewest hops; Auto and TCP are both `network` medium. A fresher Auto path can stay **active** even when a private hub path to the same peer is also 0-hop (equal-hop tie / learn order). If that Auto link is unhealthy (multicast, carrier, beacon issues), Direct waits on Auto while the private hub path sits unused as a backup. A 0-hop path **to the hub itself** does not mean Direct already chose the hub for the peer. + +**Automatic recovery** (mesh-client sidecar): + +1. **Health preempt** — If Auto looks degraded for delivery (beacon/carrier/status) and a live **private** path exists (RFC1918 / IPv6 ULA or link-local / `.local` TCP/UDP), suppress Auto and open Direct on that private path before waiting out a full Auto link hang. +2. **Failure failover** — If Direct still fails or times out on Auto, exhaust backups **private non-Auto → public hubs → preferred PN** (does not preempt healthy Auto to the internet). + +Healthy Auto is left preferred (RNS default). Public hubs are never chosen by the health preempt. + +**Manual workaround**: Connection → Interfaces → disable **Auto** → restart stack if prompted. Keep the private hub up; confirm it is not `ECONNREFUSED` in the log (`hostLink` TCP probe). + ### 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**. @@ -1253,18 +1279,24 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) ### Reticulum: Ratspeak DMs work but mesh-client stays silent -**Symptoms**: Another Reticulum client (Ratspeak, Sideband, MeshChat) exchanges DMs with a mobile peer after both sides announce; mesh-client shows outbound stuck **Sending** / **Queued** / **Failed**, **zero inbound**, or a Chat contact that never appears under **Peers**. +**Symptoms**: Another Reticulum client (Ratspeak, Sideband, MeshChat, **Columba**) exchanges DMs with a mobile peer after both sides announce; mesh-client shows outbound stuck **Sending** / **Queued** / **Failed**, **zero inbound**, or a Chat contact that never appears under **Peers**. -**Cause**: LXMF requires (1) an **`lxmf.delivery` announce** so peers learn a path _to_ this identity and (2) inbound destination registration (`RegisterDestination` + LinkManager) so link payloads reach Chat. Older sidecars stored announce interval in config without scheduling announces; current builds send startup + periodic delivery announces and register `lxmf.delivery`. +**Cause**: LXMF requires (1) an **`lxmf.delivery` announce** so peers learn a path _to_ this identity and (2) inbound destination registration (`RegisterDestination` + LinkManager) so link payloads reach Chat. Older sidecars stored announce interval in config without scheduling announces; current builds send startup + periodic delivery announces and register `lxmf.delivery`. Short messages from Python clients (Sideband/Columba) often use **opportunistic** DATA — current sidecars wire `set_inbound_raw_channel` (lxmd parity) so those packets are not dropped after proof. **Checks**: 1. Upgrade / rebuild the sidecar (`pnpm run reticulum:sidecar:build`) and **Restart stack**. -2. On Network, use **Announce now**; on the other client, confirm mesh-client’s LXMF hash (Network identity) appears after the announce. +2. On Network, use **Announce now**; on the other client, confirm mesh-client’s **LXMF** hash (Network identity → LXMF destination, not only the identity hash) appears after the announce. 3. Same fabric: enable the same TCP hub / Auto / RNode paths on both clients when A/B testing. 4. Contact named in Chat but missing from Peers → path dead; use **Peers → Request path / Probe**, or wait for the peer’s announce on that fabric. 5. **Auto interface up ≠ Auto peers.** Auto “up” means LAN multicast carrier works; peer rows appear only when another RNS node announces onto that Auto group (or paths are owned by that interface). Multi-hop peers labeled with a TCP hub name and a shared `via` are hub fanout, not LAN neighbors. 6. Configure a remote **propagation node** if the peer is often offline (does not replace missing announces). +7. **Columba / Sideband opportunistic triage** (developer bundle, default `RUST_LOG=warn`): after a send from the phone, look for `[ReticulumSidecar]` lines: + - `LXMF inbound opportunistic packet` — frame reached the raw channel + - `opportunistic LXMF decrypt failed` (+ `error=…`) — ciphertext did not decrypt to this identity + - `inbound data not an LXMF message` with `via=opportunistic` — decrypt OK, unpack failed + - `inbound LXMF queued for clients` — sidecar accepted the message (if Chat still empty, look at renderer ingest / identity) + - **None of the above** after a Columba send → packet never hit `lxmf.delivery` (wrong dest hash on the phone, missing reverse path/announce, or not opportunistic/link traffic at all) ### Reticulum interface add/edit/delete fails @@ -1392,12 +1424,12 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). MQTT ingest must map inbound text to the **receiver's** local channel slot using the MQTT topic channel name (`LongFast`, regional names, etc.) via `channelNameToIndex`. `MeshPacket.channel` in the ServiceEnvelope is the **sender's** local RF slot and must not drive attribution — remote gateways often use a different slot layout (e.g. LongFast on slot 1 while you use slot 0). -Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, or delayed `mqtt:updateChannelKeys` after connect. +Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, or MQTT connecting before RF channel configs arrive (cold-start empty map). **Fix** 1. Update to a build that prefers the **topic channel name** for MQTT text ingest (sampled log `mqtt-channel-topic-mismatch:*` when topic index disagrees with packet channel). -2. Connect the radio so channel keys and slot indexes sync to MQTT (`mqtt:updateChannelKeys` in logs after configure). +2. Connect the radio so channel keys and slot indexes sync to MQTT. Current builds **re-push** `mqtt:updateChannelKeys` when RF `resolvedChannelConfigs` land after MQTT is already connected — look for `[Meshtastic MQTT] channelNameToIndex updated` in the App log (e.g. `LongFast=1`). 3. **MQTT-only (no radio):** add `ChannelName@index=base64` lines in Connection → Channel PSKs (e.g. `LongFast@1=AQ==` for Colorado-mesh slot-1 public). The Connection panel shows an inline hint when no radio is configured and no `@index` lines are present. 4. On **Export for Developer** / **Copy Debug Snapshot**, check `meshtastic.channelPills`, `meshtastic.channelConfigsSummary`, `meshtastic.mqttChannelKeyEntryCount`, and `meshtastic.mqttChannelNameToIndex` (main-process topic→slot map; e.g. `{ "LongFast": 1 }` for Colorado-style public on slot 1). Slot 1 with empty name and `isDefaultPublicPsk: true` is the common Colorado-mesh layout. 5. When reporting, note whether mis-filed messages are **MQTT-only**, **RF-only**, or **both**, and attach a Radio tab screenshot of channel names + slot indices. @@ -1415,7 +1447,7 @@ Meshtastic node numbers are frequently the lower 32 bits of the BLE MAC. With bo **Fix** -1. Update to a build that suppresses Meshtastic `last_heard` bumps for node IDs matching the **connected MeshCore BLE MAC**. +1. Update to a build that suppresses Meshtastic `last_heard` bumps for node IDs matching the **remembered/connected MeshCore BLE MAC** (`connectedMeshcoreBleMac.ts`). A valid MAC is **persisted and pre-armed** across cold start, failed reconnect, and user disconnect; it clears only on **Forget** or switching MeshCore to a non-BLE transport. 2. Until then: delete the ghost node on Meshtastic Nodes (it may return while both radios are on-air on older builds). 3. Confirm MeshCore Connection is BLE to that peripheral; Diagnostics foreign-LoRa is separate from the Nodes list. diff --git a/electron-builder.yml b/electron-builder.yml index ea7a95ffe..59a31077d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -45,7 +45,7 @@ mac: helperBundleId: com.mesh-client.app.helper extendInfo: NSBluetoothAlwaysUsageDescription: 'Mesh-client needs Bluetooth to communicate with Meshtastic devices.' - NSMicrophoneUsageDescription: 'Mesh-client needs the microphone to record Reticulum voice clips.' + NSMicrophoneUsageDescription: 'Mesh-client needs the microphone for Reticulum LXST voice calls.' NSCameraUsageDescription: 'Mesh-client needs the camera to scan QR codes for channel and contact sharing.' target: - dmg diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index ee3c46fdd..18d8e0497 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -88,15 +88,15 @@ modules: path: . - flatpak/generated-sources.json - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.15.1/pnpm-linux-x64.tar.gz - sha256: 0c1373b6390f6b89ff8b896d3647c9826577ddc49173a2f732da69b36569f9e0 + url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz + sha256: b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572 dest: pnpm-vendor # pnpm tarball has root-level `pnpm` + `dist/`; default strip-components:1 drops the binary. strip-components: 0 only-arches: [x86_64] - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.15.1/pnpm-linux-arm64.tar.gz - sha256: 361e385867146972d0635a41a1871cb44c9c23f65acce78a5f1ca1d44ac0afcd + url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz + sha256: f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5 dest: pnpm-vendor strip-components: 0 only-arches: [aarch64] diff --git a/package.json b/package.json index f9e2231c8..de522378b 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" }, - "packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "engines": { "node": ">=22.13.0", "pnpm": ">=11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f4a21e0..11d182e9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,14 +14,15 @@ overrides: brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 fast-uri: ^3.1.5 + ip-address: ^10.3.1 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' - postcss: ^8.5.18 + postcss: ^8.5.25 shell-quote: ^1.9.0 tar: ^7.5.18 tmp: ^0.2.6 - undici: ^7.28.0 - undici-types: ^7.28.0 + undici: ^7.29.0 + undici-types: ^7.29.0 ws: ^8.21.0 patchedDependencies: @@ -251,7 +252,7 @@ importers: specifier: ^0.22.1 version: 0.22.1(supports-color@8.1.1) postcss: - specifier: ^8.5.18 + specifier: ^8.5.25 version: 8.5.25 prettier: specifier: ^3.9.6 @@ -488,15 +489,6 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/core@2.0.0-alpha.3': - resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - - '@emnapi/runtime@2.0.0-alpha.3': - resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - - '@emnapi/wasi-threads@2.0.1': - resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -772,13 +764,6 @@ packages: resolution: {integrity: sha512-vdBbYEKw8mzE7y5br0OpfwjBAZIowsrPOIbh8a7KnL+eSQDH6jw4yy11JzAZZW0km6l4Ntmg8fvsZwEzAVTdHA==} hasBin: true - '@napi-rs/wasm-runtime@1.2.2': - resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} @@ -887,96 +872,92 @@ packages: engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} hasBin: true - '@rolldown/binding-android-arm64@1.2.1': - resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.1': - resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.1': - resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.1': - resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': - resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.1': - resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.1': - resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.1': - resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.1': - resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.1': - resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.1': - resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.1': - resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.2.1': - resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - - '@rolldown/binding-win32-arm64-msvc@1.2.1': - resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.1': - resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1263,9 +1244,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3458,8 +3436,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4013,8 +3991,8 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rolldown@1.2.1: - resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5063,22 +5041,6 @@ snapshots: - supports-color optional: true - '@emnapi/core@2.0.0-alpha.3': - dependencies: - '@emnapi/wasi-threads': 2.0.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@2.0.0-alpha.3': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@2.0.1': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -5296,13 +5258,6 @@ snapshots: commander: 12.1.0 crypto-js: 4.2.0 - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 @@ -5401,53 +5356,46 @@ snapshots: '@reteps/dockerfmt-linux-arm64': 0.5.4 '@reteps/dockerfmt-linux-x64': 0.5.4 - '@rolldown/binding-android-arm64@1.2.1': + '@rolldown/binding-android-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-arm64@1.2.1': + '@rolldown/binding-darwin-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-x64@1.2.1': + '@rolldown/binding-darwin-x64@1.2.2': optional: true - '@rolldown/binding-freebsd-x64@1.2.1': + '@rolldown/binding-freebsd-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.1': + '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.1': + '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.1': + '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.1': + '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.1': + '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-musl@1.2.1': + '@rolldown/binding-linux-x64-musl@1.2.2': optional: true - '@rolldown/binding-openharmony-arm64@1.2.1': + '@rolldown/binding-openharmony-arm64@1.2.2': optional: true - '@rolldown/binding-wasm32-wasi@1.2.1': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.2.1': + '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.1': + '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -5708,11 +5656,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/aria-query@5.0.4': {} '@types/cacheable-request@6.0.3': @@ -8264,7 +8207,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.17: {} natural-compare@1.4.0: {} @@ -8523,7 +8466,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8805,26 +8748,25 @@ snapshots: sprintf-js: 1.1.3 optional: true - rolldown@1.2.1: + rolldown@1.2.2: dependencies: '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.1 - '@rolldown/binding-darwin-arm64': 1.2.1 - '@rolldown/binding-darwin-x64': 1.2.1 - '@rolldown/binding-freebsd-x64': 1.2.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 - '@rolldown/binding-linux-arm64-gnu': 1.2.1 - '@rolldown/binding-linux-arm64-musl': 1.2.1 - '@rolldown/binding-linux-ppc64-gnu': 1.2.1 - '@rolldown/binding-linux-s390x-gnu': 1.2.1 - '@rolldown/binding-linux-x64-gnu': 1.2.1 - '@rolldown/binding-linux-x64-musl': 1.2.1 - '@rolldown/binding-openharmony-arm64': 1.2.1 - '@rolldown/binding-wasm32-wasi': 1.2.1 - '@rolldown/binding-win32-arm64-msvc': 1.2.1 - '@rolldown/binding-win32-x64-msvc': 1.2.1 + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 run-parallel@1.2.0: dependencies: @@ -9428,7 +9370,7 @@ snapshots: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.2.1 + rolldown: 1.2.2 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d6f2c6d7..1708cd524 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,24 +40,30 @@ overrides: # Security floors (Dependabot GHSA-52cp-r559-cp3m / GHSA-395f-4hp3-45gv / # GHSA-w8wr-v893-vjvp / GHSA-3jxr-9vmj-r5cp / GHSA-r28c-9q8g-f849 / # GHSA-p2f4-r6v6-j797 / GHSA-7g7r-gx96-252g / GHSA-mh99-v99m-4gvg / - # GHSA-rgw5-rvv9-x895). + # GHSA-rgw5-rvv9-x895 / GHSA-4cwx-7wf7-3272 / GHSA-8xcm-r25x-g524 / + # GHSA-v3r7-h72x-cjcm / GHSA-jr45-8vmc-qm54 / GHSA-m8rv-5g2x-5cg5 / + # GHSA-fxqj-rqcc-2cmp / GHSA-mwp4-54f8-5fhr). # brace-expansion: keep a single 5.0.9 floor. GHSA-rgw5-rvv9-x895 is a # bypass of the CVE-2026-14257 mitigation and marks >=4.0.0 <5.0.9 vulnerable # (only >=5.0.9 counts as patched). CI audit is blocking. # fast-uri: GHSA-7p8r-x3mc-p8w7 (host confusion via backslash authority); # electron-builder → app-builder-lib → ajv still pulls 3.1.4. + # undici: GHSA-4cwx-7wf7-3272 (+ sibling undici GHSAs) — floor >=7.29.0. + # postcss: GHSA-fxqj-rqcc-2cmp — floor >=8.5.23. + # ip-address: GHSA-mwp4-54f8-5fhr (mqtt → socks) — floor >=10.3.1. app-builder-lib: ^26.15.0 brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 fast-uri: ^3.1.5 + ip-address: ^10.3.1 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' - postcss: ^8.5.18 + postcss: ^8.5.25 shell-quote: ^1.9.0 tar: ^7.5.18 tmp: ^0.2.6 - undici: ^7.28.0 - undici-types: ^7.28.0 + undici: ^7.29.0 + undici-types: ^7.29.0 ws: ^8.21.0 patchedDependencies: '@jsr/meshtastic__core@2.6.6': patches/@jsr__meshtastic__core@2.6.6.patch diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 3b03aed2f..131d2b86f 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1352,6 +1352,46 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "lxst-core" +version = "0.1.2" +dependencies = [ + "half", + "opus-rs", + "rmpv", + "thiserror 2.0.18", +] + +[[package]] +name = "lxst-rns" +version = "0.1.2" +dependencies = [ + "bytes", + "lxst-core", + "rns-link", + "rns-transport", + "rns-wire", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "lxst-telephony" +version = "0.1.2" +dependencies = [ + "bytes", + "lxst-core", + "lxst-rns", + "rns-crypto", + "rns-identity", + "rns-link", + "rns-runtime", + "rns-transport", + "rns-wire", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "macaddr" version = "1.0.1" @@ -1411,6 +1451,8 @@ dependencies = [ "hex", "http", "lxmf-core", + "lxst-core", + "lxst-telephony", "nomad-core", "notify", "rand 0.8.6", @@ -1647,6 +1689,10 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opus-rs" +version = "0.1.19" + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 1cc47ac9b..5b205364e 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -12,11 +12,14 @@ path = "src/main.rs" [features] default = [] -# Full stack: requires sibling checkouts at ../../rsReticulum and ../../rsLXMF (Ratspeak layout). +# Full stack: requires sibling checkouts at ../../rsReticulum, ../../rsLXMF, +# ../../rsNomad, and ../../rsLXST (Ratspeak layout). rns-stack = [ "dep:rns-runtime", "dep:lxmf-core", "dep:nomad-core", + "dep:lxst-telephony", + "dep:lxst-core", "dep:rns-identity", "dep:rns-wire", "dep:rns-ratkey", @@ -92,6 +95,16 @@ package = "nomad-core" path = "../../rsNomad/crates/nomad-core" optional = true +[dependencies.lxst-telephony] +package = "lxst-telephony" +path = "../../rsLXST/crates/lxst-telephony" +optional = true + +[dependencies.lxst-core] +package = "lxst-core" +path = "../../rsLXST/crates/lxst-core" +optional = true + [dependencies.rns-interface] package = "rns-interface" path = "../../rsReticulum/crates/rns-interface" diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index 9365059f9..7abed763a 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -14,20 +14,21 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See ./scripts/clone-ratspeak-stack.sh ``` -That floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. +That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. -**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, and `rsNomad` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): +**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, `rsNomad`, and `rsLXST` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): ```bash pnpm run reticulum:sidecar:build ``` -**Full rsReticulum + rsLXMF + rsNomad** — sibling checkout (Ratspeak layout + Colorado-Mesh rsNomad): +**Full rsReticulum + rsLXMF + rsNomad + rsLXST** — sibling checkout (Ratspeak layout + Colorado-Mesh rsNomad + LXST voice): ``` parent/ rsReticulum/ rsLXMF/ + rsLXST/ rsNomad/ mesh-client/reticulum-sidecar/ ``` @@ -82,7 +83,7 @@ Install coverage tooling once: `cargo install cargo-llvm-cov`. - **Pre-commit** runs sibling `rsNomad` fmt/clippy plus sidecar stub fmt/clippy/test when `cargo` is on `PATH` (no coverage). - **CI lint** (`reticulum-sidecar.yaml`): `rsNomad` fmt/clippy, then full-feature sidecar `fmt --check` + Clippy. - **CI coverage** (`tests.yaml`): `cargo llvm-cov --fail-under-lines 45` when sidecar paths change (ratchet toward ~52%; ignores `rsReticulum`/`rsLXMF`/`rsNomad` path deps). -- **Ratspeak / Nomad siblings:** `scripts/clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`); overlays must apply. +- **Ratspeak / Nomad / LXST siblings:** `scripts/clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`); overlays must apply. ## API diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index f53da1483..f6888849a 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -15,6 +15,7 @@ mod rrc; mod status; mod system; pub(crate) mod validate; +mod voice; mod ws; use std::sync::Arc; @@ -244,7 +245,13 @@ pub fn router(stack: Arc) -> Router { .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/voice/status", get(voice::voice_status)) + .route("/api/v1/voice/call", post(voice::voice_call)) + .route("/api/v1/voice/answer", post(voice::voice_answer)) + .route("/api/v1/voice/reject", post(voice::voice_reject)) + .route("/api/v1/voice/hangup", post(voice::voice_hangup)) + .route("/api/v1/voice/mute", post(voice::voice_mute)) + .route("/api/v1/voice/audio", post(voice::voice_audio)) .route("/api/v1/games/status", get(system::games_status)) .route( "/api/v1/identities", @@ -253,6 +260,7 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/identities/switch", post(system::switch_identity)) .route("/api/v1/identities/delete", post(system::delete_identity)) .route("/ws", get(ws::ws_handler)) + .route("/ws/voice", get(ws::ws_voice_handler)) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)) .layer(localhost_cors_layer()) .with_state(stack) diff --git a/reticulum-sidecar/src/api/system.rs b/reticulum-sidecar/src/api/system.rs index 236d19818..ee9915450 100644 --- a/reticulum-sidecar/src/api/system.rs +++ b/reticulum-sidecar/src/api/system.rs @@ -23,10 +23,6 @@ pub async fn diagnostics(State(stack): State>) -> Json>) -> Json { - Json(stack.voice_status().await) -} - pub async fn games_status(State(stack): State>) -> Json { Json(stack.games_status().await) } diff --git a/reticulum-sidecar/src/api/voice.rs b/reticulum-sidecar/src/api/voice.rs new file mode 100644 index 000000000..92dea784b --- /dev/null +++ b/reticulum-sidecar/src/api/voice.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; + +use crate::api::validate::{MAX_DEST_HASH_CHARS, reject_oversize}; +use crate::stack::StackHandle; + +const MAX_SAMPLES_B64_CHARS: usize = 512 * 1024; + +#[derive(Debug, Deserialize)] +pub struct VoiceCallBody { + pub identity_hash: String, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceMuteBody { + pub muted: bool, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceAudioBody { + #[serde(default)] + pub profile: Option, + #[serde(default = "default_channels")] + pub channels: u8, + pub samples_b64: String, +} + +fn default_channels() -> u8 { + 1 +} + +pub async fn voice_status(State(stack): State>) -> Json { + Json(stack.voice_status().await) +} + +pub async fn voice_call( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("identity_hash", &body.identity_hash, MAX_DEST_HASH_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + Json(stack.voice_call(&body.identity_hash).await) +} + +pub async fn voice_answer(State(stack): State>) -> Json { + Json(stack.voice_answer().await) +} + +pub async fn voice_reject(State(stack): State>) -> Json { + Json(stack.voice_reject().await) +} + +pub async fn voice_hangup(State(stack): State>) -> Json { + Json(stack.voice_hangup().await) +} + +pub async fn voice_mute( + State(stack): State>, + Json(body): Json, +) -> Json { + Json(stack.voice_mute(body.muted).await) +} + +pub async fn voice_audio( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("samples_b64", &body.samples_b64, MAX_SAMPLES_B64_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + Json( + stack + .voice_audio(body.profile, body.channels, &body.samples_b64) + .await, + ) +} diff --git a/reticulum-sidecar/src/api/ws.rs b/reticulum-sidecar/src/api/ws.rs index aeb0c756c..bc383b933 100644 --- a/reticulum-sidecar/src/api/ws.rs +++ b/reticulum-sidecar/src/api/ws.rs @@ -4,6 +4,7 @@ use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::response::IntoResponse; use futures_util::StreamExt; +use tokio::sync::broadcast; use crate::stack::StackHandle; @@ -11,11 +12,22 @@ pub async fn ws_handler( ws: WebSocketUpgrade, State(stack): State>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws(socket, stack)) + ws.on_upgrade(move |socket| handle_ws(socket, stack.subscribe_events(), true)) } -async fn handle_ws(mut socket: WebSocket, stack: Arc) { - let mut rx = stack.subscribe_events(); +/// Dedicated high-rate stream for `voice.audio` PCM (keeps shared `/ws` free for LXMF). +pub async fn ws_voice_handler( + ws: WebSocketUpgrade, + State(stack): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, stack.subscribe_voice_audio(), false)) +} + +async fn handle_ws( + mut socket: WebSocket, + mut rx: broadcast::Receiver, + emit_lag_notice: bool, +) { loop { tokio::select! { evt = rx.recv() => { @@ -26,6 +38,13 @@ async fn handle_ws(mut socket: WebSocket, stack: Arc) { } } Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + if !emit_lag_notice { + tracing::warn!( + skipped, + "voice audio websocket subscriber lagged; frames dropped" + ); + continue; + } // Critical events (lxmf_message) may have been dropped. Notify the // client so it can catch up via GET /api/v1/lxmf/recent. tracing::warn!( diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs index 7cc5b8dbb..ad3a8cab2 100644 --- a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -162,6 +162,7 @@ pub fn resolve_announce_aspect(name_hash: &[u8; 10]) -> Option<&'static str> { "lxmf.propagation", "nomadnetwork.node", "rrc.hub", + "lxst.telephony", ] .into_iter() .map(|aspect| (rns_identity::name_hash::name_hash(aspect), aspect)) @@ -457,6 +458,10 @@ mod tests { resolve_announce_aspect(&rns_identity::name_hash::name_hash("rrc.hub")), Some("rrc.hub") ); + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("lxst.telephony")), + Some("lxst.telephony") + ); assert_eq!(resolve_announce_aspect(&[0u8; 10]), None); assert_eq!(resolve_announce_aspect(&[1u8; 10]), None); } diff --git a/reticulum-sidecar/src/stack/auto_path_policy.rs b/reticulum-sidecar/src/stack/auto_path_policy.rs new file mode 100644 index 000000000..d2f153f2c --- /dev/null +++ b/reticulum-sidecar/src/stack/auto_path_policy.rs @@ -0,0 +1,553 @@ +//! AutoInterface vs private LAN path policy for LXMF Direct. +//! +//! RNS correctly prefers 0-hop Auto neighbors. When Auto is unhealthy for delivery +//! (or Direct already failed on Auto) and a live **private** TCP/UDP path exists, +//! demote Auto toward that path. Never preempt healthy Auto to public internet hubs. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use crate::stack::types::InterfaceRow; + +fn interface_status_live(status: &str) -> bool { + matches!( + status.to_ascii_lowercase().as_str(), + "up" | "connected" | "online" | "running" + ) +} + +/// True when `name_or_type` looks like AutoInterface. +pub fn is_auto_iface_name_or_type(name_or_type: &str) -> bool { + let lower = name_or_type.trim().to_ascii_lowercase(); + lower == "auto" + || lower == "autointerface" + || lower.starts_with("autointerface") + || lower == "default interface" +} + +/// True when the config/live row is an AutoInterface. +pub fn is_auto_interface_row(row: &InterfaceRow) -> bool { + is_auto_iface_name_or_type(&row.iface_type) || is_auto_iface_name_or_type(&row.name) +} + +/// True when a TCP/UDP host string is on-LAN / private (no DNS). +/// +/// Private: RFC1918, IPv4 link-local, IPv6 ULA (`fd00::/8`) / link-local (`fe80::/10`), +/// or hostname ending in `.local`. Non-`.local` hostnames and public IPs are public. +pub fn host_is_private_lan(host: &str) -> bool { + let trimmed = host.trim(); + if trimmed.is_empty() { + return false; + } + // Bracketed IPv6 literals: `[fe80::1]` or `[fe80::1%en0]` + let unbracketed = trimmed + .strip_prefix('[') + .and_then(|s| s.split(']').next()) + .unwrap_or(trimmed); + let without_zone = unbracketed.split('%').next().unwrap_or(unbracketed); + if let Ok(ip) = without_zone.parse::() { + return ip_is_private_lan(ip); + } + let host_only = trimmed.split('%').next().unwrap_or(trimmed); + host_only.to_ascii_lowercase().ends_with(".local") +} + +fn ip_is_private_lan(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => ipv4_is_private_lan(v4), + IpAddr::V6(v6) => ipv6_is_private_lan(v6), + } +} + +fn ipv4_is_private_lan(ip: Ipv4Addr) -> bool { + ip.is_private() || ip.is_link_local() || ip.is_loopback() +} + +fn ipv6_is_private_lan(ip: Ipv6Addr) -> bool { + if ip.is_loopback() || ip.is_unicast_link_local() { + return true; + } + // Unique local address fd00::/8 (includes fc00::/7 ULA). + (ip.octets()[0] & 0xfe) == 0xfc +} + +/// TCP/UDP (non-Auto) row whose configured host is private LAN. +pub fn interface_row_is_private_network(row: &InterfaceRow) -> bool { + if is_auto_interface_row(row) { + return false; + } + let lower_type = row.iface_type.to_ascii_lowercase(); + let is_ip_client = lower_type.contains("tcp") + || lower_type.contains("udp") + || lower_type == "tcpclientinterface" + || lower_type == "udpclientinterface" + || lower_type == "tcpserverinterface" + || lower_type == "udpserverinterface"; + if !is_ip_client { + // Fall back: name contains tcp/udp and has a host. + let name_l = row.name.to_ascii_lowercase(); + if !(name_l.contains("tcp") || name_l.contains("udp")) { + return false; + } + } + row.host.as_deref().is_some_and(host_is_private_lan) +} + +/// Whether Auto is unhealthy for Direct delivery. +/// +/// - `delivery_degraded`: recent Auto Direct failure still within suppress window. +/// - Otherwise evaluate the **active** Auto path row when known (a second down Auto +/// must not demote a live active Auto). +/// - When the active iface is Auto by name only (no matching row), unhealthy only if +/// every enabled Auto is down. +pub fn auto_unhealthy_for_delivery( + interfaces: &[InterfaceRow], + active_path_iface: Option<&str>, + delivery_degraded: bool, +) -> bool { + if delivery_degraded { + return true; + } + let autos: Vec<&InterfaceRow> = interfaces + .iter() + .filter(|r| is_auto_interface_row(r) && r.enabled) + .collect(); + if let Some(name) = active_path_iface.map(str::trim).filter(|s| !s.is_empty()) { + if let Some(row) = find_interface_row(name, interfaces) { + if is_auto_interface_row(row) { + return !interface_status_live(&row.status); + } + return false; + } + if is_auto_iface_name_or_type(name) { + if autos.is_empty() { + return true; + } + return autos.iter().all(|r| !interface_status_live(&r.status)); + } + return false; + } + false +} + +/// Live private TCP/UDP interface display names. +pub fn live_private_iface_names(interfaces: &[InterfaceRow]) -> Vec { + interfaces + .iter() + .filter(|r| r.enabled && interface_status_live(&r.status)) + .filter(|r| interface_row_is_private_network(r)) + .filter(|r| !r.name.trim().is_empty()) + .map(|r| r.name.clone()) + .collect() +} + +/// Reorder live iface names: private LAN first, then the rest (stable within tiers). +pub fn order_live_ifaces_private_first( + live_ifaces: &[String], + interfaces: &[InterfaceRow], +) -> Vec { + let private = live_private_iface_names(interfaces); + let mut out: Vec = Vec::with_capacity(live_ifaces.len()); + for name in live_ifaces { + if private.iter().any(|p| p.eq_ignore_ascii_case(name)) { + out.push(name.clone()); + } + } + for name in live_ifaces { + if !out.iter().any(|o| o.eq_ignore_ascii_case(name)) { + out.push(name.clone()); + } + } + out +} + +/// Match a path-table interface name to a local row (name or id). +pub fn find_interface_row<'a>( + path_iface: &str, + interfaces: &'a [InterfaceRow], +) -> Option<&'a InterfaceRow> { + interfaces + .iter() + .find(|i| i.name.eq_ignore_ascii_case(path_iface) || i.id.eq_ignore_ascii_case(path_iface)) +} + +/// Active path iface is Auto. +pub fn path_iface_is_auto(path_iface: Option<&str>, interfaces: &[InterfaceRow]) -> bool { + let Some(name) = path_iface.map(str::trim).filter(|s| !s.is_empty()) else { + return false; + }; + if let Some(row) = find_interface_row(name, interfaces) { + return is_auto_interface_row(row); + } + is_auto_iface_name_or_type(name) +} + +/// Whether path slot interface names include a live private non-Auto iface. +pub fn path_has_live_private_backup( + path_slot_ifaces: &[String], + interfaces: &[InterfaceRow], +) -> bool { + let private = live_private_iface_names(interfaces); + if private.is_empty() { + return false; + } + path_slot_ifaces.iter().any(|slot_iface| { + private + .iter() + .any(|p| p.eq_ignore_ascii_case(slot_iface.trim())) + }) +} + +/// Preempt Auto Direct toward a private path when Auto is unhealthy and a private +/// LAN hub is live. When `path_slot_ifaces` is non-empty, require the private hub +/// to already appear in those slots; when empty (active-route-only path cache), +/// any live private hub is enough — Suppress + RequestPath can promote it. +pub fn should_preempt_auto_for_private_direct( + active_path_iface: Option<&str>, + path_slot_ifaces: &[String], + interfaces: &[InterfaceRow], + delivery_degraded: bool, +) -> bool { + if !path_iface_is_auto(active_path_iface, interfaces) { + return false; + } + if !auto_unhealthy_for_delivery(interfaces, active_path_iface, delivery_degraded) { + return false; + } + let private = live_private_iface_names(interfaces); + if private.is_empty() { + return false; + } + if path_slot_ifaces.is_empty() { + return true; + } + path_has_live_private_backup(path_slot_ifaces, interfaces) +} + +/// Live iface names for failover prefer tier (private LAN first when requested). +pub fn prefer_ifaces_for_failover( + interfaces: &[InterfaceRow], + blocked_ifaces: &[String], + prefer_private_first: bool, +) -> Vec { + use super::path_failover::{live_interface_names, remaining_live_ifaces}; + let live = live_interface_names(interfaces); + let ordered = if prefer_private_first { + order_live_ifaces_private_first(&live, interfaces) + } else { + live + }; + remaining_live_ifaces(&ordered, blocked_ifaces) +} + +/// After Auto Direct failure, prefer private live ifaces when choosing rediscovery targets. +pub fn should_prefer_private_after_auto_failure( + failed_iface: Option<&str>, + interfaces: &[InterfaceRow], +) -> bool { + if !path_iface_is_auto(failed_iface, interfaces) { + return false; + } + !live_private_iface_names(interfaces).is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stack::types::interface_discovery_defaults; + use std::collections::HashMap; + + fn row( + name: &str, + iface_type: &str, + enabled: bool, + status: &str, + host: Option<&str>, + ) -> InterfaceRow { + let ( + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + ) = interface_discovery_defaults(); + InterfaceRow { + id: name.to_lowercase().replace(' ', "-"), + name: name.into(), + iface_type: iface_type.into(), + enabled, + status: status.into(), + host: host.map(str::to_string), + 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![], + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + network_name: None, + passphrase: None, + extra_config: HashMap::default(), + } + } + + #[test] + fn host_private_literals() { + assert!(host_is_private_lan("192.168.1.111")); + assert!(host_is_private_lan("10.0.0.1")); + assert!(host_is_private_lan("172.16.0.5")); + assert!(host_is_private_lan("172.31.255.1")); + assert!(host_is_private_lan("169.254.1.1")); + assert!(host_is_private_lan("fe80::1")); + assert!(host_is_private_lan("[fe80::1]")); + assert!(host_is_private_lan("fd12::1")); + assert!(host_is_private_lan("pi.local")); + assert!(host_is_private_lan("Pi.Local")); + } + + #[test] + fn host_public_and_empty() { + assert!(!host_is_private_lan("2.ratspeak.org")); + assert!(!host_is_private_lan("8.8.8.8")); + assert!(!host_is_private_lan("2001:db8::1")); + assert!(!host_is_private_lan("")); + assert!(!host_is_private_lan(" ")); + } + + #[test] + fn auto_row_not_private_network_target() { + let auto = row("Auto", "auto", true, "up", None); + assert!(is_auto_interface_row(&auto)); + assert!(!interface_row_is_private_network(&auto)); + } + + #[test] + fn private_tcp_row_detected() { + let pi = row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ); + assert!(interface_row_is_private_network(&pi)); + let public = row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")); + assert!(!interface_row_is_private_network(&public)); + let no_host = row("TCP", "tcp", true, "up", None); + assert!(!interface_row_is_private_network(&no_host)); + } + + #[test] + fn healthy_auto_no_preempt() { + let ifaces = [ + row("Auto", "auto", true, "up", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + assert!(!auto_unhealthy_for_delivery(&ifaces, Some("Auto"), false)); + assert!(!should_preempt_auto_for_private_direct( + Some("Auto"), + &["Auto".into(), "Local Transport Pi".into()], + &ifaces, + false, + )); + } + + #[test] + fn mixed_auto_live_active_not_unhealthy() { + let ifaces = [ + row("Auto", "auto", true, "up", None), + row("Auto Backup", "auto", true, "down", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + assert!(!auto_unhealthy_for_delivery(&ifaces, Some("Auto"), false)); + assert!(!should_preempt_auto_for_private_direct( + Some("Auto"), + &[], + &ifaces, + false, + )); + } + + #[test] + fn delivery_degraded_preempts_while_auto_up() { + let ifaces = [ + row("Auto", "auto", true, "up", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + assert!(auto_unhealthy_for_delivery(&ifaces, Some("Auto"), true)); + assert!(should_preempt_auto_for_private_direct( + Some("Auto"), + &["Auto".into(), "Local Transport Pi".into()], + &ifaces, + true, + )); + } + + #[test] + fn unhealthy_auto_with_private_preempts() { + let ifaces = [ + row("Auto", "auto", true, "down", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + assert!(auto_unhealthy_for_delivery(&ifaces, Some("Auto"), false)); + assert!(should_preempt_auto_for_private_direct( + Some("Auto"), + &["Auto".into(), "Local Transport Pi".into()], + &ifaces, + false, + )); + } + + #[test] + fn unhealthy_auto_public_only_no_preempt() { + let ifaces = [ + row("Auto", "auto", true, "down", None), + row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")), + ]; + assert!(!should_preempt_auto_for_private_direct( + Some("Auto"), + &["Auto".into(), "Ratspeak 2".into()], + &ifaces, + false, + )); + } + + #[test] + fn unhealthy_auto_empty_slots_preempts_when_private_live() { + let ifaces = [ + row("Auto", "auto", true, "down", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + assert!(should_preempt_auto_for_private_direct( + Some("Auto"), + &[], + &ifaces, + false, + )); + } + + #[test] + fn unhealthy_auto_slots_without_private_no_preempt() { + let ifaces = [ + row("Auto", "auto", true, "down", None), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")), + ]; + // Non-empty slots list that does not include the private hub. + assert!(!should_preempt_auto_for_private_direct( + Some("Auto"), + &["Auto".into(), "Ratspeak 2".into()], + &ifaces, + false, + )); + } + + #[test] + fn prefer_ifaces_private_first_excludes_blocked() { + let ifaces = [ + row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + row("Auto", "auto", true, "up", None), + ]; + let prefer = prefer_ifaces_for_failover(&ifaces, &["Auto".into()], true); + assert_eq!( + prefer, + vec!["Local Transport Pi".to_string(), "Ratspeak 2".to_string()] + ); + } + + #[test] + fn order_private_first() { + let ifaces = [ + row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")), + row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]; + let live = vec!["Ratspeak 2".into(), "Local Transport Pi".into()]; + assert_eq!( + order_live_ifaces_private_first(&live, &ifaces), + vec!["Local Transport Pi".to_string(), "Ratspeak 2".to_string()] + ); + } + + #[test] + fn prefer_private_after_auto_failure() { + let ifaces = [row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + )]; + assert!(should_prefer_private_after_auto_failure( + Some("Auto"), + &ifaces + )); + assert!(!should_prefer_private_after_auto_failure( + Some("Ratspeak 2"), + &ifaces + )); + } +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 0c5536d62..5468a1621 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -72,6 +72,7 @@ use super::via::{ classify_path_interface_name, merge_live_interfaces_with_config, merge_observed_egress_vias, resolve_lxmf_sent_via, }; +use super::voice_session::VoiceSessionManager; use lxmf_outbound::{LxmfOutboundDriver, PathTableRoute}; /// Settle window for PacketTap Tx correlation after LXMF enqueue. @@ -133,6 +134,7 @@ pub struct LiveBridge { rrc_session: Arc, rnsh_session: Arc, rncp_transfer: Arc, + voice_session: Arc, /// Local Nomad page/file host (rsNomad / nomad-core). nomad_server: Arc, /// Shared persisted stack state (Nomad node list, prefs). @@ -448,6 +450,11 @@ impl LiveBridge { storage_dir.clone(), config_dir.clone(), )), + voice_session: Arc::new(VoiceSessionManager::spawn( + handle.transport_tx.clone(), + &identity, + event_tx.clone(), + )), nomad_server: Arc::new(NomadServerHandle::new()), persisted: inner.clone(), #[cfg(feature = "rns-ble")] @@ -1016,7 +1023,11 @@ impl LiveBridge { let mut current_iface = path_iface.clone(); let mut current_via = active_via_hash_from_slots(&path_slots_snapshot); - let live_ifaces = live_interface_names(interfaces); + // Private LAN hubs before public when selecting failover prefer targets. + let live_ifaces = super::auto_path_policy::order_live_ifaces_private_first( + &live_interface_names(interfaces), + interfaces, + ); // One generation for this page request + all via failovers. Bumping per // Link attempt would cancel a newer request when the older one retries. let link_gen = self @@ -1372,6 +1383,12 @@ impl LiveBridge { .unwrap_or_default(); let failed_via = active_via_hash_from_slots(&slots_before); let prefer = remaining_live_ifaces(live_ifaces, blocked_ifaces); + let failover_ops = path_failover::build_path_failover_control_ops( + dest, + blocked_vias, + failed_via.as_deref(), + &prefer, + ); // Promote an already-known unblocked backup before waiting on rediscovery. let known_backup = select_unblocked_slot( @@ -1384,8 +1401,8 @@ impl LiveBridge { if self .query_control_timed(TransportQuery::SuppressCurrentPathInterface { - dest, - duration: path_failover::IFACE_SUPPRESS_SECS, + dest: failover_ops.dest, + duration: failover_ops.suppress_secs, }) .await .is_none() @@ -1398,13 +1415,7 @@ impl LiveBridge { } // Drop every known-bad next hop (not only the currently active slot — // after a timeout the table may flip to another iface sharing an older via). - let mut vias_to_drop: Vec = blocked_vias.to_vec(); - if let Some(via) = failed_via.clone() { - if !vias_to_drop.iter().any(|b| b.eq_ignore_ascii_case(&via)) { - vias_to_drop.push(via); - } - } - for via_hex in &vias_to_drop { + for via_hex in &failover_ops.vias_to_drop { if let Ok(next_hop) = parse_hash16(via_hex) { if self .query_control_timed(TransportQuery::DropAllVia { next_hop }) @@ -2124,6 +2135,45 @@ impl LiveBridge { self.rnsh_session.status_snapshot().await } + pub async fn voice_status(&self) -> serde_json::Value { + self.voice_session.status().await + } + + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + self.voice_session.subscribe_voice_audio() + } + + pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { + self.voice_session.call(identity_hash).await + } + + pub async fn voice_answer(&self) -> serde_json::Value { + self.voice_session.answer().await + } + + pub async fn voice_reject(&self) -> serde_json::Value { + self.voice_session.reject().await + } + + pub async fn voice_hangup(&self) -> serde_json::Value { + self.voice_session.hangup().await + } + + pub async fn voice_mute(&self, muted: bool) -> serde_json::Value { + self.voice_session.set_mute(muted).await + } + + pub async fn voice_audio( + &self, + profile: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + self.voice_session + .send_audio(profile, channels, samples_b64) + .await + } + pub async fn rncp_send(&self, destination_hash_hex: &str, path: &str) -> serde_json::Value { match self.rncp_transfer.send(destination_hash_hex, path).await { Ok(transfer_id) => serde_json::json!({ "ok": true, "transfer_id": transfer_id }), @@ -2274,12 +2324,67 @@ impl LiveBridge { let outbound = self.outbound.clone(); let event_tx = self.event_tx.clone(); let propagation = self.propagation.clone(); + let config_dir = self.config_dir.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(2)); let mut known_path_hashes: HashSet = HashSet::new(); let mut prev_peer_by_hash: HashMap = HashMap::new(); loop { interval.tick().await; + // Keep Auto / private-LAN policy inputs fresh (host from config, online from stats). + { + let config_rows = + config::interfaces_from_config_dir(&config_dir).unwrap_or_default(); + let merged = if let Ok(Some(TransportQueryResponse::InterfaceStats(stats))) = + tokio::time::timeout( + TRANSPORT_QUERY_TIMEOUT, + handle.query_control(TransportQuery::GetInterfaceStats), + ) + .await + { + 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(), + discoverable: None, + latitude: None, + longitude: None, + height: None, + discovery_name: None, + announce_interval_min: None, + connectable: None, + reachable_on: None, + network_name: None, + passphrase: None, + extra_config: std::collections::HashMap::new(), + }) + .collect(); + merge_live_interfaces_with_config(&config_rows, live_rows) + } else { + config_rows + }; + if let Ok(mut driver) = outbound.lock() { + driver.update_interfaces(merged); + } + } // Only replace the outbound path table on a successful GetPathTable. // Timeout/empty fallback must NOT wipe known routes (that forced every // LXMF send onto the propagation node with hasPath:false). @@ -2429,6 +2534,7 @@ impl LiveBridge { let outbound = self.outbound.clone(); let event_tx = self.event_tx.clone(); let display_name_cache = self.display_name_cache.clone(); + let voice_session = self.voice_session.clone(); tokio::spawn(async move { let (callback_tx, mut callback_rx) = tokio::sync::mpsc::channel::(256); @@ -2474,12 +2580,18 @@ impl LiveBridge { if coalescer.is_empty() { window_start = Some(tokio::time::Instant::now()); } + let identity_hash_hex = evt.identity_hash.map(hex::encode); + if let Some(ref id_hex) = identity_hash_hex { + voice_session + .remember_identity_for_dest(&dest_hex, id_hex) + .await; + } coalescer.push(AnnounceWsRow { destination_hash: dest_hex, display_name, hops: evt.hops, aspect: resolve_announce_aspect(&evt.name_hash).map(str::to_string), - identity_hash: evt.identity_hash.map(hex::encode), + identity_hash: identity_hash_hex, }); } () = async { @@ -3717,6 +3829,9 @@ impl LiveBridge { "apply_interfaces: syncing {} interface(s) from config", interfaces.len() ); + if let Ok(mut driver) = self.outbound.lock() { + driver.update_interfaces(interfaces.clone()); + } self.sync_ble_peer_interfaces(&interfaces).await } @@ -5169,6 +5284,95 @@ mod announce_display_name_tests { } } +#[cfg(test)] +mod nomad_private_first_failover_tests { + use super::*; + use crate::stack::auto_path_policy::order_live_ifaces_private_first; + use crate::stack::path_failover::{live_interface_names, remaining_live_ifaces}; + use crate::stack::types::interface_discovery_defaults; + + fn iface_row(name: &str, status: &str, host: Option<&str>) -> InterfaceRow { + let ( + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + ) = interface_discovery_defaults(); + InterfaceRow { + id: name.to_lowercase().replace(' ', "-"), + name: name.into(), + iface_type: "tcp".into(), + enabled: true, + status: status.into(), + host: host.map(str::to_string), + 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![], + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + network_name: None, + passphrase: None, + extra_config: std::collections::HashMap::default(), + } + } + + /// Mirrors LiveBridge::query_nomad_node prefer-list construction after a failed path. + #[test] + fn live_bridge_prefer_list_is_private_first_excluding_blocked() { + let interfaces = [ + iface_row("Ratspeak 2", "up", Some("2.ratspeak.org")), + iface_row("Local Transport Pi", "up", Some("192.168.1.111")), + iface_row("Auto", "up", None), + ]; + // LiveBridge uses iface_type detection via auto_path_policy for private; + // mark Auto as auto type so it is not a private network target. + let mut interfaces = interfaces.to_vec(); + interfaces[2].iface_type = "auto".into(); + + let live_ifaces = + order_live_ifaces_private_first(&live_interface_names(&interfaces), &interfaces); + assert_eq!( + live_ifaces.first().map(String::as_str), + Some("Local Transport Pi") + ); + let prefer = remaining_live_ifaces(&live_ifaces, &["Auto".into()]); + assert_eq!( + prefer, + vec!["Local Transport Pi".to_string(), "Ratspeak 2".to_string()] + ); + // Neither host private → preserve input order after filter. + let public_only = [ + iface_row("Ratspeak 2", "up", Some("2.ratspeak.org")), + iface_row("TTP", "up", Some("1.ratspeak.org")), + ]; + let live_public = + order_live_ifaces_private_first(&live_interface_names(&public_only), &public_only); + assert_eq!( + live_public, + vec!["Ratspeak 2".to_string(), "TTP".to_string()] + ); + } +} + #[cfg(test)] mod icon_appearance_tests { use super::*; diff --git a/reticulum-sidecar/src/stack/lxmf_delivery.rs b/reticulum-sidecar/src/stack/lxmf_delivery.rs index 82f440e1f..0b832b5c1 100644 --- a/reticulum-sidecar/src/stack/lxmf_delivery.rs +++ b/reticulum-sidecar/src/stack/lxmf_delivery.rs @@ -35,21 +35,50 @@ pub const PROPAGATION_SYNC_ANNOUNCE_SETTLE: Duration = Duration::from_secs(2); const UNPACK_WARN_INTERVAL: Duration = Duration::from_secs(5); static LAST_UNPACK_WARN_MS: AtomicU64 = AtomicU64::new(0); +static LAST_OPPORTUNISTIC_RECV_WARN_MS: AtomicU64 = AtomicU64::new(0); +static LAST_OPPORTUNISTIC_DECRYPT_WARN_MS: AtomicU64 = AtomicU64::new(0); -fn rate_limited_unpack_warn(via: &str, error: &str, len: usize) { - let now_ms = std::time::SystemTime::now() +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let prev = LAST_UNPACK_WARN_MS.load(Ordering::Relaxed); - if now_ms.saturating_sub(prev) < UNPACK_WARN_INTERVAL.as_millis() as u64 { + .unwrap_or(0) +} + +fn rate_limited_warn(last_ms: &AtomicU64, interval: Duration) -> bool { + let now_ms = now_unix_ms(); + let prev = last_ms.load(Ordering::Relaxed); + if now_ms.saturating_sub(prev) < interval.as_millis() as u64 { + return false; + } + last_ms.store(now_ms, Ordering::Relaxed); + true +} + +fn rate_limited_unpack_warn(via: &str, error: &str, len: usize) { + if !rate_limited_warn(&LAST_UNPACK_WARN_MS, UNPACK_WARN_INTERVAL) { tracing::debug!(via, error = %error, len, "inbound data not an LXMF message"); return; } - LAST_UNPACK_WARN_MS.store(now_ms, Ordering::Relaxed); tracing::warn!(via, error = %error, len, "inbound data not an LXMF message"); } +fn rate_limited_opportunistic_recv_warn(len: usize) { + if !rate_limited_warn(&LAST_OPPORTUNISTIC_RECV_WARN_MS, UNPACK_WARN_INTERVAL) { + tracing::debug!(len, "LXMF inbound opportunistic packet"); + return; + } + tracing::warn!(len, "LXMF inbound opportunistic packet"); +} + +fn rate_limited_opportunistic_decrypt_warn(len: usize, error: &str) { + if !rate_limited_warn(&LAST_OPPORTUNISTIC_DECRYPT_WARN_MS, UNPACK_WARN_INTERVAL) { + tracing::debug!(len, error = %error, "opportunistic LXMF decrypt failed"); + return; + } + tracing::warn!(len, error = %error, "opportunistic LXMF decrypt failed"); +} + fn mark_announce_sent(last_at: &Arc>>) { if let Ok(mut slot) = last_at.lock() { *slot = Some(Instant::now()); @@ -236,7 +265,7 @@ pub fn spawn_lxmf_inbound_receiver( handle_link_delivered_data(&router, lxmf_dest_hash, &data).await; } Some(raw) = inbound_raw_rx.recv() => { - tracing::debug!(len = raw.len(), "LXMF inbound opportunistic packet"); + rate_limited_opportunistic_recv_warn(raw.len()); handle_opportunistic_raw_packet(&router, &identity_for_raw, lxmf_dest_hash, &raw) .await; } @@ -296,16 +325,24 @@ pub(crate) fn try_enqueue_inbound_raw( /// /// LinkManager already decrypts once to emit an RNS proof; we decrypt again from the raw /// frame (same as lxmd `decrypt_inbound`) because the raw channel forwards ciphertext. -pub(crate) fn decrypt_opportunistic_payload(identity: &Identity, raw: &[u8]) -> Option> { - let (header, data_offset) = PacketHeader::unpack(raw).ok()?; +/// +/// Returns `Err` with a short reason when header/type/payload/decrypt fails so callers can +/// emit developer-bundle-visible warns (default `RUST_LOG=warn`). +pub(crate) fn decrypt_opportunistic_payload( + identity: &Identity, + raw: &[u8], +) -> Result, &'static str> { + let (header, data_offset) = PacketHeader::unpack(raw).map_err(|_| "bad_header")?; if header.flags.packet_type != PacketType::Data { - return None; + return Err("not_data"); } - let payload = raw.get(data_offset..)?; + let payload = raw.get(data_offset..).ok_or("truncated_payload")?; if payload.is_empty() { - return None; + return Err("empty_payload"); } - identity.decrypt(payload, None, false).ok() + identity + .decrypt(payload, None, false) + .map_err(|_| "decrypt") } async fn deliver_unpacked_lxmf(router: &Arc>, msg: &LxMessage, via: &str) { @@ -346,9 +383,12 @@ async fn handle_opportunistic_raw_packet( lxmf_dest_hash: [u8; 16], raw: &[u8], ) { - let Some(plaintext) = decrypt_opportunistic_payload(identity, raw) else { - tracing::debug!(len = raw.len(), "opportunistic LXMF decrypt failed"); - return; + let plaintext = match decrypt_opportunistic_payload(identity, raw) { + Ok(p) => p, + Err(error) => { + rate_limited_opportunistic_decrypt_warn(raw.len(), error); + return; + } }; let unpack_data = prepend_lxmf_dest_hash(lxmf_dest_hash, &plaintext); let msg = match LxMessage::unpack(&unpack_data) { @@ -489,6 +529,26 @@ mod tests { assert_eq!(recovered.destination_hash, lxmf_hash); } + #[test] + fn opportunistic_decrypt_rejects_non_data_and_empty_payload() { + let recipient = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let mut header = opportunistic_data_header(lxmf_hash); + header.flags.packet_type = PacketType::Announce; + let raw = header.pack(); + assert_eq!( + decrypt_opportunistic_payload(&recipient, &raw).unwrap_err(), + "not_data" + ); + + let data_header = opportunistic_data_header(lxmf_hash); + let empty_raw = data_header.pack(); + assert_eq!( + decrypt_opportunistic_payload(&recipient, &empty_raw).unwrap_err(), + "empty_payload" + ); + } + #[tokio::test] async fn opportunistic_handler_delivers_to_callback() { let recipient = Identity::new(); diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index d243ee7e2..5682649c6 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -19,9 +19,15 @@ use rns_transport::messages::{TransportMessage, TransportQuery}; use tokio::sync::broadcast; use tokio::sync::mpsc; +use super::super::auto_path_policy::{ + prefer_ifaces_for_failover, should_preempt_auto_for_private_direct, + should_prefer_private_after_auto_failure, +}; use super::super::path_failover::{ - IFACE_SUPPRESS_SECS, push_tried_iface, should_retry_direct_path_failover, + IFACE_SUPPRESS_SECS, build_path_failover_control_ops, push_tried_iface, + should_retry_direct_path_failover, }; +use super::super::types::InterfaceRow; use super::super::via::classify_interface; use super::{lxmf_payload_from_message, parse_hash16}; @@ -125,6 +131,11 @@ pub struct LxmfOutboundDriver { path_interfaces: HashMap<[u8; 16], String>, /// Last known next-hop via hash hex per destination. path_vias: HashMap<[u8; 16], String>, + /// Local interface rows (config + live status) for Auto / private LAN policy. + interfaces: Vec, + /// Until this unix time, treat Auto as delivery-degraded for private preempt + /// (Auto status may still report "up" after a Direct failure on Auto). + auto_delivery_degraded_until: f64, path_request_gate: PathRequestGate, /// Message hashes that already consumed the one-shot Direct→PN fallback. pn_fallback_attempted: HashSet<[u8; 32]>, @@ -157,6 +168,8 @@ impl LxmfOutboundDriver { path_table_hashes: HashSet::new(), path_interfaces: HashMap::new(), path_vias: HashMap::new(), + interfaces: Vec::new(), + auto_delivery_degraded_until: 0.0, path_request_gate: PathRequestGate::new(), pn_fallback_attempted: HashSet::new(), direct_path_failovers: HashMap::new(), @@ -253,6 +266,11 @@ impl LxmfOutboundDriver { } } + /// Refresh local interface rows used for Auto / private LAN Direct policy. + pub fn update_interfaces(&mut self, interfaces: Vec) { + self.interfaces = interfaces; + } + /// Remove a single destination from the local path cache (e.g. after transport DropPath). pub fn clear_path_to(&mut self, destination_hex: &str) { let key = destination_hex.to_lowercase(); @@ -527,6 +545,21 @@ impl LxmfOutboundDriver { self.fail_outbound_message(router, event_tx, message); } DirectDeliveryPlan::UseReusableLink | DirectDeliveryPlan::StartNewLink { .. } => { + // Unhealthy Auto + live private hub → suppress Auto before opening Direct. + if matches!(plan, DirectDeliveryPlan::StartNewLink { .. }) + && self.maybe_preempt_unhealthy_auto_path(dest_hash) + { + if !router_owned { + let now = now_f64(); + message.method = DeliveryMethod::Direct; + message.last_delivery_attempt = now; + message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); + router.send(message); + } + // router_owned: message remains in pending_outbound; cleared path + // forces RequestPath on the next tick after Auto suppress. + return; + } let hops = match plan { DirectDeliveryPlan::StartNewLink { hops } => hops, _ => route_hops_for(&self.route_hops, dest_hash), @@ -546,6 +579,39 @@ impl LxmfOutboundDriver { } } + /// When Auto is active but unhealthy and a private LAN hub is live, suppress + /// Auto and RequestPath so Direct can use the private path. + fn maybe_preempt_unhealthy_auto_path(&mut self, dest_hash: [u8; 16]) -> bool { + let active = self.path_interfaces.get(&dest_hash).cloned(); + let delivery_degraded = now_f64() < self.auto_delivery_degraded_until; + if !should_preempt_auto_for_private_direct( + active.as_deref(), + &[], + &self.interfaces, + delivery_degraded, + ) { + return false; + } + let blocked: Vec = active.iter().cloned().collect(); + let prefer = prefer_ifaces_for_failover(&self.interfaces, &blocked, true); + tracing::info!( + dest = %hex::encode(dest_hash), + active = ?active, + prefer = ?prefer, + delivery_degraded, + "AutoInterface unhealthy for delivery; suppressing Auto toward private LAN path" + ); + queue_path_failover_queries( + &self.transport_tx, + dest_hash, + &[], + &prefer, + "auto unhealthy private preempt", + ); + self.clear_path_to(&hex::encode(dest_hash)); + true + } + #[allow(clippy::too_many_arguments)] // path-gate + router ownership split is intentional fn request_path_gated( &mut self, @@ -870,7 +936,19 @@ impl LxmfOutboundDriver { return Err(Box::new(message)); } }; - queue_path_failover_queries(&self.transport_tx, dest_hash, &vias_to_drop, reason); + let prefer_private = + should_prefer_private_after_auto_failure(iface.as_deref(), &self.interfaces); + if prefer_private { + self.auto_delivery_degraded_until = now_f64() + IFACE_SUPPRESS_SECS; + } + let prefer = prefer_ifaces_for_failover(&self.interfaces, &tried, prefer_private); + queue_path_failover_queries( + &self.transport_tx, + dest_hash, + &vias_to_drop, + &prefer, + reason, + ); self.clear_path_to(&hex::encode(dest_hash)); let now = now_f64(); @@ -882,6 +960,8 @@ impl LxmfOutboundDriver { msg = %hex::encode(msg_hash), rounds, tried = ?tried, + prefer_private, + prefer = ?prefer, reason, "Direct path failover: suppress/drop via + RequestPath; re-queuing Direct" ); @@ -1200,17 +1280,23 @@ fn try_queue_path_request( } /// Fire-and-forget suppress + DropAllVia + DropPath + RequestPath for Direct failover. +/// +/// When `prefer_ifaces` is non-empty, issue a second RequestPath (Nomad parity) so +/// remaining live hubs — especially private LAN — get another rediscovery chance +/// after Auto was suppressed. fn queue_path_failover_queries( transport_tx: &mpsc::Sender, dest: [u8; 16], vias_to_drop: &[String], + prefer_ifaces: &[String], reason: &str, ) { + let ops = build_path_failover_control_ops(dest, vias_to_drop, None, prefer_ifaces); let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); if let Err(e) = transport_tx.try_send(TransportMessage::Rpc { query: TransportQuery::SuppressCurrentPathInterface { - dest, - duration: IFACE_SUPPRESS_SECS, + dest: ops.dest, + duration: ops.suppress_secs, }, response_tx, }) { @@ -1221,7 +1307,7 @@ fn queue_path_failover_queries( "path failover SuppressCurrentPathInterface try_send rejected" ); } - for via_hex in vias_to_drop { + for via_hex in &ops.vias_to_drop { if let Ok(next_hop) = parse_hash16(via_hex) { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); if let Err(e) = transport_tx.try_send(TransportMessage::Rpc { @@ -1239,6 +1325,17 @@ fn queue_path_failover_queries( } } let _ = try_queue_path_request(transport_tx, dest, true, reason); + if !ops.prefer_ifaces.is_empty() { + tracing::debug!( + dest = %hex::encode(dest), + prefer = ?ops.prefer_ifaces, + reason, + "path failover: extra RequestPath toward prefer-tier live interfaces" + ); + let _ = transport_tx.try_send(TransportMessage::RequestPath { + destination_hash: dest, + }); + } } pub fn parse_propagation_hash(hex_str: &str) -> Option<[u8; 16]> { @@ -1425,6 +1522,157 @@ mod tests { ); } + fn iface_row( + name: &str, + iface_type: &str, + enabled: bool, + status: &str, + host: Option<&str>, + ) -> InterfaceRow { + use crate::stack::types::interface_discovery_defaults; + let ( + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + ) = interface_discovery_defaults(); + InterfaceRow { + id: name.to_lowercase().replace(' ', "-"), + name: name.into(), + iface_type: iface_type.into(), + enabled, + status: status.into(), + host: host.map(str::to_string), + 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![], + discoverable, + latitude, + longitude, + height, + discovery_name, + announce_interval_min, + connectable, + reachable_on, + network_name: None, + passphrase: None, + extra_config: std::collections::HashMap::default(), + } + } + + #[test] + fn auto_failure_sets_degraded_and_queues_extra_request_path() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, mut rx) = mpsc::channel(64); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + driver.update_interfaces(vec![ + iface_row("Auto", "auto", true, "up", None), + iface_row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + iface_row("Ratspeak 2", "tcp", true, "up", Some("2.ratspeak.org")), + ]); + let dest_hash = dest(0xef); + let msg_hash = [0x43u8; 32]; + driver.update_path_table(&[PathTableRoute { + hash: dest_hash, + hops: 1, + hex_key: hex::encode(dest_hash), + interface: Some("Auto".into()), + via: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()), + }]); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(8); + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Direct); + msg.hash = Some(msg_hash); + + let result = driver.requeue_direct_after_path_failover( + &mut router, + &event_tx, + msg, + dest_hash, + "timed out waiting for link proof", + ); + assert!(result.is_ok()); + assert!( + driver.auto_delivery_degraded_until > now_f64(), + "Auto Direct failure must latch delivery-degraded window" + ); + + let mut request_path_count = 0usize; + while let Ok(msg) = rx.try_recv() { + if matches!(msg, TransportMessage::RequestPath { .. }) { + request_path_count += 1; + } + } + assert!( + request_path_count >= 2, + "prefer_private should queue DropPath RequestPath plus extra RequestPath, got {request_path_count}" + ); + + // Degraded + private live → preempt next Direct start. + driver.update_path_table(&[PathTableRoute { + hash: dest_hash, + hops: 1, + hex_key: hex::encode(dest_hash), + interface: Some("Auto".into()), + via: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()), + }]); + assert!(driver.maybe_preempt_unhealthy_auto_path(dest_hash)); + assert!(!driver.has_path_to(&hex::encode(dest_hash))); + } + + #[test] + fn healthy_auto_with_down_sibling_does_not_preempt() { + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(8); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + driver.update_interfaces(vec![ + iface_row("Auto", "auto", true, "up", None), + iface_row("Auto Backup", "auto", true, "down", None), + iface_row( + "Local Transport Pi", + "tcp", + true, + "up", + Some("192.168.1.111"), + ), + ]); + let dest_hash = dest(0xaa); + driver.update_path_table(&[PathTableRoute { + hash: dest_hash, + hops: 1, + hex_key: hex::encode(dest_hash), + interface: Some("Auto".into()), + via: None, + }]); + assert!(!driver.maybe_preempt_unhealthy_auto_path(dest_hash)); + assert!(driver.has_path_to(&hex::encode(dest_hash))); + } + #[test] fn path_gate_warn_is_rate_limited() { let mut gate = PathRequestGate::new(); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index e9907eb4a..baa577704 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1,6 +1,7 @@ //! Persistent stack state + optional live RNS/LXMF bridge. mod announce_ws_coalesce; +mod auto_path_policy; mod ble; pub mod config; pub mod config_audit; @@ -53,6 +54,8 @@ mod rnsh_session; mod rrc_link; #[cfg(feature = "rns-stack")] mod rrc_session; +#[cfg(feature = "rns-stack")] +mod voice_session; use std::fs; use std::path::PathBuf; @@ -305,6 +308,18 @@ impl StackHandle { self.event_tx.subscribe() } + /// High-rate `voice.audio` PCM frames (dedicated `/ws/voice` bus, not shared `/ws`). + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.subscribe_voice_audio(); + } + // No live stack: closed channel so `/ws/voice` clients exit cleanly. + let (tx, rx) = broadcast::channel(1); + drop(tx); + rx + } + pub fn list_packets(&self, limit: usize) -> Vec { self.packet_log.snapshot(limit) } @@ -2499,16 +2514,82 @@ impl StackHandle { config_audit::repair_config(&self.config_dir, &request) } - #[allow(clippy::unused_async)] // async matches StackHandle feature-status API awaited by HTTP handlers pub async fn voice_status(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_status().await; + } serde_json::json!({ "available": cfg!(feature = "rns-stack"), "enabled": false, + "running": false, + "microphone_muted": false, "codec": "opus", - "reason": "LXST voice pipeline pending rsLXST integration" + "reason": if cfg!(feature = "rns-stack") { + "stack not running" + } else { + "rns-stack feature required" + }, + "active_call": null, }) } + pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_call(identity_hash).await; + } + let _ = identity_hash; + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_answer(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_answer().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_reject(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_reject().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_hangup(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_hangup().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_mute(&self, muted: bool) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_mute(muted).await; + } + let _ = muted; + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_audio( + &self, + profile: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_audio(profile, channels, samples_b64).await; + } + let _ = (profile, channels, samples_b64); + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + #[allow(clippy::unused_async)] // async matches StackHandle feature-status API awaited by HTTP handlers pub async fn games_status(&self) -> serde_json::Value { serde_json::json!({ diff --git a/reticulum-sidecar/src/stack/path_failover.rs b/reticulum-sidecar/src/stack/path_failover.rs index 0320f4c9a..b4c1d4986 100644 --- a/reticulum-sidecar/src/stack/path_failover.rs +++ b/reticulum-sidecar/src/stack/path_failover.rs @@ -170,6 +170,37 @@ pub fn remaining_live_ifaces(live_ifaces: &[String], blocked_ifaces: &[String]) .collect() } +/// Control-plane ops for Suppress + DropAllVia + RequestPath failover (sync or async senders). +#[derive(Debug, Clone)] +pub struct PathFailoverControlOps { + pub dest: [u8; 16], + pub vias_to_drop: Vec, + pub suppress_secs: f64, + /// Prefer-tier iface names (private-first when requested); empty = no extra RequestPath hint. + pub prefer_ifaces: Vec, +} + +/// Build shared failover control ops from blocked vias + optional failed active via. +pub fn build_path_failover_control_ops( + dest: [u8; 16], + blocked_vias: &[String], + failed_via: Option<&str>, + prefer_ifaces: &[String], +) -> PathFailoverControlOps { + let mut vias_to_drop: Vec = blocked_vias.to_vec(); + if let Some(via) = failed_via.map(str::trim).filter(|s| !s.is_empty()) { + if !vias_to_drop.iter().any(|b| b.eq_ignore_ascii_case(via)) { + vias_to_drop.push(via.to_string()); + } + } + PathFailoverControlOps { + dest, + vias_to_drop, + suppress_secs: IFACE_SUPPRESS_SECS, + prefer_ifaces: prefer_ifaces.to_vec(), + } +} + /// True when Direct LXMF should attempt another path before preferred-PN fallback. pub fn should_retry_direct_path_failover(rounds_already: u8) -> bool { rounds_already < MAX_VIA_FAILOVERS @@ -383,6 +414,26 @@ mod tests { ); } + #[test] + fn build_path_failover_control_ops_merges_failed_via() { + let dest = [0x11u8; 16]; + let ops = build_path_failover_control_ops( + dest, + &["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()], + Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + &["Local Transport Pi".into()], + ); + assert_eq!(ops.dest, dest); + assert!((ops.suppress_secs - IFACE_SUPPRESS_SECS).abs() < f64::EPSILON); + assert_eq!(ops.prefer_ifaces, vec!["Local Transport Pi".to_string()]); + assert_eq!(ops.vias_to_drop.len(), 2); + assert!( + ops.vias_to_drop + .iter() + .any(|v| v == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + ); + } + #[test] fn remaining_live_ifaces_excludes_blocked() { let live = vec!["TTP_TCP".into(), "Local Transport Pi".into()]; diff --git a/reticulum-sidecar/src/stack/voice_session.rs b/reticulum-sidecar/src/stack/voice_session.rs new file mode 100644 index 000000000..e29cf8080 --- /dev/null +++ b/reticulum-sidecar/src/stack/voice_session.rs @@ -0,0 +1,1232 @@ +//! LXST voice telephony via rsLXST `TelephonyService`. +//! +//! Registers `lxst.telephony`, bridges control/events to HTTP + WS, and accepts +//! renderer-owned PCM frames (Opus encode/decode stays inside rsLXST). + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use lxst_core::{CallRole, Profile, RawAudioFrame, SignallingStatus}; +use lxst_telephony::{ + IdentityHash, TelephonyControl, TelephonyService, TelephonyServiceEvent, TelephonyServiceParts, +}; +use rns_identity::identity::Identity; +use rns_transport::messages::TransportMessage; +use serde_json::json; +use tokio::sync::{RwLock, broadcast, mpsc}; + +use super::live::parse_hash16; + +/// Ratspeak-compatible default profile for outbound calls. +const DEFAULT_CALL_PROFILE: Profile = Profile::QualityHigh; +const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15); + +/// QualityHigh frame duration in ms (2880 samples @ 48 kHz). +const QUALITY_HIGH_FRAME_MS: u64 = 60; + +#[derive(Debug, Clone, Copy, Default)] +pub struct VoiceMediaCounters { + pub tx_frames: u64, + pub tx_packets: u64, + pub rx_frames: u64, + pub local_tx_drops: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VoiceAuditLogPhase { + None, + StartLogged, + OutcomeLogged, + EndLogged, +} + +#[derive(Debug, Clone)] +pub struct VoiceAuditSession { + pub role: &'static str, + pub remote_hex: String, + pub link_id_hex: Option, + pub started_at: Instant, + pub established_at: Option, + pub ever_established: bool, + log_phase: VoiceAuditLogPhase, +} + +#[derive(Default)] +struct VoiceState { + running: bool, + microphone_muted: bool, + active_call: Option, + last_error: Option, + /// Maps LXMF / peer destination hashes → identity hashes (from announce cache). + dest_to_identity: HashMap, + media: VoiceMediaCounters, + audit: Option, +} + +/// Bound dest→identity cache independently of the live path table (same cap as display names). +const MAX_DEST_TO_IDENTITY_CACHE: usize = 100_000; + +/// High-rate PCM frames — keep off the shared `/ws` event bus (same intent as packet tap). +const VOICE_AUDIO_BROADCAST_CAP: usize = 256; + +/// Insert or refresh a dest→identity mapping; evict an arbitrary entry when full. +fn insert_dest_identity_bounded(cache: &mut HashMap, dest: String, id: String) { + if cache.len() >= MAX_DEST_TO_IDENTITY_CACHE && !cache.contains_key(&dest) { + if let Some(evict) = cache.keys().next().cloned() { + cache.remove(&evict); + } + } + cache.insert(dest, id); +} + +struct ManagerShared { + control_tx: Option>, + event_tx: broadcast::Sender, + /// Dedicated bus for `voice.audio` PCM frames (not shared `event_tx` / `/ws`). + voice_audio_tx: broadcast::Sender, + state: RwLock, + /// When true, PCM ingest is dropped (renderer mute). + muted: AtomicBool, + /// Set when `TelephonyService::registered` failed at stack start. + register_error: Option, +} + +pub struct VoiceSessionManager { + shared: Arc, +} + +impl VoiceSessionManager { + /// Register telephony on the live transport and spawn the service + event bridge. + /// On registration failure returns a disabled manager (stack stays up). + pub fn spawn( + transport_tx: mpsc::Sender, + identity: &Identity, + event_tx: broadcast::Sender, + ) -> Self { + match TelephonyService::registered(transport_tx, identity) { + Ok(TelephonyServiceParts { + service, + control_tx, + mut event_rx, + }) => { + let (voice_audio_tx, _) = broadcast::channel::(VOICE_AUDIO_BROADCAST_CAP); + let shared = Arc::new(ManagerShared { + control_tx: Some(control_tx), + event_tx: event_tx.clone(), + voice_audio_tx, + state: RwLock::new(VoiceState { + running: true, + ..VoiceState::default() + }), + muted: AtomicBool::new(false), + register_error: None, + }); + + tokio::spawn(service.run()); + + let bridge = Arc::clone(&shared); + tokio::spawn(async move { + while let Some(evt) = event_rx.recv().await { + bridge_service_event(&bridge, evt).await; + } + let mut st = bridge.state.write().await; + st.running = false; + st.active_call = None; + }); + + Self { shared } + } + Err(e) => { + let msg = format!("lxst telephony register: {e}"); + tracing::error!(target: "voice", "{msg}"); + let (voice_audio_tx, _) = broadcast::channel::(VOICE_AUDIO_BROADCAST_CAP); + Self { + shared: Arc::new(ManagerShared { + control_tx: None, + event_tx, + voice_audio_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: Some(msg), + }), + } + } + } + } + + /// Subscribe to high-rate `voice.audio` frames (dedicated bus, not shared `/ws`). + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + self.shared.voice_audio_tx.subscribe() + } + + pub async fn status(&self) -> serde_json::Value { + let st = self.shared.state.read().await; + if let Some(ref err) = self.shared.register_error { + return json!({ + "available": true, + "enabled": false, + "running": false, + "microphone_muted": false, + "codec": "opus", + "reason": err, + "active_call": null, + "last_error": err, + }); + } + json!({ + "available": true, + "enabled": true, + "running": st.running, + "microphone_muted": st.microphone_muted, + "codec": "opus", + "active_call": st.active_call, + "last_error": st.last_error, + }) + } + + pub async fn call(&self, identity_or_dest_hex: &str) -> serde_json::Value { + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + let remote = match self.resolve_identity_hash(identity_or_dest_hex).await { + Ok(h) => h, + Err(e) => return json!({ "ok": false, "error": e }), + }; + match control_tx + .send(TelephonyControl::Call { + remote_identity: remote, + profile: Some(DEFAULT_CALL_PROFILE), + discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT, + }) + .await + { + Ok(()) => json!({ "ok": true, "identity_hash": hex::encode(remote) }), + Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + } + } + + pub async fn answer(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Answer).await + } + + pub async fn reject(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Hangup { ring_timeout: true }) + .await + } + + pub async fn hangup(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Hangup { + ring_timeout: false, + }) + .await + } + + pub async fn set_mute(&self, muted: bool) -> serde_json::Value { + self.shared.muted.store(muted, Ordering::Relaxed); + let mut st = self.shared.state.write().await; + st.microphone_muted = muted; + json!({ "ok": true, "microphone_muted": muted }) + } + + /// Ingest one PCM frame (base64 little-endian f32 interleaved samples). + pub async fn send_audio( + &self, + profile_wire: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + if self.shared.muted.load(Ordering::Relaxed) { + return json!({ "ok": true, "dropped": "muted" }); + } + let samples = match decode_samples_b64(samples_b64) { + Ok(s) => s, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let frame = match RawAudioFrame::new(channels, samples) { + Ok(f) => f, + Err(e) => return json!({ "ok": false, "error": format!("invalid pcm frame: {e}") }), + }; + // Soft-drop pre-establish frames — SendOpusFrames before Established emits fatal + // lxst Error ("active call is not established") and sticks the line busy. + { + let st = self.shared.state.read().await; + let status = st + .active_call + .as_ref() + .and_then(|c| c.get("status")) + .and_then(|s| s.as_str()); + if status != Some("established") { + return json!({ "ok": true, "dropped": "not_established" }); + } + } + let profile = match profile_wire.and_then(Profile::from_wire) { + Some(p) => p, + None => DEFAULT_CALL_PROFILE, + }; + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + match control_tx + .send(TelephonyControl::SendOpusFrames { + profile, + frames: vec![frame], + }) + .await + { + Ok(()) => json!({ "ok": true }), + Err(e) => { + { + let mut st = self.shared.state.write().await; + if st.active_call.is_some() { + st.media.local_tx_drops = st.media.local_tx_drops.saturating_add(1); + } + } + json!({ "ok": false, "error": format!("voice control closed: {e}") }) + } + } + } + + /// Cache dest → identity from announce/peer refresh (renderer/sidecar). + pub async fn remember_identity_for_dest(&self, destination_hash: &str, identity_hash: &str) { + let dest = destination_hash.trim().to_lowercase(); + let id = identity_hash.trim().to_lowercase(); + if dest.len() == 32 && id.len() == 32 { + let mut st = self.shared.state.write().await; + insert_dest_identity_bounded(&mut st.dest_to_identity, dest, id); + } + } + + async fn resolve_identity_hash(&self, input: &str) -> Result { + let st = self.shared.state.read().await; + resolve_identity_hash_with_cache(input, &st.dest_to_identity) + } + + async fn send_control(&self, control: TelephonyControl) -> serde_json::Value { + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + match control_tx.send(control).await { + Ok(()) => json!({ "ok": true }), + Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + } + } +} + +async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent) { + match evt { + TelephonyServiceEvent::OutgoingCallPending { remote_identity } => { + let remote_hex = hex::encode(remote_identity); + let call = json!({ + "link_id": "", + "remote_identity": remote_hex, + "role": "outgoing", + "status": "calling", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + st.last_error = None; + st.media = VoiceMediaCounters::default(); + begin_audit_session(&mut st, "outgoing", &remote_hex, None); + } + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "outgoing_pending", + "remote_identity": remote_hex, + }), + ); + } + TelephonyServiceEvent::OutgoingCallStarted { + link_id, + remote_identity, + } => { + let link_hex = hex::encode(link_id); + let remote_hex = hex::encode(remote_identity); + let call = json!({ + "link_id": link_hex, + "remote_identity": remote_hex, + "role": "outgoing", + "status": "connecting", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + if let Some(audit) = st.audit.as_mut() { + audit.link_id_hex = Some(link_hex.clone()); + } else { + begin_audit_session(&mut st, "outgoing", &remote_hex, Some(link_hex.clone())); + } + } + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "outgoing", + "link_id": link_hex, + "remote_identity": remote_hex, + }), + ); + } + TelephonyServiceEvent::OutgoingCallFailed { + remote_identity, + message, + } => { + let remote_hex = hex::encode(remote_identity); + { + let mut st = shared.state.write().await; + st.active_call = None; + st.last_error = Some(message.clone()); + log_audit_outcome_and_end(&mut st, "failed", Some(message.as_str())); + } + emit( + &shared.event_tx, + "voice.error", + &json!({ + "type": "outgoing_failed", + "remote_identity": remote_hex, + "message": message, + }), + ); + } + TelephonyServiceEvent::IncomingCall { + link_id, + remote_identity, + } => { + let link_hex = hex::encode(link_id); + let remote_hex = hex::encode(remote_identity); + let call = json!({ + "link_id": link_hex, + "remote_identity": remote_hex, + "role": "incoming", + "status": "ringing", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + st.last_error = None; + st.media = VoiceMediaCounters::default(); + begin_audit_session(&mut st, "incoming", &remote_hex, Some(link_hex)); + } + emit(&shared.event_tx, "voice.incoming", &call); + } + TelephonyServiceEvent::CallTerminated { link_id, reason } => { + let reason_str = reason.map(signalling_status_str); + let outcome = match reason_str { + Some("busy") => "busy", + Some("rejected") => "rejected", + Some("established") => "completed", + Some(_) | None => "terminated", + }; + { + let mut st = shared.state.write().await; + st.active_call = None; + log_audit_outcome_and_end(&mut st, outcome, reason_str); + } + emit( + &shared.event_tx, + "voice.terminated", + &json!({ + "link_id": hex::encode(link_id), + "reason": reason_str, + }), + ); + } + TelephonyServiceEvent::Snapshot(snap) => { + let active = snap.active_call.as_ref().map(|c| { + json!({ + "link_id": hex::encode(c.link_id), + "remote_identity": hex::encode(c.remote_identity), + "role": call_role_str(c.role), + "status": signalling_status_str(c.status), + "profile": c.profile.map(Profile::wire_value), + "answered": c.answered, + }) + }); + { + let mut st = shared.state.write().await; + st.active_call = active.clone(); + if let Some(c) = snap.active_call.as_ref() { + if c.status == SignallingStatus::Established { + mark_audit_established(&mut st); + } + } + } + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "snapshot", + "external_busy": snap.external_busy, + "pending_link_count": snap.pending_link_count, + "active_call": active, + }), + ); + } + TelephonyServiceEvent::MediaSent { + link_id, + frames, + packets, + } => { + let link_hex = hex::encode(link_id); + let payload = { + let mut st = shared.state.write().await; + st.media.tx_frames = st.media.tx_frames.saturating_add(frames as u64); + st.media.tx_packets = st.media.tx_packets.saturating_add(packets as u64); + json!({ + "link_id": link_hex, + "tx_frames": st.media.tx_frames, + "tx_packets": st.media.tx_packets, + "rx_frames": st.media.rx_frames, + }) + }; + emit(&shared.event_tx, "voice.stats", &payload); + } + TelephonyServiceEvent::MediaReceived { link_id, frames } => { + let link_hex = hex::encode(link_id); + let payload = { + let mut st = shared.state.write().await; + st.media.rx_frames = st.media.rx_frames.saturating_add(frames as u64); + json!({ + "link_id": link_hex, + "tx_frames": st.media.tx_frames, + "tx_packets": st.media.tx_packets, + "rx_frames": st.media.rx_frames, + }) + }; + emit(&shared.event_tx, "voice.stats", &payload); + } + TelephonyServiceEvent::OpusFramesReceived { + link_id, + profile, + frames, + } => { + for frame in frames { + let samples_b64 = encode_f32_le_b64(&frame.samples); + // Dedicated high-rate bus — do not use shared event_tx / `/ws`. + emit( + &shared.voice_audio_tx, + "voice.audio", + &json!({ + "link_id": hex::encode(link_id), + "profile": profile.wire_value(), + "channels": frame.channels, + "samples_b64": samples_b64, + }), + ); + } + } + TelephonyServiceEvent::Error { message } => { + let link_hex = { + let mut st = shared.state.write().await; + st.last_error = Some(message.clone()); + let link = st + .active_call + .as_ref() + .and_then(|c| c.get("link_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| { + st.audit + .as_ref() + .and_then(|a| a.link_id_hex.clone()) + .filter(|s| !s.is_empty()) + }); + if st.active_call.is_some() { + st.active_call = None; + log_audit_outcome_and_end(&mut st, "error", Some(message.as_str())); + } + link + }; + let mut payload = json!({ "type": "error", "message": message }); + if let Some(link_id) = link_hex { + payload["link_id"] = json!(link_id); + } + emit(&shared.event_tx, "voice.error", &payload); + } + TelephonyServiceEvent::Stopped => { + let mut st = shared.state.write().await; + st.running = false; + st.active_call = None; + if st.audit.is_some() { + log_audit_outcome_and_end(&mut st, "stopped", None); + } + } + TelephonyServiceEvent::OpusTransmitStreamStarted { .. } + | TelephonyServiceEvent::OpusTransmitStreamStopped { .. } + | TelephonyServiceEvent::OpusReceiveStreamStarted { .. } + | TelephonyServiceEvent::OpusReceiveStreamStopped { .. } + | TelephonyServiceEvent::OpusReceiveStreamFrames { .. } + | TelephonyServiceEvent::Drive(_) => {} + } +} + +fn begin_audit_session( + st: &mut VoiceState, + role: &'static str, + remote_hex: &str, + link_id_hex: Option, +) { + st.audit = Some(VoiceAuditSession { + role, + remote_hex: remote_hex.to_string(), + link_id_hex, + started_at: Instant::now(), + established_at: None, + ever_established: false, + log_phase: VoiceAuditLogPhase::None, + }); + log_audit_start(st); +} + +fn log_audit_start(st: &mut VoiceState) { + let Some(audit) = st.audit.as_mut() else { + return; + }; + if audit.log_phase != VoiceAuditLogPhase::None { + return; + } + audit.log_phase = VoiceAuditLogPhase::StartLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + let link = audit.link_id_hex.as_deref().unwrap_or("-"); + // warn: default sidecar RUST_LOG=warn so developer bundles capture call lifecycle + tracing::warn!( + target: "voice", + "call start role={} remote={} link_id={}", + audit.role, + remote_prefix, + link + ); +} + +fn mark_audit_established(st: &mut VoiceState) { + let Some(audit) = st.audit.as_mut() else { + return; + }; + if audit.ever_established { + return; + } + audit.ever_established = true; + audit.established_at = Some(Instant::now()); + if matches!( + audit.log_phase, + VoiceAuditLogPhase::None | VoiceAuditLogPhase::StartLogged + ) { + audit.log_phase = VoiceAuditLogPhase::OutcomeLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + tracing::warn!( + target: "voice", + "call connected role={} remote={}", + audit.role, + remote_prefix + ); + } +} + +fn log_audit_outcome_and_end(st: &mut VoiceState, outcome: &str, reason: Option<&str>) { + let Some(mut audit) = st.audit.take() else { + return; + }; + if matches!( + audit.log_phase, + VoiceAuditLogPhase::None | VoiceAuditLogPhase::StartLogged + ) { + audit.log_phase = VoiceAuditLogPhase::OutcomeLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + let reason_s = reason.unwrap_or(outcome); + let successful = + outcome == "completed" || (audit.ever_established && outcome == "terminated"); + if successful && audit.ever_established && outcome == "terminated" { + tracing::warn!( + target: "voice", + "call ended role={} remote={} reason={}", + audit.role, + remote_prefix, + reason_s + ); + } else if outcome == "completed" { + tracing::warn!( + target: "voice", + "call connected role={} remote={}", + audit.role, + remote_prefix + ); + } else { + tracing::warn!( + target: "voice", + "call {} role={} remote={} reason={}", + outcome, + audit.role, + remote_prefix, + reason_s + ); + } + } + if audit.log_phase != VoiceAuditLogPhase::EndLogged { + audit.log_phase = VoiceAuditLogPhase::EndLogged; + let summary = format_voice_audit_end_summary(&audit, outcome, &st.media, Instant::now()); + // warn: default RUST_LOG=warn so end summaries appear in developer bundles + tracing::warn!(target: "voice", "{summary}"); + } + st.media = VoiceMediaCounters::default(); +} + +fn remote_prefix(remote_hex: &str) -> &str { + let len = remote_hex.len().min(16); + &remote_hex[..len] +} + +/// Estimated RX gap percent from established duration vs received frames (QualityHigh). +pub fn estimate_rx_gap_pct(established_ms: u64, rx_frames: u64) -> u32 { + if established_ms == 0 { + return 0; + } + let expected = established_ms.saturating_add(QUALITY_HIGH_FRAME_MS.saturating_sub(1)) + / QUALITY_HIGH_FRAME_MS; + if expected == 0 { + return 0; + } + if rx_frames >= expected { + return 0; + } + let missing = expected - rx_frames; + ((missing.saturating_mul(100)) / expected) as u32 +} + +/// Lifecycle end summary line (no per-packet logs). Pure for unit tests. +pub fn format_voice_audit_end_summary( + audit: &VoiceAuditSession, + outcome: &str, + media: &VoiceMediaCounters, + now: Instant, +) -> String { + let duration_ms = now.duration_since(audit.started_at).as_millis(); + let loss_field = if audit.ever_established { + let established_ms = audit + .established_at + .map(|t| now.duration_since(t).as_millis() as u64) + .unwrap_or(0); + format!( + "est_rx_gap_pct={}", + estimate_rx_gap_pct(established_ms, media.rx_frames) + ) + } else { + "packet_loss=n/a".to_string() + }; + format!( + "call end outcome={} role={} remote={} duration_ms={} tx_frames={} tx_packets={} rx_frames={} local_tx_drops={} {}", + outcome, + audit.role, + remote_prefix(&audit.remote_hex), + duration_ms, + media.tx_frames, + media.tx_packets, + media.rx_frames, + media.local_tx_drops, + loss_field + ) +} + +fn emit(event_tx: &broadcast::Sender, event_type: &str, payload: &serde_json::Value) { + let frame = json!({ "type": event_type, "payload": payload }); + let _ = event_tx.send(frame.to_string()); +} + +fn encode_f32_le_b64(samples: &[f32]) -> String { + let mut bytes = Vec::with_capacity(samples.len() * 4); + for s in samples { + bytes.extend_from_slice(&s.to_le_bytes()); + } + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn signalling_status_str(status: SignallingStatus) -> &'static str { + match status { + SignallingStatus::Busy => "busy", + SignallingStatus::Rejected => "rejected", + SignallingStatus::Calling => "calling", + SignallingStatus::Available => "available", + SignallingStatus::Ringing => "ringing", + SignallingStatus::Connecting => "connecting", + SignallingStatus::Established => "established", + } +} + +fn call_role_str(role: CallRole) -> &'static str { + match role { + CallRole::Incoming => "incoming", + CallRole::Outgoing => "outgoing", + } +} + +/// Pure helper for tests / dial path: prefer cached dest→identity, else parse as identity. +pub fn resolve_identity_hash_with_cache( + input: &str, + dest_to_identity: &HashMap, +) -> Result<[u8; 16], String> { + let trimmed = input.trim().to_lowercase(); + if let Some(id_hex) = dest_to_identity.get(&trimmed) { + return parse_hash16(id_hex); + } + parse_hash16(&trimmed) +} + +/// Decode base64 LE f32 PCM; used by audio ingest validation tests. +pub fn decode_samples_b64(samples_b64: &str) -> Result, String> { + if samples_b64.is_empty() { + return Err("empty samples_b64".into()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(samples_b64.as_bytes()) + .map_err(|e| format!("invalid base64 samples: {e}"))?; + if bytes.len() % 4 != 0 { + return Err("samples_b64 length must be multiple of 4".into()); + } + let mut samples = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + samples.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + Ok(samples) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_identity_passthrough() { + let hex = "aabbccddeeff00112233445566778899"; + let h = resolve_identity_hash_with_cache(hex, &HashMap::new()).unwrap(); + assert_eq!(hex::encode(h), hex); + } + + #[test] + fn resolve_identity_via_dest_cache() { + let dest = "11112222333344445555666677778888".to_string(); + let id = "aabbccddeeff00112233445566778899".to_string(); + let mut map = HashMap::new(); + map.insert(dest.clone(), id.clone()); + let h = resolve_identity_hash_with_cache(&dest, &map).unwrap(); + assert_eq!(hex::encode(h), id); + } + + #[test] + fn resolve_identity_missing_rejects_short() { + assert!(resolve_identity_hash_with_cache("aabb", &HashMap::new()).is_err()); + } + + #[test] + fn decode_samples_rejects_empty() { + assert!(decode_samples_b64("").is_err()); + } + + #[test] + fn decode_samples_roundtrip_one_sample() { + let b64 = base64::engine::general_purpose::STANDARD.encode(0.5f32.to_le_bytes()); + let samples = decode_samples_b64(&b64).unwrap(); + assert_eq!(samples.len(), 1); + assert!((samples[0] - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn signalling_status_strings_cover_wire_set() { + assert_eq!( + signalling_status_str(SignallingStatus::Established), + "established" + ); + assert_eq!(signalling_status_str(SignallingStatus::Ringing), "ringing"); + assert_eq!(call_role_str(CallRole::Incoming), "incoming"); + } + + #[test] + fn quality_high_frame_size_contract() { + // Keep renderer/sidecar PCM packing aligned with lxst-core Profile::QualityHigh. + assert_eq!(Profile::QualityHigh.channels(), 1); + assert_eq!(Profile::QualityHigh.sample_rate_hz(), 48_000); + assert_eq!(Profile::QualityHigh.sample_frames_per_packet(), 2_880); + } + + #[test] + fn estimate_rx_gap_pct_zero_when_full() { + // 600 ms ≈ 10 QualityHigh frames. + assert_eq!(estimate_rx_gap_pct(600, 10), 0); + assert_eq!(estimate_rx_gap_pct(600, 5), 50); + assert_eq!(estimate_rx_gap_pct(600, 0), 100); + } + + #[test] + fn audit_end_summary_marks_packet_loss_na_when_never_established() { + let started = Instant::now(); + let audit = VoiceAuditSession { + role: "outgoing", + remote_hex: "aabbccddeeff00112233445566778899".into(), + link_id_hex: None, + started_at: started, + established_at: None, + ever_established: false, + log_phase: VoiceAuditLogPhase::OutcomeLogged, + }; + let summary = format_voice_audit_end_summary( + &audit, + "failed", + &VoiceMediaCounters::default(), + started + Duration::from_secs(2), + ); + assert!(summary.contains("outcome=failed")); + assert!(summary.contains("packet_loss=n/a")); + assert!(summary.contains("tx_frames=0")); + assert!(summary.contains("tx_packets=0")); + assert!(summary.contains("rx_frames=0")); + } + + #[test] + fn audit_end_summary_includes_est_rx_gap_after_established() { + let started = Instant::now(); + let established = started + Duration::from_secs(1); + let audit = VoiceAuditSession { + role: "outgoing", + remote_hex: "aabbccddeeff00112233445566778899".into(), + link_id_hex: Some("11".repeat(16)), + started_at: started, + established_at: Some(established), + ever_established: true, + log_phase: VoiceAuditLogPhase::OutcomeLogged, + }; + let media = VoiceMediaCounters { + tx_frames: 20, + tx_packets: 20, + rx_frames: 5, + local_tx_drops: 1, + }; + let summary = format_voice_audit_end_summary( + &audit, + "completed", + &media, + established + Duration::from_millis(600), + ); + assert!(summary.contains("tx_packets=20")); + assert!(summary.contains("local_tx_drops=1")); + assert!(summary.contains("est_rx_gap_pct=")); + assert!(!summary.contains("packet_loss=n/a")); + } + + #[tokio::test] + async fn status_shape_when_manager_spawns() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let status = mgr.status().await; + assert_eq!(status["available"], true); + assert_eq!(status["codec"], "opus"); + assert!(status["enabled"].is_boolean()); + let _ = mgr.hangup().await; + } + + #[tokio::test] + async fn call_rejects_invalid_hex() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let resp = mgr.call("not-a-hash").await; + assert_eq!(resp["ok"], false); + let err = resp["error"].as_str().unwrap_or(""); + assert!( + err.contains("32 hex") || err.contains("not available"), + "{err}" + ); + } + + #[tokio::test] + async fn send_audio_rejects_empty_and_respects_mute() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let empty = mgr.send_audio(None, 1, "").await; + assert_eq!(empty["ok"], false); + let _ = mgr.set_mute(true).await; + let muted = mgr.send_audio(None, 1, "AAAA").await; + // Muted path only when control channel is live; otherwise "not available". + if muted["dropped"] == "muted" { + assert_eq!(muted["ok"], true); + } else { + assert_eq!(muted["ok"], false); + } + } + + fn disabled_manager(event_tx: broadcast::Sender) -> VoiceSessionManager { + let (voice_audio_tx, _) = broadcast::channel::(4); + VoiceSessionManager { + shared: Arc::new(ManagerShared { + control_tx: None, + event_tx, + voice_audio_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: Some("voice disabled for test".into()), + }), + } + } + + #[tokio::test] + async fn status_disabled_when_register_failed() { + let (event_tx, _) = broadcast::channel::(4); + let mgr = disabled_manager(event_tx); + let status = mgr.status().await; + assert_eq!(status["available"], true); + assert_eq!(status["enabled"], false); + assert_eq!(status["running"], false); + assert!(status["reason"].as_str().unwrap_or("").contains("disabled")); + } + + #[tokio::test] + async fn answer_reject_hangup_stable_when_unavailable() { + let (event_tx, _) = broadcast::channel::(4); + let mgr = disabled_manager(event_tx); + for resp in [mgr.answer().await, mgr.reject().await, mgr.hangup().await] { + assert_eq!(resp["ok"], false); + assert_eq!(resp["error"], "voice not available"); + } + } + + #[tokio::test] + async fn bridge_emits_incoming_update_terminated_and_error() { + let (event_tx, mut event_rx) = broadcast::channel::(16); + let (voice_audio_tx, mut voice_audio_rx) = broadcast::channel::(16); + let shared = Arc::new(ManagerShared { + control_tx: None, + event_tx, + voice_audio_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: None, + }); + let link = [0x11u8; 16]; + let remote = [0x22u8; 16]; + + bridge_service_event( + &shared, + TelephonyServiceEvent::IncomingCall { + link_id: link, + remote_identity: remote, + }, + ) + .await; + let incoming = event_rx.try_recv().expect("voice.incoming"); + assert!(incoming.contains("\"type\":\"voice.incoming\"")); + assert!(shared.state.read().await.active_call.is_some()); + + bridge_service_event( + &shared, + TelephonyServiceEvent::OutgoingCallPending { + remote_identity: remote, + }, + ) + .await; + let update = event_rx.try_recv().expect("voice.update"); + assert!(update.contains("\"type\":\"voice.update\"")); + assert!( + shared.state.read().await.active_call.is_some(), + "outgoing pending must set active_call" + ); + + bridge_service_event( + &shared, + TelephonyServiceEvent::MediaSent { + link_id: link, + frames: 2, + packets: 2, + }, + ) + .await; + let stats = event_rx.try_recv().expect("voice.stats"); + assert!(stats.contains("\"type\":\"voice.stats\"")); + assert!(stats.contains("\"tx_frames\":2")); + + let pcm = RawAudioFrame::new(1, vec![0.0f32; 48]).expect("pcm"); + bridge_service_event( + &shared, + TelephonyServiceEvent::OpusFramesReceived { + link_id: link, + profile: Profile::QualityHigh, + frames: vec![pcm], + }, + ) + .await; + assert!( + event_rx.try_recv().is_err(), + "voice.audio must not use shared event bus" + ); + let audio = voice_audio_rx + .try_recv() + .expect("voice.audio on dedicated bus"); + assert!(audio.contains("\"type\":\"voice.audio\"")); + assert!(audio.contains("samples_b64")); + + bridge_service_event( + &shared, + TelephonyServiceEvent::CallTerminated { + link_id: link, + reason: Some(SignallingStatus::Rejected), + }, + ) + .await; + let terminated = event_rx.try_recv().expect("voice.terminated"); + assert!(terminated.contains("\"type\":\"voice.terminated\"")); + assert!(shared.state.read().await.active_call.is_none()); + assert!(shared.state.read().await.audit.is_none()); + + // Re-install an active call so Error can stamp link_id before clear. + { + let mut st = shared.state.write().await; + st.active_call = Some(json!({ + "link_id": hex::encode(link), + "remote_identity": "22".repeat(16), + "role": "outgoing", + "status": "established", + })); + st.audit = Some(VoiceAuditSession { + role: "outgoing", + remote_hex: "22".repeat(16), + link_id_hex: Some(hex::encode(link)), + started_at: Instant::now(), + established_at: None, + ever_established: true, + log_phase: VoiceAuditLogPhase::StartLogged, + }); + } + bridge_service_event( + &shared, + TelephonyServiceEvent::Error { + message: "boom".into(), + }, + ) + .await; + let err = event_rx.try_recv().expect("voice.error"); + assert!(err.contains("\"type\":\"voice.error\"")); + assert!( + err.contains(&hex::encode(link)), + "voice.error must include active link_id: {err}" + ); + assert_eq!( + shared.state.read().await.last_error.as_deref(), + Some("boom") + ); + } + + #[test] + fn dest_identity_cache_evicts_when_full() { + let mut cache = HashMap::new(); + for i in 0..MAX_DEST_TO_IDENTITY_CACHE { + let dest = format!("{i:032x}"); + let id = format!("{:032x}", i + 1); + insert_dest_identity_bounded(&mut cache, dest, id); + } + assert_eq!(cache.len(), MAX_DEST_TO_IDENTITY_CACHE); + let overflow_dest = "f".repeat(32); + let overflow_id = "e".repeat(32); + insert_dest_identity_bounded(&mut cache, overflow_dest.clone(), overflow_id.clone()); + assert_eq!(cache.len(), MAX_DEST_TO_IDENTITY_CACHE); + assert_eq!( + cache.get(&overflow_dest).map(String::as_str), + Some(overflow_id.as_str()) + ); + } + + #[tokio::test] + async fn send_audio_soft_drops_when_not_established() { + let (control_tx, mut control_rx) = mpsc::channel::(4); + let (event_tx, _) = broadcast::channel::(4); + let (voice_audio_tx, _) = broadcast::channel::(4); + let mgr = VoiceSessionManager { + shared: Arc::new(ManagerShared { + control_tx: Some(control_tx), + event_tx, + voice_audio_tx, + state: RwLock::new(VoiceState { + running: true, + active_call: Some(json!({ + "link_id": "aa", + "remote_identity": "bb", + "role": "outgoing", + "status": "connecting", + "answered": false, + })), + ..VoiceState::default() + }), + muted: AtomicBool::new(false), + register_error: None, + }), + }; + let n = Profile::QualityHigh.sample_frames_per_packet() + * usize::from(Profile::QualityHigh.channels()); + let mut bytes = Vec::with_capacity(n * 4); + for _ in 0..n { + bytes.extend_from_slice(&0f32.to_le_bytes()); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let resp = mgr.send_audio(None, 1, &b64).await; + assert_eq!(resp["ok"], true); + assert_eq!(resp["dropped"], "not_established"); + assert!( + control_rx.try_recv().is_err(), + "must not enqueue SendOpusFrames" + ); + } + + #[tokio::test] + async fn send_audio_accepts_quality_high_frame_size() { + let (control_tx, mut control_rx) = mpsc::channel::(4); + let (event_tx, _) = broadcast::channel::(4); + let (voice_audio_tx, _) = broadcast::channel::(4); + let mgr = VoiceSessionManager { + shared: Arc::new(ManagerShared { + control_tx: Some(control_tx), + event_tx, + voice_audio_tx, + state: RwLock::new(VoiceState { + running: true, + active_call: Some(json!({ + "link_id": "aa", + "remote_identity": "bb", + "role": "outgoing", + "status": "established", + "answered": true, + })), + ..VoiceState::default() + }), + muted: AtomicBool::new(false), + register_error: None, + }), + }; + let n = Profile::QualityHigh.sample_frames_per_packet() + * usize::from(Profile::QualityHigh.channels()); + let mut bytes = Vec::with_capacity(n * 4); + for _ in 0..n { + bytes.extend_from_slice(&0f32.to_le_bytes()); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let resp = mgr.send_audio(None, 1, &b64).await; + assert_eq!(resp["ok"], true, "{resp}"); + match control_rx.recv().await { + Some(TelephonyControl::SendOpusFrames { frames, .. }) => { + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].samples.len(), n); + } + other => panic!("expected SendOpusFrames, got {other:?}"), + } + } +} diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index cc83250b4..f11000cad 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -1196,6 +1196,37 @@ function checkReticulumConnectionPanelIssues(ctx) { } } + // LXST voice Answer must be a phone-UI verb, not the noun "answer/response". + if (flatKey === 'reticulumVoice.answer' && enVal === 'Answer' && locale !== 'en') { + if (/^(Antwort|Antwoord)$/i.test(val)) { + issues.push( + 'reticulumVoice.answer must be a phone accept verb (e.g. Annehmen/Beantwoorden), not noun Antwort/Antwoord', + ); + } + } + if (flatKey === 'reticulumVoice.capabilityUnknownShort' && enVal === '?' && locale !== 'en') { + if (val.trim().length > 2 || /\s/.test(val) || /^\.\?/.test(val)) { + issues.push( + 'reticulumVoice.capabilityUnknownShort must stay a short "?" badge, not a sentence', + ); + } + } + if (flatKey === 'reticulumVoice.errors.noIdentity' && locale !== 'en') { + if (/\bKollegen\b/i.test(val) || /\bcollega\b/i.test(val)) { + issues.push( + 'reticulumVoice.errors.noIdentity must use peer wording, not office-colleague false friends', + ); + } + } + if (flatKey.startsWith('reticulumVoice.') && locale !== 'en') { + for (const token of ['LXST', 'Sideband', 'rsLXST', 'mesh-client', 'Ratspeak']) { + if (enVal.includes(token) && !val.includes(token)) { + issues.push(`reticulumVoice key must preserve wire/product token "${token}"`); + break; + } + } + } + if (leafKey === 'reticulumStopStack' && locale !== 'en') { for (const { re, hint } of RETICULUM_STOP_STACK_FALSE_FRIENDS[locale] ?? []) { if (re.test(val)) { diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index a171b1584..d344150bb 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Clone rsReticulum + rsLXMF + rsNomad (float origin/main by default), +# Clone rsReticulum + rsLXMF + rsNomad + rsLXST (float origin/main by default), # then apply mesh-client overlays for rns-stack sidecar builds. set -euo pipefail @@ -12,6 +12,7 @@ source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" RNS_DIR="${WORKSPACE_ROOT}/rsReticulum" LXMF_DIR="${WORKSPACE_ROOT}/rsLXMF" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" +LXST_DIR="${WORKSPACE_ROOT}/rsLXST" # So apply-*.sh targets the same siblings as this script (WORKSPACE_ROOT may differ from ..). export RS_RETICULUM_DIR="${RNS_DIR}" @@ -21,11 +22,27 @@ export RS_LXMF_DIR="${LXMF_DIR}" RS_RETICULUM_REF="${RS_RETICULUM_REF:-}" RS_LXMF_REF="${RS_LXMF_REF:-}" RS_NOMAD_REF="${RS_NOMAD_REF:-}" +RS_LXST_REF="${RS_LXST_REF:-}" + +# Last selected ref from ensure_repo (origin/main, origin/master, or pin). +ENSURE_REPO_SELECTED_REF='' + +# Format status mode from the ref ensure_repo actually selected. +format_repo_mode() { + local selected_ref="$1" pin_ref="$2" + if [[ -n "${pin_ref}" ]]; then + echo "pinned ${pin_ref:0:12}" + else + echo "floated ${selected_ref}" + fi +} # Ensure an existing checkout has the correct origin remote and is on the # requested ref (or floated origin/main). If the directory does not exist, clone it first. +# Sets ENSURE_REPO_SELECTED_REF to the ref that was (or would be) checked out. ensure_repo() { local dir="$1" expected_origin="$2" ref_or_empty="$3" label="$4" + ENSURE_REPO_SELECTED_REF='' if [[ ! -d "${dir}/.git" ]]; then git clone "${expected_origin}" "${dir}" fi @@ -71,6 +88,8 @@ ensure_repo() { target_sha="$(git -C "${dir}" rev-parse --verify "${target_ref}^{commit}")" fi + ENSURE_REPO_SELECTED_REF="${target_ref}" + if [[ -n "$(git -C "${dir}" status --porcelain)" ]]; then # Overlays leave siblings dirty after a successful float — allow that when already # on the target tip. Refuse only when checkout would rewrite a dirty tree. @@ -92,35 +111,35 @@ ensure_repo() { fi } -echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad float origin/main unless RS_*_REF set)..." +# Allow tests to `source` this script and call ensure_repo without cloning remotes. +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + +echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad/rsLXST float origin/main unless RS_*_REF set)..." ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${RS_RETICULUM_REF}" 'rsReticulum' +rns_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_RETICULUM_REF}")" apply_ratspeak_rns_overlays "${SCRIPT_DIR}" ensure_repo "${LXMF_DIR}" 'https://github.com/ratspeak/rsLXMF.git' \ "${RS_LXMF_REF}" 'rsLXMF' +lxmf_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LXMF_REF}")" apply_ratspeak_lxmf_overlays "${SCRIPT_DIR}" # Float Colorado-Mesh/rsNomad to origin/main (override via RS_NOMAD_REF above). ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' "${RS_NOMAD_REF}" 'rsNomad' +nomad_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_NOMAD_REF}")" -rns_mode='floated origin/main' -lxmf_mode='floated origin/main' -nomad_mode='floated origin/main' -if [[ -n "${RS_RETICULUM_REF}" ]]; then - rns_mode="pinned ${RS_RETICULUM_REF:0:12}" -fi -if [[ -n "${RS_LXMF_REF}" ]]; then - lxmf_mode="pinned ${RS_LXMF_REF:0:12}" -fi -if [[ -n "${RS_NOMAD_REF}" ]]; then - nomad_mode="pinned ${RS_NOMAD_REF:0:12}" -fi +# rsLXST (LXST telephony) — required for rns-stack voice; float origin/main unless RS_LXST_REF set. +ensure_repo "${LXST_DIR}" 'https://github.com/ratspeak/rsLXST.git' "${RS_LXST_REF}" 'rsLXST' +lxst_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LXST_REF}")" rns_sha="$(git -C "${RNS_DIR}" rev-parse HEAD)" lxmf_sha="$(git -C "${LXMF_DIR}" rev-parse HEAD)" nomad_sha="$(git -C "${NOMAD_DIR}" rev-parse HEAD)" -echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode})" -echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha}" +lxst_sha="$(git -C "${LXST_DIR}" rev-parse HEAD)" +echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode}), rsLXST @ ${lxst_sha:0:12} (${lxst_mode})" +echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha} rsLXST=${lxst_sha}" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 5d15a000a..c8bf84c36 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -1,11 +1,88 @@ -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; const cloneScriptPath = fileURLToPath(new URL('./clone-ratspeak-stack.sh', import.meta.url)); const cloneScript = readFileSync(cloneScriptPath, 'utf8'); +const tempDirs = []; + +function makeTempDir(prefix) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } +}); + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }).trim(); +} + +/** Local bare remote with a default branch (main or master) + optional tagged pin. */ +function createLocalRemote({ defaultBranch = 'main', pinTag = null } = {}) { + const remote = makeTempDir('rsLXST-remote-'); + const seed = makeTempDir('rsLXST-seed-'); + git(seed, 'init', '-b', defaultBranch); + git(seed, 'config', 'user.email', 'test@example.com'); + git(seed, 'config', 'user.name', 'test'); + writeFileSync(join(seed, 'README'), 'seed\n'); + git(seed, 'add', 'README'); + git(seed, 'commit', '-m', 'seed'); + const tipSha = git(seed, 'rev-parse', 'HEAD'); + let pinSha = tipSha; + if (pinTag) { + writeFileSync(join(seed, 'PIN'), 'pin\n'); + git(seed, 'add', 'PIN'); + git(seed, 'commit', '-m', 'pin'); + pinSha = git(seed, 'rev-parse', 'HEAD'); + git(seed, 'tag', pinTag); + // Float tip stays on default branch first commit for clearer float vs pin. + git(seed, 'reset', '--hard', tipSha); + } + git(seed, 'clone', '--bare', seed, remote); + return { remote, tipSha, pinSha }; +} + +function runEnsureRepo({ remoteUrl, destDir, pinRef = '' }) { + // Plain strings so bash ${...}/$(...) is not JS template interpolation. + const script = [ + 'set -euo pipefail', + 'source ' + JSON.stringify(cloneScriptPath), + 'ensure_repo ' + + JSON.stringify(destDir) + + ' ' + + JSON.stringify(remoteUrl) + + ' ' + + JSON.stringify(pinRef) + + " 'rsLXST'", + 'echo "SELECTED=${ENSURE_REPO_SELECTED_REF}"', + 'echo "MODE=$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" ' + JSON.stringify(pinRef) + ')"', + 'echo "SHA=$(git -C ' + JSON.stringify(destDir) + ' rev-parse HEAD)"', + ].join('\n'); + return execFileSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }); +} + describe('clone-ratspeak-stack.sh float policy', () => { it('floats rsReticulum and rsLXMF to origin/main by default', () => { expect(cloneScript).toContain("target_ref='origin/main'"); @@ -20,6 +97,8 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(cloneScript).toContain('origin/${ref_or_empty}'); expect(cloneScript).toContain('Ratspeak stack SHAs (full)'); expect(cloneScript).toContain('ratspeak-overlay-apply-list.sh'); + expect(cloneScript).toContain('format_repo_mode'); + expect(cloneScript).toContain('ENSURE_REPO_SELECTED_REF'); expect(cloneScript).not.toMatch(/9928abed269a83ec5a7ef165ff1142d938cad706/); expect(cloneScript).not.toMatch(/68ad7c835187c052c763bb28c41b04a655f35c64/); }); @@ -27,11 +106,42 @@ describe('clone-ratspeak-stack.sh float policy', () => { it('floats rsNomad to origin/main with optional RS_NOMAD_REF pin', () => { expect(cloneScript).toMatch(/RS_NOMAD_REF="\$\{RS_NOMAD_REF:-\}"/); expect(cloneScript).toContain('Colorado-Mesh/rsNomad.git'); - expect(cloneScript).toContain("nomad_mode='floated origin/main'"); + expect(cloneScript).toContain( + 'format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_NOMAD_REF}"', + ); expect(cloneScript).not.toContain('6e3b288fbc6931b1e2633d986cf0d49608d578b7'); expect(cloneScript).not.toContain('RS_NOMAD_SKIP_PIN'); }); + it('ensure_repo floats rsLXST and reports selected ref + SHA', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'main' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(out).toContain('SELECTED=origin/main'); + expect(out).toContain('MODE=floated origin/main'); + expect(out).toContain(`SHA=${tipSha}`); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); + }); + + it('ensure_repo falls back to origin/master when main is absent', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'master' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(out).toContain('SELECTED=origin/master'); + expect(out).toContain('MODE=floated origin/master'); + expect(out).toContain(`SHA=${tipSha}`); + }); + + it('ensure_repo pins RS_LXST_REF and reports pinned mode + checkout SHA', () => { + const { remote, pinSha } = createLocalRemote({ defaultBranch: 'main', pinTag: 'v-test-pin' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest, pinRef: 'v-test-pin' }); + expect(out).toMatch(/SELECTED=v-test-pin|SELECTED=origin\/v-test-pin/); + expect(out).toContain('MODE=pinned v-test-pin'); + expect(out).toContain(`SHA=${pinSha}`); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(pinSha); + }); + it('applies rsReticulum and rsLXMF overlays after checkout via shared list', () => { expect(cloneScript).toContain('apply_ratspeak_rns_overlays'); expect(cloneScript).toContain('apply_ratspeak_lxmf_overlays'); diff --git a/scripts/update.sh b/scripts/update.sh index 8f9e03d3f..0654e24a1 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -405,9 +405,10 @@ process.stdin.on("end", () => { # Curated release watch + known org repos (keep in sync when adopting new ratspeak libs). # Format: "owner/repo|stub-kind-or-empty|display-label" -# stub-kind: voice|games → warn while mesh-client still has sidecar stubs only. +# stub-kind: games → warn while mesh-client still has sidecar stubs only. +# (voice cleared after lxst-telephony integration; empty stub field = informational.) RATSPEAK_RELEASE_WATCH_ENTRIES=( - 'ratspeak/rsLXST|voice|rsLXST voice (sidecar stub)' + 'ratspeak/rsLXST||rsLXST voice (lxst-telephony)' 'ratspeak/lrgp-rs|games|lrgp-rs games (sidecar stub)' 'ratspeak/Ratspeak||Ratspeak client (reference)' 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)' diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index f1c5cf352..a15dadef1 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -97,7 +97,7 @@ describe('update.sh Reticulum stack functionality check', () => { const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' }); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); - expect(result.stdout).toContain('ratspeak/rsLXST|voice|'); + expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)'); expect(result.stdout).toContain('ratspeak/lrgp-rs|games|'); expect(result.stdout).toContain('ratspeak/Ratspeak||'); expect(result.stdout).toContain('ratspeak/LXMFace||'); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 0c6710c47..3362d45cf 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -317,6 +317,7 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => { expect(PRELOAD_SOURCE).toContain("'/api/v1/rrc/hubs'"); expect(PRELOAD_SOURCE).toContain('rrc:'); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPost'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceSendAudio'"); 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'"); diff --git a/src/main/ipc/reticulum-handlers.test.ts b/src/main/ipc/reticulum-handlers.test.ts index 9a9847356..92c71f7d6 100644 --- a/src/main/ipc/reticulum-handlers.test.ts +++ b/src/main/ipc/reticulum-handlers.test.ts @@ -148,6 +148,7 @@ describe('registerReticulumIpcHandlers', () => { 'reticulum:syncInterfaceIssueScope', 'reticulum:proxyGet', 'reticulum:proxyPost', + 'reticulum:voiceSendAudio', 'reticulum:proxyPut', 'reticulum:proxyDelete', 'reticulum:readDefaultConfigFile', @@ -174,6 +175,10 @@ describe('registerReticulumIpcHandlers', () => { handlers.get('reticulum:syncInterfaceIssueScope')?.(event, []); await handlers.get('reticulum:proxyGet')?.(event, '/api/v1/x'); await handlers.get('reticulum:proxyPost')?.(event, '/api/v1/x', {}); + await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: 'AAAA', + }); await handlers.get('reticulum:proxyPut')?.(event, '/api/v1/x', {}); await handlers.get('reticulum:proxyDelete')?.(event, '/api/v1/x'); handlers.get('reticulum:readDefaultConfigFile')?.(event); @@ -183,9 +188,10 @@ describe('registerReticulumIpcHandlers', () => { await handlers.get('reticulum:setNomadContentSource')?.(event, '/tmp/site'); await handlers.get('reticulum:validateConfig')?.(event); - expect(assertIpcSenderMock).toHaveBeenCalledTimes(14); + expect(assertIpcSenderMock).toHaveBeenCalledTimes(15); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:start'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:proxyPost'); + expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:voiceSendAudio'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:setNomadContentSource'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:validateConfig'); }); @@ -592,6 +598,34 @@ describe('registerReticulumIpcHandlers', () => { ).rejects.toThrow(/rncpSend\/rncpFetch\/setRncpListener/); expect(manager.proxyPost).not.toHaveBeenCalled(); }); + + it('proxyPost rejects voice PCM path; voiceSendAudio forwards validated frames', async () => { + await expect( + handlers.get('reticulum:proxyPost')?.(event, '/api/v1/voice/audio', { + channels: 1, + samples_b64: 'AAAA', + }), + ).rejects.toThrow(/voiceSendAudio/); + expect(manager.proxyPost).not.toHaveBeenCalled(); + + const ok = await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: 'AAAA', + profile: 0x50, + }); + expect(ok).toEqual({ ok: true }); + expect(manager.proxyPost).toHaveBeenCalledWith('/api/v1/voice/audio', { + channels: 1, + samples_b64: 'AAAA', + profile: 0x50, + }); + + const bad = await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: '', + }); + expect(bad).toEqual({ ok: false, error: 'empty_samples_b64' }); + }); }); }); @@ -619,6 +653,22 @@ describe('wireReticulumSidecarBridge', () => { }); }); + it('forwards manager "voiceAudio" emissions to reticulum:voiceAudio', () => { + const manager = createManagerStub(); + const win = createWinStub(); + wireReticulumSidecarBridge(manager as never, () => win as unknown as BrowserWindow); + + const voiceHandler = manager.on.mock.calls.find((call) => call[0] === 'voiceAudio')?.[1] as ( + evt: unknown, + ) => void; + expect(voiceHandler).toBeTypeOf('function'); + voiceHandler({ type: 'voice.audio', payload: { channels: 1, samples_b64: 'AAAA' } }); + expect(win.webContents.send).toHaveBeenCalledWith('reticulum:voiceAudio', { + type: 'voice.audio', + payload: { channels: 1, samples_b64: 'AAAA' }, + }); + }); + it('forwards manager "status" emissions to the main window', () => { const manager = createManagerStub(); const win = createWinStub(); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index fc50be9d8..36bfc1b9f 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -12,6 +12,7 @@ import { reticulumProxyIpcErrorEnvelope, } from '../../shared/reticulumProxyIpcError'; import { MS_PER_MINUTE } from '../../shared/timeConstants'; +import { parseVoiceAudioRequest, VOICE_AUDIO_API_PATH } from '../../shared/voice-types'; import { createIpcRateLimiter } from '../ipcRateLimit'; import { sanitizeLogMessage } from '../log-service'; import { @@ -43,6 +44,20 @@ const reticulumProxyIpcRateLimit = createIpcRateLimiter({ label: 'reticulum:proxy', }); +/** + * Realtime LXST PCM ingest: QualityHigh is ~16.7 frames/s (~1000/min). + * Separate from the shared 300/min proxy bucket so calls do not starve mesh control IPC. + */ +const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ + max: 2000, + windowMs: MS_PER_MINUTE, + label: 'reticulum:voiceSendAudio', +}); + +function isVoiceAudioApiPath(apiPath: string): boolean { + return apiPath === VOICE_AUDIO_API_PATH; +} + export interface ReticulumIpcDeps { idleStatus: ReticulumSidecarStatus; ensureManager: () => ReticulumSidecarManager; @@ -200,6 +215,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { 'rncp send/fetch/listener changes require reticulum:rncpSend/rncpFetch/setRncpListener (picker-backed)', ); } + if (isVoiceAudioApiPath(pathArg)) { + throw new Error('voice PCM ingest requires reticulum:voiceSendAudio'); + } try { const m = ensureManager(); return await m.proxyPost(pathArg, body); @@ -209,6 +227,26 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { } }); + /** + * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 300/min + * proxy ceiling) so voice TX does not starve control-plane proxy IPC. + */ + ipcMain.handle('reticulum:voiceSendAudio', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceSendAudio'); + reticulumVoiceAudioIpcRateLimit.checkOrThrow(); + const parsed = parseVoiceAudioRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + const m = ensureManager(); + return await m.proxyPost(VOICE_AUDIO_API_PATH, parsed); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceSendAudio', err, VOICE_AUDIO_API_PATH); + } + }); + ipcMain.handle('reticulum:factoryReset', async (event) => { assertIpcSender(event, 'reticulum:factoryReset'); try { @@ -517,6 +555,11 @@ export function wireReticulumSidecarBridge( if (!win || win.isDestroyed()) return; win.webContents.send('reticulum:event', evt); }); + manager.on('voiceAudio', (evt) => { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + win.webContents.send('reticulum:voiceAudio', evt); + }); manager.on('status', (status) => { const win = getMainWindow(); if (!win || win.isDestroyed()) return; diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts index e7d4454a6..3f7566c98 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -41,6 +41,18 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () } }); + it('routes LXST PCM through a dedicated higher-budget IPC channel', () => { + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceSendAudio'"); + expect(HANDLERS_SOURCE).toMatch(/max:\s*2000/); + expect(HANDLERS_SOURCE).toContain("label: 'reticulum:voiceSendAudio'"); + expect(HANDLERS_SOURCE).toContain('reticulumVoiceAudioIpcRateLimit.checkOrThrow()'); + expect(HANDLERS_SOURCE).toContain('voice PCM ingest requires reticulum:voiceSendAudio'); + expect(HANDLERS_SOURCE).toContain('VOICE_AUDIO_API_PATH'); + const preload = readFileSync(join(__dirname, '../../preload/index.ts'), 'utf-8'); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceSendAudio'"); + expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/voice\/audio'/); + }); + it('aligns sidecar peer cache and WS added batch with ~100k scale', () => { expect(SIDECAR_STACK_SOURCE).toMatch(/const MAX_PEER_CACHE: usize = 100_000;/); expect(SIDECAR_LIVE_SOURCE).toMatch(/const MAX_PEERS_UPDATED_ADDED: usize = 4096;/); diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index 04a767a3a..bdded22c7 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -1379,6 +1379,30 @@ describe('updateChannelKeys', () => { expect(manager.getChannelNameToIndex()).toEqual({ LongFast: 1 }); }); + it('cold-start then RF re-push: empty map then LongFast@1 (resolvedChannelConfigs path)', () => { + const manager = new MQTTManager(); + stubMqttConnect(manager); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + }); + + // MQTT connected before RF channel configs arrived. + expect(manager.getChannelNameToIndex().LongFast).toBeUndefined(); + + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringMatching(/\[Meshtastic MQTT\] channelNameToIndex updated \(1\): LongFast=1/), + ); + debugSpy.mockRestore(); + }); + it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => { const manager = new MQTTManager(); const access = mqttChannelTestAccess(manager); diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index d04c3151c..c395f5243 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -384,6 +384,12 @@ export class MQTTManager extends EventEmitter { this.channelNameToIndex.set(name, idx); } this.rebuildAllDecryptKeys(); + const mapSummary = Array.from(this.channelNameToIndex.entries()) + .map(([name, idx]) => `${sanitizeLogMessage(name)}=${idx}`) + .join(','); + console.debug( + `[Meshtastic MQTT] channelNameToIndex updated (${this.channelNameToIndex.size}): ${mapSummary || '(empty)'}`, + ); // log-filter-ok Meshtastic MQTT logs → App log panel } /** Idempotent: Connection panel manual lines win over prior radio entries for the same name. */ diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 159b411c8..58a5fdaad 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -42,6 +42,7 @@ vi.mock('./reticulum-ble-rnode-config', () => ({ const mockWsInstances: MockWebSocketInstance[] = []; interface MockWebSocketInstance { + url: string; handlers: Map void>; close: ReturnType; removeAllListeners: ReturnType; @@ -476,14 +477,20 @@ describe('ReticulumSidecarManager', () => { const manager = new ReticulumSidecarManager(); await manager.start(); - expect(mockWsInstances.length).toBeGreaterThan(0); - const wsInstance = mockWsInstances[mockWsInstances.length - 1]; - expect(wsInstance.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); + expect(mockWsInstances.length).toBeGreaterThanOrEqual(2); + const wsInstance = mockWsInstances.find((w) => w.url.endsWith('/ws')); + const voiceWsInstance = mockWsInstances.find((w) => w.url.endsWith('/ws/voice')); + expect(wsInstance).toBeDefined(); + expect(voiceWsInstance).toBeDefined(); + expect(wsInstance!.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); + expect(voiceWsInstance!.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); const events: unknown[] = []; + const voiceAudio: unknown[] = []; manager.on('event', (e) => events.push(e)); + manager.on('voiceAudio', (e) => voiceAudio.push(e)); - const openHandler = wsInstance.handlers.get('open'); + const openHandler = wsInstance!.handlers.get('open'); expect(openHandler).toBeDefined(); openHandler?.(); expect(events).toEqual([{ type: 'ws_connected', payload: { reconnect: false } }]); @@ -494,7 +501,7 @@ describe('ReticulumSidecarManager', () => { expect(events).toEqual([{ type: 'ws_connected', payload: { reconnect: true } }]); events.length = 0; - const messageHandler = wsInstance.handlers.get('message'); + const messageHandler = wsInstance!.handlers.get('message'); expect(messageHandler).toBeDefined(); // Oversized frame is dropped, not forwarded as an 'event'. @@ -507,6 +514,29 @@ describe('ReticulumSidecarManager', () => { messageHandler?.(normal); expect(events).toEqual([{ type: 'status', payload: { ok: true } }]); + // Stray voice.audio on shared /ws is ignored (dedicated /ws/voice owns PCM). + messageHandler?.( + Buffer.from( + JSON.stringify({ type: 'voice.audio', payload: { channels: 1, samples_b64: 'AA' } }), + ), + ); + expect(events).toHaveLength(1); // still only status above + events.length = 0; + + const voiceMessageHandler = voiceWsInstance!.handlers.get('message'); + expect(voiceMessageHandler).toBeDefined(); + voiceMessageHandler?.( + Buffer.from( + JSON.stringify({ + type: 'voice.audio', + payload: { channels: 1, samples_b64: 'AAAA' }, + }), + ), + ); + expect(voiceAudio).toEqual([ + { type: 'voice.audio', payload: { channels: 1, samples_b64: 'AAAA' } }, + ]); + await manager.stop(); existsSpy.mockRestore(); mkdirSpy.mockRestore(); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 42eb40a86..43d499aca 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -162,9 +162,12 @@ async function pollSidecarHealth(port: number): Promise export class ReticulumSidecarManager extends EventEmitter { private proc: ChildProcess | null = null; private ws: { close: () => void } | null = null; + private voiceWs: { close: () => void } | null = null; private wsPort = 0; private wsReconnectTimer: ReturnType | null = null; + private voiceWsReconnectTimer: ReturnType | null = null; private wsReconnectAttempt = 0; + private voiceWsReconnectAttempt = 0; /** True after the first successful WS open for this sidecar process (reconnects set reconnect=true). */ private wsEverConnected = false; private startPromise: Promise | null = null; @@ -637,6 +640,8 @@ export class ReticulumSidecarManager extends EventEmitter { const text = data.toString('utf8'); try { const parsed = JSON.parse(text) as { type?: string; payload?: unknown }; + // voice.audio must arrive on /ws/voice → voiceAudio (not shared event bus). + if (parsed.type === 'voice.audio') return; this.emit('event', { type: parsed.type ?? 'message', payload: parsed.payload ?? parsed, @@ -676,6 +681,70 @@ export class ReticulumSidecarManager extends EventEmitter { ); this.scheduleWsReconnect(); } + this.connectVoiceWs(port); + } + + /** Dedicated high-rate PCM stream (`/ws/voice` → `voiceAudio` IPC). */ + private connectVoiceWs(port: number): void { + this.clearVoiceWsReconnectTimer(); + const prev = this.voiceWs; + this.voiceWs = null; + prev?.close(); + try { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws/voice`, { + maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES, + }); + socket.on('open', () => { + this.voiceWsReconnectAttempt = 0; + }); + socket.on('message', (data: Buffer) => { + if (data.length > RETICULUM_WS_MAX_MESSAGE_BYTES) { + console.warn( + `[ReticulumSidecar] voice ws message exceeded ${RETICULUM_WS_MAX_MESSAGE_BYTES} byte cap, dropping`, + ); + return; + } + const text = data.toString('utf8'); + try { + const parsed = JSON.parse(text) as { type?: string; payload?: unknown }; + if (parsed.type !== 'voice.audio') return; + this.emit('voiceAudio', { + type: 'voice.audio', + payload: parsed.payload ?? parsed, + }); + } catch { + // catch-no-log-ok: ignore non-JSON on the voice audio socket + } + }); + socket.on('error', (err: Error) => { + console.warn('[ReticulumSidecar] voice ws error:', sanitizeLogMessage(err.message)); + }); + socket.on('close', () => { + if (this.wsPort === port) { + this.voiceWs = null; + this.scheduleVoiceWsReconnect(); + } + }); + this.voiceWs = { + close: () => { + try { + socket.removeAllListeners(); + socket.on('error', () => { + // catch-no-log-ok: intentional teardown; CONNECTING abort is expected + }); + socket.close(); + } catch { + // catch-no-log-ok: socket may already be closed + } + }, + }; + } catch (err) { + console.warn( + '[ReticulumSidecar] voice ws bridge unavailable:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + this.scheduleVoiceWsReconnect(); + } } private clearWsReconnectTimer(): void { @@ -685,6 +754,13 @@ export class ReticulumSidecarManager extends EventEmitter { } } + private clearVoiceWsReconnectTimer(): void { + if (this.voiceWsReconnectTimer) { + clearTimeout(this.voiceWsReconnectTimer); + this.voiceWsReconnectTimer = null; + } + } + /** Reconnect WS while the sidecar HTTP process is still running (event loss otherwise). */ private scheduleWsReconnect(): void { this.clearWsReconnectTimer(); @@ -702,14 +778,35 @@ export class ReticulumSidecarManager extends EventEmitter { }, delayMs); } + private scheduleVoiceWsReconnect(): void { + this.clearVoiceWsReconnectTimer(); + if (!this._status.running || this.wsPort <= 0) return; + const attempt = this.voiceWsReconnectAttempt; + this.voiceWsReconnectAttempt = Math.min(attempt + 1, 8); + const delayMs = Math.min(30_000, 500 * 2 ** attempt); + this.voiceWsReconnectTimer = setTimeout(() => { + this.voiceWsReconnectTimer = null; + if (!this._status.running || this.wsPort <= 0) return; + console.debug( + `[ReticulumSidecar] voice ws reconnect attempt=${this.voiceWsReconnectAttempt} port=${this.wsPort}`, + ); + this.connectVoiceWs(this.wsPort); + }, delayMs); + } + /** Tear down the WS bridge and cancel reconnect (used on sidecar stop). */ private teardownWs(): void { this.clearWsReconnectTimer(); + this.clearVoiceWsReconnectTimer(); this.wsPort = 0; this.wsReconnectAttempt = 0; + this.voiceWsReconnectAttempt = 0; this.wsEverConnected = false; const prev = this.ws; this.ws = null; prev?.close(); + const prevVoice = this.voiceWs; + this.voiceWs = null; + prevVoice?.close(); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index bf6df9abb..4b6f6dff3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1104,6 +1104,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('reticulum:event', handler); return () => ipcRenderer.off('reticulum:event', handler); }, + onVoiceAudio: (cb: (event: ReticulumSidecarEvent) => void): (() => void) => { + const handler = (_: unknown, event: ReticulumSidecarEvent) => { + cb(event); + }; + ipcRenderer.on('reticulum:voiceAudio', handler); + return () => ipcRenderer.off('reticulum:voiceAudio', handler); + }, onStatus: (cb: (status: ReticulumSidecarStatus) => void): (() => void) => { const handler = (_: unknown, status: ReticulumSidecarStatus) => { cb(status); @@ -1174,6 +1181,22 @@ contextBridge.exposeInMainWorld('electronAPI', { getStatus: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rnsh/status')), }, + voice: { + getStatus: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/voice/status')), + call: (opts: { identity_hash: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/call', opts)), + answer: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/answer', {})), + reject: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/reject', {})), + hangup: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/hangup', {})), + mute: (opts: { muted: boolean }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/mute', opts)), + sendAudio: (opts: { profile?: number; channels: number; samples_b64: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceSendAudio', opts)), + }, rncp: { send: (opts: { destination_hash: string; path: string }) => ipcRenderer.invoke('reticulum:rncpSend', opts), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 808398a49..db54ef1c2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -63,6 +63,8 @@ import { meshtasticMqttOwnNodeIds } from '@/renderer/lib/meshtasticMqttIdentity' import { remoteConfigChannelRetryRoute } from '@/renderer/lib/meshtasticRemoteAdminSnapshot'; import { Z_NODE_DETAIL_MODAL } from '@/renderer/lib/modalZIndex'; import { useReticulumRawPacketPoll } from '@/renderer/lib/reticulum/useReticulumRawPacketPoll'; +import { persistReticulumSelfLxmfHash } from '@/renderer/lib/reticulumLastSelfLxmfHash'; +import { resolveReticulumOwnNodeIdSet } from '@/renderer/lib/reticulumOwnNodeIds'; import { resolveInactiveRrcNotificationType } from '@/renderer/lib/rrcInactiveNotifications'; import { rrcRoomsMatch } from '@/renderer/lib/rrcRoomName'; import { createUpdateMenuNotifyController } from '@/renderer/lib/updateMenuNotifyController'; @@ -82,6 +84,7 @@ import { ProtocolAutoConnectCoordinator } from './components/ProtocolAutoConnect import { ProtocolSwitcher } from './components/ProtocolSwitcher'; import { RncpEnableRequestModal } from './components/remote/RncpEnableRequestModal'; import RemoteAdminErrorNotifier from './components/RemoteAdminErrorNotifier'; +import { ReticulumVoiceOverlay } from './components/reticulum/ReticulumVoiceOverlay'; import { ReticulumStackAutostartCoordinator } from './components/ReticulumStackAutostartCoordinator'; import Sidebar from './components/Sidebar'; import { LinkIcon } from './components/SignalBars'; @@ -479,6 +482,7 @@ export default function App() { + ); @@ -1200,13 +1204,23 @@ function AppContent() { }); }, [meshcoreConnectionView.state.myNodeNum, meshcoreIdentityId, meshcoreRuntime.selfNodeId]); - const reticulumOwnNodeIdSet = useMemo(() => { - const selfId = - typeof reticulumRuntime.selfNodeId === 'number' - ? reticulumRuntime.selfNodeId - : reticulumRuntime.state.myNodeNum; - return selfId > 0 ? new Set([selfId >>> 0]) : new Set(); - }, [reticulumRuntime.selfNodeId, reticulumRuntime.state.myNodeNum]); + const reticulumIdentity = useReticulumIdentityStore((s) => s.identity); + useEffect(() => { + const hash = reticulumIdentity?.lxmf_hash?.trim(); + if (!hash) return; + persistReticulumSelfLxmfHash(hash); + }, [reticulumIdentity?.lxmf_hash]); + + const reticulumOwnNodeIdSet = useMemo( + () => + resolveReticulumOwnNodeIdSet({ + runtimeSelfNodeId: + typeof reticulumRuntime.selfNodeId === 'number' ? reticulumRuntime.selfNodeId : null, + connectionMyNodeNum: reticulumRuntime.state.myNodeNum, + lxmfHash: reticulumIdentity?.lxmf_hash, + }), + [reticulumIdentity, reticulumRuntime.selfNodeId, reticulumRuntime.state.myNodeNum], + ); useEffect(() => { if (!reticulumIdentityId || reticulumLastReadSanitizedRef.current) return; @@ -1459,13 +1473,12 @@ function AppContent() { () => Array.from(reticulumOwnNodeIdSet), [reticulumOwnNodeIdSet], ); - const reticulumIdentityForHeader = useReticulumIdentityStore((s) => s.identity); const headerMyNodeNum = (() => { if (protocol !== 'reticulum') return activeConnectionView.state.myNodeNum; const fromRuntime = typeof reticulumRuntime.selfNodeId === 'number' ? reticulumRuntime.selfNodeId : 0; - const fromIdentity = reticulumIdentityForHeader?.lxmf_hash - ? reticulumHashToNodeId(reticulumIdentityForHeader.lxmf_hash) + const fromIdentity = reticulumIdentity?.lxmf_hash + ? reticulumHashToNodeId(reticulumIdentity.lxmf_hash) : 0; return Math.max(activeConnectionView.state.myNodeNum, fromRuntime, fromIdentity); })(); @@ -1477,8 +1490,8 @@ function AppContent() { ? useNodeStore.getState().nodes[reticulumIdentityId]?.[selfId >>> 0]?.longName : undefined; return resolveReticulumSelfHeaderLabel({ - identityDisplayName: reticulumIdentityForHeader?.display_name, - lxmfHash: reticulumIdentityForHeader?.lxmf_hash ?? null, + identityDisplayName: reticulumIdentity?.display_name, + lxmfHash: reticulumIdentity?.lxmf_hash ?? null, storedLongName: stored, }); } @@ -3056,6 +3069,7 @@ function AppContent() { protocol={protocol} dmOnlyChat={capabilities.hasReticulumInterfaceConfig} hasRncpTransfer={capabilities.hasRncpTransfer} + hasLxstVoice={capabilities.hasLxstVoice} showLxmfDeliveryStatus={capabilities.hasLxmfDeliveryStatus} showLxmfAttachmentLine={capabilities.hasReticulumInterfaceConfig} composerPayloadLimit={capabilities.lxmfPayloadLimit} @@ -3269,6 +3283,7 @@ function AppContent() { } groupMemberIds={contactGroups.groupMemberIds} contactGroupsEnabled={capabilities.hasUserManagedContactGroups} + hasLxstVoice={capabilities.hasLxstVoice} /> ) : ( { }); }); +describe('AppPanel: MeshCore path hash mode persist', () => { + const defaultProps = { + nodeCount: 0, + messageCount: 0, + channels: [] as { index: number; name: string }[], + myNodeNum: null as number | null, + onLocationFilterChange: vi.fn(), + }; + + beforeEach(() => { + localStorage.removeItem('mesh-client:appSettings'); + }); + + it('does not stamp default meshcorePathHashMode into app settings on mount', async () => { + render( + + + , + ); + await screen.findByLabelText(/Default path hash size/i); + await waitFor( + () => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toBeTruthy(); + }, + { timeout: 1500 }, + ); + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).not.toContain('meshcorePathHashMode'); + }); + + it('persists meshcorePathHashMode when the user changes the dropdown', async () => { + render( + + + , + ); + const select = await screen.findByLabelText(/Default path hash size/i); + fireEvent.change(select, { target: { value: '1' } }); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"meshcorePathHashMode":1'); + }); + }); + + it('syncs dropdown from device-reported mode when user has not changed it', async () => { + const { rerender } = render( + + + , + ); + const select = await screen.findByLabelText(/Default path hash size/i); + expect(select).toHaveValue('0'); + + rerender( + + + , + ); + await waitFor(() => { + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('1'); + }); + }); +}); + describe('AppPanel: MeshCore Open wire toggle', () => { const defaultProps = { nodeCount: 0, diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 12ac583a3..1d172173d 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -8,6 +8,7 @@ import { exportSupportBundleToDisk } from '@/renderer/lib/exportSupportBundle'; import type { MessageClearRefreshOptions } from '@/renderer/lib/hydrateIdentityStoresFromDb'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { parseDatabaseSchemaTooNewFromMessage } from '@/shared/databaseSchemaTooNew'; +import { isMeshcorePathHashMode } from '@/shared/meshcorePathHash'; import type { SupportBundleMode } from '@/shared/support-bundle.types'; import type { LocationFilter } from '../App'; @@ -20,6 +21,7 @@ import { formatCoordPair } from '../lib/coordUtils'; import { DEFAULT_APP_SETTINGS_SHARED } from '../lib/defaultAppSettings'; import type { OurPosition } from '../lib/gpsSource'; import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; +import { appPanelSettingsPersistPayload } from '../lib/meshcorePathHashMode'; import { DEFAULT_MESSAGE_RETENTION, MESSAGE_RETENTION_KEYS, @@ -307,6 +309,7 @@ export default function AppPanel({ // ─── Node retention settings ──────────────────────────────── const [settings, setSettings] = useState(loadSettings); + const pathHashModeUserChangedRef = useRef(false); const [themeColors, setThemeColors] = useState>(loadThemeColors); const [hasSavedThemeSnapshot, setHasSavedThemeSnapshot] = useState(hasThemeSnapshot); const [deleteAgeDays, setDeleteAgeDays] = useState(90); @@ -387,7 +390,10 @@ export default function AppPanel({ if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { mergeAppSettingsPartial( - settings as unknown as Record, + appPanelSettingsPersistPayload( + settings as unknown as Record, + pathHashModeUserChangedRef.current, + ), 'AppPanel saveSettings', ); }, 300); @@ -396,6 +402,17 @@ export default function AppPanel({ }; }, [settings]); + // Keep dropdown aligned with companion when connect adopts radio mode into settings. + useEffect(() => { + if (!isMeshcorePathHashMode(deviceReportedPathHashMode)) return; + if (pathHashModeUserChangedRef.current) return; + setSettings((prev) => + prev.meshcorePathHashMode === deviceReportedPathHashMode + ? prev + : { ...prev, meshcorePathHashMode: deviceReportedPathHashMode }, + ); + }, [deviceReportedPathHashMode]); + useEffect(() => { onLocationFilterChange({ enabled: settings.distanceFilterEnabled, @@ -1458,6 +1475,7 @@ export default function AppPanel({ onChange={(e) => { const raw = Number.parseInt(e.target.value, 10); if (raw !== 0 && raw !== 1 && raw !== 2) return; + pathHashModeUserChangedRef.current = true; updateSetting('meshcorePathHashMode', raw); if (isMeshcoreRadioConnected && onApplyMeshcorePathHashMode) { void onApplyMeshcorePathHashMode(raw).catch((err: unknown) => { diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index 6ab4698ab..07e84ba68 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -5,7 +5,12 @@ import { axe } from 'vitest-axe'; import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; import { MESHTASTIC_PAYLOAD_LIMIT } from '@/renderer/lib/chatComposerLimits'; -import { draftsStorageKey } from '@/renderer/lib/chatPanelProtocolStorage'; +import { + draftsStorageKey, + FLOOD_SCOPE_OVERRIDE_UNSCOPED, + floodScopeOverridesStorageKey, + loadFloodScopeOverridesInitial, +} from '@/renderer/lib/chatPanelProtocolStorage'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; @@ -31,12 +36,12 @@ vi.mock('react-i18next', () => ({ 'chatPanel.meshcoreGifSend': 'Send GIF', 'chatPanel.floodScopeOverrideDefault': 'Default scope', 'chatPanel.floodScopeOverrideUnscoped': 'Unscoped', - 'chatPanel.floodScopeOverrideAria': 'Per-message flood scope override', - 'chatPanel.floodScopeOverrideMenuButton': 'Change flood scope for this message', - 'chatPanel.floodScopeOverrideHint': 'Regional flood scope for this message only.', + 'chatPanel.floodScopeOverrideAria': 'Per-channel flood scope override', + 'chatPanel.floodScopeOverrideMenuButton': 'Change flood scope for this channel', + 'chatPanel.floodScopeOverrideHint': 'Remembered per channel.', 'chatPanel.floodScopeOverrideCustom': 'Custom scope…', 'chatPanel.floodScopeOverrideCustomLabel': 'Custom flood scope hashtag', - 'chatPanel.floodScopeOverrideCustomPlaceholder': '#myregion', + 'chatPanel.floodScopeOverrideCustomPlaceholder': '#metro', 'chatPanel.floodScopeOverrideCustomApply': 'Use scope', 'chatPanel.floodScopeOverrideCustomInvalid': 'Enter a valid region hashtag', 'common.cancel': 'Cancel', @@ -455,7 +460,7 @@ describe('ChatComposer', () => { />, ); expect( - screen.queryByRole('button', { name: 'Change flood scope for this message' }), + screen.queryByRole('button', { name: 'Change flood scope for this channel' }), ).toBeNull(); }); @@ -471,7 +476,7 @@ describe('ChatComposer', () => { />, ); expect( - screen.getByRole('button', { name: 'Change flood scope for this message' }), + screen.getByRole('button', { name: 'Change flood scope for this channel' }), ).toBeInTheDocument(); }); @@ -491,12 +496,12 @@ describe('ChatComposer', () => { onSendChunk={onSendChunk} />, ); - await user.click(screen.getByRole('button', { name: 'Change flood scope for this message' })); + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); // fireEvent: portaled menu is off-screen in jsdom (zero button rect). fireEvent.click(screen.getByRole('button', { name: '#eu' })); expect( screen.getByRole('button', { - name: 'Change flood scope for this message: #eu', + name: 'Change flood scope for this channel: #eu', }), ).toBeInTheDocument(); expect(screen.getByText('#eu')).toBeInTheDocument(); @@ -530,11 +535,11 @@ describe('ChatComposer', () => { onSendChunk={onSendChunk} />, ); - await user.click(screen.getByRole('button', { name: 'Change flood scope for this message' })); + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); fireEvent.click(screen.getByRole('button', { name: 'Unscoped' })); expect( screen.getByRole('button', { - name: 'Change flood scope for this message: Unscoped', + name: 'Change flood scope for this channel: Unscoped', }), ).toBeInTheDocument(); @@ -565,21 +570,21 @@ describe('ChatComposer', () => { onSendChunk={onSendChunk} />, ); - await user.click(screen.getByRole('button', { name: 'Change flood scope for this message' })); + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); fireEvent.click(screen.getByRole('button', { name: '#jp' })); expect( screen.getByRole('button', { - name: 'Change flood scope for this message: #jp', + name: 'Change flood scope for this channel: #jp', }), ).toBeInTheDocument(); await user.click( screen.getByRole('button', { - name: 'Change flood scope for this message: #jp', + name: 'Change flood scope for this channel: #jp', }), ); fireEvent.click(screen.getByRole('button', { name: 'Default scope' })); expect( - screen.getByRole('button', { name: 'Change flood scope for this message' }), + screen.getByRole('button', { name: 'Change flood scope for this channel' }), ).toBeInTheDocument(); const textarea = screen.getByRole('textbox'); @@ -597,6 +602,117 @@ describe('ChatComposer', () => { }); }); + it('remembers Unscoped on Public and #metro on metrolink across view switches', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); + fireEvent.click(screen.getByRole('button', { name: 'Unscoped' })); + expect( + screen.getByRole('button', { + name: 'Change flood scope for this channel: Unscoped', + }), + ).toBeInTheDocument(); + expect(loadFloodScopeOverridesInitial('meshcore')['ch:0']).toBe(FLOOD_SCOPE_OVERRIDE_UNSCOPED); + + rerender( + , + ); + expect( + screen.getByRole('button', { name: 'Change flood scope for this channel' }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); + fireEvent.click(screen.getByRole('button', { name: '#metro' })); + expect( + screen.getByRole('button', { + name: 'Change flood scope for this channel: #metro', + }), + ).toBeInTheDocument(); + expect(loadFloodScopeOverridesInitial('meshcore')).toEqual({ + 'ch:0': FLOOD_SCOPE_OVERRIDE_UNSCOPED, + 'ch:1': '#metro', + }); + + rerender( + , + ); + expect( + screen.getByRole('button', { + name: 'Change flood scope for this channel: Unscoped', + }), + ).toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.getByRole('button', { + name: 'Change flood scope for this channel: #metro', + }), + ).toBeInTheDocument(); + }); + + it('restores per-channel flood scope from localStorage on mount', () => { + localStorage.setItem( + floodScopeOverridesStorageKey('meshcore'), + JSON.stringify({ + 'ch:0': FLOOD_SCOPE_OVERRIDE_UNSCOPED, + 'ch:1': '#metro', + }), + ); + render( + , + ); + expect( + screen.getByRole('button', { + name: 'Change flood scope for this channel: #metro', + }), + ).toBeInTheDocument(); + }); + it('applies a custom scope from the menu and remembers it after successful send', async () => { const onSendChunk = vi.fn().mockResolvedValue(undefined); const onRememberFloodScopePreset = vi.fn(); @@ -613,7 +729,7 @@ describe('ChatComposer', () => { onSendChunk={onSendChunk} />, ); - await user.click(screen.getByRole('button', { name: 'Change flood scope for this message' })); + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); fireEvent.click(screen.getByRole('button', { name: 'Custom scope…' })); fireEvent.change(screen.getByRole('textbox', { name: 'Custom flood scope hashtag' }), { target: { value: 'berlin' }, @@ -621,7 +737,7 @@ describe('ChatComposer', () => { fireEvent.click(screen.getByRole('button', { name: 'Use scope' })); expect( screen.getByRole('button', { - name: 'Change flood scope for this message: #berlin', + name: 'Change flood scope for this channel: #berlin', }), ).toBeInTheDocument(); @@ -654,7 +770,7 @@ describe('ChatComposer', () => { onSendChunk={onSendChunk} />, ); - await user.click(screen.getByRole('button', { name: 'Change flood scope for this message' })); + await user.click(screen.getByRole('button', { name: 'Change flood scope for this channel' })); fireEvent.click(screen.getByRole('button', { name: 'Custom scope…' })); fireEvent.change(screen.getByRole('textbox', { name: 'Custom flood scope hashtag' }), { target: { value: '#fail' }, diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 47a948905..a90dd2cdd 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -24,7 +24,14 @@ import { splitChatMessage, } from '../lib/chatComposerLimits'; import { formatLocationMessage } from '../lib/chatLocationUtils'; -import { clearDraft, loadDraftsInitial, saveDraft } from '../lib/chatPanelProtocolStorage'; +import { + clearDraft, + FLOOD_SCOPE_OVERRIDE_UNSCOPED, + loadDraftsInitial, + loadFloodScopeOverridesInitial, + saveDraft, + saveFloodScopeOverride, +} from '../lib/chatPanelProtocolStorage'; import { normalizeMeshcoreFloodScopeHashtag } from '../lib/meshcoreFloodScope'; import { isValidMeshcoreFloodScopeHashtag, @@ -69,7 +76,10 @@ export interface ChatComposerSendOpts { /** Reticulum ratspeak.chat.v2 reply target (LXMF message hash). */ replyHash?: string; chunkIndex?: number; - /** MeshCore: ephemeral flood-scope hashtag for this send only (not persisted). */ + /** + * MeshCore: flood-scope hashtag for this send only (applied then radio default restored). + * Composer UI remembers the selection per viewKey separately. + */ floodScopeOverride?: string; } @@ -107,7 +117,7 @@ export interface ChatComposerProps { onSendSuccess?: () => void; /** Use LXMF message hash for reply threading (Reticulum). */ lxmfReplyHashReplies?: boolean; - /** MeshCore: show per-message flood-scope override control. */ + /** MeshCore: show per-channel flood-scope override control (remembered per viewKey). */ showFloodScopeOverride?: boolean; /** MeshCore: user-managed flood-scope quick-picks. */ floodScopePresets?: string[]; @@ -202,6 +212,8 @@ export function ChatComposer({ const floodScopeCustomInputRef = useRef(null); const inputValueRef = useRef(input); inputValueRef.current = input; + const floodScopeOverrideRef = useRef(floodScopeOverride); + floodScopeOverrideRef.current = floodScopeOverride; const prevViewKeyRef = useRef(null); const closeFloodScopeMenu = useCallback(() => { @@ -212,10 +224,30 @@ export function ChatComposer({ setFloodScopeCustomError(null); }, []); + const persistFloodScopeOverride = useCallback( + (next: string) => { + setFloodScopeOverride(next); + floodScopeOverrideRef.current = next; + if (!showFloodScopeOverride) return; + saveFloodScopeOverride(protocol, viewKey, next); + }, + [protocol, showFloodScopeOverride, viewKey], + ); + + const commitCustomFloodScopeDraft = useCallback(() => { + const normalized = normalizeMeshcoreFloodScopeHashtag(floodScopeCustomDraft); + if (!isValidMeshcoreFloodScopeHashtag(normalized)) { + setFloodScopeCustomError(t('chatPanel.floodScopeOverrideCustomInvalid')); + return; + } + persistFloodScopeOverride(normalized); + closeFloodScopeMenu(); + }, [closeFloodScopeMenu, floodScopeCustomDraft, persistFloodScopeOverride, t]); + const rememberFloodScopeIfNeeded = useCallback( (override: string) => { - // Default (`''`) and Unscoped (`__unscoped__`) must not enter the quick-pick list. - if (!override || override === '__unscoped__') return; + // Default (`''`) and Unscoped must not enter the quick-pick list. + if (!override || override === FLOOD_SCOPE_OVERRIDE_UNSCOPED) return; if (!isValidMeshcoreFloodScopeHashtag(override)) return; if (onRememberFloodScopePreset) { onRememberFloodScopePreset(override); @@ -338,7 +370,7 @@ export function ChatComposer({ const queueOutbox = queueOutboxProp ?? noopQueue; - // Draft persistence: save/restore unsent input when viewKey changes + // Draft + flood-scope persistence: save/restore when viewKey changes useEffect(() => { const prevKey = prevViewKeyRef.current; if (prevKey !== null && prevKey !== viewKey) { @@ -348,14 +380,26 @@ export function ChatComposer({ } else { clearDraft(protocol, prevKey); } + if (showFloodScopeOverride) { + saveFloodScopeOverride(protocol, prevKey, floodScopeOverrideRef.current); + } } prevViewKeyRef.current = viewKey; const drafts = loadDraftsInitial(protocol); // eslint-disable-next-line react-hooks/set-state-in-effect -- restore per-view draft from localStorage on tab switch setInput(drafts[viewKey] ?? ''); + if (showFloodScopeOverride) { + const overrides = loadFloodScopeOverridesInitial(protocol); + const restored = overrides[viewKey] ?? ''; + setFloodScopeOverride(restored); + floodScopeOverrideRef.current = restored; + } else { + setFloodScopeOverride(''); + floodScopeOverrideRef.current = ''; + } setMentionQuery(null); setChatActionError(null); - }, [viewKey, protocol]); + }, [viewKey, protocol, showFloodScopeOverride]); const mentionCandidates = useMemo( () => (mentionQuery != null ? buildMentionCandidates(nodes, protocol, mentionQuery) : []), @@ -467,7 +511,7 @@ export function ChatComposer({ replyHash: i === 0 ? reticulumReplyHash : undefined, chunkIndex: i, floodScopeOverride: - floodScopeOverride === '__unscoped__' + floodScopeOverride === FLOOD_SCOPE_OVERRIDE_UNSCOPED ? '' : floodScopeOverride ? floodScopeOverride @@ -827,7 +871,7 @@ export function ChatComposer({ const floodScopeOverrideActive = floodScopeOverride !== ''; const floodScopeOverrideIndicator = - floodScopeOverride === '__unscoped__' + floodScopeOverride === FLOOD_SCOPE_OVERRIDE_UNSCOPED ? t('chatPanel.floodScopeOverrideUnscoped') : floodScopeOverride || null; @@ -1212,16 +1256,7 @@ export function ChatComposer({ } if (e.key === 'Enter') { e.preventDefault(); - const normalized = - normalizeMeshcoreFloodScopeHashtag(floodScopeCustomDraft); - if (!isValidMeshcoreFloodScopeHashtag(normalized)) { - setFloodScopeCustomError( - t('chatPanel.floodScopeOverrideCustomInvalid'), - ); - return; - } - setFloodScopeOverride(normalized); - closeFloodScopeMenu(); + commitCustomFloodScopeDraft(); } }} ref={floodScopeCustomInputRef} @@ -1249,16 +1284,7 @@ export function ChatComposer({ ) : null; - if (!pathBadge && !dmNode && !rncpControl && !peerDetailsControl) return null; + if (!pathBadge && !dmNode && !rncpControl && !voiceCallControl && !peerDetailsControl) { + return null; + } + // Order: path status → last heard → peer details → Probe/Path → Call → Send file. return (
{pathBadge} - {pathActions} - {peerDetailsControl} {dmNode ? : null} + {peerDetailsControl} + {pathActions} + {voiceCallControl} {rncpControl}
); diff --git a/src/renderer/components/MeshcoreFloodScopeSection.test.tsx b/src/renderer/components/MeshcoreFloodScopeSection.test.tsx index 966ed38c2..31d49b507 100644 --- a/src/renderer/components/MeshcoreFloodScopeSection.test.tsx +++ b/src/renderer/components/MeshcoreFloodScopeSection.test.tsx @@ -21,7 +21,7 @@ vi.mock('react-i18next', () => ({ 'radioPanel.floodScopeRemoveSavedAria': `Remove ${hashtag} from saved scopes`, 'radioPanel.floodScopeInvalidHashtag': 'Enter a valid region hashtag', 'radioPanel.floodScopeCustom': 'Custom hashtag', - 'radioPanel.floodScopeCustomPlaceholder': '#myregion', + 'radioPanel.floodScopeCustomPlaceholder': '#metro', 'radioPanel.floodScopeApply': 'Apply flood scope', 'radioPanel.floodScopeApplySuccess': 'Flood scope applied', 'radioPanel.floodScopeApplyFailed': `Flood scope failed: ${message}`, diff --git a/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx new file mode 100644 index 000000000..658aec6d0 --- /dev/null +++ b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RETICULUM_DM_HEADER_ACTION_CLASS } from '@/renderer/lib/reticulumDmHeaderActions'; + +import { + ReticulumDmPathActions, + ReticulumDmPathReachabilityBadge, +} from './ReticulumDmPathReachabilityBadge'; +import { ToastProvider } from './Toast'; + +vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ + isReticulumSidecarRunning: vi.fn().mockResolvedValue(true), + probeReticulumPeer: vi.fn(), + requestReticulumPeerPath: vi.fn(), + formatReticulumPeerPathToast: () => ({ message: 'ok', variant: 'success' as const }), + formatReticulumPeerProbeToast: () => ({ message: 'ok', variant: 'success' as const }), +})); + +vi.mock('@/renderer/stores/reticulumPeerStore', () => ({ + refreshReticulumPeersFromSidecar: vi.fn(), + useReticulumPeerStore: { getState: () => ({ updatePeer: vi.fn() }) }, +})); + +function precedes(a: Element, b: Element): boolean { + return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); +} + +describe('ReticulumDmPathReachabilityBadge', () => { + it('uses slate status chip tokens for reachable state', () => { + render(); + const badge = screen.getByRole('status', { name: 'Destination path is reachable' }); + expect(badge.className).toContain('bg-slate-800/60'); + expect(badge.className).toContain('rounded-lg'); + }); +}); + +describe('ReticulumDmPathActions', () => { + it('renders Probe before Path with shared outlined cyan chip class', () => { + render( + + + , + ); + const probe = screen.getByRole('button', { + name: 'Probe Reticulum path reachability for this destination', + }); + const path = screen.getByRole('button', { + name: 'Request Reticulum path to this destination', + }); + expect(precedes(probe, path)).toBe(true); + expect(probe.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); + expect(path.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); + expect(probe.className).toContain('border-cyan-500/35'); + expect(probe.className).toMatch(/text-cyan-/); + expect(probe.className).not.toMatch(/border-gray-600/); + expect(path.className).not.toMatch(/border-gray-600/); + }); + + it('keeps Probe before Path when unreachable', () => { + render( + + + , + ); + const probe = screen.getByRole('button', { + name: 'Probe Reticulum path reachability for this destination', + }); + const path = screen.getByRole('button', { + name: 'Request Reticulum path to this destination', + }); + expect(precedes(probe, path)).toBe(true); + }); +}); diff --git a/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx b/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx index 26e0590e2..16204fc53 100644 --- a/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx +++ b/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx @@ -10,6 +10,10 @@ import { probeReticulumPeer, requestReticulumPeerPath, } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { + RETICULUM_DM_HEADER_ACTION_CLASS, + RETICULUM_DM_HEADER_STATUS_CLASS, +} from '@/renderer/lib/reticulumDmHeaderActions'; import { refreshReticulumPeersFromSidecar, useReticulumPeerStore, @@ -63,7 +67,7 @@ export function ReticulumDmPathReachabilityBadge({ {status === 'probing' ? ( +
); diff --git a/src/renderer/components/ReticulumPeerDetailModal.tsx b/src/renderer/components/ReticulumPeerDetailModal.tsx index e55285b46..21710dd1a 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.tsx @@ -34,6 +34,7 @@ import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import { useBlockStore } from '@/renderer/stores/blockStore'; import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; import { + refreshReticulumPeersFromSidecar, resolveReticulumPeerLabel, useReticulumPeerStore, } from '@/renderer/stores/reticulumPeerStore'; @@ -395,7 +396,6 @@ export default function ReticulumPeerDetailModal({ favorited: Boolean(peer.favorited), }); registerReticulumDestinationHash(reticulumHashToNodeId(key), key); - const { refreshReticulumPeersFromSidecar } = await import('../stores/reticulumPeerStore'); await refreshReticulumPeersFromSidecar(); } catch (e) { console.warn('[ReticulumPeerDetailModal] save contact ' + errLikeToLogString(e)); diff --git a/src/renderer/components/ReticulumPeerListPanel.test.tsx b/src/renderer/components/ReticulumPeerListPanel.test.tsx index bbcbd9ce9..11ad06da9 100644 --- a/src/renderer/components/ReticulumPeerListPanel.test.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.test.tsx @@ -36,6 +36,7 @@ const reticulumSidecarMocks = vi.hoisted(() => ({ })); vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: () => {} }, useTranslation: () => ({ t: (key: string, opts?: Record) => { if (opts && 'count' in opts) return `${key}:${String(opts.count)}`; diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index 1eabd9355..bf159caf7 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -55,6 +55,7 @@ import { useReticulumPeerStore, } from '../stores/reticulumPeerStore'; import { ReticulumPeerPathsDetail } from './reticulum/ReticulumPeerPathsDetail'; +import { ReticulumVoiceCallButton } from './reticulum/ReticulumVoiceCallButton'; import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { useToast } from './Toast'; @@ -79,6 +80,8 @@ export interface ReticulumPeerListPanelProps { onManageGroups?: () => void; groupMemberIds?: Set; contactGroupsEnabled?: boolean; + /** LXST voice Call button on each peer row. */ + hasLxstVoice?: boolean; } function peerHashToNodeNum(hash: string): number { @@ -238,6 +241,7 @@ export default function ReticulumPeerListPanel({ onManageGroups, groupMemberIds, contactGroupsEnabled = false, + hasLxstVoice = false, }: ReticulumPeerListPanelProps) { const { t } = useTranslation(); const { addToast } = useToast(); @@ -578,6 +582,13 @@ export default function ReticulumPeerListPanel({ > + {hasLxstVoice ? ( + + ) : null} + ); +} diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx new file mode 100644 index 000000000..8138e20c5 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; + +import { ReticulumVoiceOverlay } from './ReticulumVoiceOverlay'; + +const answer = vi.fn(); +const reject = vi.fn(); +const hangup = vi.fn(); +const setMuted = vi.fn(); +const startMedia = vi.fn(); +const syncTones = vi.fn(); + +vi.mock('@/renderer/lib/reticulumVoiceSession', () => ({ + reticulumVoiceAnswer: () => answer(), + reticulumVoiceReject: () => reject(), + reticulumVoiceHangup: () => hangup(), + reticulumVoiceSetMuted: (...args: unknown[]) => setMuted(...args), + startReticulumVoiceMediaForActiveCall: (...args: unknown[]) => startMedia(...args), + stopReticulumVoiceMedia: vi.fn(), + syncReticulumVoiceProgressTones: (...args: unknown[]) => syncTones(...args), +})); + +describe('ReticulumVoiceOverlay', () => { + beforeEach(() => { + answer.mockReset(); + reject.mockReset(); + hangup.mockReset(); + setMuted.mockReset(); + startMedia.mockReset(); + syncTones.mockReset(); + act(() => { + useReticulumVoiceStore.getState().clearCall(); + useReticulumPeerStore.getState().clearPeers(); + }); + }); + + it('shows incoming dialog and fires answer/reject', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + const { container } = render(); + expect(screen.getByRole('dialog', { name: /incoming voice call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /answer voice call/i })); + expect(answer).toHaveBeenCalled(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + render(); + await user.click(screen.getAllByRole('button', { name: /reject voice call/i })[0]); + expect(reject).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('in-call bar mute and hangup fire helpers with no axe violations', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'outgoing', + status: 'established', + }, + }); + }); + const { container } = render(); + expect(screen.getByRole('status', { name: /in call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /mute microphone/i })); + expect(setMuted).toHaveBeenCalledWith(true); + await user.click(screen.getByRole('button', { name: /hang up voice call/i })); + expect(hangup).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('closes incoming dialog after answer progresses to connecting/established', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + render(); + expect(screen.getByRole('dialog', { name: /incoming voice call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /answer voice call/i })); + expect(answer).toHaveBeenCalled(); + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'connecting', + answered: true, + }, + }); + }); + expect(screen.queryByRole('dialog', { name: /incoming voice call/i })).toBeNull(); + expect(screen.getByRole('status', { name: /ringing/i })).toBeTruthy(); + expect(startMedia).not.toHaveBeenCalled(); + + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'established', + answered: true, + }, + }); + }); + expect(screen.queryByRole('dialog', { name: /incoming voice call/i })).toBeNull(); + expect(screen.getByRole('status', { name: /in call/i })).toBeTruthy(); + expect(startMedia).toHaveBeenCalledTimes(1); + }); + + it('inbound connecting does not start media; inbound established starts once', () => { + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'connecting', + answered: true, + }, + }); + }); + render(); + expect(screen.getByRole('status', { name: /ringing/i })).toBeTruthy(); + expect(startMedia).not.toHaveBeenCalled(); + + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'established', + answered: true, + }, + }); + }); + expect(screen.getByRole('status', { name: /in call/i })).toBeTruthy(); + expect(startMedia).toHaveBeenCalledTimes(1); + }); + + it('shows Connecting on calling and does not start media until established', () => { + act(() => { + useReticulumVoiceStore.getState().beginOutgoing('b'.repeat(32)); + }); + render(); + expect(screen.getByRole('status', { name: /connecting/i })).toBeTruthy(); + expect(startMedia).not.toHaveBeenCalled(); + expect(syncTones).toHaveBeenCalledWith('calling'); + + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + }); + }); + expect(screen.getByRole('status', { name: /ringing/i })).toBeTruthy(); + expect(startMedia).not.toHaveBeenCalled(); + }); + + it('shows hangup while optimistic calling with TX/RX counters', async () => { + const user = userEvent.setup(); + const dest = 'c'.repeat(32); + const id = 'b'.repeat(32); + act(() => { + useReticulumPeerStore.setState({ + peers: new Map([ + [ + dest, + { + destination_hash: dest, + identity_hash: id, + display_name: 'Dial Peer', + hops: 1, + }, + ], + ]), + peersRevision: 1, + }); + useReticulumVoiceStore.getState().beginOutgoing(id); + useReticulumVoiceStore.getState().applyStats({ + tx_frames: 4, + tx_packets: 3, + rx_frames: 1, + }); + }); + const { container } = render(); + expect(screen.getByRole('status', { name: /connecting/i })).toBeTruthy(); + const panel = screen.getByRole('status', { name: /connecting/i }); + expect(panel.className).toContain('top-1/2'); + expect(panel.className).toContain('left-1/2'); + expect(panel.className).toContain('-translate-x-1/2'); + expect(panel.className).toContain('-translate-y-1/2'); + expect(panel.className).not.toContain('bottom-3'); + expect(screen.getByText('Dial Peer')).toBeTruthy(); + expect(screen.queryByText(id)).toBeNull(); + expect(screen.getByText(/TX 4/i)).toBeTruthy(); + expect(screen.getByText(/RX 1/i)).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /hang up voice call/i })); + expect(hangup).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('shows peer display name on incoming dialog instead of raw hash', () => { + const dest = 'd'.repeat(32); + const id = 'e'.repeat(32); + act(() => { + useReticulumPeerStore.setState({ + peers: new Map([ + [ + dest, + { + destination_hash: dest, + identity_hash: id, + display_name: 'Incoming Peer', + hops: 0, + }, + ], + ]), + peersRevision: 1, + }); + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: id, + role: 'incoming', + status: 'ringing', + }); + }); + render(); + expect(screen.getByText('Incoming Peer')).toBeTruthy(); + expect(screen.queryByText(id)).toBeNull(); + }); +}); diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx new file mode 100644 index 000000000..af704332a --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx @@ -0,0 +1,198 @@ +import { Mic, MicOff, PhoneOff } from 'lucide-react-motion'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { resolveReticulumVoiceRemoteLabel } from '@/renderer/lib/reticulumVoiceRemoteLabel'; +import { + reticulumVoiceAnswer, + reticulumVoiceHangup, + reticulumVoiceReject, + reticulumVoiceSetMuted, + startReticulumVoiceMediaForActiveCall, + stopReticulumVoiceMedia, + syncReticulumVoiceProgressTones, +} from '@/renderer/lib/reticulumVoiceSession'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; +import { isReticulumIncomingRinging } from '@/shared/voice-types'; + +function formatElapsed(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function phaseLabelKey(status: string): string { + switch (status) { + case 'calling': + // Dial-tone / path discovery phase. + return 'reticulumVoice.connecting'; + case 'connecting': + case 'ringing': + // Link up — ringback while waiting for answer. + return 'reticulumVoice.ringing'; + case 'established': + return 'reticulumVoice.inCall'; + default: + return 'reticulumVoice.connecting'; + } +} + +/** + * Incoming-call modal + compact in-call mini-panel. Mount once from App when hasLxstVoice. + */ +export function ReticulumVoiceOverlay() { + const { t } = useTranslation(); + const incoming = useReticulumVoiceStore((s) => s.incomingCall); + const active = useReticulumVoiceStore((s) => s.activeCall); + const muted = useReticulumVoiceStore((s) => s.microphoneMuted); + const callStartedAtMs = useReticulumVoiceStore((s) => s.callStartedAtMs); + const callGeneration = useReticulumVoiceStore((s) => s.callGeneration); + const stats = useReticulumVoiceStore((s) => s.stats); + // Re-resolve name when peer table updates (dial may start before peers hydrate). + useReticulumPeerStore((s) => s.peersRevision); + const [nowMs, setNowMs] = useState(null); + + const activeStatus = active?.status ?? null; + + useEffect(() => { + syncReticulumVoiceProgressTones(activeStatus); + // PCM TX only after established — early frames cause fatal lxst CallNotEstablished. + if (activeStatus === 'established') { + void startReticulumVoiceMediaForActiveCall(); + } + if (activeStatus == null) { + stopReticulumVoiceMedia(); + syncReticulumVoiceProgressTones(null); + } + // Key media start on status + generation only — link_id fill must not restart capture. + }, [activeStatus, callGeneration]); + + useEffect(() => { + if (callStartedAtMs == null) return; + const immediate = setTimeout(() => { + setNowMs(Date.now()); + }, 0); + const id = setInterval(() => { + setNowMs(Date.now()); + }, 1000); + return () => { + clearTimeout(immediate); + clearInterval(id); + }; + }, [callStartedAtMs]); + + const elapsedMs = + callStartedAtMs == null || nowMs == null ? 0 : Math.max(0, nowMs - callStartedAtMs); + const elapsedLabel = formatElapsed(elapsedMs); + + const showIncoming = isReticulumIncomingRinging(incoming); + + const showInCall = + active != null && + !showIncoming && + (active.status === 'calling' || + active.status === 'connecting' || + active.status === 'established' || + active.status === 'ringing'); + + const remoteHash = showIncoming + ? (incoming?.remote_identity ?? '') + : (active?.remote_identity ?? ''); + const remoteLabel = remoteHash ? resolveReticulumVoiceRemoteLabel(remoteHash) : ''; + + if (!showIncoming && !showInCall) return null; + + if (showIncoming && incoming) { + return ( +
+
+

+ {t('reticulumVoice.incomingTitle')} +

+

+ {remoteLabel} +

+
+ + +
+
+
+ ); + } + + if (!active) return null; + + return ( +
+
+ {t(phaseLabelKey(active.status))} + + {elapsedLabel} + + + {remoteLabel} + + + +
+
+ + {t('reticulumVoice.txFrames', { count: stats.txFrames })} + {stats.txPackets > 0 + ? ` ${t('reticulumVoice.statsSeparator')} ${t('reticulumVoice.txPackets', { count: stats.txPackets })}` + : ''} + + + {t('reticulumVoice.rxFrames', { count: stats.rxFrames })} + +
+
+ ); +} diff --git a/src/renderer/components/rrc/RrcChatView.test.tsx b/src/renderer/components/rrc/RrcChatView.test.tsx index 8ee8f3c78..667cb66e9 100644 --- a/src/renderer/components/rrc/RrcChatView.test.tsx +++ b/src/renderer/components/rrc/RrcChatView.test.tsx @@ -1,9 +1,36 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RrcChatMessage } from '@/shared/rrc-types'; -import { RrcChatView } from './RrcChatView'; +import { estimateRrcRowHeight, RrcChatView } from './RrcChatView'; + +const mockScrollToEnd = vi.fn(); +let mockIsAtEnd = true; + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (opts: Record & { count: number }) => { + const count = opts.count; + return { + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 22, + })), + getTotalSize: () => count * 22, + measureElement: () => {}, + containerRef: { current: null }, + isAtEnd: () => mockIsAtEnd, + scrollToEnd: mockScrollToEnd, + scrollToIndex: vi.fn(), + scrollDirection: 'forward' as const, + shouldAdjustScrollPositionOnItemSizeChange: undefined as + ((item: { index: number }) => boolean) | undefined, + }; + }, +})); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -24,19 +51,36 @@ function makeMsg( }; } +const baseProps = { + connected: true as const, + activeRoom: '#general', + showTimestamps: false, + draft: '', + onDraftChange: vi.fn(), + onSend: vi.fn(), + canSend: true, + isMuted: false, +}; + +describe('estimateRrcRowHeight', () => { + it('scales with wrapped body length', () => { + expect(estimateRrcRowHeight(makeMsg({ id: '1', body: 'hi' }))).toBe(22); + expect(estimateRrcRowHeight(makeMsg({ id: '2', body: 'x'.repeat(160) }))).toBe(42); + expect(estimateRrcRowHeight(undefined)).toBe(22); + }); +}); + describe('RrcChatView alwaysShowMessageActions', () => { + beforeEach(() => { + mockIsAtEnd = true; + mockScrollToEnd.mockClear(); + }); + it('keeps copy control visible when alwaysShowMessageActions is set', () => { render( , ); @@ -46,22 +90,151 @@ describe('RrcChatView alwaysShowMessageActions', () => { }); it('uses hover/focus-within visibility for copy by default', () => { - render( - , - ); + render(); const btn = screen.getByLabelText('rrc.copyMessage'); expect(btn.className).toMatch(/(?:^|\s)opacity-0(?:\s|$)/); expect(btn.className).toContain('group-focus-within:opacity-100'); expect(btn.className).toContain('group-hover:opacity-100'); }); }); + +describe('RrcChatView stick-to-bottom', () => { + beforeEach(() => { + mockIsAtEnd = true; + mockScrollToEnd.mockClear(); + }); + + it('scrolls to end when a message appends while pinned', async () => { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + mockScrollToEnd.mockClear(); + + rerender( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + }); + + it('does not follow appends when scrolled up and shows Jump to Latest', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + + mockIsAtEnd = false; + fireEvent.scroll(screen.getByTestId('rrc-message-stream')); + expect(screen.getByLabelText('rrc.jumpToLatest')).toBeInTheDocument(); + + mockScrollToEnd.mockClear(); + rerender( + , + ); + // Allow the follow effect to run; it must not scroll while unpinned. + await waitFor(() => { + expect(screen.getByLabelText('rrc.jumpToLatest')).toBeInTheDocument(); + }); + expect(mockScrollToEnd).not.toHaveBeenCalled(); + + await user.click(screen.getByLabelText('rrc.jumpToLatest')); + expect(mockScrollToEnd).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); + + it('scrolls to end when activeRoom changes', async () => { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + mockScrollToEnd.mockClear(); + + rerender( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + }); + + it('restores scrollTop on tab re-entry when not pinned', () => { + const { rerender } = render( + , + ); + const stream = screen.getByTestId('rrc-message-stream'); + Object.defineProperty(stream, 'scrollTop', { + value: 500, + writable: true, + configurable: true, + }); + mockIsAtEnd = false; + fireEvent.scroll(stream); + + rerender( + , + ); + (stream as HTMLDivElement).scrollTop = 0; + + mockScrollToEnd.mockClear(); + rerender( + , + ); + + expect((stream as HTMLDivElement).scrollTop).toBe(500); + }); + + it('scrolls to end on tab re-entry when pinned', () => { + const { rerender } = render( + , + ); + const stream = screen.getByTestId('rrc-message-stream'); + Object.defineProperty(stream, 'scrollTop', { + value: 400, + writable: true, + configurable: true, + }); + // Stay pinned (no scroll event — isPinnedToBottomRef defaults true; isAtEnd true). + + rerender( + , + ); + + mockScrollToEnd.mockClear(); + mockScrollToEnd.mockImplementation(() => { + (stream as HTMLDivElement).scrollTop = 900; + }); + + rerender( + , + ); + + expect(mockScrollToEnd).toHaveBeenCalled(); + expect((stream as HTMLDivElement).scrollTop).toBe(900); + }); +}); diff --git a/src/renderer/components/rrc/RrcChatView.tsx b/src/renderer/components/rrc/RrcChatView.tsx index ede022d4d..5b4cde1a4 100644 --- a/src/renderer/components/rrc/RrcChatView.tsx +++ b/src/renderer/components/rrc/RrcChatView.tsx @@ -1,8 +1,25 @@ -import { Copy } from 'lucide-react-motion'; -import type { ReactNode } from 'react'; +/* eslint-disable react-hooks/incompatible-library -- TanStack Virtual useVirtualizer; same as RoomsPanel */ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { ArrowDown, Copy } from 'lucide-react-motion'; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { ChatPayloadText } from '@/renderer/components/ChatPayloadText'; +import { + CHAT_SCROLL_END_THRESHOLD, + createChatScrollAdjustPredicate, + createStableChatMeasureElement, + getDistFromChatBottom, + VIRTUALIZER_SCROLL_END_THRESHOLD, +} from '@/renderer/lib/chatScrollUtils'; import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { bodyMentionsRrcNick, findNextRrcNickMention } from '@/renderer/lib/rrcMention'; import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; @@ -12,6 +29,19 @@ function formatHash(hash: string): string { return hash.slice(0, 8); } +/** Compact IRC line height for virtualization (not ChatMessage card estimates). */ +export function estimateRrcRowHeight(msg: RrcChatMessage | null | undefined): number { + const bodyLen = msg?.body.length ?? 0; + const lines = Math.max(1, Math.ceil(bodyLen / 80)); + // ~20px leading-snug + 2px row gap + return lines * 20 + 2; +} + +function rrcMessageVirtualizerKey(msg: RrcChatMessage | null | undefined, index: number): string { + if (!msg) return `rrc-slot-${index}`; + return msg.id || `rrc-slot-${index}`; +} + /** Highlight IRC-style @nick tokens that match the local nickname. */ function highlightRrcSelfMentions(text: string, nickname: string): ReactNode { const nick = nickname.trim(); @@ -73,6 +103,10 @@ export interface RrcChatViewProps { nickname?: string; /** Keep the per-message copy control visible (same App Appearance setting as Chat). */ alwaysShowMessageActions?: boolean; + /** Composer placeholder override (e.g. whisper reply hint). */ + placeholder?: string; + /** When false, skip follow-on-append and snapshot scroll for tab restore. */ + isActive?: boolean; } export function RrcChatView({ @@ -87,9 +121,133 @@ export function RrcChatView({ isMuted, nickname = '', alwaysShowMessageActions = false, + placeholder, + isActive = true, }: RrcChatViewProps) { const { t } = useTranslation(); const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); + const composerPlaceholder = placeholder ?? t('rrc.messagePlaceholder'); + + const scrollContainerRef = useRef(null); + const messagesEndRef = useRef(null); + /** Sticky intent: user is reading latest messages and wants auto-follow on new traffic. */ + const isPinnedToBottomRef = useRef(true); + const unreadStartIndexRef = useRef(-1); + const savedScrollTopRef = useRef(null); + const savedWasPinnedToBottomRef = useRef(false); + const wasActiveRef = useRef(isActive); + const prevActiveRoomRef = useRef(activeRoom); + const [showScrollButton, setShowScrollButton] = useState(false); + + const estimateSize = useCallback( + (index: number) => estimateRrcRowHeight(messages[index]), + [messages], + ); + + const measureElement = useMemo( + () => createStableChatMeasureElement(estimateSize), + [estimateSize], + ); + + const messageVirtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize, + measureElement, + overscan: 10, + getItemKey: (index) => rrcMessageVirtualizerKey(messages[index], index), + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: VIRTUALIZER_SCROLL_END_THRESHOLD, + }); + + messageVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = createChatScrollAdjustPredicate({ + unreadStartIndexRef, + isPinnedToBottomRef, + }); + + const messageVirtualizerRef = useRef(messageVirtualizer); + messageVirtualizerRef.current = messageVirtualizer; + + const computeIsAtChatEnd = useCallback(() => { + if (!scrollContainerRef.current) return false; + return messageVirtualizerRef.current.isAtEnd(CHAT_SCROLL_END_THRESHOLD); + }, []); + + const updateScrollButtonVisibility = useCallback(() => { + const atEnd = computeIsAtChatEnd(); + isPinnedToBottomRef.current = atEnd; + setShowScrollButton(!atEnd); + return getDistFromChatBottom(scrollContainerRef.current, messagesEndRef.current, null); + }, [computeIsAtChatEnd]); + + const handleStreamScroll = useCallback(() => { + updateScrollButtonVisibility(); + }, [updateScrollButtonVisibility]); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { + messageVirtualizerRef.current.scrollToEnd({ behavior }); + isPinnedToBottomRef.current = true; + setShowScrollButton(false); + }, []); + + // Follow new messages when pinned (Rooms/Chat contract). + useEffect(() => { + if (!isActive || document.hidden || !activeRoom) return; + if (isPinnedToBottomRef.current) { + messageVirtualizerRef.current.scrollToEnd(); + } + requestAnimationFrame(() => { + updateScrollButtonVisibility(); + }); + }, [messages.length, isActive, activeRoom, updateScrollButtonVisibility]); + + // Room switch while active → pin + scroll to end. + useLayoutEffect(() => { + const prevRoom = prevActiveRoomRef.current; + prevActiveRoomRef.current = activeRoom; + if (!isActive) return; + if (prevRoom === activeRoom) return; + if (!activeRoom) return; + isPinnedToBottomRef.current = true; + messageVirtualizerRef.current.scrollToEnd(); + setShowScrollButton(false); + }, [activeRoom, isActive]); + + // Tab exit snapshot / tab return restore (Rooms contract). + useLayoutEffect(() => { + const el = scrollContainerRef.current; + const wasActive = wasActiveRef.current; + wasActiveRef.current = isActive; + + if (!isActive) { + if (el) { + savedScrollTopRef.current = el.scrollTop; + savedWasPinnedToBottomRef.current = isPinnedToBottomRef.current; + } + return; + } + + if (!wasActive) { + if (savedScrollTopRef.current !== null) { + if (savedWasPinnedToBottomRef.current) { + messageVirtualizerRef.current.scrollToEnd(); + isPinnedToBottomRef.current = true; + setShowScrollButton(false); + } else if (el) { + el.scrollTop = savedScrollTopRef.current; + } + savedScrollTopRef.current = null; + savedWasPinnedToBottomRef.current = false; + } + } + }, [isActive]); + + useLayoutEffect(() => { + requestAnimationFrame(() => { + updateScrollButtonVisibility(); + }); + }, [updateScrollButtonVisibility]); if (!connected) { return ( @@ -101,75 +259,118 @@ export function RrcChatView({ return (
-
- {!activeRoom && ( -
-

{t('rrc.joinRoomPrompt')}

-

{t('rrc.joinRoomHelp')}

-
- )} - {activeRoom && - messages.map((msg) => { - const nick = msg.nickname || (msg.sender_hash ? formatHash(msg.sender_hash) : ''); - const time = showTimestamps - ? formatDisplayTime(msg.timestamp, { withSeconds: true, use24Hour: use24HourTime }) - : null; - const lineClass = - msg.kind === 'notice' || msg.kind === 'system' - ? 'text-amber-300/90' - : msg.kind === 'action' - ? 'text-cyan-200/90 italic' - : msg.kind === 'error' - ? 'text-red-300' - : 'text-amber-50/90'; - const body = highlightRrcSelfMentions(msg.body, nickname); - - return ( -
- {time && [{time}]} -
- {msg.kind === 'action' ? ( - <> - * {nick} {body} - - ) : msg.kind === 'notice' || msg.kind === 'system' || msg.kind === 'error' ? ( - <> - {msg.kind === 'notice' && nick ? ( - -{nick}- - ) : ( - * +
+
+ {!activeRoom && ( +
+

{t('rrc.joinRoomPrompt')}

+

{t('rrc.joinRoomHelp')}

+
+ )} + {activeRoom && ( +
+ {messageVirtualizer.getVirtualItems().map((vi) => { + const msg = messages[vi.index]; + if (!msg) return null; + const nick = msg.nickname || (msg.sender_hash ? formatHash(msg.sender_hash) : ''); + const time = showTimestamps + ? formatDisplayTime(msg.timestamp, { + withSeconds: true, + use24Hour: use24HourTime, + }) + : null; + const lineClass = + msg.kind === 'notice' || msg.kind === 'system' + ? 'text-amber-300/90' + : msg.kind === 'action' + ? 'text-cyan-200/90 italic' + : msg.kind === 'error' + ? 'text-red-300' + : 'text-amber-50/90'; + const body = highlightRrcSelfMentions(msg.body, nickname); + + return ( +
+
+ {time && ( + [{time}] )} - {body} - - ) : ( - <> - <{nick}> {body} - - )} -
- -
- ); - })} +
+ {msg.kind === 'action' ? ( + <> + * {nick} {body} + + ) : msg.kind === 'notice' || + msg.kind === 'system' || + msg.kind === 'error' ? ( + <> + {msg.kind === 'notice' && nick ? ( + -{nick}- + ) : ( + * + )} + {body} + + ) : ( + <> + <{nick}>{' '} + {body} + + )} +
+ +
+
+ ); + })} +
+ )} +
+
+ {showScrollButton && activeRoom && ( + + )}