diff --git a/AGENTS.md b/AGENTS.md index 13d16a674..1cf444fca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,8 +146,8 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev` - **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. -- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) -- **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts`, `QrIngestControl` / `QrCodeImage`. External contact imports confirm before upsert. +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) +- **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled - **Propagation sync:** `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation — `refreshFromSidecar` must **not** clear the active attempt while `sync.active` @@ -158,7 +158,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` (Direct-first; **one-shot fallback** to preferred **remote** PN on Direct fail; intermediate WS `sending` + `delivery_method: "propagated"`); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when an effective remote PN target exists; also skips `propagated` rows so fallback is not killed), `markStaleReticulumOutbound.ts`. Propagated Completes UI: **Stored at propagation node**. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` (Direct-first; **one-shot fallback** to preferred **remote** PN on Direct fail; intermediate WS `sending` + `delivery_method: "propagated"`); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when an effective remote PN target exists; also skips `propagated` rows so fallback is not killed), `markStaleReticulumOutbound.ts`. Propagated Completes UI: **Stored at propagation node**. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). @@ -281,7 +281,7 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel ### Chat Panel -- **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). +- **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`). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), 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`). diff --git a/README.md b/README.md index aff12e6a0..b676826b7 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ Reticulum is the third protocol tab (**amber** pill). The stack runs in an **AGP **Messaging (Chat + RRC)** -- **Chat tab:** **DM-only** LXMF text and reactions (peer file transfer via Remote rncp; historic LXMF attachment labels still render; **cached raster images** on disk under `reticulum/attachments/` also display inline); **LXST voice Call** on DM headers (live telephony — not an LXMF voice-note clip) +- **Chat tab:** **DM-only** LXMF text and reactions (peer file transfer via Remote rncp; historic LXMF attachment labels still render; **cached raster images** on disk under `reticulum/attachments/` also display inline); **LXST voice Call** on DM headers (live telephony — not an LXMF voice-note clip); **Share as paper** / **Scan paper** for offline encrypted LXMF QR/`lxm://` handoff (**Paper** delivery badge) - **RRC tab:** multi-hub relay chat (rooms, nicklists, slash commands, favourites, auto-join, reconnect; up to 8 hubs) - **Remote tab:** **rnsh** interactive shell sessions and **rncp** file transfer (send / receive / fetch) over Reticulum, with saved addresses and inbound-policy controls; also available from Chat DMs as send-file (distinct from Meshtastic remote admin) - **Direct** delivery when the destination is in the path table (then **one-shot remote PN fallback** on Direct fail when a preferred remote PN is set); **propagated (PN)** when offline — Completes show **Stored at propagation node**, not recipient Delivered diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3bb0b996f..d8a56643a 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -87,14 +87,16 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ ### LXMF and contacts -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | -| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | -| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | -| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | -| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | -| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/paper/create` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` (Chat UI currently sends `destination_hash` + `text` only) | Live: signed `DeliveryMethod::Paper` + `to_paper_uri` (encrypt to peer pubkey in `known_identities`); **no** network send. `{ ok, uri, message_hash, delivery_method: "paper", message? }` or `{ ok: false, error: "identity_unknown" \| "paper_too_large" \| "identity_not_configured" \| "invalid_hash" \| "internal_error" }`. Emits WS `lxmf_message` outbound with `delivery_method`/`sent_via`/`received_via`=`paper` and `delivery_status: "delivered"` — **does not** emit `lxmf_outbound_status`. Stub: `{ ok: false, error: "identity_not_configured" }` | +| POST | `/api/v1/lxmf/paper/ingest` | `{ uri }` (`lxm://` base64url paper blob; URI length capped before decrypt) | Live: `from_paper_uri` + local identity decrypt + `ingest_lxm_uri` → delivery callback / WS `lxmf_message` inbound (`delivery_method`/`received_via`=`paper`). `{ ok, message? }` or `{ ok: false, error: "invalid_uri" \| "decrypt_failed" \| "identity_not_configured" \| "paper_too_large" \| "internal_error" }`. Stub: `{ ok: false, error: "identity_not_configured" }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | +| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | +| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | +| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | +| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | ### Peers, topology, and propagation @@ -261,11 +263,11 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. -- **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. +- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. - **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. -`lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. +`lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows; `delivered` on paper Completes), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, `network`, or **`paper`** (offline QR handoff); multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`) or `paper` for decrypted paper URIs. Never use Meshtastic-style `both` for Reticulum network egress (legacy `both` may still appear in SQLite allowlists). `lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). diff --git a/docs/reticulum.md b/docs/reticulum.md index 6c180d6d2..f48c23fb9 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -32,7 +32,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | | Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | | RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges, toasts when the RRC tab is inactive, automatic reconnect with backoff | -| Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set. Path/transport badges (RF/BLE/TCP/NET, multi, PN) are egress evidence — UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set; **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` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | | Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | | Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | @@ -264,7 +264,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Identity:** generate BIP-39 recovery phrase, import **private key** (paste or file picker via `reticulum:showIdentityImportDialog`), import **backup JSON**, export with passphrase, display name; **replace identity** confirm when keys already exist (`replace: true` on generate/import) - **Identity slots:** Network panel lists local slots (`GET /api/v1/identities`), create / switch / delete (`POST /api/v1/identities`, `/switch`, `/delete`). Create/switch are serialized and commit the active pointer only after the working key is applied; the sidecar restarts after a successful change. Soft cap **16** slots; display names are sanitized (control chars rejected, max 128 chars). -- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl`) including `lxma://` (registers pubkey + saved contact). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). External contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. +- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl` under Network **Scan / import**) including `lxma://` (registers pubkey + saved contact) and encrypted **LXMF paper** `lxm://` blobs (`POST /api/v1/lxmf/paper/ingest`) — in-app Network/Chat QR apply **immediately** via `handleReticulumQrIngest`. OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). **OS-opened** contact / MeshCore imports require confirmation (`MeshClientDeepLinkHost`); paper OS deep links ingest without confirm. - **Peer fingerprint verification:** Peer detail can mark a contact verified (pins `verified_identity_hash` + `verified_at` in SQLite via `db:setReticulumDestinationVerified`) and warns on mismatch when the live announce hash drifts. - **Header self label:** when configured, the app header shows your Network **display name** (`reticulumSelfNodeLabel.ts`) — not a hash-prefix stub; omit the `Node:` label when no real name is set - **Identity vault:** optional passcode (minimum 8 characters) to encrypt secrets in the main process; unlock is rate-limited @@ -281,7 +281,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **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. - **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 -- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. On Direct failure with a preferred **remote** PN, the sidecar re-emits `sending` with `delivery_method: "propagated"` for the one-shot PN deposit +- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. On Direct failure with a preferred **remote** PN, the sidecar re-emits `sending` with `delivery_method: "propagated"` for the one-shot PN deposit. **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`). - Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct labeling; early WS events before provisional id→hash rekey are buffered - Propagated Completes render as **Stored at propagation node** (PN badge) — not recipient **Delivered** - **DM path reachability:** active DM header shows a reachability badge (`ReticulumDmPathReachabilityBadge` + `useReticulumDmPathProbe`) seeded from path-table/contact hops, then settled by peer probe; when settled, **Request path** / **Probe** use the same sidecar endpoints as the Peers tab. Chat **Probe** mirrors Peer List UX: stack-running check → `/probe` → toast → peer refresh; `onProbeSettled` / `applyProbeResult(forHash, …)` applies the result without a second `/probe` (stale hashes after DM switch are ignored); manual reprobe forces Checking… even when passive hops already look reachable @@ -308,8 +308,9 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): | Destination present | Preferred **remote** PN set | Same Direct-first attempt; if Direct **fails**, sidecar **one-shot retries via preferred remote PN** (not local PN hosting). UI switches to **PN** / **Stored at propagation node** on PN Complete | | Destination absent | Preferred PN set | **Propagated** via preferred propagation node; **PN** badge = store-and-forward — Completes as **Stored at propagation node** (not recipient-delivered) | | Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | +| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node. **Local PN hosting** is this device’s optional local serving / inbox — it does **not** deposit outbound DMs for unreachable peers. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff). **Local PN hosting** is this device’s optional local serving / inbox — it does **not** deposit outbound DMs for unreachable peers. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. --- @@ -379,10 +380,10 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ### SQLite (main process) -| Table | Contents | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | -| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`; terminal outbound status written on `lxmf_outbound_status`; stale `sending` rows marked failed on startup) | +| Table | Contents | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | +| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`/`paper`; `received_via` atoms include `rf`/`ble`/`tcp`/`network`/`mqtt`/`both`/`paper`; terminal outbound status written on `lxmf_outbound_status` except paper Completes from create/ingest; stale `sending` rows marked failed on startup) | ### Sidecar `userData` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 234575971..83c8f5a87 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -216,6 +216,21 @@ If microphone permission is denied when placing or answering an LXST voice call: - **Windows:** Settings → Privacy & security → Microphone — allow desktop apps / Mesh-client. The app opens this page when OS status is `denied`. - **Linux:** Ensure PulseAudio or PipeWire can capture; Flatpak builds already include `--socket=pulseaudio`. AppImage/deb use the host audio stack. +### Reticulum LXMF paper create/ingest fails + +**Symptoms**: Chat **Share as paper** errors; Scan paper / Network QR / OS `lxm://` toast fails; paper badge missing after restart. + +| Sidecar / UI error | Likely cause | Fix | +| -------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `identity_unknown` / `shareAsPaperIdentityUnknown` | Peer pubkey not in `known_identities` | Import peer `lxma://` contact QR or wait for an announce, then retry | +| `decrypt_failed` / `paperDecryptFailed` | Paper encrypted to a different identity | Switch to the recipient identity slot (Network) that matches the paper | +| `paper_too_large` / `shareAsPaperTooLarge` | Message exceeds LXMF paper size cap | Shorten the text and recreate | +| `invalid_uri` / `paperInvalidUri` | Truncated or non-paper `lxm://` blob | Rescan / recopy the full QR or URI | +| `identity_not_configured` | No local LXMF identity | Generate/import identity on Network, ensure stack is running | +| Paper badge gone after restart | Older builds stripped `received_via: paper` on SQLite save | Update to a build that allowlists `paper` in `reticulumMessageTransport` / DB IPC | + +See [reticulum.md](reticulum.md#chat-lxmf) and [sidecar IPC](reticulum-sidecar-ipc.md) paper routes. + ### Reticulum Games challenge fails or board does not update - **Stack not running / games disabled:** Games need a live `rns-stack` sidecar with sibling `lrgp-rs`. Check Connection → Start stack and `GET` status via Games tab (or logs for `games requires live rns-stack`). diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs index 308b5f494..bcfc7e29a 100644 --- a/reticulum-sidecar/src/api/lxmf.rs +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -4,7 +4,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; use serde::Deserialize; -use crate::stack::{LxmfReactionRequest, LxmfSendRequest, StackHandle}; +use crate::stack::{ + LxmfPaperCreateRequest, LxmfPaperIngestRequest, LxmfReactionRequest, LxmfSendRequest, + StackHandle, +}; pub async fn lxmf_send( State(stack): State>, @@ -16,6 +19,65 @@ pub async fn lxmf_send( } } +/// Normalize paper create transport errors to stable API codes for the renderer. +pub(crate) fn map_paper_create_error(e: String) -> String { + if e == "identity_unknown" + || e == "paper_too_large" + || e == "identity_not_configured" + || e == "invalid_hash" + || e == "internal_error" + { + e + } else if e.contains("exactly 32 hex") || e.contains("invalid hex") { + "invalid_hash".to_string() + } else if e.contains("exceeds maximum size") { + "paper_too_large".to_string() + } else if e.contains("identity") || e.contains("not configured") { + "identity_not_configured".to_string() + } else { + "internal_error".to_string() + } +} + +/// Normalize paper ingest transport errors to stable API codes for the renderer. +pub(crate) fn map_paper_ingest_error(e: String) -> String { + if e == "invalid_uri" + || e == "decrypt_failed" + || e == "identity_not_configured" + || e == "paper_too_large" + || e == "identity_unknown" + || e == "internal_error" + { + e + } else if e.contains("invalid_uri") || e.contains("TooShort") { + "invalid_uri".to_string() + } else if e.contains("decrypt") { + "decrypt_failed".to_string() + } else { + "internal_error".to_string() + } +} + +pub async fn lxmf_paper_create( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_paper_create(body).await { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": map_paper_create_error(e) })), + } +} + +pub async fn lxmf_paper_ingest( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_paper_ingest(body.uri).await { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": map_paper_ingest_error(e) })), + } +} + pub async fn lxmf_reaction( State(stack): State>, Json(body): Json, @@ -67,7 +129,7 @@ pub async fn list_peers( #[cfg(test)] mod peers_query_tests { - use super::peers_query_forces_refresh; + use super::{map_paper_ingest_error, peers_query_forces_refresh}; #[test] fn peers_query_forces_refresh_accepts_truthy_variants() { @@ -79,6 +141,48 @@ mod peers_query_tests { assert!(!peers_query_forces_refresh(Some("no"))); assert!(!peers_query_forces_refresh(Some("maybe"))); } + + #[test] + fn map_paper_ingest_error_preserves_and_normalizes_codes() { + assert_eq!(map_paper_ingest_error("invalid_uri".into()), "invalid_uri"); + assert_eq!( + map_paper_ingest_error("decrypt_failed".into()), + "decrypt_failed" + ); + assert_eq!( + map_paper_ingest_error("identity_unknown".into()), + "identity_unknown" + ); + assert_eq!( + map_paper_ingest_error("paper create: invalid_uri detail".into()), + "invalid_uri" + ); + assert_eq!( + map_paper_ingest_error("paper ingest: decrypt boom".into()), + "decrypt_failed" + ); + assert_eq!( + map_paper_ingest_error("paper ingest: weird Debug".into()), + "internal_error" + ); + } + + #[test] + fn map_paper_create_error_normalizes_codes() { + use super::map_paper_create_error; + assert_eq!( + map_paper_create_error("hash must be exactly 32 hex characters".into()), + "invalid_hash" + ); + assert_eq!( + map_paper_create_error("paper create: PackFailed".into()), + "internal_error" + ); + assert_eq!( + map_paper_create_error("paper_too_large".into()), + "paper_too_large" + ); + } } #[derive(Debug, serde::Deserialize)] diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 89a353b66..30f56c02e 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -94,6 +94,8 @@ pub fn router(stack: Arc) -> Router { ) .route("/api/v1/ble/scan", get(interfaces::ble_scan)) .route("/api/v1/lxmf/send", post(lxmf::lxmf_send)) + .route("/api/v1/lxmf/paper/create", post(lxmf::lxmf_paper_create)) + .route("/api/v1/lxmf/paper/ingest", post(lxmf::lxmf_paper_ingest)) .route("/api/v1/lxmf/reaction", post(lxmf::lxmf_reaction)) .route("/api/v1/lxmf/recent", get(lxmf::list_recent_lxmf)) .route( diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 66f48e27c..59f5b9119 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -335,16 +335,20 @@ impl LiveBridge { } // Match path-table iface name to local config (same as outbound) so // TCP hubs named e.g. "RNS Testnet" classify as tcp, not network. - let received_via = cache_for_cb - .lock() - .ok() - .and_then(|cache| cache.get(&sender_hex).cloned()) - .map(|iface_name| { - let config_rows = - config::interfaces_from_config_dir(&config_dir_for_cb).unwrap_or_default(); - classify_path_interface_name(&iface_name, &config_rows).to_string() - }) - .unwrap_or_else(|| "network".into()); + let received_via = if msg.method == DeliveryMethod::Paper { + "paper".to_string() + } else { + cache_for_cb + .lock() + .ok() + .and_then(|cache| cache.get(&sender_hex).cloned()) + .map(|iface_name| { + let config_rows = config::interfaces_from_config_dir(&config_dir_for_cb) + .unwrap_or_default(); + classify_path_interface_name(&iface_name, &config_rows).to_string() + }) + .unwrap_or_else(|| "network".into()) + }; let inbound_sender_name = name_cache_for_cb .lock() .ok() @@ -4063,6 +4067,198 @@ impl LiveBridge { })) } + /// Encode a signed LXMF message as an encrypted `lxm://` paper URI (no network send). + pub async fn create_lxmf_paper( + &self, + req: &LxmfSendRequest, + ) -> Result { + let dest = parse_hash16(&req.destination_hash)?; + let mut identity_known = self + .outbound + .lock() + .map(|d| d.identity_known_for(&req.destination_hash)) + .unwrap_or(false); + if !identity_known { + identity_known = self.ensure_identity_for_direct(&req.destination_hash).await; + } + if !identity_known { + return Ok(serde_json::json!({ + "ok": false, + "error": "identity_unknown", + "destination_hash": req.destination_hash, + })); + } + + let reply_to = parse_optional_reply_to_hash(req.reply_to_hash.as_deref()); + let reply_quote = req + .reply_preview_text + .as_deref() + .map(str::trim) + .filter(|q| !q.is_empty()); + let (msg, message_hash_hex) = self.prepare_signed_outbound_lxmf( + dest, + "", + &req.text, + DeliveryMethod::Paper, + reply_to, + reply_quote, + )?; + + let dest_hex = req.destination_hash.to_lowercase(); + let uri_result = { + let driver = self + .outbound + .lock() + .map_err(|_| "outbound lock poisoned".to_string())?; + msg.to_paper_uri(|plaintext| { + driver + .encrypt_for_destination(&dest_hex, plaintext) + .ok_or_else(|| { + lxmf_core::message::MessageError::PackFailed(format!( + "no identity key for destination {dest_hex}" + )) + }) + }) + }; + let uri = match uri_result { + Ok(uri) => uri, + Err(lxmf_core::message::MessageError::PackFailed(ref s)) + if s.contains("exceeds maximum size") => + { + return Ok(serde_json::json!({ + "ok": false, + "error": "paper_too_large", + })); + } + Err(lxmf_core::message::MessageError::PackFailed(ref s)) + if s.contains("no identity key") => + { + return Ok(serde_json::json!({ + "ok": false, + "error": "identity_unknown", + "destination_hash": req.destination_hash, + })); + } + Err(_other) => { + return Ok(serde_json::json!({ + "ok": false, + "error": "internal_error", + })); + } + }; + + let ts_ms = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000) as i64; + let reply_to_hash_echo = reply_to + .map(hex::encode) + .or_else(|| req.reply_to_hash.clone()); + let mut payload = serde_json::json!({ + "sender_hash": self.lxmf_hash_hex, + "sender_name": self.display_name, + "text": req.text, + "timestamp": ts_ms, + "to_hash": req.destination_hash, + "reply_to_hash": reply_to_hash_echo, + "reply_to_id": req.reply_to_id, + "direction": "outbound", + "delivery_method": "paper", + "sent_via": "paper", + "received_via": "paper", + "delivery_status": "delivered", + "message_hash": message_hash_hex.clone(), + }); + if let Some(quote) = reply_quote { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "reply_preview_text".into(), + serde_json::Value::String(quote.to_string()), + ); + } + } + + Ok(serde_json::json!({ + "ok": true, + "uri": uri, + "message_hash": message_hash_hex, + "delivery_method": "paper", + "message": payload, + })) + } + + /// Decrypt an `lxm://` paper URI with the local identity and deliver as inbound LXMF. + pub async fn ingest_lxmf_paper(&self, uri: &str) -> Result { + // PAPER_MDU ciphertext bound + dest hash + base64url overhead; reject before router lock. + const MAX_PAPER_URI_CHARS: usize = 16_384; + let trimmed = uri.trim(); + if trimmed.is_empty() { + return Ok(serde_json::json!({ + "ok": false, + "error": "invalid_uri", + })); + } + if trimmed.len() > MAX_PAPER_URI_CHARS { + return Ok(serde_json::json!({ + "ok": false, + "error": "paper_too_large", + })); + } + + let identity = self.identity.clone(); + let mut router = self.router.lock().await; + let message = match router.ingest_lxm_uri(trimmed, |ciphertext| { + identity + .decrypt(ciphertext, None, false) + .map_err(|_| lxmf_core::message::MessageError::PackFailed("decrypt".into())) + }) { + Ok(message) => message, + Err( + lxmf_core::message::MessageError::InvalidUri(_) + | lxmf_core::message::MessageError::TooShort(_), + ) => { + return Ok(serde_json::json!({ + "ok": false, + "error": "invalid_uri", + })); + } + Err(lxmf_core::message::MessageError::PackFailed(ref s)) if s == "decrypt" => { + return Ok(serde_json::json!({ + "ok": false, + "error": "decrypt_failed", + })); + } + Err(_) => { + return Ok(serde_json::json!({ + "ok": false, + "error": "internal_error", + })); + } + }; + + let sender_hex = hex::encode(message.source_hash); + let inbound_sender_name = self + .display_name_cache + .lock() + .ok() + .map(|cache| resolve_inbound_sender_name_map(&cache, &sender_hex)) + .unwrap_or_else(|| sender_hex.get(..12).unwrap_or(&sender_hex).to_string()); + let payload = lxmf_payload_from_message( + &message, + &self.lxmf_hash_hex, + &self.display_name, + Some("paper"), + None, + "inbound", + Some(&inbound_sender_name), + ); + Ok(serde_json::json!({ + "ok": true, + "message": payload, + })) + } + pub async fn apply_interfaces(&self, stack: &StackHandle) -> Result<(), String> { let interfaces = stack.list_interfaces().await; tracing::info!( @@ -4232,7 +4428,13 @@ pub(super) fn lxmf_payload_from_message( "timestamp": ts_ms, "to_hash": to_hex, "direction": direction, - "message_hash": message_hash + "message_hash": message_hash, + "delivery_method": match msg.method { + DeliveryMethod::Direct => "direct", + DeliveryMethod::Propagated => "propagated", + DeliveryMethod::Opportunistic => "opportunistic", + DeliveryMethod::Paper => "paper", + }, }); if let Some(via) = received_via { if let Some(obj) = payload.as_object_mut() { @@ -5717,4 +5919,28 @@ mod reply_field_tests { Some(REPLY_QUOTE_MAX_CHARS) ); } + + #[test] + fn lxmf_payload_sets_paper_delivery_method_and_received_via() { + let msg = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "paper body", + DeliveryMethod::Paper, + ); + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + Some("paper"), + None, + "inbound", + Some("Bob"), + ); + assert_eq!(payload["delivery_method"], "paper"); + assert_eq!(payload["received_via"], "paper"); + assert_eq!(payload["text"], "paper body"); + assert_eq!(payload["sender_name"], "Bob"); + } } diff --git a/reticulum-sidecar/src/stack/lxmf_delivery.rs b/reticulum-sidecar/src/stack/lxmf_delivery.rs index 0b832b5c1..bf5e17aa9 100644 --- a/reticulum-sidecar/src/stack/lxmf_delivery.rs +++ b/reticulum-sidecar/src/stack/lxmf_delivery.rs @@ -736,4 +736,118 @@ mod tests { "full inbound_raw queue must log saturation (drop-newest policy)" ); } + + #[test] + fn paper_uri_round_trip_with_identity_crypto() { + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + + let mut msg = LxMessage::new( + lxmf_hash, + sender_lxmf, + "", + "paper hello", + DeliveryMethod::Paper, + ); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + + let uri = msg + .to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }) + .expect("to_paper_uri"); + + assert!(uri.starts_with("lxm://")); + + let recovered = LxMessage::from_paper_uri(&uri, |ciphertext| { + recipient + .decrypt(ciphertext, None, false) + .map_err(|_| MessageError::PackFailed("decrypt".into())) + }) + .expect("from_paper_uri"); + assert_eq!(recovered.content, "paper hello"); + assert_eq!(recovered.method, DeliveryMethod::Paper); + assert_eq!(recovered.destination_hash, lxmf_hash); + } + + #[test] + fn paper_uri_wrong_identity_decrypt_fails() { + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let wrong = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + + let mut msg = LxMessage::new(lxmf_hash, sender_lxmf, "", "secret", DeliveryMethod::Paper); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + let uri = msg + .to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }) + .expect("to_paper_uri"); + + let err = LxMessage::from_paper_uri(&uri, |ciphertext| { + wrong + .decrypt(ciphertext, None, false) + .map_err(|_| MessageError::PackFailed("decrypt".into())) + }); + assert!(err.is_err(), "wrong identity must not decrypt paper"); + } + + #[test] + fn paper_uri_oversized_rejected() { + use lxmf_core::constants::PAPER_MDU; + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + // Large enough that dest‖ciphertext exceeds PAPER_MDU after identity encryption. + let big = "x".repeat(PAPER_MDU); + let mut msg = LxMessage::new(lxmf_hash, sender_lxmf, "", &big, DeliveryMethod::Paper); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + let err = msg.to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }); + match err { + Err(MessageError::PackFailed(s)) => { + assert!( + s.contains("exceeds maximum size"), + "unexpected pack error: {s}" + ); + } + other => panic!("expected oversized PackFailed, got {other:?}"), + } + } } diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 5682649c6..2619a9280 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -796,9 +796,14 @@ impl LxmfOutboundDriver { Some(packed) } - fn encrypt_for_destination(&self, dest_hash_hex: &str, plaintext: &[u8]) -> Option> { - let pub_key = self.known_identities.get(&dest_hash_hex.to_lowercase())?; - let remote = Identity::from_public_key(pub_key).ok()?; + /// Encrypt plaintext to a known peer destination identity (Direct/PN/paper). + pub fn encrypt_for_destination( + &self, + dest_hash_hex: &str, + plaintext: &[u8], + ) -> Option> { + let pub_key = self.public_key_for(dest_hash_hex)?; + let remote = Identity::from_public_key(&pub_key).ok()?; remote.encrypt(plaintext, None).ok() } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index cc4176743..c4ae0283b 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -71,8 +71,9 @@ use persistence::PersistedState; pub use pn_hosting_policy::PnHostingPolicy; use tokio::sync::{Mutex, RwLock, broadcast}; pub use types::{ - AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, LxmfReactionRequest, - LxmfSendRequest, NomadNodeRow, NomadServingStatus, PeerRow, RrcHubRow, StackIdentity, + AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, + LxmfPaperCreateRequest, LxmfPaperIngestRequest, LxmfReactionRequest, LxmfSendRequest, + NomadNodeRow, NomadServingStatus, PeerRow, RrcHubRow, StackIdentity, }; #[cfg(not(feature = "rns-stack"))] @@ -2393,6 +2394,38 @@ impl StackHandle { Ok(res) } + pub async fn lxmf_paper_create( + &self, + req: LxmfSendRequest, + ) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.create_lxmf_paper(&req).await?; + if res.get("ok") == Some(&serde_json::Value::Bool(true)) { + if let Some(payload) = res.get("message").cloned() { + self.emit_event("lxmf_message", payload); + } + } + return Ok(res); + } + Ok(serde_json::json!({ + "ok": false, + "error": "identity_not_configured", + })) + } + + pub async fn lxmf_paper_ingest(&self, uri: String) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + // ingest_lxm_uri fires the delivery callback (WS lxmf_message); return HTTP body only. + return live.ingest_lxmf_paper(&uri).await; + } + Ok(serde_json::json!({ + "ok": false, + "error": "identity_not_configured", + })) + } + fn maybe_emit_identity_restart(&self) { #[cfg(feature = "rns-stack")] if self.live.is_some() { diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 8fa3fa0b9..4a7618302 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -271,6 +271,14 @@ pub struct LxmfSendRequest { pub reply_preview_text: Option, } +/// Create an encrypted `lxm://` paper URI (no network send). +pub type LxmfPaperCreateRequest = LxmfSendRequest; + +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfPaperIngestRequest { + pub uri: String, +} + #[derive(Debug, Clone, Deserialize)] pub struct LxmfReactionRequest { pub destination_hash: String, diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index f11000cad..8e3c1291d 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -1718,6 +1718,25 @@ export const CHAT_PANEL_MUST_TRANSLATE_LEAF_KEYS = new Set([ 'reticulumSendDelivered', 'reticulumSendSending', 'reticulumSendFailed', + 'reticulumSendPaper', + 'reticulumSendPaperTooltip', + 'shareAsPaper', + 'shareAsPaperAria', + 'shareAsPaperTitle', + 'shareAsPaperMessageLabel', + 'shareAsPaperGenerate', + 'shareAsPaperHint', + 'shareAsPaperCopyUri', + 'shareAsPaperCopied', + 'shareAsPaperCopyFailed', + 'shareAsPaperClose', + 'shareAsPaperEmpty', + 'shareAsPaperFailed', + 'shareAsPaperIdentityUnknown', + 'shareAsPaperTooLarge', + 'scanPaper', + 'scanPaperAria', + 'scanPaperHint', ]); /** Reticulum DM-only chat copy — contact must not become customer inquiry (문의). */ @@ -2202,6 +2221,17 @@ export const ROUTING_PORT_TOKENS = [ const MOJIBAKE_RE = /Ð[\u0080-\u00FF]{2,}|Ã[\u0080-\u00BF]{2,}|Â[\u0080-\u00BF]{2,}/; const BROKEN_MESHTASTIC_SCHEME_RE = /meshtastic[\s\u00a0]+:\/\//i; +/** Auto-translate often inserts spaces / NBSP before `://` in `lxm://` / `lxma://`. */ +const BROKEN_LXM_SCHEME_RE = /\blxma?[\s\u00a0]+:\/\//i; +/** + * Prose glued onto a scheme after stripping complete URI tokens + * (e.g. leftover `lxm://` + word). Paper payloads are URL-safe base64 and may + * start with letters — do not flag those; strip `lxm(a)://…` tokens first. + */ +const LXM_SCHEME_GLUED_TO_WORD_RE = + /\blxma?:\/\/(?=[A-Za-z\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF])/u; +/** Complete `lxm://` / `lxma://` URI token (scheme + non-whitespace payload). */ +const LXM_URI_TOKEN_RE = /\blxma?:\/\/[^\s<>"']+/gi; const MESHTASTIC_MISSPELLING_RE = /meshtastisch/i; @@ -2376,6 +2406,17 @@ function checkCatEncodingAndMeshtasticIssues(ctx) { issues.push('meshtastic:// scheme must not contain whitespace before "://"'); } + if (BROKEN_LXM_SCHEME_RE.test(val)) { + issues.push('lxm:// / lxma:// scheme must not contain whitespace before "://"'); + } + + // Strip complete URI tokens first so alphabetic paper payloads (e.g. lxm://AbC…) + // are not treated as scheme-glued-to-prose false positives. + const valWithoutLxmUris = val.replace(LXM_URI_TOKEN_RE, ''); + if (LXM_SCHEME_GLUED_TO_WORD_RE.test(valWithoutLxmUris)) { + issues.push('lxm:// / lxma:// must be followed by a space or punctuation, not glued to a word'); + } + if ( enVal.includes('meshtastic://') && /meshtastic/i.test(val) && @@ -2384,6 +2425,19 @@ function checkCatEncodingAndMeshtasticIssues(ctx) { issues.push('meshtastic:// URL scheme is broken or missing'); } + if (enVal.includes('lxma://') && /lxma/i.test(val) && !val.includes('lxma://')) { + issues.push('lxma:// URL scheme is broken or missing'); + } + + if ( + enVal.includes('lxm://') && + !enVal.includes('lxma://') && + /\blxm\b/i.test(val) && + !val.includes('lxm://') + ) { + issues.push('lxm:// URL scheme is broken or missing'); + } + if (MESHTASTIC_MISSPELLING_RE.test(val)) { issues.push('use protocol spelling "meshtastic", not "meshtastisch"'); } diff --git a/scripts/check-i18n-quality.test.mjs b/scripts/check-i18n-quality.test.mjs index 8c8a57613..59075f175 100644 --- a/scripts/check-i18n-quality.test.mjs +++ b/scripts/check-i18n-quality.test.mjs @@ -115,6 +115,30 @@ describe('localeStringQualityIssues', () => { expectIssue(issues, 'meshtastic:// scheme must not contain whitespace before "://"'); }); + it('flags whitespace inside lxm:// before ://', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'qrIngest.hint', + val: 'Paste lxm :// blob here', + enVal: 'Paste lxm:// blob here', + }); + expectIssue(issues, 'lxm:// / lxma:// scheme must not contain whitespace before "://"'); + }); + + it('accepts alphabetic paper payload prefixes on lxm:// URIs', () => { + // Alphabetic base64url-style prefix (not a secret — repeated alphabet for lint). + const paperBlob = `${'Abcdefghij'.repeat(5)}${'0123456789'.repeat(2)}`; + const issues = localeStringQualityIssues({ + locale: 'en', + flatKey: 'chatPanel.shareAsPaperHint', + val: `Show QR for lxm://${paperBlob} offline`, + enVal: `Show QR for lxm://${paperBlob} offline`, + }); + expect(issues).not.toEqual( + expect.arrayContaining([expect.stringContaining('glued to a word')]), + ); + }); + it('flags meshtastisch misspelling', () => { const issues = localeStringQualityIssues({ locale: 'de', diff --git a/src/main/database.test.ts b/src/main/database.test.ts index 62d3bc7a5..1b6135283 100644 --- a/src/main/database.test.ts +++ b/src/main/database.test.ts @@ -414,6 +414,7 @@ describe('app_settings table + message retention defaults (schema sync)', () => expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:'); expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:'); + expect(INDEX_SOURCE).toContain('reticulumLastSelfLxmfHash'); expect(INDEX_SOURCE).toContain('reticulumRmapAnnounceIntervalMin'); expect(INDEX_SOURCE).toContain('reticulumRmapReachableOn'); expect(INDEX_SOURCE).toContain('reticulumRmapHeightMeters'); @@ -428,6 +429,7 @@ describe('app_settings table + message retention defaults (schema sync)', () => INDEX_SOURCE.indexOf('APP_SETTINGS_ALLOWED_KEYS'), INDEX_SOURCE.indexOf('APP_SETTINGS_MAX_VALUE_LENGTH'), ); + expect(allowListBlock).toContain("'reticulumLastSelfLxmfHash'"); expect(allowListBlock).toContain("'reticulumRmapAnnounceIntervalMin'"); expect(allowListBlock).toContain("'reticulumRmapReachableOn'"); expect(allowListBlock).toContain("'reticulumRmapHeightMeters'"); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 596543293..a2b50e31f 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -149,6 +149,7 @@ describe('Persistent app settings IPC (source contract)', () => { expect(INDEX_SOURCE).toMatch(/key not allowed/); expect(INDEX_SOURCE).toContain("'meshtasticLastRfSelfNodeId'"); expect(INDEX_SOURCE).toContain("'meshcoreLastSelfNodeId'"); + expect(INDEX_SOURCE).toContain("'reticulumLastSelfLxmfHash'"); expect(INDEX_SOURCE).toContain("'use24HourTime'"); expect(INDEX_SOURCE).toContain('meshtasticRemoteAdminKey:'); expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); diff --git a/src/main/index.ts b/src/main/index.ts index edd089ae3..1794385b2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3539,6 +3539,7 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet = new Set([ 'use24HourTime', 'alwaysShowMessageActions', 'reticulumAutostart', + 'reticulumLastSelfLxmfHash', 'reticulumRmapAnnounceIntervalMin', 'reticulumRmapReachableOn', 'reticulumRmapHeightMeters', diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index 202800a58..302b0f2f0 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -276,6 +276,30 @@ 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); + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: 'paper hello', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'delivered', + delivery_method: 'paper', + received_via: 'paper', + }); + const row = db! + .prepareOnce( + 'SELECT received_via, delivery_method 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'); + }); + it('pruneReticulumIdentityActivityByAge deletes stale millisecond last_seen rows', () => { const nowMs = Date.now(); db! diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index d572e7f8e..835cfc5fa 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -10,15 +10,20 @@ import type { RemoteInboundDecision, RemoteInboundPolicyRow, } from '../../shared/remote-types'; +import { RETICULUM_DELIVERY_METHODS } from '../../shared/reticulumDeliveryMethod'; import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash'; import { sanitizeReticulumDisplayNameForDb } from '../../shared/reticulumDisplayName'; +import { isAllowedReticulumReceivedVia } from '../../shared/reticulumMessageTransport'; import { finishDbIpcHandler, getDbForIpc } from '../db-ipc-lifecycle'; import { buildFtsMatchQuery, isMessageFtsReady } from '../messageFts'; import { sanitizeReticulumAttachmentPathForDb } from '../reticulum-attachment-path'; import { assertIpcSender } from '../validate-ipc-sender'; +export { isAllowedReticulumReceivedVia }; + const REMOTE_ADDRESS_SERVICES = new Set(['rnsh', 'rncp']); const REMOTE_INBOUND_DECISIONS = new Set(['allow', 'block']); +const ALLOWED_DELIVERY_METHOD = new Set(RETICULUM_DELIVERY_METHODS); interface ParsedRemoteAddressUpsert { id: string; @@ -88,19 +93,6 @@ const ALLOWED_DELIVERY_STATUS = new Set([ 'queued', ]); -const ALLOWED_DELIVERY_METHOD = new Set(['direct', 'propagated', 'opportunistic', 'paper']); - -const RETICULUM_VIA_ATOMS = new Set(['rf', 'ble', 'tcp', 'network', 'mqtt', 'both']); -const RETICULUM_MULTI_VIA_ATOMS = new Set(['ble', 'rf', 'tcp', 'network']); - -/** Single atom or explicit `+`-joined multi-egress (e.g. `rf+tcp`). */ -export function isAllowedReticulumReceivedVia(value: string): boolean { - if (RETICULUM_VIA_ATOMS.has(value)) return true; - const parts = value.split('+'); - if (parts.length < 2 || parts.length > 4) return false; - return parts.every((p) => RETICULUM_MULTI_VIA_ATOMS.has(p)); -} - export interface ReticulumDbIpcDeps { ipcMain: IpcMain; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 22529937e..c5216ab4b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3082,6 +3082,7 @@ function AppContent() { hasRncpTransfer={capabilities.hasRncpTransfer} hasLxstVoice={capabilities.hasLxstVoice} hasLrgpGames={capabilities.hasLrgpGames} + hasLxmfPaper={capabilities.hasLxmfPaper} showLxmfDeliveryStatus={capabilities.hasLxmfDeliveryStatus} showLxmfAttachmentLine={capabilities.hasReticulumInterfaceConfig} composerPayloadLimit={capabilities.lxmfPayloadLimit} diff --git a/src/renderer/components/ChatDmPaperControls.test.tsx b/src/renderer/components/ChatDmPaperControls.test.tsx new file mode 100644 index 000000000..c4c8fd2f9 --- /dev/null +++ b/src/renderer/components/ChatDmPaperControls.test.tsx @@ -0,0 +1,174 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { + ChatDmPaperShareControl, + ChatPaperScanControl, +} from '@/renderer/components/ChatDmPaperControls'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { mockConsoleWarn } from '@/renderer/lib/vitestConsoleMock'; + +const addToast = vi.fn(); +const createReticulumPaperMessage = vi.fn(); +const handleReticulumQrIngest = vi.fn(); +const writeClipboardText = vi.fn().mockResolvedValue(undefined); +const loadDraftsInitial = vi.fn().mockReturnValue({ 'dm:peer': 'draft text' }); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/renderer/components/Toast', () => ({ + useToast: () => ({ addToast }), +})); + +vi.mock('@/renderer/hooks/useActiveMeshIdentity', () => ({ + useActiveMeshIdentity: () => ({ focusedIdentityId: 'id-reticulum' }), +})); + +vi.mock('@/renderer/lib/chatPanelProtocolStorage', () => ({ + loadDraftsInitial: (...args: unknown[]) => loadDraftsInitial(...args), +})); + +vi.mock('@/renderer/lib/reticulum/createReticulumPaperMessage', () => ({ + createReticulumPaperMessage: (...args: unknown[]) => createReticulumPaperMessage(...args), +})); + +vi.mock('@/renderer/lib/reticulum/handleReticulumQrIngest', () => ({ + handleReticulumQrIngest: (...args: unknown[]) => handleReticulumQrIngest(...args), +})); + +vi.mock('@/renderer/lib/writeClipboardText', () => ({ + writeClipboardText: (...args: unknown[]) => writeClipboardText(...args), +})); + +vi.mock('@/renderer/components/QrCodeImage', () => ({ + default: ({ value }: { value: string }) =>
{value}
, +})); + +vi.mock('@/renderer/components/QrIngestControl', () => ({ + default: ({ onDecoded, disabled }: { onDecoded: (text: string) => void; disabled?: boolean }) => ( + + ), +})); + +async function renderAndAssertAxe(ui: ReactElement): Promise> { + const view = render(ui); + hydrateAxeThemeColors(view.container); + expect(await axe(view.container)).toHaveNoViolations(); + return view; +} + +describe('ChatDmPaperControls', () => { + beforeEach(() => { + addToast.mockReset(); + createReticulumPaperMessage.mockReset(); + handleReticulumQrIngest.mockReset(); + writeClipboardText.mockReset(); + writeClipboardText.mockResolvedValue(undefined); + loadDraftsInitial.mockReturnValue({ 'dm:peer': 'draft text' }); + }); + + it('opens share modal with draft and creates paper QR', async () => { + const user = userEvent.setup(); + createReticulumPaperMessage.mockResolvedValue({ + ok: true, + uri: `lxm://${'B'.repeat(48)}`, + messageHash: 'hh'.repeat(16), + }); + + await renderAndAssertAxe( + , + ); + + await user.click(screen.getByLabelText('chatPanel.shareAsPaperAria')); + expect(screen.getByLabelText('chatPanel.shareAsPaperTitle')).toBeTruthy(); + expect(screen.getByDisplayValue('draft text')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'chatPanel.shareAsPaperGenerate' })); + await waitFor(() => { + expect(createReticulumPaperMessage).toHaveBeenCalledWith({ + identityId: 'id-reticulum', + destinationHash: 'aa'.repeat(16), + text: 'draft text', + }); + }); + expect(await screen.findByTestId('qr')).toHaveTextContent(`lxm://${'B'.repeat(48)}`); + }); + + it('toasts clipboard copy success and failure', async () => { + const user = userEvent.setup(); + createReticulumPaperMessage.mockResolvedValue({ + ok: true, + uri: `lxm://${'C'.repeat(48)}`, + messageHash: 'ii'.repeat(16), + }); + + render( + , + ); + await user.click(screen.getByLabelText('chatPanel.shareAsPaperAria')); + await user.click(screen.getByRole('button', { name: 'chatPanel.shareAsPaperGenerate' })); + await screen.findByTestId('qr'); + + await user.click(screen.getByRole('button', { name: 'chatPanel.shareAsPaperCopyUri' })); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('chatPanel.shareAsPaperCopied', 'success'); + }); + + const { spy, restore } = mockConsoleWarn(); + try { + writeClipboardText.mockRejectedValueOnce(new Error('denied')); + await user.click(screen.getByRole('button', { name: 'chatPanel.shareAsPaperCopyUri' })); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('chatPanel.shareAsPaperCopyFailed', 'error'); + }); + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('[ChatDmPaperShareControl] clipboard failed:'), + ); + } finally { + restore(); + } + }); + + it('disables share when sidecar is stopped', async () => { + await renderAndAssertAxe( + , + ); + expect(screen.getByLabelText('chatPanel.shareAsPaperAria')).toBeDisabled(); + }); + + it('scan control ingests decoded QR and toasts', async () => { + const user = userEvent.setup(); + handleReticulumQrIngest.mockResolvedValue({ + handled: true, + toast: { key: 'qrIngest.paperIngested', variant: 'success' }, + }); + + await renderAndAssertAxe(); + await user.click(screen.getByLabelText('chatPanel.scanPaperAria')); + await user.click(screen.getByLabelText('mock-qr-ingest')); + await waitFor(() => { + expect(handleReticulumQrIngest).toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith('qrIngest.paperIngested', 'success'); + }); + }); +}); diff --git a/src/renderer/components/ChatDmPaperControls.tsx b/src/renderer/components/ChatDmPaperControls.tsx new file mode 100644 index 000000000..0fbe6fc02 --- /dev/null +++ b/src/renderer/components/ChatDmPaperControls.tsx @@ -0,0 +1,264 @@ +import { FileText, QrCode } from 'lucide-react-motion'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import QrCodeImage from '@/renderer/components/QrCodeImage'; +import QrIngestControl from '@/renderer/components/QrIngestControl'; +import { useToast } from '@/renderer/components/Toast'; +import { useActiveMeshIdentity } from '@/renderer/hooks/useActiveMeshIdentity'; +import { loadDraftsInitial } from '@/renderer/lib/chatPanelProtocolStorage'; +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { createReticulumPaperMessage } from '@/renderer/lib/reticulum/createReticulumPaperMessage'; +import { handleReticulumQrIngest } from '@/renderer/lib/reticulum/handleReticulumQrIngest'; +import { showReticulumQrIngestToast } from '@/renderer/lib/reticulum/showReticulumQrIngestToast'; +import { RETICULUM_DM_HEADER_ACTION_CLASS } from '@/renderer/lib/reticulumDmHeaderActions'; +import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; + +export interface ChatDmPaperShareControlProps { + lxmfPeerHash: string; + viewKey: string; + sidecarRunning: boolean; + className?: string; +} + +/** + * Chat DM header: open a modal to create an encrypted LXMF paper QR from the draft (or typed text). + */ +export function ChatDmPaperShareControl({ + lxmfPeerHash, + viewKey, + sidecarRunning, + className = RETICULUM_DM_HEADER_ACTION_CLASS, +}: Readonly) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const { focusedIdentityId } = useActiveMeshIdentity('reticulum'); + const [open, setOpen] = useState(false); + const [text, setText] = useState(''); + const [uri, setUri] = useState(null); + const [busy, setBusy] = useState(false); + const triggerRef = useRef(null); + const dialogPanelRef = useRef(null); + + const openModal = useCallback(() => { + const drafts = loadDraftsInitial('reticulum'); + setText((drafts[viewKey] ?? '').trim()); + setUri(null); + setOpen(true); + }, [viewKey]); + + const closeModal = useCallback(() => { + setOpen(false); + setUri(null); + }, []); + + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) { + e.preventDefault(); + closeModal(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [busy, closeModal, open]); + + useEffect(() => { + if (!open) return undefined; + const trigger = triggerRef.current; + const frame = requestAnimationFrame(() => { + const focusable = dialogPanelRef.current?.querySelector( + 'textarea:not([disabled]), button:not([disabled])', + ); + focusable?.focus(); + }); + return () => { + cancelAnimationFrame(frame); + trigger?.focus(); + }; + }, [open]); + + const createPaper = useCallback(async () => { + if (busy || !focusedIdentityId) return; + setBusy(true); + try { + const result = await createReticulumPaperMessage({ + identityId: focusedIdentityId, + destinationHash: lxmfPeerHash, + text, + }); + if (!result.ok) { + addToast(t(result.errorKey), 'error'); + return; + } + setUri(result.uri); + } finally { + setBusy(false); + } + }, [addToast, busy, focusedIdentityId, lxmfPeerHash, t, text]); + + const shareButton = ( + + ); + + if (!open) { + return shareButton; + } + + return ( + <> + {shareButton} +
+