diff --git a/docs/agents/chat.md b/docs/agents/chat.md index 78dc4e802..7ed07285e 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -4,7 +4,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Ch - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). - **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` via `ProtocolCapabilities.composerMaxChunks` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock and can show the advisory — text (`handleSend`), GIF, and share-location in `ChatComposer`, plus outbox drain (`useChatOutbox.ts`). Legacy MeshCore outbox rows with `groupTotal > 1` or `[i/N]` payloads are quarantined (`blocked`) on drain instead of being transmitted. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`, `chatPanel.outboxLegacyMultipartBlocked`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). -- **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`. +- **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`. LXMF voice memos (`hasReticulumVoiceMemo`): Chat DM mic → sidecar `/api/v1/voice/memo/*` via `electronAPI.reticulum.voiceMemo.*`; playback via `chat:readReticulumAttachmentBytes` (`reticulum-attachment-audio.ts`; OggS sniff, 256 KiB) → `ReticulumVoiceMemoLine`. 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), 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. diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index 3b061c977..62b5dc97e 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -28,8 +28,9 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` - **Nomad request bodies:** MessagePack `field_*`/`var_*` encode/decode lives in rsNomad `nomad-core` (`encode_request_fields` / `decode_request_fields`); sidecar `nomad_request_payload.rs` only unwraps HTTP base64 JSON before calling encode - **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + push channel `reticulum:voiceAudio` (`/ws/voice`; preload `onVoiceAudio`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). +- **LXMF voice memos:** `hasReticulumVoiceMemo` gates Chat DM mic (not LXST Call). Sidecar encodes Opus/`AM_OPUS_OGG` via `/api/v1/voice/memo/{start,audio,stop,cancel}` + dedicated IPC `electronAPI.reticulum.voiceMemo.*` (`reticulum:voiceMemoStart` / `voiceMemoSendAudio` / `voiceMemoStop` / `voiceMemoCancel`; proxy blocked). Capture resamples to 24 kHz / 60 ms frames and serializes PCM IPC before stop. Ogg capped ~240 KiB under the 256 KiB LXMF field / default PN deposit. Key files: `reticulumVoiceMemo.ts`, `sendReticulumVoiceMemo.ts`, `reticulumVoiceMemoStore.ts`, `reticulum-attachment-audio.ts`, `ReticulumVoiceMemoLine.tsx`. Ingest stamps `FIELD_AUDIO` → attachment jail + playback (`chat:readReticulumAttachmentBytes`). Oversize-for-PN emits `message_too_large_for_propagation` (notice toast + Direct-only badge — never a PN-outage toast). Tap mic to record, tap again to send; Esc/DM-switch cancels; mic stays visible while recording even if the draft is non-empty. - **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; companion `games_outbound.db` persists last envelope + `delivery_state` (LXMF outbound bridge → session chips / Resend). Dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [../reticulum-games-parity.md](../reticulum-games-parity.md). -- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` +- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasReticulumVoiceMemo` (LXMF voice memos); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` - **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start - **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF — marks **configured** when HTTP + identity ready (live attach may still run); `RETICULUM_CONFIGURED_EVENT` wakes RRC. Cancel/stop is fire-and-forget vs cargo/BLE (`START_ABORTED` checkpoints; next start does not rejoin a doomed promise). LXMF/RRC proxy sends: **15 s** `RETICULUM_IPC_SEND_TIMEOUT_MS`. RRC auto-connect (`useRrcStartupAutoConnect`): ~**500 ms** while hubs pending, ~4 s steady. 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; HTTP health, not listen-first ready lag), `reticulum/rns-not-ready` / `reticulum/lxmf-not-ready`, `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 8e2968cbd..0127d9830 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -242,6 +242,10 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi | 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 900/min proxy ceiling. | +| POST | `/api/v1/voice/memo/start` | | Start LXMF voice-memo encode session (`AM_OPUS_OGG` / QualityMedium). Dedicated IPC `reticulum:voiceMemoStart` — generic `proxyPost` rejects `/api/v1/voice/memo/*`. | +| POST | `/api/v1/voice/memo/audio` | `{ session_id, channels, samples_b64 }` | Push one 24 kHz / 60 ms (1440 f32) PCM frame for memo encode. Dedicated IPC `reticulum:voiceMemoSendAudio` (rate-limited like live voice PCM). | +| POST | `/api/v1/voice/memo/stop` | `{ session_id }` | Finish encode; returns `{ ok, ogg_base64, duration_ms, size_bytes, mode }`. Cap ~240 KiB. Dedicated IPC `reticulum:voiceMemoStop`. | +| POST | `/api/v1/voice/memo/cancel` | `{ session_id }` | Drop memo session without producing Ogg. Dedicated IPC `reticulum:voiceMemoCancel`. | | GET | `/api/v1/games/status` | | LRGP live status (`available`, `enabled`, `running`, registered apps). Use dedicated IPC `reticulum:gamesStatus` — generic `proxyGet` rejects `/api/v1/games/*` | | GET | `/api/v1/games/apps` | | Registered game manifests (ttt, chess) | | GET | `/api/v1/games/sessions` | optional `?peer=` | Session list (sidecar `LrgpStore`) | @@ -286,27 +290,29 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent` uses a dedicated **120/min** bucket so WS-lag catch-up does not starve mesh control. On rate-limit errors the renderer applies exponential backoff (`reticulumProxyRateLimitBackoff.ts`). -| IPC channel | Role | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | -| `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | -| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above (shared 900/min; lxmf/recent 120/min) | -| `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | -| `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | -| `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | -| `reticulum:showConfigImportDialog` | Native file picker for config import | -| `reticulum:showIdentityImportDialog` | Native file picker for 64-byte private key (`.retid`, `.key`, …) | -| `reticulum:showNomadContentSourceDialog` | Native folder picker for Nomad My Pages content source (site root or `pages/` dir); records picker allowlist | -| `reticulum:setNomadContentSource` | Apply Nomad watched content source; path must match last folder-picker result (blocks arbitrary proxyPut) | -| `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:event` / `reticulum:status` | Shared `/ws` events and sidecar status (preload: `onEvent` / `onStatus`) | -| `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | -| `reticulum:voiceAudio` | Dedicated `/ws/voice` PCM frames (`voice.audio`; preload: `onVoiceAudio`) | -| `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | -| `reticulum:gamesStatus` / `gamesApps` / `gamesSessions` / … | Dedicated LRGP games IPC (~600/min); generic proxy rejects `/api/v1/games/*` | -| `electronAPI.reticulum.games.*` | Preload: `getStatus` / `listApps` / `listSessions` / `getSession` / `sendAction` / `resend` / `markRead` / `deleteSession` | +| IPC channel | Role | +| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | +| `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | +| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above (shared 900/min; lxmf/recent 120/min) | +| `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | +| `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | +| `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | +| `reticulum:showConfigImportDialog` | Native file picker for config import | +| `reticulum:showIdentityImportDialog` | Native file picker for 64-byte private key (`.retid`, `.key`, …) | +| `reticulum:showNomadContentSourceDialog` | Native folder picker for Nomad My Pages content source (site root or `pages/` dir); records picker allowlist | +| `reticulum:setNomadContentSource` | Apply Nomad watched content source; path must match last folder-picker result (blocks arbitrary proxyPut) | +| `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:event` / `reticulum:status` | Shared `/ws` events and sidecar status (preload: `onEvent` / `onStatus`) | +| `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | +| `reticulum:voiceAudio` | Dedicated `/ws/voice` PCM frames (`voice.audio`; preload: `onVoiceAudio`) | +| `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | +| `reticulum:voiceMemoStart` / `voiceMemoSendAudio` / `voiceMemoStop` / `voiceMemoCancel` | Dedicated LXMF voice-memo encode IPC; generic proxy rejects `/api/v1/voice/memo/*` | +| `electronAPI.reticulum.voiceMemo.*` | Preload surface: `start` / `sendAudio` / `stop` / `cancel` | +| `reticulum:gamesStatus` / `gamesApps` / `gamesSessions` / … | Dedicated LRGP games IPC (~600/min); generic proxy rejects `/api/v1/games/*` | +| `electronAPI.reticulum.games.*` | Preload: `getStatus` / `listApps` / `listSessions` / `getSession` / `sendAction` / `resend` / `markRead` / `deleteSession` | `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops — including BLE / bond-stale cause keys used by Diagnostics and Connection hints, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds (sticky until stack stop / interface remove; not pruned solely by the 5‑minute window), **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`) for most issues; 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 ae0137107..25c09a999 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -29,7 +29,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | | 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; Ratspeak `.rsi` PIN backup; official raw identity file export/import; **identity vault** optional local `.rsi` store | -| 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. | +| LXMF chat | DM-only text, reactions, and **voice memos** (`FIELD_AUDIO` / `AM_OPUS_OGG`, Ratspeak wire parity; ~240 KiB / ~4 min cap under the 256 KiB LXMF field + default PN deposit). 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. Oversized memos that exceed the PN deposit limit surface as Direct-only (`message_too_large_for_propagation`) — never as a PN outage. | | 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`, …), unread badges + sounds for any new room message by default (also badges the **Reticulum protocol pill** with LXMF Chat; **App → Notifications** can switch to IRC-style DMs/@mentions only), toasts when the RRC tab is inactive, automatic reconnect with backoff | | Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted, never persisted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**, PN + green check); local-prop Completes as `stored_locally` (deposited on your hosted PN — PN + amber house; full PN parity with `lxmd`, including peer `/offer` sync — not an outbox). Propagation mode **Off** disables the cascade entirely. **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Hosted-PN Completes ≠ recipient opened Chat. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | @@ -348,7 +348,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s ## Chat (LXMF) - **DM-only** on the Chat tab — no RF channel pills (RRC covers hub rooms separately) -- Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. +- Text and emoji reactions. **LXMF voice memos** use native `FIELD_AUDIO` (Ogg/Opus `AM_OPUS_OGG`); Chat DM mic records via sidecar `/api/v1/voice/memo/*` (dedicated IPC). Historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited), and **voice memos** play via `chat:readReticulumAttachmentBytes` (OggS sniff, 256 KiB). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash - **Reactions:** outbound tapbacks stamp standard LXMF `FIELD_REACTION` (0x40) before sign — a msgpack map (`REACTION_TO` = 32-byte parent hash, `REACTION_CONTENT` = emoji) — while still carrying the emoji as message content, so Ratspeak/Sideband render a structured reaction and clients that ignore `0x40` still show the emoji. Inbound decode is fail-open: a valid `0x40` maps to `reaction_target` (accepting a 32-byte binary or 64-hex `REACTION_TO`, normalized to lowercase) and wins over a reply for classification; a missing or malformed field leaves the reply/plain-text path unchanged. Ratspeak legacy `0xFB`/`0xFC` reaction envelopes are not emitted or decoded. - Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (hosted local PN) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 0eaf28ac9..f5af60467 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -352,6 +352,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -1572,6 +1578,7 @@ dependencies = [ "lxst-telephony", "nomad-core", "notify", + "ogg", "rand 0.8.6", "rmpv", "rns-crypto", @@ -1796,6 +1803,15 @@ dependencies = [ "objc2", ] +[[package]] +name = "ogg" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 6a41dfa49..411e7537e 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -59,6 +59,7 @@ rmpv = { version = "1", optional = true } base64 = "0.22" bytes = "1" ciborium = "0.2" +ogg = "0.9" rand = "0.8" thiserror = "2" notify = { version = "6.1", optional = true, default-features = true } diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index dcd703239..2e23625ac 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -17,6 +17,7 @@ mod status; mod system; pub(crate) mod validate; mod voice; +mod voice_memo; mod ws; use std::sync::Arc; @@ -275,6 +276,19 @@ pub fn router(stack: Arc) -> Router { .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/voice/memo/start", + post(voice_memo::voice_memo_start), + ) + .route( + "/api/v1/voice/memo/audio", + post(voice_memo::voice_memo_audio), + ) + .route("/api/v1/voice/memo/stop", post(voice_memo::voice_memo_stop)) + .route( + "/api/v1/voice/memo/cancel", + post(voice_memo::voice_memo_cancel), + ) .route("/api/v1/games/status", get(games::games_status)) .route("/api/v1/games/apps", get(games::games_apps)) .route("/api/v1/games/sessions", get(games::games_sessions)) diff --git a/reticulum-sidecar/src/api/voice_memo.rs b/reticulum-sidecar/src/api/voice_memo.rs new file mode 100644 index 000000000..87e400d37 --- /dev/null +++ b/reticulum-sidecar/src/api/voice_memo.rs @@ -0,0 +1,69 @@ +//! Voice memo encode HTTP endpoints (dedicated IPC; not generic proxy). + +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; +const MAX_SESSION_ID_CHARS: usize = 64; + +#[derive(Debug, Deserialize)] +pub struct VoiceMemoSessionBody { + pub session_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceMemoAudioBody { + pub session_id: String, + #[serde(default = "default_channels")] + pub channels: u8, + pub samples_b64: String, +} + +fn default_channels() -> u8 { + 1 +} + +pub async fn voice_memo_start(State(stack): State>) -> Json { + Json(stack.voice_memo_start()) +} + +pub async fn voice_memo_audio( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("session_id", &body.session_id, MAX_SESSION_ID_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + 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_memo_audio(&body.session_id, body.channels, &body.samples_b64)) +} + +pub async fn voice_memo_stop( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("session_id", &body.session_id, MAX_SESSION_ID_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + // Reuse dest-hash char budget as a generic short-id cap when session_id is huge. + let _ = MAX_DEST_HASH_CHARS; + Json(stack.voice_memo_stop(&body.session_id)) +} + +pub async fn voice_memo_cancel( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("session_id", &body.session_id, MAX_SESSION_ID_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + Json(stack.voice_memo_cancel(&body.session_id)) +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 5283b9ac4..856fc9e51 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use lxmf_core::constants::{ - DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE, FIELD_REACTION, + AM_OPUS_OGG, DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE, FIELD_REACTION, REACTION_CONTENT, REACTION_TO, }; use lxmf_core::message::LxMessage; @@ -24,6 +24,8 @@ const FIELD_REPLY_TO: u8 = 0x30; const FIELD_REPLY_QUOTE: u8 = 0x31; /// Cap wire quote length (matches renderer `REPLY_PREVIEW_MAX_LEN` without ellipsis). const REPLY_QUOTE_MAX_CHARS: usize = 50; +/// lxmf-core per-field unpack limit — audio above this cannot be received by peers. +const MAX_LXMF_AUDIO_FIELD_BYTES: usize = 256 * 1024; use lxmf_core::peer::OutboundOfferPolicy; use lxmf_core::router::LxmRouter; use rns_identity::destination::Destination; @@ -478,6 +480,9 @@ impl LiveBridge { lxmf_dest_hash, router.clone(), )); + outbound_driver.set_propagation_max_message_size( + pn_hosting_policy.propagation_limit_kb.saturating_mul(1024), + ); let outbound = Arc::new(Mutex::new(outbound_driver)); let bridge = Self { @@ -3227,6 +3232,30 @@ impl LiveBridge { method: DeliveryMethod, reply_to: Option<[u8; 32]>, reply_quote: Option<&str>, + ) -> Result<(LxMessage, String), String> { + self.prepare_signed_outbound_lxmf_with_audio( + dest, + title, + content, + method, + reply_to, + reply_quote, + None, + ) + } + + /// Like [`prepare_signed_outbound_lxmf`], optionally stamping native + /// `FIELD_AUDIO` (voice memo) before `sign()` so it is covered by the hash. + #[allow(clippy::too_many_arguments)] // mirrors prepare_signed_outbound_lxmf + optional audio bytes + fn prepare_signed_outbound_lxmf_with_audio( + &self, + dest: [u8; 16], + title: &str, + content: &str, + method: DeliveryMethod, + reply_to: Option<[u8; 32]>, + reply_quote: Option<&str>, + audio: Option<&[u8]>, ) -> Result<(LxMessage, String), String> { let mut msg = LxMessage::new( dest, @@ -3236,6 +3265,17 @@ impl LiveBridge { method, ); apply_reply_fields(&mut msg, reply_to, reply_quote); + if let Some(bytes) = audio { + if bytes.len() > MAX_LXMF_AUDIO_FIELD_BYTES { + return Err(format!( + "audio_too_large: {} > {}", + bytes.len(), + MAX_LXMF_AUDIO_FIELD_BYTES + )); + } + msg.set_audio_field(AM_OPUS_OGG, bytes) + .map_err(|e| format!("lxmf set_audio_field: {e:?}"))?; + } let signing_key = self .identity .get_signing_key() @@ -3612,6 +3652,10 @@ impl LiveBridge { if let Ok(mut node) = self.propagation.local_node().lock() { apply_pn_hosting_policy_to_node(&mut node, policy); } + if let Ok(mut driver) = self.outbound.lock() { + driver + .set_propagation_max_message_size(policy.propagation_limit_kb.saturating_mul(1024)); + } // Restart announce loop with updated interval / name when serving. if self.propagation.is_local_serving() { self.prop_announce.start( @@ -4457,13 +4501,20 @@ impl LiveBridge { .as_deref() .map(str::trim) .filter(|q| !q.is_empty()); - let (msg, message_hash_hex) = self.prepare_signed_outbound_lxmf( + let audio_bytes = decode_lxmf_audio_request(req.audio.as_ref())?; + let content = if req.text.trim().is_empty() && audio_bytes.is_some() { + "[voice:0]".to_string() + } else { + req.text.clone() + }; + let (msg, message_hash_hex) = self.prepare_signed_outbound_lxmf_with_audio( dest, "", - &req.text, + &content, delivery_method, reply_to, reply_quote, + audio_bytes.as_deref(), )?; let mut router = self.router.lock().await; router @@ -4481,7 +4532,7 @@ impl LiveBridge { let mut payload = serde_json::json!({ "sender_hash": self.lxmf_hash_hex, "sender_name": self.display_name, - "text": req.text, + "text": content, "timestamp": ts_ms, "to_hash": req.destination_hash, "reply_to_hash": reply_to_hash_echo, @@ -4501,6 +4552,11 @@ impl LiveBridge { ); } } + if let Some(ref bytes) = audio_bytes { + if let Some(obj) = payload.as_object_mut() { + obj.insert("audio".into(), audio_json_from_bytes(AM_OPUS_OGG, bytes)); + } + } if let Ok(mut driver) = self.outbound.lock() { driver.process_tick(&mut router, &self.event_tx); @@ -4518,7 +4574,7 @@ impl LiveBridge { Ok(serde_json::json!({ "ok": true, "destination_hash": req.destination_hash, - "text": req.text, + "text": content, "delivery_method": delivery_method_str, "sent_via": egress_via, "delivery_status": "queued", @@ -4923,6 +4979,18 @@ pub(super) fn lxmf_payload_from_message( obj.insert("attachment".into(), attachment); } } + if let Some(audio) = audio_json_from_message(msg) { + if let Some(obj) = payload.as_object_mut() { + let text_empty = obj + .get("text") + .and_then(|t| t.as_str()) + .is_none_or(|t| t.trim().is_empty()); + if text_empty { + obj.insert("text".into(), serde_json::Value::String("[voice:0]".into())); + } + obj.insert("audio".into(), audio); + } + } if let Some(icon) = icon_appearance_json_from_message(msg) { if let Some(obj) = payload.as_object_mut() { obj.insert("icon_appearance".into(), icon); @@ -5177,6 +5245,51 @@ fn attachment_json_from_message(msg: &LxMessage) -> Option { })) } +fn audio_json_from_bytes(mode: u8, bytes: &[u8]) -> serde_json::Value { + use base64::Engine as _; + serde_json::json!({ + "mode": mode, + "size_bytes": bytes.len(), + "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes), + }) +} + +/// Decode native `FIELD_AUDIO` for WS/HTTP payloads. Malformed audio fails open +/// (returns `None`) without poisoning the rest of the message. +fn audio_json_from_message(msg: &LxMessage) -> Option { + let audio = msg.audio_field().ok()??; + Some(audio_json_from_bytes(audio.mode, audio.bytes)) +} + +fn decode_lxmf_audio_request( + audio: Option<&super::types::LxmfAudioRequest>, +) -> Result>, String> { + use base64::Engine as _; + let Some(audio) = audio else { + return Ok(None); + }; + if audio.mode != AM_OPUS_OGG { + return Err(format!( + "unsupported_audio_mode: {} (expected {AM_OPUS_OGG})", + audio.mode + )); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(audio.data_base64.as_bytes()) + .map_err(|e| format!("audio_base64_decode: {e}"))?; + if bytes.len() > MAX_LXMF_AUDIO_FIELD_BYTES { + return Err(format!( + "audio_too_large: {} > {}", + bytes.len(), + MAX_LXMF_AUDIO_FIELD_BYTES + )); + } + if bytes.is_empty() { + return Err("audio_empty".into()); + } + Ok(Some(bytes)) +} + #[allow(clippy::needless_pass_by_value)] // payload is moved into the broadcast frame pub(super) fn emit_lxmf_event(event_tx: &broadcast::Sender, payload: serde_json::Value) { let frame = serde_json::json!({ @@ -6816,6 +6929,101 @@ mod icon_appearance_tests { } } +#[cfg(test)] +mod audio_field_tests { + use super::*; + use base64::Engine as _; + use lxmf_core::constants::AM_OPUS_OGG; + use lxmf_core::message::LxMessage; + + #[test] + fn audio_json_from_message_round_trips_opus_ogg() { + let ogg = b"OggS\0fake-opus-bytes"; + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "", DeliveryMethod::Direct); + msg.set_audio_field(AM_OPUS_OGG, ogg).expect("set audio"); + let json = audio_json_from_message(&msg).expect("audio json"); + assert_eq!(json["mode"], AM_OPUS_OGG); + assert_eq!(json["size_bytes"], ogg.len()); + let decoded = base64::engine::general_purpose::STANDARD + .decode(json["data_base64"].as_str().expect("b64")) + .expect("decode"); + assert_eq!(decoded, ogg); + } + + #[test] + fn audio_json_from_message_malformed_fail_open() { + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "hi", DeliveryMethod::Direct); + // Intentionally malformed: array of 3 elements instead of [mode, bytes]. + msg.set_msgpack_field( + lxmf_core::constants::FIELD_AUDIO, + vec![0x93, AM_OPUS_OGG, 0xc4, 0x00, 0xc0], + ) + .expect("set malformed"); + assert!(audio_json_from_message(&msg).is_none()); + let payload = lxmf_payload_from_message( + &msg, + "aa".repeat(16).as_str(), + "Self", + None, + None, + "inbound", + None, + ); + assert_eq!(payload["text"], "hi"); + assert!(payload.get("audio").is_none()); + } + + #[test] + fn lxmf_payload_sets_voice_marker_when_text_empty() { + let ogg = b"OggS\0memo"; + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "", DeliveryMethod::Direct); + msg.set_audio_field(AM_OPUS_OGG, ogg).expect("set audio"); + let payload = lxmf_payload_from_message( + &msg, + "aa".repeat(16).as_str(), + "Self", + None, + None, + "inbound", + None, + ); + assert_eq!(payload["text"], "[voice:0]"); + assert!(payload.get("audio").is_some()); + assert!(payload.get("attachment").is_none()); + } + + #[test] + fn decode_lxmf_audio_request_rejects_oversize() { + let oversized = vec![0u8; MAX_LXMF_AUDIO_FIELD_BYTES + 1]; + let req = super::super::types::LxmfAudioRequest { + mode: AM_OPUS_OGG, + data_base64: base64::engine::general_purpose::STANDARD.encode(&oversized), + }; + let err = decode_lxmf_audio_request(Some(&req)).expect_err("oversize"); + assert!(err.starts_with("audio_too_large"), "{err}"); + } + + #[test] + fn set_audio_field_changes_message_hash_after_sign() { + use rns_identity::identity::Identity; + let identity = Identity::new(); + let signing = identity.get_signing_key().expect("signing key"); + let source = identity.hash; + + let mut without = + LxMessage::new([0u8; 16], source, "", "[voice:0]", DeliveryMethod::Direct); + without.sign(&signing).expect("sign"); + let hash_without = without.hash.expect("hash"); + + let mut with = LxMessage::new([0u8; 16], source, "", "[voice:0]", DeliveryMethod::Direct); + with.set_audio_field(AM_OPUS_OGG, b"OggS\0x") + .expect("set audio"); + with.sign(&signing).expect("sign"); + let hash_with = with.hash.expect("hash"); + assert_ne!(hash_without, hash_with); + } +} + #[cfg(test)] mod reply_field_tests { use super::*; diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index cbfad6b98..fb267b4c9 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -193,6 +193,8 @@ pub struct LxmfOutboundDriver { pending_pn_targets: HashMap<[u8; 32], [u8; 16]>, /// When local-prop is serving, cascade deposits go in-process (no self-Link). local_prop_node: Option>>, + /// Effective PN deposit size limit (from `propagation_limit_kb`). + propagation_max_message_size: usize, /// Local LXMF identity (retained for driver construction / future failed-detail payloads). #[allow(dead_code)] self_lxmf_hash: String, @@ -234,6 +236,8 @@ impl LxmfOutboundDriver { pending_pn_deposits: HashMap::new(), pending_pn_targets: HashMap::new(), local_prop_node: None, + propagation_max_message_size: + crate::stack::pn_hosting_policy::DEFAULT_PROPAGATION_LIMIT_KB.saturating_mul(1024), self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -306,6 +310,11 @@ impl LxmfOutboundDriver { self.local_prop_node = node; } + /// Update the PN deposit size ceiling used for oversize preflight. + pub fn set_propagation_max_message_size(&mut self, max_bytes: usize) { + self.propagation_max_message_size = max_bytes.max(1); + } + pub fn known_identities_for_propagation(&self) -> HashMap { let mut out = self.known_identities.clone(); for (k, v) in &self.pinned_identities { @@ -701,6 +710,27 @@ impl LxmfOutboundDriver { } } }; + // Preflight vs PN max_message_size — rsLXMF rejects oversized deposits silently; + // surface a distinct terminal error so the UI never treats this as a PN outage. + let limit = self.propagation_max_message_size; + if packed.len() > limit { + tracing::warn!( + target: "lxmf-outbound", + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + size_bytes = packed.len(), + limit_bytes = limit, + "DeliverPropagated: message too large for propagation — terminal (no cascade)" + ); + self.emit_outbound_failed_too_large_for_propagation( + router, + event_tx, + message, + limit, + packed.len(), + ); + return; + } let hops = route_hops_for(&self.route_hops, prop_hash); let message_hash_hex = message.hash.as_ref().map(hex::encode); let transient_id_hex = message.transient_id.as_ref().map(hex::encode); @@ -1161,6 +1191,58 @@ impl LxmfOutboundDriver { None, None, Some(attempts), + None, + None, + None, + ); + } + } + + /// Terminal failure when packed size exceeds the PN deposit limit. + /// Does **not** advance the cascade — Direct was already tried; offline store is impossible. + fn emit_outbound_failed_too_large_for_propagation( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + mut message: LxMessage, + limit_bytes: usize, + size_bytes: usize, + ) { + message.mark_failed(); + let method = message + .hash + .or(message.message_id) + .map(|h| self.cascade_wire_delivery_method(h)) + .unwrap_or("propagated"); + tracing::warn!( + target: "lxmf-outbound", + dest = %hex::encode(message.destination_hash), + method, + limit_bytes, + size_bytes, + "LXMF outbound failed: message_too_large_for_propagation" + ); + if let Some(hash) = message.hash.or(message.message_id) { + let attempts = message.delivery_attempts; + self.clear_pn_cascade_state(hash); + self.direct_path_failovers.remove(&hash); + self.pending_pn_deposits.remove(&hash); + let _ = router.mark_outbound_failed(&hash); + emit_outbound_status_detailed_with_attempts( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + Some(serde_json::Value::String(hex::encode( + message.destination_hash, + ))), + "failed", + Some(method), + None, + None, + None, + Some(attempts), + Some("message_too_large_for_propagation"), + Some(limit_bytes), + Some(size_bytes), ); } } @@ -1762,6 +1844,9 @@ fn emit_outbound_status_detailed( tried_interfaces, failover_rounds, None, + None, + None, + None, ); } @@ -1776,6 +1861,9 @@ fn emit_outbound_status_detailed_with_attempts( tried_interfaces: Option>, failover_rounds: Option, delivery_attempts: Option, + error: Option<&str>, + limit_bytes: Option, + size_bytes: Option, ) { let mut payload = serde_json::Map::new(); if let Some(h) = message_hash { @@ -1803,6 +1891,15 @@ fn emit_outbound_status_detailed_with_attempts( if let Some(attempts) = delivery_attempts { payload.insert("delivery_attempts".into(), serde_json::json!(attempts)); } + if let Some(err) = error { + payload.insert("error".into(), serde_json::Value::String(err.into())); + } + if let Some(limit) = limit_bytes { + payload.insert("limit_bytes".into(), serde_json::json!(limit)); + } + if let Some(size) = size_bytes { + payload.insert("size_bytes".into(), serde_json::json!(size)); + } let frame = serde_json::json!({ "type": "lxmf_outbound_status", "payload": payload, diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index fe5841cc3..0f1f34f74 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -71,6 +71,8 @@ mod rrc_link; #[cfg(feature = "rns-stack")] mod rrc_session; #[cfg(feature = "rns-stack")] +mod voice_memo; +#[cfg(feature = "rns-stack")] mod voice_session; use std::fs; @@ -190,6 +192,9 @@ pub struct StackHandle { /// Serializes attach_live so concurrent callers cannot spawn duplicate live bridges. #[cfg(feature = "rns-stack")] attach_live_lock: Mutex<()>, + /// Opus/Ogg voice-memo encoder sessions (independent of live LXST calls). + #[cfg(feature = "rns-stack")] + voice_memo: Arc, /// Test-only: next preference/pin apply returns this error after persist (exercises rollback). #[cfg(test)] test_path_medium_apply_error: Mutex>, @@ -314,6 +319,7 @@ impl StackHandle { path_medium_op_lock: Mutex::new(()), live: std::sync::OnceLock::new(), attach_live_lock: Mutex::new(()), + voice_memo: Arc::new(voice_memo::VoiceMemoManager::new()), #[cfg(test)] test_path_medium_apply_error: Mutex::new(None), }; @@ -3025,6 +3031,71 @@ impl StackHandle { serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) } + pub fn voice_memo_start(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + { + match self.voice_memo.start() { + Ok(v) => v, + Err(e) => serde_json::json!({ "ok": false, "error": e }), + } + } + #[cfg(not(feature = "rns-stack"))] + serde_json::json!({ "ok": false, "error": "voice_memo requires rns-stack sidecar" }) + } + + pub fn voice_memo_audio( + &self, + session_id: &str, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + { + match self + .voice_memo + .push_audio(session_id, channels, samples_b64) + { + Ok(v) => v, + Err(e) => serde_json::json!({ "ok": false, "error": e }), + } + } + #[cfg(not(feature = "rns-stack"))] + { + let _ = (session_id, channels, samples_b64); + serde_json::json!({ "ok": false, "error": "voice_memo requires rns-stack sidecar" }) + } + } + + pub fn voice_memo_stop(&self, session_id: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + { + match self.voice_memo.stop(session_id) { + Ok(v) => v, + Err(e) => serde_json::json!({ "ok": false, "error": e }), + } + } + #[cfg(not(feature = "rns-stack"))] + { + let _ = session_id; + serde_json::json!({ "ok": false, "error": "voice_memo requires rns-stack sidecar" }) + } + } + + pub fn voice_memo_cancel(&self, session_id: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + { + match self.voice_memo.cancel(session_id) { + Ok(v) => v, + Err(e) => serde_json::json!({ "ok": false, "error": e }), + } + } + #[cfg(not(feature = "rns-stack"))] + { + let _ = session_id; + serde_json::json!({ "ok": false, "error": "voice_memo requires rns-stack sidecar" }) + } + } + pub async fn games_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] if let Some(live) = self.live.get() { diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index ab6a44ebe..1e8aca26f 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -1060,6 +1060,7 @@ mod tests { reply_to_hash: None, reply_to_id: None, reply_preview_text: None, + audio: None, }) .expect("send"); assert_eq!(payload["to_hash"], dest); diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 287fdea73..8524b95c1 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -283,6 +283,14 @@ pub struct AddInterfaceRequest { pub extra_config: HashMap, } +/// Native LXMF `FIELD_AUDIO` payload for chat voice memos (Ratspeak parity). +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfAudioRequest { + /// LXMF audio mode (`AM_OPUS_OGG` = 0x10). + pub mode: u8, + pub data_base64: String, +} + #[derive(Debug, Clone, Deserialize)] pub struct LxmfSendRequest { pub destination_hash: String, @@ -294,6 +302,9 @@ pub struct LxmfSendRequest { /// Optional UTF-8 quote snippet for LXMF `FIELD_REPLY_QUOTE` (0x31). #[serde(default)] pub reply_preview_text: Option, + /// Optional native LXMF audio field (Ogg/Opus voice memo). + #[serde(default)] + pub audio: Option, } /// Create an encrypted `lxm://` paper URI (no network send). diff --git a/reticulum-sidecar/src/stack/voice_memo.rs b/reticulum-sidecar/src/stack/voice_memo.rs new file mode 100644 index 000000000..551cbec61 --- /dev/null +++ b/reticulum-sidecar/src/stack/voice_memo.rs @@ -0,0 +1,448 @@ +//! LXMF voice-memo encode: PCM → Opus (`QualityMedium`) → Ogg container. +//! +//! Wire parity with Ratspeak: `AM_OPUS_OGG`, OpusHead 1ch/24 kHz, vendor `Ratspeak`, +//! 60 ms packets. Cap ~240 KiB so memos stay under the 256 KiB LXMF field limit and +//! default PN deposit size. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use lxst_core::{OpusEncoderState, Profile, RawAudioFrame}; +use ogg::{PacketWriteEndInfo, PacketWriter}; +use uuid::Uuid; + +/// Soft cap under lxmf-core 256 KiB field + default 256 KB PN deposit. +pub const VOICE_MEMO_MAX_OGG_BYTES: usize = 240 * 1024; +/// ~4 minutes of 60 ms QualityMedium frames. +pub const VOICE_MEMO_MAX_FRAME_COUNT: usize = 4_000; +const FRAME_MS: u64 = 60; +const SAMPLE_RATE_HZ: u32 = 24_000; +/// Opus Ogg granule clock is fixed at 48 kHz (RFC 7845 §4), not the input rate. +const OPUS_GRANULE_HZ: u64 = 48_000; +const CHANNELS: u8 = 1; +const SAMPLES_PER_FRAME: usize = (SAMPLE_RATE_HZ as usize * FRAME_MS as usize) / 1000; +/// QualityMedium ~8 kbps → ~60 B/packet; allow headroom. +const MAX_OPUS_PACKET_BYTES: usize = 60; +/// Drop memo sessions with no push_audio activity for this long. +const IDLE_TIMEOUT_MS: u64 = 5 * 60 * 1000; +/// OpusHead + OpusTags + margin for Ogg page headers. +const OGG_FIXED_OVERHEAD: usize = 128; +/// Per audio page: Ogg page header (~27 B) + segment table (~1 B) + rounding. +const OGG_PAGE_OVERHEAD: usize = 32; + +pub struct VoiceMemoManager { + sessions: Mutex>, +} + +struct VoiceMemoSession { + encoder: OpusEncoderState, + opus_packets: Vec>, + started_ms: u64, + last_activity_ms: u64, +} + +impl Default for VoiceMemoManager { + fn default() -> Self { + Self::new() + } +} + +impl VoiceMemoManager { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + } + } + + pub fn start(&self) -> Result { + let encoder = OpusEncoderState::new(Profile::QualityMedium) + .map_err(|e| format!("voice_memo_encoder: {e}"))?; + let session_id = Uuid::new_v4().to_string(); + let started_ms = now_ms(); + let mut guard = self + .sessions + .lock() + .map_err(|_| "voice_memo_lock".to_string())?; + prune_idle_sessions(&mut guard, started_ms); + // Bound concurrent memo sessions (one per typical UI). + if guard.len() >= 4 { + return Err("voice_memo_busy".into()); + } + guard.insert( + session_id.clone(), + VoiceMemoSession { + encoder, + opus_packets: Vec::new(), + started_ms, + last_activity_ms: started_ms, + }, + ); + Ok(serde_json::json!({ + "ok": true, + "session_id": session_id, + })) + } + + pub fn push_audio( + &self, + session_id: &str, + channels: u8, + samples_b64: &str, + ) -> Result { + if channels != CHANNELS { + return Err(format!("invalid_channels: expected {CHANNELS}")); + } + let samples = decode_f32_le_base64(samples_b64)?; + if samples.len() != SAMPLES_PER_FRAME { + return Err(format!( + "invalid_frame_samples: {} (expected {SAMPLES_PER_FRAME})", + samples.len() + )); + } + let mut guard = self + .sessions + .lock() + .map_err(|_| "voice_memo_lock".to_string())?; + let now = now_ms(); + prune_idle_sessions(&mut guard, now); + let session = guard + .get_mut(session_id) + .ok_or_else(|| "voice_memo_session_unknown".to_string())?; + if session.opus_packets.len() >= VOICE_MEMO_MAX_FRAME_COUNT { + return Err("voice_memo_too_long".into()); + } + let frame = + RawAudioFrame::new(CHANNELS, samples).map_err(|e| format!("voice_memo_frame: {e}"))?; + let encoded = session + .encoder + .encode_frame(&frame) + .map_err(|e| format!("voice_memo_encode: {e}"))?; + if encoded.payload.len() > MAX_OPUS_PACKET_BYTES { + return Err(format!( + "voice_memo_packet_too_large: {}", + encoded.payload.len() + )); + } + session.opus_packets.push(encoded.payload); + session.last_activity_ms = now; + let approx = estimate_ogg_bytes(&session.opus_packets); + if approx > VOICE_MEMO_MAX_OGG_BYTES { + return Err("voice_memo_too_large".into()); + } + Ok(serde_json::json!({ + "ok": true, + "frames": session.opus_packets.len(), + "duration_ms": session.opus_packets.len() as u64 * FRAME_MS, + })) + } + + pub fn stop(&self, session_id: &str) -> Result { + let mut guard = self + .sessions + .lock() + .map_err(|_| "voice_memo_lock".to_string())?; + prune_idle_sessions(&mut guard, now_ms()); + let session = guard + .remove(session_id) + .ok_or_else(|| "voice_memo_session_unknown".to_string())?; + if session.opus_packets.is_empty() { + return Err("voice_memo_empty".into()); + } + let duration_ms = session.opus_packets.len() as u64 * FRAME_MS; + let ogg = mux_opus_ogg(&session.opus_packets)?; + if ogg.len() > VOICE_MEMO_MAX_OGG_BYTES { + return Err(format!( + "voice_memo_too_large: {} > {VOICE_MEMO_MAX_OGG_BYTES}", + ogg.len() + )); + } + Ok(serde_json::json!({ + "ok": true, + "session_id": session_id, + "ogg_base64": base64::engine::general_purpose::STANDARD.encode(&ogg), + "duration_ms": duration_ms, + "size_bytes": ogg.len(), + "started_ms": session.started_ms, + "mode": lxmf_core::constants::AM_OPUS_OGG, + })) + } + + pub fn cancel(&self, session_id: &str) -> Result { + let mut guard = self + .sessions + .lock() + .map_err(|_| "voice_memo_lock".to_string())?; + prune_idle_sessions(&mut guard, now_ms()); + let removed = guard.remove(session_id).is_some(); + Ok(serde_json::json!({ + "ok": true, + "cancelled": removed, + })) + } +} + +#[cfg(test)] +impl VoiceMemoManager { + /// Set every session's last activity for idle-prune tests. + fn test_set_all_last_activity_ms(&self, last_activity_ms: u64) { + let mut guard = self.sessions.lock().expect("voice_memo_lock"); + for session in guard.values_mut() { + session.last_activity_ms = last_activity_ms; + } + } +} + +fn prune_idle_sessions(guard: &mut HashMap, now: u64) { + guard.retain(|_, session| now.saturating_sub(session.last_activity_ms) <= IDLE_TIMEOUT_MS); +} + +fn estimate_ogg_bytes(packets: &[Vec]) -> usize { + let packet_bytes: usize = packets.iter().map(Vec::len).sum(); + packet_bytes + OGG_FIXED_OVERHEAD + packets.len() * OGG_PAGE_OVERHEAD +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn decode_f32_le_base64(samples_b64: &str) -> Result, String> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(samples_b64.as_bytes()) + .map_err(|e| format!("samples_b64_decode: {e}"))?; + if bytes.len() % 4 != 0 { + return Err("samples_b64_misaligned".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) +} + +/// RFC 7845 Ogg Opus mux: OpusHead + OpusTags (vendor Ratspeak) + audio pages. +/// +/// Granule positions are always in **48 kHz PCM samples** (RFC 7845 §4), even when +/// OpusHead advertises a 24 kHz input rate. Using the input rate here made Chromium +/// `decodeAudioData` report ~half duration and truncate playback. +pub fn mux_opus_ogg(opus_packets: &[Vec]) -> Result, String> { + if opus_packets.is_empty() { + return Err("voice_memo_empty".into()); + } + let mut out = Vec::new(); + { + let mut writer = PacketWriter::new(&mut out); + let head = opus_head_packet(); + writer + .write_packet(head, 0x1000_0001, PacketWriteEndInfo::EndPage, 0) + .map_err(|e| format!("ogg_write_head: {e}"))?; + let tags = opus_tags_packet(); + writer + .write_packet(tags, 0x1000_0001, PacketWriteEndInfo::EndPage, 0) + .map_err(|e| format!("ogg_write_tags: {e}"))?; + // Opus granule clock is fixed at 48 kHz regardless of encoder input rate. + let granule_per_packet = (OPUS_GRANULE_HZ * FRAME_MS) / 1000; + let last = opus_packets.len() - 1; + for (i, packet) in opus_packets.iter().enumerate() { + let granule = granule_per_packet * (i as u64 + 1); + let end = if i == last { + PacketWriteEndInfo::EndStream + } else { + // One packet per page keeps granule positions visible to demuxers. + PacketWriteEndInfo::EndPage + }; + writer + .write_packet(packet.clone(), 0x1000_0001, end, granule) + .map_err(|e| format!("ogg_write_audio: {e}"))?; + } + } + if !out.starts_with(b"OggS") { + return Err("ogg_missing_magic".into()); + } + Ok(out) +} + +fn opus_head_packet() -> Vec { + // RFC 7845 §5.1 OpusHead + let mut head = Vec::with_capacity(19); + head.extend_from_slice(b"OpusHead"); + head.push(1); // version + head.push(CHANNELS); + head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip + head.extend_from_slice(&SAMPLE_RATE_HZ.to_le_bytes()); + head.extend_from_slice(&0i16.to_le_bytes()); // output gain + head.push(0); // channel mapping family + head +} + +fn opus_tags_packet() -> Vec { + // RFC 7845 §5.2 OpusTags — vendor "Ratspeak", 0 comments + let vendor = b"Ratspeak"; + let mut tags = Vec::with_capacity(8 + 4 + vendor.len() + 4); + tags.extend_from_slice(b"OpusTags"); + tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + tags.extend_from_slice(vendor); + tags.extend_from_slice(&0u32.to_le_bytes()); // user comment list length + tags +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mux_opus_ogg_writes_head_and_magic() { + let packets = vec![vec![0u8; 8], vec![1u8; 10]]; + let ogg = mux_opus_ogg(&packets).expect("mux"); + assert!(ogg.starts_with(b"OggS")); + assert!(ogg.windows(8).any(|w| w == b"OpusHead")); + assert!(ogg.windows(8).any(|w| w == b"OpusTags")); + assert!(ogg.windows(8).any(|w| w == b"Ratspeak")); + // OpusHead: 1 channel, 24 kHz + let head_at = ogg.windows(8).position(|w| w == b"OpusHead").expect("head"); + assert_eq!(ogg[head_at + 9], 1); // channels + let rate = u32::from_le_bytes([ + ogg[head_at + 12], + ogg[head_at + 13], + ogg[head_at + 14], + ogg[head_at + 15], + ]); + assert_eq!(rate, 24_000); + } + + fn ogg_page_granules(ogg: &[u8]) -> Vec { + let mut granules = Vec::new(); + let mut i = 0usize; + while i + 27 <= ogg.len() { + if &ogg[i..i + 4] != b"OggS" { + i += 1; + continue; + } + let granule = u64::from_le_bytes(ogg[i + 6..i + 14].try_into().expect("8 bytes")); + let page_segments = ogg[i + 26] as usize; + let table_end = i + 27 + page_segments; + if table_end > ogg.len() { + break; + } + let body_len: usize = ogg[i + 27..table_end].iter().map(|&b| b as usize).sum(); + let page_end = table_end + body_len; + if page_end > ogg.len() { + break; + } + granules.push(granule); + i = page_end; + } + granules + } + + #[test] + fn mux_opus_ogg_granules_use_48khz_clock() { + let packets = vec![vec![0u8; 8], vec![1u8; 10], vec![2u8; 12]]; + let ogg = mux_opus_ogg(&packets).expect("mux"); + let granules = ogg_page_granules(&ogg); + // Head + tags pages use granule 0; each 60 ms audio page advances by 2880 @ 48 kHz. + assert!( + granules.len() >= 5, + "expected header + audio pages, got {granules:?}" + ); + assert_eq!(granules[0], 0); + assert_eq!(granules[1], 0); + assert_eq!(granules[2], 2_880); + assert_eq!(granules[3], 5_760); + assert_eq!(granules[4], 8_640); + } + + #[test] + fn memo_encode_silent_frames_round_trip_size() { + let mgr = VoiceMemoManager::new(); + let start = mgr.start().expect("start"); + let session_id = start["session_id"].as_str().expect("id").to_string(); + let silence = vec![0f32; SAMPLES_PER_FRAME]; + let mut bytes = Vec::with_capacity(silence.len() * 4); + for s in silence { + bytes.extend_from_slice(&s.to_le_bytes()); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + for _ in 0..5 { + mgr.push_audio(&session_id, 1, &b64).expect("audio"); + } + let stop = mgr.stop(&session_id).expect("stop"); + assert_eq!(stop["ok"], true); + assert_eq!(stop["duration_ms"], 300); + let ogg = base64::engine::general_purpose::STANDARD + .decode(stop["ogg_base64"].as_str().expect("b64")) + .expect("decode"); + assert!(ogg.starts_with(b"OggS")); + assert!(ogg.len() <= VOICE_MEMO_MAX_OGG_BYTES); + assert!(ogg.len() > 40); + } + + #[test] + fn cancel_then_stop_fails() { + let mgr = VoiceMemoManager::new(); + let start = mgr.start().expect("start"); + let session_id = start["session_id"].as_str().expect("id").to_string(); + mgr.cancel(&session_id).expect("cancel"); + let err = mgr.stop(&session_id).expect_err("stop after cancel"); + assert!(err.contains("unknown"), "{err}"); + } + + #[test] + fn idle_sessions_pruned_before_start() { + let mgr = VoiceMemoManager::new(); + for _ in 0..4 { + mgr.start().expect("start"); + } + mgr.start().expect_err("busy when four sessions active"); + + let expired = now_ms().saturating_sub(IDLE_TIMEOUT_MS + 1); + mgr.test_set_all_last_activity_ms(expired); + mgr.start().expect("start after idle prune"); + } + + #[test] + fn estimate_ogg_bytes_accounts_for_page_overhead() { + let packets = vec![vec![0u8; 40], vec![1u8; 50]]; + let approx = estimate_ogg_bytes(&packets); + assert_eq!(approx, 90 + OGG_FIXED_OVERHEAD + 2 * OGG_PAGE_OVERHEAD); + } + + #[test] + fn encode_then_lxmf_audio_field_round_trips() { + use base64::Engine as _; + use lxmf_core::constants::AM_OPUS_OGG; + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + + let mgr = VoiceMemoManager::new(); + let start = mgr.start().expect("start"); + let session_id = start["session_id"].as_str().expect("id").to_string(); + let silence = vec![0f32; SAMPLES_PER_FRAME]; + let mut bytes = Vec::with_capacity(silence.len() * 4); + for s in silence { + bytes.extend_from_slice(&s.to_le_bytes()); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + mgr.push_audio(&session_id, 1, &b64).expect("audio"); + let stop = mgr.stop(&session_id).expect("stop"); + let ogg = base64::engine::general_purpose::STANDARD + .decode(stop["ogg_base64"].as_str().expect("b64")) + .expect("decode"); + + let mut msg = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "[voice:60]", + DeliveryMethod::Direct, + ); + msg.set_audio_field(AM_OPUS_OGG, &ogg).expect("set"); + let audio = msg.audio_field().expect("field").expect("some"); + assert_eq!(audio.mode, AM_OPUS_OGG); + assert_eq!(audio.bytes, ogg.as_slice()); + } +} diff --git a/src/main/db-schema-sync.ts b/src/main/db-schema-sync.ts index d058d159a..c8171d609 100644 --- a/src/main/db-schema-sync.ts +++ b/src/main/db-schema-sync.ts @@ -445,6 +445,8 @@ export const DESIRED_COLUMNS: Readonly { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + if (typeof filePath !== 'string' || !filePath.trim() || filePath.length > 512) { + throw new Error('filePath must be a non-empty string'); + } + if (!takeReticulumAttachmentAudioRateToken()) { + console.debug('[IPC] chat:readReticulumAttachmentBytes rate limited'); + return { dataBase64: null }; + } + try { + const dataBase64 = await readReticulumAttachmentBytes(filePath); + return { dataBase64 }; + } catch (err) { + console.error( + '[IPC] chat:readReticulumAttachmentBytes failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); + ipcMain.handle('meshtastic:xmodemPickUpload', async (event) => { if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); if (!mainWindow) return null; diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index ce29029b7..79acfb091 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -1,9 +1,27 @@ // @vitest-environment node import type { IpcMain, IpcMainInvokeEvent } from 'electron'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { join, resolve } from 'path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testUserDataDir } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- vi.hoisted runs before ESM imports for electron mock + const fs = require('node:fs') as { + mkdtempSync: (prefix: string) => string; + }; + // eslint-disable-next-line @typescript-eslint/no-require-imports -- see above + const os = require('node:os') as { tmpdir: () => string }; + // eslint-disable-next-line @typescript-eslint/no-require-imports -- see above + const path = require('node:path') as { join: (...parts: string[]) => string }; + return { testUserDataDir: fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-rns-db-')) }; +}); + +vi.mock('electron', () => ({ + app: { + getPath: () => testUserDataDir, + }, +})); vi.mock('../db-ipc-lifecycle', () => ({ getDbForIpc: vi.fn(() => null), @@ -19,12 +37,17 @@ vi.mock('../validate-ipc-sender', () => ({ import { NodeSqliteDB } from '../db-compat'; import { getDbForIpc } from '../db-ipc-lifecycle'; import { runSchemaUpgrade } from '../db-schema-sync'; +import { getReticulumAttachmentsDir } from '../reticulum-attachment-path'; import { registerReticulumDbIpcHandlers } from './reticulum-db-handlers'; type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; const getDbForIpcMock = vi.mocked(getDbForIpc); +afterAll(() => { + rmSync(testUserDataDir, { recursive: true, force: true }); +}); + describe('reticulum-db-handlers validation', () => { const handlers = new Map(); const event = {} as IpcMainInvokeEvent; @@ -276,28 +299,72 @@ describe('reticulum destination / activity prune IPC', () => { expect(row.delivery_status).toBe('sending'); }); - it('saveReticulumMessage persists paper received_via and delivery_method', () => { - const identityId = 'id-rt-paper'; - const messageHash = 'ef'.repeat(32); + it('saveReticulumMessage coalesces attachment_path onto an existing Completes row', () => { + const identityId = 'id-rt-attach-coalesce'; + const messageHash = 'a1'.repeat(32); const save = handlers.get('db:saveReticulumMessage'); save?.(event, { identity_id: identityId, sender_id: 'cc'.repeat(16), sender_name: 'Me', - payload: 'paper hello', + payload: '[voice:600]', timestamp: 1_700_000_000_000, message_hash: messageHash, delivery_status: 'delivered', - delivery_method: 'paper', - received_via: 'paper', + }); + const attachmentPath = join(getReticulumAttachmentsDir(), 'voice-memo-out.ogg'); + mkdirSync(getReticulumAttachmentsDir(), { recursive: true }); + writeFileSync(attachmentPath, Buffer.from('OggS')); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: '[voice:600]', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'sending', + attachment_path: attachmentPath, }); const row = db! .prepareOnce( - 'SELECT received_via, delivery_method FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', + 'SELECT delivery_status, attachment_path FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', ) - .get(identityId, messageHash) as { received_via: string; delivery_method: string }; - expect(row.received_via).toBe('paper'); - expect(row.delivery_method).toBe('paper'); + .get(identityId, messageHash) as { delivery_status: string; attachment_path: string | null }; + expect(row.delivery_status).toBe('delivered'); + expect(row.attachment_path).toBe(resolve(attachmentPath)); + }); + + it('saveReticulumMessage persists audio_mode and audio_duration_sec', () => { + const identityId = 'id-rt-audio-meta'; + const messageHash = 'b2'.repeat(32); + const save = handlers.get('db:saveReticulumMessage'); + const attachmentPath = join(getReticulumAttachmentsDir(), 'voice-memo-meta.ogg'); + mkdirSync(getReticulumAttachmentsDir(), { recursive: true }); + writeFileSync(attachmentPath, Buffer.from('OggS')); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: '[voice:600]', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'sending', + attachment_path: attachmentPath, + audio_mode: 16, + audio_duration_sec: 1.25, + }); + const row = db! + .prepareOnce( + 'SELECT attachment_path, audio_mode, audio_duration_sec FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', + ) + .get(identityId, messageHash) as { + attachment_path: string | null; + audio_mode: number | null; + audio_duration_sec: number | null; + }; + expect(row.attachment_path).toBe(resolve(attachmentPath)); + expect(row.audio_mode).toBe(16); + expect(row.audio_duration_sec).toBe(1.25); }); it('saveReticulumMessage replaces exact pending hash while still sending', () => { diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index d0f522432..48c5756c4 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -175,6 +175,14 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): const attachmentPath = sanitizeReticulumAttachmentPathForDb( typeof m.attachment_path === 'string' ? m.attachment_path : null, ); + const audioMode = + m.audio_mode != null && Number.isFinite(Number(m.audio_mode)) + ? Math.trunc(Number(m.audio_mode)) + : null; + const audioDurationSec = + m.audio_duration_sec != null && Number.isFinite(Number(m.audio_duration_sec)) + ? Number(m.audio_duration_sec) + : null; const deliveryAttempts = m.delivery_attempts != null && Number.isFinite(Number(m.delivery_attempts)) ? Math.trunc(Number(m.delivery_attempts)) @@ -211,7 +219,10 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): END, received_via = COALESCE(?, received_via), sender_name = COALESCE(?, sender_name), - delivery_method = COALESCE(?, delivery_method) + delivery_method = COALESCE(?, delivery_method), + attachment_path = COALESCE(?, attachment_path), + audio_mode = COALESCE(?, audio_mode), + audio_duration_sec = COALESCE(?, audio_duration_sec) WHERE id = ?`, ).run( deliveryStatus, @@ -219,6 +230,9 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): receivedVia, senderName, deliveryMethod, + attachmentPath, + audioMode, + audioDurationSec, existing.id, ); return { changes: 1 }; @@ -226,8 +240,8 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): } db.prepareOnce( - `INSERT INTO reticulum_messages (identity_id, sender_id, sender_name, payload, timestamp, to_hash, reply_to_hash, message_hash, received_via, delivery_status, delivery_attempts, next_delivery_attempt_at, attachment_path, delivery_method) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO reticulum_messages (identity_id, sender_id, sender_name, payload, timestamp, to_hash, reply_to_hash, message_hash, received_via, delivery_status, delivery_attempts, next_delivery_attempt_at, attachment_path, delivery_method, audio_mode, audio_duration_sec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( identityId, senderId, @@ -243,6 +257,8 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): nextDeliveryAttemptAt, attachmentPath, deliveryMethod, + audioMode, + audioDurationSec, ); return { changes: 1 }; }); diff --git a/src/main/ipc/reticulum-handlers.test.ts b/src/main/ipc/reticulum-handlers.test.ts index 5d80befa1..38b1d153c 100644 --- a/src/main/ipc/reticulum-handlers.test.ts +++ b/src/main/ipc/reticulum-handlers.test.ts @@ -10,11 +10,33 @@ const { showItemInFolderMock } = vi.hoisted(() => ({ showItemInFolderMock: vi.fn(), })); +const { memoAudioRateLimitMock } = vi.hoisted(() => ({ + memoAudioRateLimitMock: { + checkOrThrow: vi.fn(), + resetForTests: vi.fn(), + }, +})); + vi.mock('electron', () => ({ ipcMain: { handle: ipcMainHandleMock }, shell: { showItemInFolder: showItemInFolderMock }, })); +vi.mock('../ipcRateLimit', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importOriginal needs typeof import() + const actual = await importOriginal(); + return { + ...actual, + createIpcRateLimiter: (...args: Parameters) => { + const [opts] = args; + if (opts.label === 'reticulum:voiceMemoSendAudio') { + return memoAudioRateLimitMock; + } + return actual.createIpcRateLimiter(...args); + }, + }; +}); + vi.mock('../validate-ipc-sender', () => ({ assertIpcSender: vi.fn(), })); @@ -60,6 +82,13 @@ vi.mock('../reticulum-remote-paths', () => ({ ), })); +import { + VOICE_MEMO_AUDIO_API_PATH, + VOICE_MEMO_CANCEL_API_PATH, + VOICE_MEMO_DATA_BASE64_MAX, + VOICE_MEMO_START_API_PATH, + VOICE_MEMO_STOP_API_PATH, +} from '../../shared/reticulum-voice-memo-types'; import { isAllowedNomadContentSourcePath, readFirstExistingConfig, @@ -121,6 +150,7 @@ describe('registerReticulumIpcHandlers', () => { handlers.set(channel, fn); }); assertIpcSenderMock.mockReset(); + memoAudioRateLimitMock.checkOrThrow.mockReset().mockImplementation(() => {}); isAllowedNomadContentSourcePathMock .mockReset() .mockImplementation((path: string | null) => Boolean(path?.trim())); @@ -151,6 +181,10 @@ describe('registerReticulumIpcHandlers', () => { 'reticulum:proxyGet', 'reticulum:proxyPost', 'reticulum:voiceSendAudio', + 'reticulum:voiceMemoStart', + 'reticulum:voiceMemoSendAudio', + 'reticulum:voiceMemoStop', + 'reticulum:voiceMemoCancel', 'reticulum:proxyPut', 'reticulum:proxyDelete', 'reticulum:readDefaultConfigFile', @@ -636,6 +670,142 @@ describe('registerReticulumIpcHandlers', () => { expect(bad).toEqual({ ok: false, error: 'empty_samples_b64' }); }); }); + + describe('voice memo IPC', () => { + it('proxyPost rejects memo paths including query strings', async () => { + await expect( + handlers.get('reticulum:proxyPost')?.(event, `${VOICE_MEMO_START_API_PATH}?foo=1`, {}), + ).rejects.toThrow(/voiceMemo\* IPC channels/); + expect(manager.proxyPost).not.toHaveBeenCalled(); + }); + + it('voiceMemoStart forwards body to sidecar', async () => { + const body = { destination_hash: 'aa'.repeat(16) }; + const ok = await handlers.get('reticulum:voiceMemoStart')?.(event, body); + expect(ok).toEqual({ ok: true }); + expect(manager.proxyPost).toHaveBeenCalledWith(VOICE_MEMO_START_API_PATH, body); + }); + + it('voiceMemoStart defaults invalid opts to {}', async () => { + await handlers.get('reticulum:voiceMemoStart')?.(event, null); + expect(manager.proxyPost).toHaveBeenCalledWith(VOICE_MEMO_START_API_PATH, {}); + }); + + it('voiceMemoSendAudio forwards validated frames', async () => { + const ok = await handlers.get('reticulum:voiceMemoSendAudio')?.(event, { + session_id: 'sess-1', + channels: 1, + samples_b64: 'AAAA', + }); + expect(ok).toEqual({ ok: true }); + expect(manager.proxyPost).toHaveBeenCalledWith(VOICE_MEMO_AUDIO_API_PATH, { + session_id: 'sess-1', + channels: 1, + samples_b64: 'AAAA', + }); + expect(memoAudioRateLimitMock.checkOrThrow).toHaveBeenCalled(); + }); + + it('voiceMemoSendAudio rejects invalid payload', async () => { + const bad = await handlers.get('reticulum:voiceMemoSendAudio')?.(event, { + session_id: 'sess-1', + channels: 1, + samples_b64: '', + }); + expect(bad).toEqual({ ok: false, error: 'empty_samples_b64' }); + expect(manager.proxyPost).not.toHaveBeenCalled(); + }); + + it('voiceMemoSendAudio throws when dedicated rate limit is exceeded', async () => { + memoAudioRateLimitMock.checkOrThrow.mockImplementationOnce(() => { + throw new Error('reticulum:voiceMemoSendAudio: rate limit exceeded'); + }); + await expect( + handlers.get('reticulum:voiceMemoSendAudio')?.(event, { + session_id: 'sess-1', + channels: 1, + samples_b64: 'AAAA', + }), + ).rejects.toThrow(/rate limit exceeded/); + expect(manager.proxyPost).not.toHaveBeenCalled(); + }); + + it('voiceMemoStop forwards session and caps oversized ogg_base64', async () => { + manager.proxyPost.mockResolvedValueOnce({ + ok: true, + ogg_base64: 'x'.repeat(VOICE_MEMO_DATA_BASE64_MAX + 1), + }); + const tooLarge = await handlers.get('reticulum:voiceMemoStop')?.(event, { + session_id: 'sess-1', + }); + expect(tooLarge).toEqual({ ok: false, error: 'ogg_base64_too_large' }); + + manager.proxyPost.mockResolvedValueOnce({ + ok: true, + ogg_base64: 'YQ==', + duration_ms: 1000, + }); + const ok = await handlers.get('reticulum:voiceMemoStop')?.(event, { + session_id: 'sess-1', + }); + expect(ok).toEqual({ ok: true, ogg_base64: 'YQ==', duration_ms: 1000 }); + expect(manager.proxyPost).toHaveBeenCalledWith(VOICE_MEMO_STOP_API_PATH, { + session_id: 'sess-1', + }); + }); + + it('voiceMemoStop rejects invalid session request', async () => { + const bad = await handlers.get('reticulum:voiceMemoStop')?.(event, {}); + expect(bad).toEqual({ ok: false, error: 'invalid_session_id' }); + expect(manager.proxyPost).not.toHaveBeenCalled(); + }); + + it('voiceMemoCancel forwards validated session request', async () => { + const ok = await handlers.get('reticulum:voiceMemoCancel')?.(event, { + session_id: 'sess-1', + }); + expect(ok).toEqual({ ok: true }); + expect(manager.proxyPost).toHaveBeenCalledWith(VOICE_MEMO_CANCEL_API_PATH, { + session_id: 'sess-1', + }); + }); + + it('voiceMemoCancel rejects invalid session request', async () => { + const bad = await handlers.get('reticulum:voiceMemoCancel')?.(event, { session_id: '' }); + expect(bad).toEqual({ ok: false, error: 'invalid_session_id' }); + expect(manager.proxyPost).not.toHaveBeenCalled(); + }); + + it('rejects unauthorized senders before ensureManager runs', async () => { + assertIpcSenderMock.mockImplementation(() => { + throw new Error('reticulum:voiceMemoStart: unauthorized sender'); + }); + const ensureManager = vi.fn(() => manager as never); + registerReticulumIpcHandlers({ + idleStatus: IDLE_STATUS, + ensureManager, + getManager: () => manager as never, + getMainWindow: () => null, + }); + await expect( + handlers.get('reticulum:voiceMemoStart')?.(event, { destination_hash: 'aa'.repeat(16) }), + ).rejects.toThrow('unauthorized sender'); + await expect( + handlers.get('reticulum:voiceMemoSendAudio')?.(event, { + session_id: 'sess-1', + channels: 1, + samples_b64: 'AAAA', + }), + ).rejects.toThrow('unauthorized sender'); + await expect( + handlers.get('reticulum:voiceMemoStop')?.(event, { session_id: 'sess-1' }), + ).rejects.toThrow('unauthorized sender'); + await expect( + handlers.get('reticulum:voiceMemoCancel')?.(event, { session_id: 'sess-1' }), + ).rejects.toThrow('unauthorized sender'); + expect(ensureManager).not.toHaveBeenCalled(); + }); + }); }); describe('wireReticulumSidecarBridge', () => { diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index fb6462b90..253516a35 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -6,6 +6,16 @@ import type { ReticulumSidecarStartOptions, ReticulumSidecarStatus, } from '../../shared/reticulum-types'; +import { + isVoiceMemoApiPath, + parseVoiceMemoAudioRequest, + parseVoiceMemoSessionRequest, + VOICE_MEMO_AUDIO_API_PATH, + VOICE_MEMO_CANCEL_API_PATH, + VOICE_MEMO_DATA_BASE64_MAX, + VOICE_MEMO_START_API_PATH, + VOICE_MEMO_STOP_API_PATH, +} from '../../shared/reticulum-voice-memo-types'; import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash'; import { isExpectedReticulumProxyError, @@ -259,6 +269,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { if (isVoiceAudioApiPath(pathArg)) { throw new Error('voice PCM ingest requires reticulum:voiceSendAudio'); } + if (isVoiceMemoApiPath(pathArg)) { + throw new Error('voice memo requires reticulum:voiceMemo* IPC channels'); + } if (isGamesApiPath(pathArg)) { throw new Error('LRGP games require reticulum:games* IPC channels'); } @@ -272,6 +285,17 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { } }); + /** + * LXMF voice memo PCM ingest. Separate bucket from live LXST voice so memo + * recording does not starve live-call TX or the shared proxy ceiling. + * 2000/min matches the live-voice bucket (QualityHigh ~16.7 frames/s). + */ + const reticulumVoiceMemoAudioIpcRateLimit = createIpcRateLimiter({ + max: 2000, + windowMs: MS_PER_MINUTE, + label: 'reticulum:voiceMemoSendAudio', + }); + /** * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 900/min * proxy ceiling) so voice TX does not starve control-plane proxy IPC. @@ -292,6 +316,75 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { } }); + ipcMain.handle('reticulum:voiceMemoStart', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceMemoStart'); + const body = opts != null && typeof opts === 'object' && !Array.isArray(opts) ? opts : {}; + try { + const m = ensureManager(); + return await m.proxyPost(VOICE_MEMO_START_API_PATH, body); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceMemoStart', err, VOICE_MEMO_START_API_PATH); + } + }); + + ipcMain.handle('reticulum:voiceMemoSendAudio', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceMemoSendAudio'); + reticulumVoiceMemoAudioIpcRateLimit.checkOrThrow(); + const parsed = parseVoiceMemoAudioRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + const m = ensureManager(); + return await m.proxyPost(VOICE_MEMO_AUDIO_API_PATH, parsed); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceMemoSendAudio', err, VOICE_MEMO_AUDIO_API_PATH); + } + }); + + ipcMain.handle('reticulum:voiceMemoStop', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceMemoStop'); + const parsed = parseVoiceMemoSessionRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + const m = ensureManager(); + const result = await m.proxyPost(VOICE_MEMO_STOP_API_PATH, parsed); + if ( + result && + typeof result === 'object' && + !Array.isArray(result) && + typeof (result as Record).ogg_base64 === 'string' && + ((result as Record).ogg_base64 as string).length > + VOICE_MEMO_DATA_BASE64_MAX + ) { + return { ok: false, error: 'ogg_base64_too_large' }; + } + return result; + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceMemoStop', err, VOICE_MEMO_STOP_API_PATH); + } + }); + + ipcMain.handle('reticulum:voiceMemoCancel', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceMemoCancel'); + const parsed = parseVoiceMemoSessionRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + const m = ensureManager(); + return await m.proxyPost(VOICE_MEMO_CANCEL_API_PATH, parsed); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceMemoCancel', err, VOICE_MEMO_CANCEL_API_PATH); + } + }); + ipcMain.handle('reticulum:factoryReset', async (event) => { assertIpcSender(event, 'reticulum:factoryReset'); try { 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 ce069f273..e73c2614f 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -65,7 +65,7 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () ] as const) { const handleIdx = HANDLERS_SOURCE.indexOf(`ipcMain.handle('${channel}'`); expect(handleIdx, channel).toBeGreaterThanOrEqual(0); - const afterHandle = HANDLERS_SOURCE.slice(handleIdx, handleIdx + 900); + const afterHandle = HANDLERS_SOURCE.slice(handleIdx, handleIdx + 1200); const tryIdx = afterHandle.indexOf('try {'); const checkIdx = afterHandle.indexOf('reticulumProxyIpcRateLimit.checkOrThrow()'); expect(tryIdx, channel).toBeGreaterThanOrEqual(0); @@ -86,6 +86,20 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/voice\/audio'/); }); + it('routes voice memo PCM through dedicated IPC with its own rate limit and blocks proxy', () => { + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceMemoStart'"); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceMemoSendAudio'"); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceMemoStop'"); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceMemoCancel'"); + expect(HANDLERS_SOURCE).toContain('voice memo requires reticulum:voiceMemo* IPC channels'); + const preload = readFileSync(join(__dirname, '../../preload/index.ts'), 'utf-8'); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceMemoStart'"); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceMemoSendAudio'"); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceMemoStop'"); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceMemoCancel'"); + expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/voice\/memo/); + }); + it('routes LRGP games through dedicated IPC with its own rate limit', () => { expect(HANDLERS_SOURCE).toMatch( /const reticulumGamesIpcRateLimit = createIpcRateLimiter\(\{\s*max:\s*600,[\s\S]*?label:\s*'reticulum:games'/, diff --git a/src/main/reticulum-attachment-audio.test.ts b/src/main/reticulum-attachment-audio.test.ts new file mode 100644 index 000000000..2144b74d7 --- /dev/null +++ b/src/main/reticulum-attachment-audio.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment node +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + readReticulumAttachmentBytes, + resetReticulumAttachmentAudioRateLimitForTests, + RETICULUM_ATTACHMENT_AUDIO_MAX_BYTES, + takeReticulumAttachmentAudioRateToken, +} from './reticulum-attachment-audio'; + +const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-rns-attach-audio-')); + +vi.mock('electron', () => ({ + app: { + getPath: () => userData, + }, +})); + +const attachmentsDir = path.join(userData, 'reticulum', 'attachments'); + +afterEach(() => { + resetReticulumAttachmentAudioRateLimitForTests(); + fs.rmSync(userData, { recursive: true, force: true }); + fs.mkdirSync(attachmentsDir, { recursive: true }); +}); + +describe('reticulum-attachment-audio', () => { + it('reads jailed OggS bytes as base64', async () => { + fs.mkdirSync(attachmentsDir, { recursive: true }); + const filePath = path.join(attachmentsDir, 'memo.ogg'); + const body = Buffer.concat([Buffer.from('OggS'), Buffer.from('opus-test-payload')]); + fs.writeFileSync(filePath, body); + const b64 = await readReticulumAttachmentBytes(filePath); + expect(b64).toBe(body.toString('base64')); + }); + + it('returns null for non-Ogg magic', async () => { + fs.mkdirSync(attachmentsDir, { recursive: true }); + const filePath = path.join(attachmentsDir, 'not-ogg.bin'); + fs.writeFileSync(filePath, Buffer.from([0x00, 0x01, 0x02, 0x03])); + await expect(readReticulumAttachmentBytes(filePath)).resolves.toBeNull(); + }); + + it('rejects paths outside the attachments jail', async () => { + const outside = path.join(userData, 'escape.bin'); + fs.writeFileSync(outside, Buffer.from('OggSxxxx')); + await expect(readReticulumAttachmentBytes(outside)).rejects.toThrow(/outside/); + }); + + it('returns null for empty files', async () => { + fs.mkdirSync(attachmentsDir, { recursive: true }); + const filePath = path.join(attachmentsDir, 'empty.ogg'); + fs.writeFileSync(filePath, Buffer.alloc(0)); + await expect(readReticulumAttachmentBytes(filePath)).resolves.toBeNull(); + }); + + it('enforces the audio read size cap', async () => { + fs.mkdirSync(attachmentsDir, { recursive: true }); + const filePath = path.join(attachmentsDir, 'huge.ogg'); + const huge = Buffer.alloc(RETICULUM_ATTACHMENT_AUDIO_MAX_BYTES + 16, 0x41); + huge.write('OggS', 0); + fs.writeFileSync(filePath, huge); + await expect(readReticulumAttachmentBytes(filePath)).rejects.toThrow(/too large/i); + }); + + it('rate-limits attachment audio reads', () => { + resetReticulumAttachmentAudioRateLimitForTests(); + let allowed = 0; + for (let i = 0; i < 80; i++) { + if (takeReticulumAttachmentAudioRateToken()) allowed += 1; + } + expect(allowed).toBe(60); + }); +}); diff --git a/src/main/reticulum-attachment-audio.ts b/src/main/reticulum-attachment-audio.ts new file mode 100644 index 000000000..667be44c8 --- /dev/null +++ b/src/main/reticulum-attachment-audio.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { isUnderCanonicalRoot } from './pathCanonical'; +import { readFileUpTo } from './readFileUpTo'; +import { + assertReticulumAttachmentPathJailed, + getReticulumAttachmentsDir, +} from './reticulum-attachment-path'; + +/** Maximum audio attachment size for inline read (256 KiB). */ +export const RETICULUM_ATTACHMENT_AUDIO_MAX_BYTES = 256 * 1024; + +/** IPC rate limit — max reads per rolling window. */ +export const RETICULUM_ATTACHMENT_AUDIO_RATE_LIMIT_MAX = 60; +export const RETICULUM_ATTACHMENT_AUDIO_RATE_LIMIT_WINDOW_MS = 60_000; + +/** OggS magic bytes for basic file-type validation (prevents non-Ogg reads). */ +const OGG_MAGIC = Buffer.from([0x4f, 0x67, 0x67, 0x53]); + +const audioRateTimestamps: number[] = []; + +/** Returns true when a new attachment audio read is allowed under the IPC rate limit. */ +export function takeReticulumAttachmentAudioRateToken(now = Date.now()): boolean { + const cutoff = now - RETICULUM_ATTACHMENT_AUDIO_RATE_LIMIT_WINDOW_MS; + while (audioRateTimestamps.length > 0) { + const oldest = audioRateTimestamps[0]; + if (oldest === undefined || oldest >= cutoff) break; + audioRateTimestamps.shift(); + } + if (audioRateTimestamps.length >= RETICULUM_ATTACHMENT_AUDIO_RATE_LIMIT_MAX) { + return false; + } + audioRateTimestamps.push(now); + return true; +} + +/** Test helper — clears the rate-limit window. */ +export function resetReticulumAttachmentAudioRateLimitForTests(): void { + audioRateTimestamps.length = 0; +} + +/** + * Read a jailed Reticulum audio attachment (OggS) and return its bytes as base64. + * Validates magic bytes so non-Ogg files are rejected before the bytes leave main. + * Returns null when the file is empty, too large, not OggS, or out of jail. + */ +export async function readReticulumAttachmentBytes(filePath: string): Promise { + const jailed = assertReticulumAttachmentPathJailed(filePath); + const realPath = await fs.realpath(path.resolve(jailed)); + if (!isUnderCanonicalRoot(realPath, getReticulumAttachmentsDir())) { + throw new Error('audio attachment path outside reticulum attachments directory'); + } + const stat = await fs.stat(realPath); + if (!stat.isFile()) { + throw new Error('audio attachment path is not a file'); + } + const buf = await readFileUpTo(realPath, RETICULUM_ATTACHMENT_AUDIO_MAX_BYTES); + if (buf.length === 0) return null; + if (buf.length < 4 || !buf.subarray(0, 4).equals(OGG_MAGIC)) { + return null; + } + return buf.toString('base64'); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index def912186..ad937a75e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ import type { OutboxStatus, ReadReticulumAttachmentAsDataUrlOpts, ReadReticulumAttachmentAsDataUrlResult, + ReadReticulumAttachmentBytesResult, ReticulumIdentityBackupImportDialogResult, ReticulumIdentityExportSaveResult, ReticulumIdentityImportDialogResult, @@ -179,6 +180,8 @@ contextBridge.exposeInMainWorld('electronAPI', { delivery_attempts?: number | null; next_delivery_attempt_at?: number | null; attachment_path?: string | null; + audio_mode?: number | null; + audio_duration_sec?: number | null; }) => ipcRenderer.invoke('db:saveReticulumMessage', message), markStaleReticulumOutbound: (identityId: string, staleAfterMs?: number) => ipcRenderer.invoke('db:markStaleReticulumOutbound', identityId, staleAfterMs), @@ -1225,6 +1228,15 @@ contextBridge.exposeInMainWorld('electronAPI', { sendAudio: (opts: { profile?: number; channels: number; samples_b64: string }) => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceSendAudio', opts)), }, + voiceMemo: { + start: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceMemoStart', {})), + sendAudio: (opts: { session_id: string; channels: 1; samples_b64: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceMemoSendAudio', opts)), + stop: (opts: { session_id: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceMemoStop', opts)), + cancel: (opts: { session_id: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceMemoCancel', opts)), + }, /** LRGP games — dedicated IPC (blocked on generic proxy). */ games: { getStatus: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesStatus')), @@ -1337,6 +1349,11 @@ contextBridge.exposeInMainWorld('electronAPI', { 'chat:readReticulumAttachmentAsDataUrl', opts, ) as Promise, + readReticulumAttachmentBytes: (filePath: string) => + ipcRenderer.invoke( + 'chat:readReticulumAttachmentBytes', + filePath, + ) as Promise, linkPreview: { fetch: (url: string) => ipcRenderer.invoke('chat:fetchLinkPreview', url) as Promise<{ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 72cb0a7c2..a21f3baf7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -243,6 +243,8 @@ import { openReticulumGameSession } from './lib/reticulum/reticulumGamesSession' import { setReticulumManualStackStopSuppress } from './lib/reticulum/reticulumManualStackStopSuppress'; import { resolveReticulumSelfHeaderLabel } from './lib/reticulum/reticulumSelfNodeLabel'; import { skipReticulumStartupAutostartGate } from './lib/reticulum/reticulumStartupAutostartGate'; +import { startReticulumVoiceMemo } from './lib/reticulum/reticulumVoiceMemo'; +import { sendReticulumVoiceMemo } from './lib/reticulum/sendReticulumVoiceMemo'; import { logRfReconnectFailure, reconnectRfFromLastConnection } from './lib/rfReconnectHelper'; import { scheduleReticulumVacuumIfNeeded } from './lib/startupDbPrune'; import { getStoredMeshProtocol, MESH_PROTOCOL_STORAGE_KEY } from './lib/storedMeshProtocol'; @@ -280,6 +282,7 @@ import { usePositionHistoryStore } from './stores/positionHistoryStore'; import { useReticulumGamesStore } from './stores/reticulumGamesStore'; import { useReticulumIdentityStore } from './stores/reticulumIdentityStore'; import { useReticulumPeerStore } from './stores/reticulumPeerStore'; +import { useReticulumVoiceMemoStore } from './stores/reticulumVoiceMemoStore'; import { useRncpTransferStore } from './stores/rncpTransferStore'; import { useRrcSessionStore } from './stores/rrcSessionStore'; import { useTimeFormatStore } from './stores/timeFormatStore'; @@ -3200,6 +3203,73 @@ function AppContent() { dmOnlyChat={capabilities.hasReticulumInterfaceConfig} hasRncpTransfer={capabilities.hasRncpTransfer} hasLxstVoice={capabilities.hasLxstVoice} + hasReticulumVoiceMemo={capabilities.hasReticulumVoiceMemo} + onVoiceMemo={ + capabilities.hasReticulumVoiceMemo && reticulumIdentityId + ? (destination: number) => { + const phase = useReticulumVoiceMemoStore.getState().phase; + if ( + phase === 'sending' || + phase === 'starting' || + phase === 'stopping' + ) { + return; + } + if (phase === 'recording' || phase === 'ready') { + sendReticulumVoiceMemo({ + identityId: reticulumIdentityId, + destination, + onOversize: () => { + addToast( + t('chatPanel.voiceMemo.tooLargeForWire'), + 'warning', + ); + }, + onNoPropagationNode: () => { + addToast( + t('chatPanel.reticulumNoPropagationNode'), + 'error', + ); + }, + onTooLargeForPropagation: () => { + addToast( + t('chatPanel.voiceMemo.tooLargeForPropagation'), + 'info', + ); + }, + }); + return; + } + void startReticulumVoiceMemo() + .then((ok) => { + if (!ok) { + const err = + useReticulumVoiceMemoStore.getState().lastError; + if (err === 'call_busy') { + addToast(t('chatPanel.voiceMemo.callBusy'), 'warning'); + } else if (err === 'mic_denied') { + addToast(t('chatPanel.voiceMemo.micDenied'), 'error'); + } else if ( + err === 'sidecar_unavailable' || + err === 'start_failed' || + err + ) { + useReticulumVoiceMemoStore.getState().reset(); + addToast(t('chatPanel.voiceMemo.startFailed'), 'error'); + } + } + }) + .catch((e: unknown) => { + console.warn( + '[App] startReticulumVoiceMemo rejected:', + errLikeToLogString(e), + ); + useReticulumVoiceMemoStore.getState().reset(); + addToast(t('chatPanel.voiceMemo.startFailed'), 'error'); + }); + } + : undefined + } hasLrgpGames={capabilities.hasLrgpGames} hasLxmfPaper={capabilities.hasLxmfPaper} showLxmfDeliveryStatus={capabilities.hasLxmfDeliveryStatus} diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index b4779be01..625f86aba 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -14,6 +14,7 @@ import { import { resetMeshcoreSendRateForTests } from '@/renderer/lib/meshcoreSendRateNotice'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; +import { useReticulumVoiceMemoStore } from '@/renderer/stores/reticulumVoiceMemoStore'; import { ChatComposer } from './ChatComposer'; @@ -55,7 +56,14 @@ vi.mock('react-i18next', () => ({ 'chatPanel.floodScopeOverrideCustomApply': 'Use scope', 'chatPanel.floodScopeOverrideCustomInvalid': 'Enter a valid region hashtag', 'common.cancel': 'Cancel', + 'chatPanel.voiceMemo.recordAria': 'Record voice memo', + 'chatPanel.voiceMemo.sendAria': 'Send voice memo', + 'chatPanel.voiceMemo.recordTooltip': 'Record voice memo tooltip', + 'chatPanel.voiceMemo.sendTooltip': 'Send voice memo tooltip', }; + if (key === 'chatPanel.voiceMemo.sendAriaWithElapsed') { + return `Send voice memo (${opts?.seconds}s recorded)`; + } if (key === 'chatPanel.composeLimit.approaching') { return `${opts?.count} / ${opts?.limit}`; } @@ -1113,4 +1121,71 @@ describe('ChatComposer', () => { }); expect(onRememberFloodScopePreset).not.toHaveBeenCalled(); }); + + describe('VoiceMemoComposerButton', () => { + beforeEach(() => { + useReticulumVoiceMemoStore.getState().reset(); + }); + + it('shows record button when compose is empty and onVoiceMemo is set', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Record voice memo' })).toBeEnabled(); + }); + + it('disables the button while starting', () => { + useReticulumVoiceMemoStore.getState().setStarting(); + render( + , + ); + expect(screen.getByRole('button', { name: 'Send voice memo' })).toBeDisabled(); + }); + + it('includes elapsed seconds in aria-label while recording', () => { + useReticulumVoiceMemoStore.getState().setStarting(); + useReticulumVoiceMemoStore.getState().startRecording('sess-1'); + useReticulumVoiceMemoStore.getState().tickElapsed(12); + render( + , + ); + expect(screen.getByRole('button', { name: 'Send voice memo (12s recorded)' })).toBeEnabled(); + }); + + it('passes axe with voice memo button visible', async () => { + const { container } = render( + , + ); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + }); }); diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 0c588fdfa..13dab37a9 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/refs */ import 'emoji-picker-element'; -import { ChevronDown, ChevronUp, CornerUpLeft, MapPin } from 'lucide-react-motion'; +import { ChevronDown, ChevronUp, CornerUpLeft, MapPin, Mic } from 'lucide-react-motion'; import { type ReactNode, type RefObject, @@ -19,6 +19,7 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { nodeDisplayName } from '@/renderer/lib/nodeLongNameOrHex'; import type { ChatMessage, MeshNode, MeshProtocol } from '@/renderer/lib/types'; +import { useReticulumVoiceMemoStore } from '@/renderer/stores/reticulumVoiceMemoStore'; import type { OutboxEntry, OutboxEntryInput } from '@/shared/electron-api.types'; import { @@ -191,6 +192,8 @@ export interface ChatComposerProps { */ onSendLocationWaypoint?: (lat: number, lon: number) => Promise; textareaRef?: RefObject; + /** When set, renders a mic button that triggers voice memo recording. */ + onVoiceMemo?: () => void; className?: string; } @@ -224,6 +227,7 @@ export function ChatComposer({ resolveShareLocation, onSendLocationWaypoint, textareaRef, + onVoiceMemo, className, }: ChatComposerProps) { const { t } = useTranslation(); @@ -236,6 +240,12 @@ export function ChatComposer({ const counterLiveId = useId(); const floodScopeListboxId = useId(); const floodScopeCustomInputId = useId(); + const memoPhase = useReticulumVoiceMemoStore((s) => s.phase); + const memoRecordingActive = + memoPhase === 'recording' || + memoPhase === 'starting' || + memoPhase === 'stopping' || + memoPhase === 'ready'; const [input, setInput] = useState(''); const [floodScopeOverride, setFloodScopeOverride] = useState(''); @@ -1519,20 +1529,29 @@ export function ChatComposer({ : null} ) : ( - +
+ {onVoiceMemo != null && !sending && (memoRecordingActive || !input.trim()) && ( + + )} + +
)} @@ -1589,3 +1608,57 @@ export function ChatComposer({ ); } + +function VoiceMemoComposerButton({ + onVoiceMemo, + disabled, + idleClassName, +}: { + onVoiceMemo: () => void; + disabled?: boolean; + /** Same chrome as emoji / location / GIF composer controls. */ + idleClassName: string; +}) { + const { t } = useTranslation(); + const phase = useReticulumVoiceMemoStore((s) => s.phase); + const elapsedSec = useReticulumVoiceMemoStore((s) => s.elapsedSec); + const recording = phase === 'recording' || phase === 'starting'; + const sendMode = recording || phase === 'ready'; + const busy = phase === 'starting' || phase === 'stopping' || phase === 'sending'; + return ( + + + + ); +} diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 827b4e6e2..b1d839846 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -61,6 +61,7 @@ import { openReticulumDmFromHash, parseReticulumDestinationInput, } from '@/renderer/lib/reticulum/reticulumDestinationInput'; +import { cancelReticulumVoiceMemo } from '@/renderer/lib/reticulum/reticulumVoiceMemo'; import { RETICULUM_DM_HEADER_ACTION_CLASS, RETICULUM_DM_HEADER_STATUS_CLASS, @@ -166,9 +167,13 @@ import { ReticulumDmPathActions, ReticulumDmPathReachabilityBadge, } from './ReticulumDmPathReachabilityBadge'; -import { ReticulumMessageStatusBadge } from './ReticulumMessageStatusBadge'; +import { + isReticulumTooLargeForPropagationError, + ReticulumMessageStatusBadge, +} from './ReticulumMessageStatusBadge'; import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { ReticulumPropagationNotice } from './ReticulumPropagationNotice'; +import { ReticulumVoiceMemoLine } from './ReticulumVoiceMemoLine'; import { useToast } from './Toast'; function chatPanelIsLinux(): boolean { @@ -529,6 +534,10 @@ export interface ChatPanelProps { hasRncpTransfer?: boolean; /** Reticulum: LXST voice Call control in the DM header. */ hasLxstVoice?: boolean; + /** Reticulum: LXMF voice memo mic button in composer + playback line in chat. */ + hasReticulumVoiceMemo?: boolean; + /** Called when the user presses the mic button (destination = active DM node). */ + onVoiceMemo?: (destination: number) => void; /** Reticulum: LRGP games Challenge control in the DM header. */ hasLrgpGames?: boolean; /** Reticulum: LXMF paper Share as paper / Scan paper controls. */ @@ -588,6 +597,8 @@ function ChatPanel({ reticulumStackLive = false, hasRncpTransfer = false, hasLxstVoice = false, + hasReticulumVoiceMemo = false, + onVoiceMemo, hasLrgpGames = false, hasLxmfPaper = false, resolveShareLocation, @@ -777,6 +788,13 @@ function ChatPanel({ saveActiveDm(protocol, activeDmNode); }, [activeDmNode, protocol]); + // Drop in-progress memo capture when switching DMs so the mic does not stay open. + useEffect(() => { + return () => { + void cancelReticulumVoiceMemo(); + }; + }, [activeDmNode]); + useEffect(() => { try { localStorage.setItem(dismissedDmTabsStorageKey(protocol), JSON.stringify(dismissedDmTabs)); @@ -2859,8 +2877,23 @@ function ChatPanel({ {/* Message text with optional search highlight (div: ChatPayloadText may render block link previews) */}
- {showLxmfAttachmentLine && - parseReticulumAttachmentPayload(msg.payload) ? ( + {/^\[voice:/i.test(msg.payload) && + !(hasReticulumVoiceMemo && msg.reticulumAttachmentPath) ? ( + + {t('chatPanel.voiceMemo.unavailable')} + + ) : hasReticulumVoiceMemo && + msg.reticulumAttachmentPath && + (msg.reticulumAttachmentKind === 'audio' || + msg.reticulumAttachmentPath.toLowerCase().endsWith('.ogg') || + /^\[voice:/i.test(msg.payload)) ? ( + + ) : showLxmfAttachmentLine && + parseReticulumAttachmentPayload(msg.payload) ? ( - {isOwn && msg.status === 'failed' && ( - - )} + {isOwn && + msg.status === 'failed' && + !isReticulumTooLargeForPropagationError(msg.error) && ( + + )} {showLxmfDeliveryStatus && msg.status ? ( { + if (activeDmNode == null) return; + onVoiceMemo(activeDmNode); + } + : undefined + } /> {chatActionError?.viewKey === viewKey && ( diff --git a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx index 7d08de34e..fec73ed46 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx @@ -95,14 +95,18 @@ describe('ReticulumMessageStatusBadge', () => { expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); }); - it('shows red X (not house) for failed stored_locally', async () => { + it('shows Direct-only notice for message_too_large_for_propagation', async () => { await renderAndAssertAxe( - , + , ); - expect( - screen.getByLabelText('chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendFailed'), - ).toBeTruthy(); - expect(screen.getByText(/reticulumPnAbbrev\s+\u2717/)).toBeTruthy(); - expect(screen.queryByText(/\u{1F3E0}/u)).toBeNull(); + // Notice tooltip (not "Propagation: failed") and info icon (not ✗). + expect(screen.getByLabelText('chatPanel.voiceMemo.tooLargeForPropagation')).toBeTruthy(); + expect(screen.getByText(/reticulumPnAbbrev\s+\u2139/u)).toBeTruthy(); + expect(screen.queryByText(/\u2717/u)).toBeNull(); }); }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.tsx b/src/renderer/components/ReticulumMessageStatusBadge.tsx index f3ff6c8c2..18aba5786 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.tsx @@ -8,6 +8,7 @@ import { type ReticulumVia, } from '@/renderer/lib/reticulum/classifyReticulumVia'; import type { MessageRecord, MessageTransport } from '@/renderer/stores/messageStore'; +import { RETICULUM_MESSAGE_TOO_LARGE_FOR_PROPAGATION } from '@/shared/reticulum-voice-memo-types'; import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; export interface ReticulumMessageStatusBadgeProps { @@ -21,6 +22,13 @@ type OutboundStatus = ReticulumMessageStatusBadgeProps['status']; /** House mark for local-prop (own PN) offline storage — not a peer-delivery check. */ const LOCAL_PN_HOUSE_ICON = '\u{1F3E0}'; +/** Info mark — Direct-only / too large for PN (notice, not a send failure). */ +const DIRECT_ONLY_NOTICE_ICON = '\u2139'; + +/** Sidecar code when packed size exceeds PN deposit; UI must not look like a PN outage. */ +export function isReticulumTooLargeForPropagationError(error: string | undefined): boolean { + return error === RETICULUM_MESSAGE_TOO_LARGE_FOR_PROPAGATION; +} function tooltipKeyForVia(via: ReticulumVia | undefined): string { switch (via) { @@ -40,7 +48,12 @@ function tooltipKeyForVia(via: ReticulumVia | undefined): string { function statusIcon( status: OutboundStatus, deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, + error: string | undefined, ): string { + // Notice-only: Direct already tried; offline PN store impossible — not a generic ✗ failure. + if (isReticulumTooLargeForPropagationError(error)) { + return DIRECT_ONLY_NOTICE_ICON; + } // Local-prop cascade last resort: show house instead of green check / red X. if (deliveryMethod === 'stored_locally' && status !== 'failed') { return LOCAL_PN_HOUSE_ICON; @@ -58,7 +71,11 @@ function statusIcon( function statusColorClass( status: OutboundStatus, deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, + error?: string, ): string { + if (isReticulumTooLargeForPropagationError(error)) { + return 'text-amber-400'; + } if (deliveryMethod === 'stored_locally' && status !== 'failed') { return 'text-amber-400'; } @@ -78,6 +95,9 @@ function statusLabelText( deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, error: string | undefined, ): string { + if (isReticulumTooLargeForPropagationError(error)) { + return t('chatPanel.voiceMemo.directOnlyBadge'); + } switch (status) { case 'sending': if (deliveryMethod === 'stored_locally') { @@ -140,14 +160,17 @@ export function ReticulumMessageStatusBadge({ : viasLabel; const statusLabel = statusLabelText(t, status, deliveryMethod, error); const viaPrefix = viaPrefixText(t, deliveryMethod, atoms, viasLabel); - // Completed paper: paper-only prefix. Failed/sending paper keep status suffix (incl. error text). - const tooltip = - deliveryMethod === 'paper' && status === 'acked' ? viaPrefix : `${viaPrefix}: ${statusLabel}`; + // Completed paper: paper-only prefix. Direct-only oversize: full notice (not "PN: failed"). + const tooltip = isReticulumTooLargeForPropagationError(error) + ? t('chatPanel.voiceMemo.tooLargeForPropagation') + : deliveryMethod === 'paper' && status === 'acked' + ? viaPrefix + : `${viaPrefix}: ${statusLabel}`; return ( ); diff --git a/src/renderer/components/ReticulumVoiceMemoLine.test.tsx b/src/renderer/components/ReticulumVoiceMemoLine.test.tsx new file mode 100644 index 000000000..bafd47309 --- /dev/null +++ b/src/renderer/components/ReticulumVoiceMemoLine.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +import { ReticulumVoiceMemoLine } from './ReticulumVoiceMemoLine'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/renderer/lib/reticulum/computeWaveform', () => ({ + computeWaveform: vi.fn().mockReturnValue(new Array(40).fill(0.5)), +})); + +const readReticulumAttachmentBytes = vi.fn(); + +class MockAudioBuffer { + duration = 3; + getChannelData = () => new Float32Array(48000); +} + +beforeEach(() => { + readReticulumAttachmentBytes.mockReset(); + readReticulumAttachmentBytes.mockResolvedValue({ dataBase64: btoa('fake-ogg') }); + window.electronAPI = { + ...window.electronAPI, + chat: { + ...window.electronAPI?.chat, + readReticulumAttachmentBytes: (...args: unknown[]) => readReticulumAttachmentBytes(...args), + }, + }; + vi.stubGlobal( + 'AudioContext', + class { + state = 'running'; + currentTime = 0; + destination = {}; + close = vi.fn().mockResolvedValue(undefined); + resume = vi.fn().mockResolvedValue(undefined); + createBufferSource = () => ({ + buffer: null as MockAudioBuffer | null, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null as (() => void) | null, + }); + decodeAudioData = vi.fn().mockResolvedValue(new MockAudioBuffer()); + }, + ); +}); + +async function renderAxe(ui: ReactElement): Promise> { + const view = render(ui); + hydrateAxeThemeColors(view.container); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'chatPanel.voiceMemo.playAria' })).toBeEnabled(); + }); + expect(await axe(view.container)).toHaveNoViolations(); + return view; +} + +describe('ReticulumVoiceMemoLine', () => { + it('renders play button and seek control', async () => { + await renderAxe( + , + ); + expect(screen.getByRole('button', { name: 'chatPanel.voiceMemo.playAria' })).toBeDefined(); + expect(screen.getByRole('slider', { name: 'chatPanel.voiceMemo.seekAria' })).toBeDefined(); + }); + + it('loads attachment bytes and enables playback', async () => { + render( + , + ); + await waitFor(() => { + expect(readReticulumAttachmentBytes).toHaveBeenCalledWith('/fake/memo.ogg'); + expect(screen.getByRole('button', { name: 'chatPanel.voiceMemo.playAria' })).toBeEnabled(); + }); + }); + + it('passes axe with no violations', async () => { + await renderAxe(); + }); +}); diff --git a/src/renderer/components/ReticulumVoiceMemoLine.tsx b/src/renderer/components/ReticulumVoiceMemoLine.tsx new file mode 100644 index 000000000..ac8fc6d20 --- /dev/null +++ b/src/renderer/components/ReticulumVoiceMemoLine.tsx @@ -0,0 +1,270 @@ +import { Pause, Play } from 'lucide-react-motion'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { computeWaveform } from '@/renderer/lib/reticulum/computeWaveform'; +import { LXMF_AUDIO_MODE_OPUS_OGG } from '@/shared/reticulum-voice-memo-types'; + +const BAR_COUNT = 40; +const BAR_MIN_HEIGHT = 2; + +export interface ReticulumVoiceMemoLineProps { + /** Local on-disk path of the jailed OggS audio file. */ + attachmentPath: string; + /** Known duration in seconds (from ingest; may be 0 before decode). */ + durationSec?: number; + /** LXMF audio mode (16 = AM_OPUS_OGG). */ + audioMode?: number; +} + +function formatDuration(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}`; +} + +/** + * Play/pause/seek for a cached LXMF voice memo. + * Uses Web Audio (`decodeAudioData`) — Chromium's `