diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index a8839a27c..29192fbc9 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -12,8 +12,6 @@ jobs: reticulum-sidecar: name: Reticulum sidecar (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} - env: - RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 permissions: contents: read strategy: diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml index 0cbf33e6a..cabe34831 100644 --- a/.github/workflows/reticulum-sidecar.yaml +++ b/.github/workflows/reticulum-sidecar.yaml @@ -97,8 +97,6 @@ jobs: retention-days: 14 build-rns-stack: - env: - RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 strategy: fail-fast: false matrix: @@ -166,8 +164,6 @@ jobs: retention-days: 14 build-windows-arm64-rns-stack: - env: - RS_RETICULUM_REF: 9928abed269a83ec5a7ef165ff1142d938cad706 permissions: contents: read runs-on: windows-latest diff --git a/AGENTS.md b/AGENTS.md index ae1898b56..00ec7d25d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF / rsNomad) and warns when a local overlay can be removed. `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** @@ -146,7 +146,7 @@ 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 — path request + probe + verified badge), `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) +- **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. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Dublin / Amsterdam / BetweenTheBorders) — stack-start auto-disable + **Add default hubs** disables matching enabled TCP rows; keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Current presets: US-East / I2P / Yggdrasil / Ratspeak / RMAP World - **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` @@ -155,8 +155,8 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` - **LXMF replies:** sidecar stamps `FIELD_REPLY_TO` / capped `FIELD_REPLY_QUOTE` before sign; renderer ingest/Chat use `reticulum_reply_to_hash` + quote preview + jump-by-hash - **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:** LXMF/Nomad announce names overlay path-table **peers and contacts** (`list_contacts` fills nameless/hash-prefix rows from announce/peer/Nomad cache and may persist; upsert rejects hash-prefix placeholders); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest omits hash-prefix placeholder `sender_name`; SQL upsert guard in `db:upsertReticulumDestination` 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 + LXMF 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) +- **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**. - **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. diff --git a/README.md b/README.md index 44bd9a27e..269365094 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ Reticulum is the third protocol tab (**amber** pill). The stack runs in an **AGP **Peers, topology, Nomad Network** -- **Peers** tab: RNS path-table peers and LXMF contacts (separate sub-tabs); virtualized large lists, path probe, and peer detail modal +- **Peers** tab: RNS path-table peers, messaged **History**, saved **Contacts**, and **Favorites** (sub-tabs); LXMFace avatars; virtualized large lists, path probe, and peer detail modal (Save as contact is manual — messaging alone does not add Contacts) - **Map** tab: local RMAP v4 discovery map (Leaflet + OSM basemaps; heard opt-in interfaces with GPS; interface-type filters; reachable vs heard-only sidebar list; publish via Network + Connection); **Global map** link to [rmap.world](https://rmap.world/) — no position trails or waypoints (contrast with Meshtastic/MeshCore Map) - **Topology** tab: best-effort graph from the RNS path table (next-hop edges, force layout) - **Nomad Network** tab: collapsible favourites/announces list (default **Favourites** sub-tab); **My Pages** hosts a static Nomad site (rsNomad `nomad-core`) — **Choose folder** for a watched site root (e.g. sibling `nomad-page` with `pages/*.mu`) or pages directory, set a display name, Start serving (off by default; auto-restores when the stack comes back up); edit pages on disk (read-only list in the UI); fit-width pages by default with an open-width toggle; panel lazy-mounts after first visit and keeps browse state across tab switches (`mesh-client:nomadNodeListCollapsed` for sidebar width) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index c7c6d2940..2d04188ef 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -76,6 +76,8 @@ Path-filtered on `reticulum-sidecar/**` and related scripts: 1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`) 2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs) +CI clones Ratspeak siblings via `scripts/clone-ratspeak-stack.sh` and **no longer hardcodes `RS_RETICULUM_REF`** — rsReticulum / rsLXMF / rsNomad float to `origin/main` (overlays must apply). Override with `RS_*_REF` only for local bisect. + Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). --- @@ -150,7 +152,9 @@ Automated dependency updates are configured in `.github/dependabot.yml`: - **GitHub Actions:** Grouped into one PR - **Open PRs:** `open-pull-requests-limit: 0` — Dependabot scans but does **not** open PRs. Dependency bumps are applied manually via `pnpm run update` (`scripts/update.sh`), which - also runs dedupe and Ratspeak/rsReticulum patch checks. See AGENTS.md §6. + also runs dedupe, Ratspeak overlay PR checks, and an upstream release / new-org-repo watch + (rsLXST, lrgp-rs, Ratspeak, LXMFace). Sibling **rsReticulum** / **rsLXMF** / **rsNomad** + float to `origin/main` via `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6. ### Testing Dependabot PRs locally diff --git a/docs/development-environment.md b/docs/development-environment.md index c40a53cbd..9bcbc6560 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -119,7 +119,9 @@ pnpm run reticulum:sidecar:build This writes `reticulum-sidecar/target/debug/mesh-client-reticulum` (macOS/Linux) or `.exe` on Windows. -When sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad` exist, the build script applies required rsReticulum overlays automatically (`scripts/ensure-rsReticulum-patches.sh`) before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. +**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to a SHA or ref before running the clone script. + +When those siblings already exist, `pnpm run reticulum:sidecar:build` applies required overlays via `scripts/ensure-rsReticulum-patches.sh` before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. **First run in Electron dev:** **Reticulum** → **Connection** → **Start stack** will run `cargo build` automatically if that binary is missing (first compile can take a few minutes). Pre-build with the command above to avoid waiting on the first click. diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3419bc428..01f732645 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -126,27 +126,27 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ **`PnHostingPolicy`** (mirrored in `src/shared/pnHostingPolicy.ts` / sidecar `pn_hosting_policy.rs`): -| Field | Default | Notes | -| -------------------------- | ------- | ----------------------------------------------- | -| `peering_cost` | `18` | Must be ≤ `max_peering_cost` | -| `max_peering_cost` | `26` | | -| `autopeer` | `true` | | -| `autopeer_maxdepth` | `4` | Cap 64 | -| `max_peers` | `20` | 1–256 | -| `propagation_stamp_cost` | `16` | | -| `propagation_stamp_flex` | `3` | Must be ≤ stamp cost | -| `message_storage_limit_mb` | `256` | 1–10240 | -| `propagation_limit_kb` | `256` | 1–102400 | -| `sync_limit_kb` | `10240` | 1–102400 | -| `delivery_limit_kb` | `1000` | 1–102400 | -| `from_static_only` | `false` | | -| `auth_required` | `false` | | -| `enforce_stamps` | `false` | | -| `enforce_ratchets` | `false` | | -| `static_peers` | `[]` | Lowercase 32-hex hashes (max 256) | -| `node_name` | `null` | Trimmed; max 128 scalar chars; no control chars | -| `pn_announce_interval_sec` | `360` | Cap 86400 | -| `announce_at_start` | `true` | | +| Field | Default | Notes | +| -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `peering_cost` | `18` | Must be ≤ `max_peering_cost` | +| `max_peering_cost` | `26` | | +| `autopeer` | `true` | | +| `autopeer_maxdepth` | `4` | Cap 64 | +| `max_peers` | `20` | 1–256 | +| `propagation_stamp_cost` | `16` | | +| `propagation_stamp_flex` | `3` | Must be ≤ stamp cost | +| `message_storage_limit_mb` | `256` | 1–10240 | +| `propagation_limit_kb` | `256` | 1–102400 | +| `sync_limit_kb` | `10240` | 1–102400 | +| `delivery_limit_kb` | `1000` | 1–102400 | +| `from_static_only` | `false` | | +| `auth_required` | `false` | | +| `enforce_stamps` | `false` | Persisted for UI/compat; **not applied** on current rsLXMF tip (`set_enforce_stamps` removed upstream; stamp gating uses `set_stamp_requirements`) | +| `enforce_ratchets` | `false` | Persisted for UI/compat; **not applied** on current rsLXMF tip (`set_enforce_ratchets` removed upstream) | +| `static_peers` | `[]` | Lowercase 32-hex hashes (max 256) | +| `node_name` | `null` | Trimmed; max 128 scalar chars; no control chars | +| `pn_announce_interval_sec` | `360` | Cap 86400 | +| `announce_at_start` | `true` | | ### Nomad Network diff --git a/docs/reticulum.md b/docs/reticulum.md index df2025863..63a9b49cc 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -33,7 +33,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | | RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges, toasts when the RRC tab is inactive, automatic reconnect with backoff | | Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set. Path/transport badges (RF/BLE/TCP/NET, multi, PN) are egress evidence — UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + LXMF contacts (Peers tab sub-tabs); probe and peer detail modal | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe and peer detail modal (Save as contact is manual) | | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | | Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | | Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | @@ -56,7 +56,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Remote | rnsh shell + rncp transfer (`ReticulumRemotePanel`): multi-session terminals, send/fetch/receive, saved addresses, inbound policy | | RRC | Multi-hub relay chat (`RrcPanel`): favourites/discovered hubs, rooms, nicklist, slash commands, reconnect | | Nomad Network | Favourites, announces, **My Pages** (watched-folder static host via rsNomad; auto-restore when stack starts), Micron page browser (dual-axis scroll shell, fit-width default + open-width toggle, navigation, cache, file downloads); lazy-mount keep-alive after first visit | -| Peers | Path-table peers and LXMF contacts; ranked multi-path slots + per-peer medium pin in peer path detail (sidebar label **Peers**; Meshtastic/MeshCore use **Nodes**) | +| Peers | Path-table **Peers**, messaged **History** (`last_heard`), saved **Contacts** (`is_contact`), **Favorites**; ranked multi-path slots + per-peer medium pin in peer path detail (sidebar label **Peers**; Meshtastic/MeshCore use **Nodes** with All/History DM tabs) | | Network | Identity, stack settings (including global **path medium** preference), announces, propagation (preferred, sync, rename/delete remote nodes), config import/export, identity vault (sidebar label **Network**; LoRa tabs use **Radio**) | | Admin | RNode firmware flasher; factory reset (danger zone) | | Diagnostics | Reticulum runtime rows + interface config audit/repair; LoRa routing/RF and foreign-LoRa findings hidden | @@ -310,7 +310,7 @@ When a destination is reachable over more than one next hop, the sidecar keeps u - **Peers tab UX:** keep-alive after first visit; opening the tab uses soft/cached path-table data (skips refresh when peers are already in the store). Manual **Refresh** forces a live dump (`?refresh=1`). Row prepare/sort for large lists is deferred so chrome paints immediately. - **After a DB wipe:** peer rows refill only as destinations announce again (or path responses arrive). Connecting to the same hub does **not** dump every known destination instantly. mesh-client applies announces / `peers_updated` patches incrementally (batched), with a full peer dump on connect, manual Refresh, stack restart, and a 30s safety poll (60s when the path table is large). - **Your node** does not appear as a peer row; identity hash is under **Network → Identity**; topology uses a synthetic **You** center node -- **Avatar:** peers without a custom icon show an empty outline; **People** maps wire `people`/`person`/`user` → Lucide `user` (`reticulumIconAppearance.ts`). Legacy `circle` is treated as unset (not a real avatar choice). +- **Avatar:** peers without a custom Lucide icon show an [LXMFace](https://github.com/ratspeak/LXMFace) default identicon (`src/renderer/lib/reticulum/lxmface.ts`); a custom icon still overrides. **People** maps wire `people`/`person`/`user` → Lucide `user` (`reticulumIconAppearance.ts`). Legacy `circle` is treated as unset (not a real avatar choice). - **`interface` column:** path learned via that interface, not “devices on this serial port” - **Display names / aliases:** sidecar peers may ship without labels; mesh-client enriches from (in order) sidecar `display_name`, **LXMF / Nomad announce** `app_data` (msgpack, JSON `server_name`, or UTF-8 — parsed in the sidecar; RMAP/geo JSON blobs are rejected), SQLite `reticulum_destinations.display_name`, and Nomad Network node list during `refreshReticulumPeersFromSidecar`. Sidecar `list_contacts` / contact upsert also fills nameless or hash-prefix contact labels from that announce/peer/Nomad cache (does not overwrite a real stored name; may persist fills; retries persist after save failure). Renderer refresh preserves peer announce aliases when contact dumps omit names after path/probe, keeps in-memory **icon/appearance** when the DB row lacks icons, and Chat/`nodeStore` sync via `reticulumContactToNodeRecordPreservingLabel` refuses hash-prefix `longName` overwrites. Renderer display (`sanitizeReticulumDisplayName` / `reticulumRealDisplayName`) mirrors sidecar rules for already-stored bad values. Inbound LXMF ingest (`reticulumIngest.ts`) treats a `sender_name` equal to the destination hash prefix as a **placeholder**, not a real alias — contact upserts omit it. SQLite upsert (`db:upsertReticulumDestination`) requires an exact **32-hex** destination hash (lowercased; no separator stripping), **refuses to overwrite** an existing name with a hash-prefix alias (case-insensitive guard on the first 12 hex chars), and leaves **`favorited` alone** when the payload omits it (icon-only patches). Schema upgrade collapses legacy case-variant destination rows onto one lowercase PK. - **Topology:** one next hop per destination (`via_hash`); sidecar infers `self → relay` when needed; force layout with hop fallback; auto-refresh debounced and paused under large path tables (manual Refresh always available) @@ -352,7 +352,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne | Table | Contents | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `reticulum_destinations` | Contact rows (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`) | +| `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) | ### Sidecar `userData` @@ -374,7 +374,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, and `rsNomad` (see `scripts/clone-ratspeak-stack.sh`). +`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, and `rsNomad` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0127daac..e4a49b1e1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -946,21 +946,23 @@ Keep Rust current with `pnpm run update` (runs `rustup update` and rebuilds the **Symptoms**: **Start stack** fails; logs show `RETICULUM_CARGO_BUILD_FAILED` or Rust errors such as `method not found in ReticulumHandle`, `register_packet_tap`, or `PacketTapEvent`. Electron may surface `RETICULUM_RNS_PATCH_MISSING` after upgrading mesh-client. -**Cause**: Full-stack (`rns-stack`) dev builds call `register_packet_tap` in the sidecar, but that API lives in a local rsReticulum overlay ([`reticulum-sidecar/patches/rsReticulum-packet-tap.patch`](../reticulum-sidecar/patches/rsReticulum-packet-tap.patch)) until [ratspeak/rsReticulum#10](https://github.com/ratspeak/rsReticulum/pull/10) merges. CI applies it automatically; a sibling `../rsReticulum` checkout without the overlay fails to compile. +**Cause**: Full-stack (`rns-stack`) dev builds call `register_packet_tap` in the sidecar, but that API lives in a local rsReticulum overlay ([`reticulum-sidecar/patches/rsReticulum-packet-tap.patch`](../reticulum-sidecar/patches/rsReticulum-packet-tap.patch)) until [ratspeak/rsReticulum#10](https://github.com/ratspeak/rsReticulum/pull/10) merges. CI applies overlays via `clone-ratspeak-stack.sh`; a sibling `../rsReticulum` checkout without the overlay fails to compile. -**Fix**: +**Fix** (canonical recover path): -1. From mesh-client repo root (requires sibling `../rsReticulum` and `../rsLXMF`): +1. From mesh-client repo root, re-float siblings and re-apply overlays: ```bash + ./scripts/clone-ratspeak-stack.sh pnpm run reticulum:sidecar:build ``` - This runs `scripts/ensure-rsReticulum-patches.sh` before `cargo build`. -2. **Manual apply** (when you prefer not to use the npm script): + `clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_*_REF` for bisect) and fails if an overlay will not apply. +2. If siblings already exist and you only need overlays: `./scripts/ensure-rsReticulum-patches.sh` then `pnpm run reticulum:sidecar:build`. +3. **Manual apply** (single overlay): ```bash git -C ../rsReticulum apply reticulum-sidecar/patches/rsReticulum-packet-tap.patch pnpm run reticulum:sidecar:build ``` -3. On **newer rsReticulum** checkouts that already include the auto-beacon utun fix upstream, only the packet-tap patch is required — `apply-rsReticulum-auto-beacon-utun.sh` is a no-op. +4. On **newer rsReticulum** checkouts that already include the auto-beacon utun fix upstream, only the packet-tap patch is required — `apply-rsReticulum-auto-beacon-utun.sh` is a no-op. Quit mesh-client fully, reopen, and click **Start stack** again. @@ -973,13 +975,12 @@ Quit mesh-client fully, reopen, and click **Start stack** again. **Fix**: 1. **Update mesh-client** to a build that includes the rsReticulum overlay `rsReticulum-auto-beacon-utun.patch` (skips `utun*` during enumeration and backs off repeated TX failures). -2. **Dev rebuild**: from repo root, apply overlays then rebuild: +2. **Dev rebuild**: from repo root, prefer the canonical recover path, then rebuild: ```bash - ./scripts/apply-rsReticulum-packet-tap.sh - ./scripts/apply-rsReticulum-auto-beacon-utun.sh - ./scripts/apply-rsReticulum-link-client-nomad.sh + ./scripts/clone-ratspeak-stack.sh pnpm run reticulum:sidecar:build ``` + Or apply individual overlays (`./scripts/apply-rsReticulum-packet-tap.sh`, `./scripts/apply-rsReticulum-auto-beacon-utun.sh`, `./scripts/apply-rsReticulum-link-client-nomad.sh`, …) then `pnpm run reticulum:sidecar:build`. 3. **Workaround on old builds**: disable **AutoInterface** under Connection → Interfaces if LAN discovery is not needed (TCP/RNode paths still work). 4. **Physical NIC failures** (`en0`, `wlan0`, …): restart the stack; check firewall/multicast permissions — that indicates real LAN discovery failure, not VPN noise. @@ -1303,13 +1304,13 @@ For bulk fixes, use Network **Config import** (merge) instead of hand-editing in ### Reticulum Peers stale or slow with many hubs or testnets -**Symptoms**: Peers looks briefly stale after opening the tab, or—after enabling several public hubs or testnets—shows thousands of path-table rows and scrolling, search, or refresh feels sluggish. UI may remain responsive on **Contacts** or **Favorites** because those tabs show a smaller LXMF contact set. +**Symptoms**: Peers looks briefly stale after opening the tab, or—after enabling several public hubs or testnets—shows thousands of path-table rows and scrolling, search, or refresh feels sluggish. UI may remain responsive on **History**, **Contacts**, or **Favorites** because those tabs show a smaller set than the full path table. **Checks**: 1. **Refresh model**: opening Peers uses the sidecar’s short-lived soft cache. Click **Refresh** to force a live path-table read (`?refresh=1`). mesh-client virtualizes peer rows above 100 entries (never mounts the full DOM when the virtualizer is not ready), prepares labels once before filter/sort, and does **not** reload the full path table on high-frequency `stats_update` / `interface.state` WS events. The sidecar still maintains the full RNS path table (often 3k–10k rows on busy hubs). Background peer refresh runs every 30 s while the stack is configured (60 s above 2,000 peers), plus announce/`peers_updated` debounced updates. 2. **Reduce noise**: disable unused TCP/community hub interfaces on **Connection → Interfaces** and restart the stack so RNS drops stale TCP clients. Official Dublin / Amsterdam / BetweenTheBorders testnet hubs are decommissioned and auto-disabled on stack start and by **Add default hubs** — focus remaining noise on community hubs you enabled. -3. **Prefer Contacts**: use the **Contacts** tab for LXMF peers you message; **Favorites** for a short pinned list. +3. **History vs Contacts**: messaging stamps **History** (`last_heard`) only. Peers you DM show under **History** until you open peer details and choose **Save as contact** (**Contacts** = `is_contact`). **Favorites** pins a short list. Removing a contact keeps History/chat messages. 4. **Search**: the peer search box debounces input and filters the full prepared list (not only the visible window) — wait a moment after typing before judging filter performance on very large lists. 5. **Topology**: automatic topology rebuilds pause above the large-mesh threshold; use its manual **Refresh** after a significant route change. diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index d2c93dc40..3b03aed2f 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1329,12 +1329,13 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lxmf-core" -version = "1.0.1" +version = "1.1.0" dependencies = [ "base64", "bytes", "hex", "rand 0.8.6", + "rmp", "rmp-serde", "rmpv", "rns-crypto", @@ -1348,6 +1349,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "unicode-normalization", ] [[package]] @@ -1498,7 +1500,7 @@ dependencies = [ "rns-transport", "rns-wire", "serde", - "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index d9808ebdd..9365059f9 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -8,7 +8,15 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See ## Build -**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, and `rsNomad` directories on disk (CI checkouts them automatically; locally clone next to `mesh-client`): +**First-time setup** — from the mesh-client repo root, clone/float siblings and apply overlays: + +```bash +./scripts/clone-ratspeak-stack.sh +``` + +That floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. + +**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, and `rsNomad` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): ```bash pnpm run reticulum:sidecar:build @@ -24,7 +32,7 @@ parent/ mesh-client/reticulum-sidecar/ ``` -Apply overlays (required for `rns-stack` until upstream merges): +Prefer `./scripts/clone-ratspeak-stack.sh` (or `./scripts/ensure-rsReticulum-patches.sh` on an existing tree). Individual apply scripts remain for single-overlay work: ```bash ./scripts/apply-rsReticulum-packet-tap.sh @@ -36,7 +44,7 @@ Apply overlays (required for `rns-stack` until upstream merges): ./scripts/apply-rsLXMF-propagation-node-policy-setters.sh ``` -See [patches/README.md](patches/README.md) for base SHA and regen steps. +See [patches/README.md](patches/README.md) for overlay regen against floated `origin/main` (record the short SHA in the PR). ```bash cd reticulum-sidecar @@ -74,7 +82,7 @@ Install coverage tooling once: `cargo install cargo-llvm-cov`. - **Pre-commit** runs sibling `rsNomad` fmt/clippy plus sidecar stub fmt/clippy/test when `cargo` is on `PATH` (no coverage). - **CI lint** (`reticulum-sidecar.yaml`): `rsNomad` fmt/clippy, then full-feature sidecar `fmt --check` + Clippy. - **CI coverage** (`tests.yaml`): `cargo llvm-cov --fail-under-lines 45` when sidecar paths change (ratchet toward ~52%; ignores `rsReticulum`/`rsLXMF`/`rsNomad` path deps). -- **`rsNomad` pin:** `scripts/clone-ratspeak-stack.sh` checks out `RS_NOMAD_REF` (override or `RS_NOMAD_SKIP_PIN=1` for local work). +- **Ratspeak / Nomad siblings:** `scripts/clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`); overlays must apply. ## API diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index dea6ea6b0..4b1187b44 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -1,18 +1,21 @@ -# rsReticulum overlays +# rsReticulum / rsLXMF overlays -Patches applied on top of pinned [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) checkouts for mesh-client `rns-stack` builds. +Patches applied on top of [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) / [ratspeak/rsLXMF](https://github.com/ratspeak/rsLXMF) checkouts for mesh-client `rns-stack` builds (sibling [Colorado-Mesh/rsNomad](https://github.com/Colorado-Mesh/rsNomad) is also required for Nomad hosting; no mesh-client overlay today). + +By default `scripts/clone-ratspeak-stack.sh` floats siblings to **`origin/main`** and applies these overlays (fails loud if a patch will not apply). Use `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to pin a known-good SHA for bisect. Per-overlay **Base commit** tables below record the last regeneration baseline, not a permanent pin — when regenerating, prefer floated `origin/main` and record the short SHA in the PR. ## Development — overlays/patches Overlays require **git checkouts** of sibling repos next to this clone (not a bare Cargo cache path): -- `../rsReticulum` — rsReticulum source at the pinned commit used by `clone-ratspeak-stack.sh` -- `../rsLXMF` — when applying LXMF overlays +- `../rsReticulum` — floated to `origin/main` unless `RS_RETICULUM_REF` is set +- `../rsLXMF` — floated to `origin/main` unless `RS_LXMF_REF` is set +- `../rsNomad` — floated to `origin/main` unless `RS_NOMAD_REF` is set **First-time setup:** ```bash -# From mesh-client repo root — clones/pins siblings and applies known overlays +# From mesh-client repo root — clones/floats siblings and applies known overlays ./scripts/clone-ratspeak-stack.sh # Or ensure patches on an existing sibling tree: ./scripts/ensure-rsReticulum-patches.sh @@ -53,22 +56,27 @@ From mesh-client repo root (sibling `../rsReticulum` required): ### Regenerate +Regenerate against floated `origin/main` (record the short SHA in the PR): + ```bash cd ../rsReticulum -git fetch origin -git diff 9928abed269a83ec5a7ef165ff1142d938cad706 -- \ +git fetch origin && git checkout --detach origin/main +# apply local packet-tap edits, then: +git diff -- \ crates/rns-runtime/src/reticulum.rs \ crates/rns-transport/src/messages.rs \ crates/rns-transport/src/actor/mod.rs \ crates/rns-transport/src/actor/inbound.rs \ > ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch -git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 +# smoke-check on a clean tip clone: +git -C /tmp/rsReticulum-patch-test fetch origin +git -C /tmp/rsReticulum-patch-test checkout --detach origin/main git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch ``` ### Sunset -When the upstream PR merges, remove this patch, drop the CI apply step, and clone `ratspeak/rsReticulum` `main` directly in `build-rns-stack` jobs. +When the upstream PR merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsReticulum-auto-beacon-utun.patch @@ -100,18 +108,22 @@ Apply after the packet-tap patch when both overlays are needed: ### Regenerate +Regenerate against floated `origin/main` (record the short SHA in the PR): + ```bash cd ../rsReticulum -# after implementing on top of RS_RETICULUM_REF -git diff 9928abed269a83ec5a7ef165ff1142d938cad706 -- crates/rns-interface/src/auto.rs \ +git fetch origin && git checkout --detach origin/main +# after implementing the utun filter/backoff, then: +git diff -- crates/rns-interface/src/auto.rs \ > ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch -git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 +git -C /tmp/rsReticulum-patch-test fetch origin +git -C /tmp/rsReticulum-patch-test checkout --detach origin/main git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch ``` ### Sunset -When [ratspeak/rsReticulum#11](https://github.com/ratspeak/rsReticulum/pull/11) merges, remove this patch and drop the CI apply step (same as packet-tap). +When [ratspeak/rsReticulum#11](https://github.com/ratspeak/rsReticulum/pull/11) merges and floated `origin/main` includes it, remove this patch and drop the apply step (same as packet-tap). ## rsReticulum-link-client-nomad.patch @@ -137,7 +149,7 @@ From mesh-client repo root (sibling `../rsReticulum` required): ./scripts/apply-rsReticulum-link-client-nomad.sh ``` -Apply after packet-tap + auto-beacon when rebuilding a pinned checkout: +Apply after packet-tap + auto-beacon when rebuilding against floated `origin/main`: ```bash ./scripts/apply-rsReticulum-packet-tap.sh @@ -147,9 +159,12 @@ Apply after packet-tap + auto-beacon when rebuilding a pinned checkout: ### Regenerate +Regenerate against floated `origin/main` (record the short SHA in the PR): + ```bash -# From a clean pin with the other overlays applied, then the upstream commit: -git -C /tmp/rsReticulum-patch-test checkout 9928abed269a83ec5a7ef165ff1142d938cad706 +# From a clean tip with the other overlays applied, then the LinkClient edits: +git -C /tmp/rsReticulum-patch-test fetch origin +git -C /tmp/rsReticulum-patch-test checkout --detach origin/main git -C /tmp/rsReticulum-patch-test apply reticulum-sidecar/patches/rsReticulum-packet-tap.patch git -C /tmp/rsReticulum-patch-test apply reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch git -C /tmp/rsReticulum-linkclient-nomad format-patch -1 --stdout \ @@ -160,7 +175,7 @@ git -C /tmp/rsReticulum-patch-test diff \ ### Sunset -When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) merges, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsReticulum-link-client-proof-budget.patch @@ -179,7 +194,7 @@ Keep `LinkClient::query` proof wait on the **remaining overall deadline** (v5.25 ## Removed: rsReticulum-rnode-tcp-activity-keepalive.patch -Sunset when upstream landed `RNodeIdleProbe` (`88d3d38` — *rnode: restore TCP application idle probes*). [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) was closed as superseded; mesh-client no longer carries that overlay (pin `9928abed269a83ec5a7ef165ff1142d938cad706` or later already includes idle probes). Tracked entry removed from `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` after sunset confirmation. +Sunset when upstream landed `RNodeIdleProbe` (`88d3d38` — *rnode: restore TCP application idle probes*). [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) was closed as superseded; mesh-client no longer carries that overlay (floated `origin/main` already includes idle probes). Tracked entry removed from `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` after sunset confirmation. ## rsReticulum-ble-rnode-pairing-transition-debounce.patch @@ -202,7 +217,7 @@ From mesh-client repo root (sibling `../rsReticulum` required): ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh ``` -Apply after the other rsReticulum overlays when rebuilding a pinned checkout: +Apply after the other rsReticulum overlays when rebuilding against floated `origin/main`: ```bash ./scripts/apply-rsReticulum-packet-tap.sh @@ -214,15 +229,19 @@ Apply after the other rsReticulum overlays when rebuilding a pinned checkout: ### Regenerate +Regenerate against floated `origin/main` (record the short SHA in the PR): + ```bash cd ../rsReticulum +git fetch origin && git checkout --detach origin/main +# apply local debounce edit, then: git diff -- crates/rns-interface/src/ble_rnode.rs \ > ../mesh-client/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch ``` ### Sunset -When [ratspeak/rsReticulum#20](https://github.com/ratspeak/rsReticulum/pull/20) merges and the clone pin includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When [ratspeak/rsReticulum#20](https://github.com/ratspeak/rsReticulum/pull/20) merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsReticulum-discovery-announce-egress.patch @@ -259,9 +278,12 @@ Apply **after** the other rsReticulum overlays (packet-tap also touches `reticul ### Regenerate +Regenerate against floated `origin/main` (record the short SHA in the PR): + ```bash -# After applying prior overlays on the pin, implement the discovery fix, then: +# After applying prior overlays on tip, implement the discovery fix, then: cd ../rsReticulum +git fetch origin && git checkout --detach origin/main git diff -- \ crates/rns-runtime/src/reticulum.rs \ crates/rns-transport/src/actor/mod.rs \ @@ -271,7 +293,7 @@ git diff -- \ ### Sunset -When [ratspeak/rsReticulum#19](https://github.com/ratspeak/rsReticulum/pull/19) merges and the clone pin includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When [ratspeak/rsReticulum#19](https://github.com/ratspeak/rsReticulum/pull/19) merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsLXMF-propagation-sync-peering.patch @@ -279,12 +301,12 @@ LinkIdentify + peering stamp before LXMF `/offer`, sticky offer/finish fields, p | Field | Value | | ----- | ----- | -| **Base commit** | `68ad7c835187c052c763bb28c41b04a655f35c64` | +| **Base commit** | historical (`68ad7c8…`); tip uses `set_identity` / `send_identify` instead | | **Upstream PR** | https://github.com/ratspeak/rsLXMF/pull/4 | **Modifies (1 file):** -- `crates/lxmf-core/src/propagation_sync.rs` — identify/stamp before `/offer`; sync task peering + sticky finish fields +- `crates/lxmf-core/src/propagation_sync.rs` — identify/stamp before `/offer` on older checkouts. On current `main`, apply script no-ops when `set_identity` is already present. ### Apply locally @@ -298,24 +320,27 @@ From mesh-client repo root (sibling `../rsLXMF` required): ### Regenerate +On current floated `origin/main`, the apply script **no-ops** when `set_identity` is already present — regenerate only if you still need the overlay for an older pin: + ```bash cd ../rsLXMF -git fetch origin -git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- crates/lxmf-core/src/propagation_sync.rs \ +git fetch origin && git checkout --detach origin/main +# only needed for older pins without set_identity / send_identify: +git diff -- crates/lxmf-core/src/propagation_sync.rs \ > ../mesh-client/reticulum-sidecar/patches/rsLXMF-propagation-sync-peering.patch ``` ### Sunset -When [ratspeak/rsLXMF#4](https://github.com/ratspeak/rsLXMF/pull/4) merges, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When floated `origin/main` always includes identify/stamp before `/offer` (tip already does via `set_identity`), remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsLXMF-propagation-node-policy-setters.patch -Live mutators for local PN hosting policy updates (`set_peering_cost`, `set_max_storage`, `set_max_message_size`). Upstream pin only exposes `set_min_stamp_cost`; mesh-client `pn_hosting_apply` needs the others so policy edits apply without recreating the node. +Live mutators for local PN hosting policy updates (`set_peering_cost`, `set_max_storage`, `set_max_message_size`). Floated rsLXMF tip only exposes `set_min_stamp_cost`; mesh-client `pn_hosting_apply` needs the others so policy edits apply without recreating the node. | Field | Value | | ----- | ----- | -| **Base commit** | `68ad7c835187c052c763bb28c41b04a655f35c64` | +| **Base commit** | tip of `ratspeak/rsLXMF` `main` (regenerated for float-to-main) | | **Upstream PR** | https://github.com/ratspeak/rsLXMF/pull/6 | **Modifies (1 file):** @@ -336,22 +361,23 @@ From mesh-client repo root (sibling `../rsLXMF` required): ```bash cd ../rsLXMF -git fetch origin -git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- crates/lxmf-core/src/propagation_node.rs \ +git fetch origin && git checkout --detach origin/main +# apply local setter edits, then: +git diff -- crates/lxmf-core/src/propagation_node.rs \ > ../mesh-client/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch ``` ### Sunset -When [ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6) merges and the clone pin includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +When [ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6) merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. ## rsLXMF-link-delivery-has-pending-to.patch -Expose `LinkDeliveryManager::has_pending_to` so the sidecar can serialize packed Propagated deposits (and propagation sync) against an in-flight Link to the same PN. Pinned rsLXMF only has `delivery_link_available` (reusable idle link), which is the wrong predicate for one-shot packed sessions. +Expose `LinkDeliveryManager::has_pending_to` so the sidecar can serialize packed Propagated deposits (and propagation sync) against an in-flight Link to the same PN. Floated rsLXMF tip only has `delivery_link_available` (reusable idle link), which is the wrong predicate for one-shot packed sessions. | Field | Value | | ----- | ----- | -| **Base commit** | `68ad7c835187c052c763bb28c41b04a655f35c64` | +| **Base commit** | tip of `ratspeak/rsLXMF` `main` (regenerated for float-to-main) | | **Upstream PR** | (none yet — mesh-client local API) | **Modifies (1 file):** @@ -370,14 +396,15 @@ Expose `LinkDeliveryManager::has_pending_to` so the sidecar can serialize packed ```bash cd ../rsLXMF -git fetch origin -git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- crates/lxmf-core/src/link_delivery.rs \ +git fetch origin && git checkout --detach origin/main +# apply local has_pending_to edit, then: +git diff -- crates/lxmf-core/src/link_delivery.rs \ > ../mesh-client/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch ``` ### Sunset -When upstream ships `has_pending_to` (or an equivalent) on the clone pin, remove this patch and drop the apply step. +When upstream ships `has_pending_to` (or an equivalent) on floated `origin/main`, remove this patch and drop the apply step. ## rsReticulum-path-medium-slots.patch diff --git a/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch b/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch index 893ce3f7f..fe77d751a 100644 --- a/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch +++ b/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch @@ -1,15 +1,19 @@ diff --git a/crates/lxmf-core/src/link_delivery.rs b/crates/lxmf-core/src/link_delivery.rs -index fc12604..30c84ae 100644 +index 778c30a..b684d72 100644 --- a/crates/lxmf-core/src/link_delivery.rs +++ b/crates/lxmf-core/src/link_delivery.rs -@@ -2301,6 +2301,12 @@ impl LinkDeliveryManager { +@@ -2303,6 +2303,16 @@ impl LinkDeliveryManager { || self.backchannel_links.contains_key(dest_hash) } -+ /// True when any in-flight (one-shot or Direct) session targets `dest_hash`. -+ /// Used to serialize packed Propagated deposits vs a second LinkRequest to the same PN. ++ /// True when any non-closed delivery link is pending to `dest_hash` (in-flight or queued). ++ /// Unlike `delivery_link_available`, this includes non-reusable one-shot links. + pub fn has_pending_to(&self, dest_hash: &[u8; 16]) -> bool { -+ self.pending.values().any(|d| d.dest_hash == *dest_hash) ++ self.direct_links ++ .get(dest_hash) ++ .and_then(|link_id| self.pending.get(link_id)) ++ .is_some_and(|delivery| delivery.link.state != LinkState::Closed) ++ || self.backchannel_links.contains_key(dest_hash) + } + pub fn direct_link_snapshot(&self, dest_hash: [u8; 16]) -> Option { diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch b/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch index ba805fb39..e2ebad8f2 100644 --- a/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch +++ b/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch @@ -1,8 +1,8 @@ diff --git a/crates/lxmf-core/src/propagation_node.rs b/crates/lxmf-core/src/propagation_node.rs -index 5032269..33772f0 100644 +index d3d58da..6f9dd33 100644 --- a/crates/lxmf-core/src/propagation_node.rs +++ b/crates/lxmf-core/src/propagation_node.rs -@@ -172,6 +172,18 @@ impl PropagationNode { +@@ -307,6 +307,18 @@ impl PropagationNode { self.config.min_stamp_cost = cost; } @@ -10,14 +10,14 @@ index 5032269..33772f0 100644 + self.config.peering_cost = cost; + } + -+ pub fn set_max_storage(&mut self, max: usize) { -+ self.config.max_storage = max; ++ pub fn set_max_storage(&mut self, max_storage: usize) { ++ self.config.max_storage = max_storage; + } + -+ pub fn set_max_message_size(&mut self, max: usize) { -+ self.config.max_message_size = max; ++ pub fn set_max_message_size(&mut self, max_message_size: usize) { ++ self.config.max_message_size = max_message_size; + } + - /// Disk-backed node. Loads existing messages from `storage_path` on startup. - pub fn with_storage( - config: PropagationNodeConfig, + pub fn offer_generation(&self) -> u64 { + self.offer_generation + } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index aff07cfd6..f6497fc2f 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -313,9 +313,7 @@ impl LiveBridge { let inbound_lxmf_cb = inbound_lxmf.clone(); let self_hash_cb = lxmf_hash_hex.clone(); let self_name_cb = display_name.clone(); - let inner_for_cb = inner.clone(); let config_dir_for_cb = config_dir.clone(); - let storage_dir_for_cb = storage_dir.clone(); router.register_delivery_callback(move |msg| { if !msg.incoming { return; @@ -355,34 +353,7 @@ impl LiveBridge { .unwrap_or(""); // Rate-limited warn so developer bundles can prove sidecar receipt without spam. rate_limited_inbound_lxmf_warn(&sender_hex, message_hash); - let inner = inner_for_cb.clone(); - let config_dir = config_dir_for_cb.clone(); - let storage_dir = storage_dir_for_cb.clone(); - let name_cache = name_cache_for_cb.clone(); - let sender = sender_hex.clone(); - tokio::spawn(async move { - let mut state = inner.write().await; - if let Some(name) = state - .contacts - .iter() - .find(|c| c.destination_hash == sender) - .and_then(|c| c.display_name.clone()) - .filter(|name| !name.trim().is_empty()) - { - if let Ok(mut cache) = name_cache.lock() { - cache.insert(sender.clone(), name); - } - } - let cache_snapshot = name_cache - .lock() - .ok() - .map(|c| c.clone()) - .unwrap_or_default(); - state.upsert_contact_with_name_cache(&sender, None, &cache_snapshot); - if let Err(e) = state.save(&config_dir, &storage_dir) { - tracing::warn!("contact persist failed: {e}"); - } - }); + // Contacts are manual-only in mesh-client; do not upsert on inbound LXMF. emit_lxmf_event(&event_tx_cb, payload); }); @@ -1986,6 +1957,7 @@ impl LiveBridge { stamp_cost: Some(parsed.stamp_cost), stamp_flexibility: Some(parsed.stamp_flex), peering_cost: Some(parsed.peering_cost), + metadata: Some(parsed.metadata.clone()), hops: Some(evt.hops), }); if !peered { @@ -3011,8 +2983,10 @@ impl LiveBridge { return Err(format!("propagation establish failed: {err}")); } let progress = self.propagation.sync_progress(); + let peak = self.propagation.last_peak_progress(); // Offering / later stages prove /offer was accepted enough to proceed. - if progress >= 25.0 { + // Peak survives tip's Complete/Failed → Idle collapse (live progress drops to 0). + if progress >= 25.0 || peak >= 25.0 { self.cancel_propagation_sync().await; return Ok(()); } diff --git a/reticulum-sidecar/src/stack/lxmf_delivery.rs b/reticulum-sidecar/src/stack/lxmf_delivery.rs index b2b70c537..75e0f9788 100644 --- a/reticulum-sidecar/src/stack/lxmf_delivery.rs +++ b/reticulum-sidecar/src/stack/lxmf_delivery.rs @@ -181,7 +181,9 @@ pub fn spawn_lxmf_inbound_receiver( router: Arc>, ) { let delivery_rx = register_destination(&transport_tx, lxmf_dest_hash, LXMF_APP); - let (link_packet_tx, mut link_packet_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + // Unbounded: rsReticulum LinkManager::set_link_packet_channel requires UnboundedSender. + // Bound only if upstream grows a bounded setter; do not buffer-copy into a second queue. + let (link_packet_tx, mut link_packet_rx) = mpsc::unbounded_channel::<(Vec, [u8; 16])>(); let (resource_tx, mut resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); let mut link_mgr = LinkManager::with_destination( diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 0feda9a01..6a62b32d1 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -643,6 +643,9 @@ impl PersistedState { } } + /// Explicit contact upsert (not called from LXMF send/receive). Kept for unit tests and + /// any future manual sidecar write path; messaging must not auto-promote contacts. + #[allow(dead_code)] // intentional: production messaging paths no longer call this pub fn upsert_contact(&mut self, hash: &str, name: Option) { let hash = super::topology::canonicalize_destination_hash(hash) .unwrap_or_else(|| hash.trim().to_ascii_lowercase()); @@ -679,6 +682,7 @@ impl PersistedState { } /// Upsert a contact, filling a missing name from announce/peer cache when needed. + #[allow(dead_code)] // intentional: production messaging paths no longer call this pub fn upsert_contact_with_name_cache( &mut self, hash: &str, @@ -714,12 +718,7 @@ impl PersistedState { return Err("identity not configured".into()); } let ts = Self::now_secs(); - let peer_names = super::topology::build_topology_name_map( - &self.peers, - &self.contacts, - &self.nomad_nodes, - ); - self.upsert_contact_with_name_cache(&req.destination_hash, None, &peer_names); + // Contacts are manual-only; offline/mock send must not auto-add the recipient. let sent_via = resolve_outbound_sent_via(&self.interfaces); let mut payload = serde_json::json!({ "sender_hash": self.identity.lxmf_hash, @@ -1004,6 +1003,35 @@ mod tests { assert_eq!(state.contacts[0].display_name.as_deref(), Some("Named")); } + #[test] + fn send_lxmf_local_does_not_auto_add_contact() { + let dest = "aabbccddeeff00112233445566778899"; + let mut state = PersistedState::default_empty(); + state.identity = StackIdentity { + configured: true, + identity_hash: "11".repeat(16), + lxmf_hash: "22".repeat(16), + display_name: Some("Self".into()), + mnemonic: None, + }; + state.peers.push(peer(dest, "Hub Peer")); + let payload = state + .send_lxmf_local(&LxmfSendRequest { + destination_hash: dest.into(), + text: "hi".into(), + reply_to_hash: None, + reply_to_id: None, + reply_preview_text: None, + }) + .expect("send"); + assert_eq!(payload["to_hash"], dest); + assert_eq!(payload["direction"], "outbound"); + assert!( + state.contacts.is_empty(), + "offline send must not promote recipient to contacts" + ); + } + #[test] fn add_propagation_node_preserves_optional_identity_fields_on_mutate() { let mut state = PersistedState::default_empty(); diff --git a/reticulum-sidecar/src/stack/pn_hosting_apply.rs b/reticulum-sidecar/src/stack/pn_hosting_apply.rs index 783655d91..3b911e0e9 100644 --- a/reticulum-sidecar/src/stack/pn_hosting_apply.rs +++ b/reticulum-sidecar/src/stack/pn_hosting_apply.rs @@ -13,11 +13,14 @@ pub fn apply_pn_hosting_policy_to_router(router: &mut LxmRouter, policy: &PnHost router.set_stamp_requirements(policy.propagation_stamp_cost, policy.propagation_stamp_flex); router.set_message_storage_limit(Some(policy.message_storage_limit_bytes())); router.set_authentication(policy.auth_required); - router.set_enforce_stamps(policy.enforce_stamps); - router.set_enforce_ratchets(policy.enforce_ratchets); + // rsLXMF tip dropped set_enforce_stamps/ratchets (stamp gating via set_stamp_requirements). + let _ = (policy.enforce_stamps, policy.enforce_ratchets); router.config.sync_limit_kb = policy.sync_limit_kb; - router.config.delivery_limit_kb = policy.delivery_limit_kb; + #[allow(clippy::cast_precision_loss)] // KB policy is integer; router tip uses f64 + { + router.config.delivery_limit_kb = policy.delivery_limit_kb as f64; + } router.config.ext.peering_cost = policy.peering_cost; router.config.ext.max_peering_cost = policy.max_peering_cost; router.config.ext.autopeer_maxdepth = policy.autopeer_maxdepth; diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index f779d92c7..5474aed4a 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -6,8 +6,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; +use lxmf_core::peer::OutboundOfferPolicy; use lxmf_core::propagation_node::{PropagationNode, PropagationNodeConfig}; -use lxmf_core::propagation_sync::{PropagationSyncTask, SyncTaskState}; +use lxmf_core::propagation_sync::{PeerSyncTerminalState, PropagationSyncTask, SyncTaskState}; use lxmf_core::router::LxmRouter; use rns_identity::identity::Identity; use rns_transport::messages::TransportMessage; @@ -20,6 +21,14 @@ pub struct PropagationBridge { local_serving: AtomicBool, /// Serializes sync-run generation changes with emitter cancel / pin / event side effects. sync_lifecycle: Mutex<()>, + /// Sticky offer failure label (rsLXMF tip keeps terminal state on the task, not these fields). + last_offer_error: Mutex>, + /// Sticky establish failure label for UI / offer-probe. + last_establish_error: Mutex>, + /// Sticky success/failure after Complete/Failed collapses to Idle. + last_finished_ok: Mutex>, + /// Peak sync progress seen before tip collapses Complete/Failed → Idle. + peak_progress: Mutex, } impl PropagationBridge { @@ -37,6 +46,7 @@ impl PropagationBridge { min_stamp_cost: policy.min_stamp_cost(), peering_cost: policy.peering_cost, max_message_size: policy.propagation_limit_kb.saturating_mul(1024), + max_offer_size: policy.sync_limit_kb.saturating_mul(1000), }; let local_node = Arc::new(Mutex::new( PropagationNode::with_storage(node_config, local_dest_hash, storage_dir) @@ -46,16 +56,32 @@ impl PropagationBridge { let signing_key = identity .get_signing_key() .ok_or_else(|| "propagation sync: identity has no signing key".to_string())?; - sync_task.set_local_identity(identity.get_public_key(), signing_key); + sync_task.set_identity(identity.get_public_key(), signing_key); Ok(Self { local_dest_hash, local_node, sync_task: Mutex::new(sync_task), local_serving: AtomicBool::new(false), sync_lifecycle: Mutex::new(()), + last_offer_error: Mutex::new(None), + last_establish_error: Mutex::new(None), + last_finished_ok: Mutex::new(None), + peak_progress: Mutex::new(0.0), }) } + /// Map rsLXMF sync-task state to UI / probe progress (single source of truth). + pub fn progress_for_state(state: SyncTaskState) -> f64 { + match state { + SyncTaskState::Establishing => 10.0, + SyncTaskState::Offering => 25.0, + SyncTaskState::AwaitingResponse => 40.0, + SyncTaskState::Transferring => 70.0, + SyncTaskState::Complete => 100.0, + SyncTaskState::Idle | SyncTaskState::Failed => 0.0, + } + } + pub fn local_node(&self) -> Arc> { self.local_node.clone() } @@ -84,7 +110,58 @@ impl PropagationBridge { .unwrap_or((0, 0)) } - #[allow(clippy::type_complexity)] // peering tuple mirrors RNS PropagationSyncTask::configure_peering + fn clear_sticky_errors(&self) { + if let Ok(mut slot) = self.last_offer_error.lock() { + *slot = None; + } + if let Ok(mut slot) = self.last_establish_error.lock() { + *slot = None; + } + if let Ok(mut slot) = self.last_finished_ok.lock() { + *slot = None; + } + if let Ok(mut slot) = self.peak_progress.lock() { + *slot = 0.0; + } + } + + #[cfg(test)] + pub fn force_peak_progress_for_test(&self, progress: f64) { + self.note_peak_progress(progress); + } + + fn note_peak_progress(&self, progress: f64) { + if progress <= 0.0 { + return; + } + if let Ok(mut peak) = self.peak_progress.lock() { + if progress > *peak { + *peak = progress; + } + } + } + + /// Peak progress observed for the current/last sync run (survives Idle collapse). + pub fn last_peak_progress(&self) -> f64 { + self.peak_progress.lock().map(|p| *p).unwrap_or(0.0) + } + + fn stamp_terminal_failure_from_peak(&self, peak: f64) { + // Tip collapses Complete/Failed → Idle in the same tick, so task.state after + // take_terminal is always Idle (progress 0). Classify from peak instead. + // Never invent "Unknown" (probe maps that to PROPAGATION_OFFER_UNSUPPORTED). + // peak >= 25 (Offering+): leave offer_error unset so probe can treat it as OK. + if peak >= 25.0 { + return; + } + if let Ok(mut slot) = self.last_establish_error.lock() { + if slot.is_none() { + *slot = Some("NoLinkProof"); + } + } + } + + #[allow(clippy::type_complexity)] // peering tuple: local_id, peer_id, cost, optional key pub fn start_sync( &self, remote_hash: [u8; 16], @@ -93,18 +170,32 @@ impl PropagationBridge { let Ok(mut task) = self.sync_task.lock() else { return false; }; - if let Some((local_id, peer_id, cost, key)) = peering { - task.configure_peering(local_id, peer_id, cost, key); + self.clear_sticky_errors(); + let mut policy = OutboundOfferPolicy::unrestricted(remote_hash); + if let Some((_local_id, _peer_id, cost, key)) = peering { + policy.peering_cost = cost; + if let Some(k) = key { + policy.peering_key = k; + } } - task.request_sync_now(remote_hash); - true + task.request_sync_now_with_policy(policy) } pub fn cancel_sync(&self) { + // Tip `cancel_peer_sync` leaves Idle + clears terminal_result. Do not force + // Failed afterward — that blocks the next `request_sync_now_*` (Idle required). if let Ok(mut task) = self.sync_task.lock() { - task.state = SyncTaskState::Failed; - // Sticky fail so the progress emitter does not emit a terminal progress=100. - task.last_finished_ok = Some(false); + if let Some(hash) = task.node_dest_hash() { + let _ = task.cancel_peer_sync(&hash); + } else { + task.state = SyncTaskState::Idle; + } + } + // Offer-probe (and other mid-progress cancels after Offering) should not look + // like sticky failure — peak ≥ 25 means /offer was accepted enough to proceed. + let peak = self.last_peak_progress(); + if let Ok(mut slot) = self.last_finished_ok.lock() { + *slot = Some(peak >= 25.0); } } @@ -157,43 +248,43 @@ impl PropagationBridge { pub fn sync_progress(&self) -> f64 { self.sync_task .lock() - .map(|task| match task.state { - SyncTaskState::Establishing => 10.0, - SyncTaskState::Offering => 25.0, - SyncTaskState::AwaitingResponse => 40.0, - SyncTaskState::Transferring => 70.0, - SyncTaskState::Complete => 100.0, - SyncTaskState::Idle | SyncTaskState::Failed => 0.0, - }) + .map(|task| Self::progress_for_state(task.state)) .unwrap_or(0.0) } pub fn last_offer_error(&self) -> Option<&'static str> { - self.sync_task - .lock() - .ok() - .and_then(|task| task.last_offer_error) + self.last_offer_error.lock().ok().and_then(|slot| *slot) } pub fn last_establish_error(&self) -> Option<&'static str> { - self.sync_task - .lock() - .ok() - .and_then(|task| task.last_establish_error) + self.last_establish_error.lock().ok().and_then(|slot| *slot) } /// Sticky success/failure after Complete/Failed collapses to Idle. pub fn last_finished_ok(&self) -> Option { - self.sync_task - .lock() - .ok() - .and_then(|task| task.last_finished_ok) + self.last_finished_ok.lock().ok().and_then(|slot| *slot) } pub fn tick(&self, known_identities: &HashMap) { - if let Ok(mut task) = self.sync_task.lock() { + let terminal = if let Ok(mut task) = self.sync_task.lock() { + // Sample before drain/tick: tip collapses Complete|Failed → Idle in tick(). + self.note_peak_progress(Self::progress_for_state(task.state)); task.drain_events(known_identities); + self.note_peak_progress(Self::progress_for_state(task.state)); task.tick(); + task.take_terminal_peer_result() + .map(|result| matches!(result.state, PeerSyncTerminalState::Complete)) + } else { + None + }; + if let Some(ok) = terminal { + if let Ok(mut slot) = self.last_finished_ok.lock() { + *slot = Some(ok); + } + if !ok { + let peak = self.last_peak_progress(); + self.stamp_terminal_failure_from_peak(peak); + } } } @@ -248,8 +339,15 @@ impl PropagationBridge { }; if active && progress <= 10.0 && started.elapsed() > SYNC_STALL_TIMEOUT { bridge.run_if_current(&active_run_id, run_id, || { + if let Ok(mut slot) = bridge.last_establish_error.lock() { + if slot.is_none() { + *slot = Some("NoLinkProof"); + } + } bridge.cancel_sync(); - let message = establish_error + let message = bridge + .last_establish_error() + .or(establish_error) .map(|e| format!("propagation establish failed: {e}")) .unwrap_or_else(|| { "propagation establish failed: NoLinkProof".to_string() @@ -405,6 +503,74 @@ mod tests { bridge.cancel_sync(); assert_eq!(bridge.last_finished_ok(), Some(false)); assert!(!bridge.sync_active()); + // Tip requires Idle for the next request_sync_now_*; cancel must not leave Failed. + assert!(matches!( + bridge.sync_task.lock().expect("lock").state, + SyncTaskState::Idle + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn cancel_sync_after_offer_peak_stamps_success() { + let dir = std::env::temp_dir().join(format!( + "mesh-prop-bridge-cancel-peak-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let identity = rns_identity::identity::Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + bridge.force_peak_progress_for_test(25.0); + bridge.cancel_sync(); + assert_eq!(bridge.last_finished_ok(), Some(true)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn progress_for_state_maps_offer_threshold() { + assert!( + (PropagationBridge::progress_for_state(SyncTaskState::Establishing) - 10.0).abs() + < f64::EPSILON + ); + assert!( + (PropagationBridge::progress_for_state(SyncTaskState::Offering) - 25.0).abs() + < f64::EPSILON + ); + assert!(PropagationBridge::progress_for_state(SyncTaskState::Failed).abs() < f64::EPSILON); + } + + #[test] + fn early_terminal_failure_stamps_establish_not_unknown() { + let dir = + std::env::temp_dir().join(format!("mesh-prop-bridge-stamp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let identity = rns_identity::identity::Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + bridge.stamp_terminal_failure_from_peak(10.0); + assert_eq!(bridge.last_establish_error(), Some("NoLinkProof")); + assert_eq!(bridge.last_offer_error(), None); + bridge.clear_sticky_errors(); + bridge.stamp_terminal_failure_from_peak(40.0); + assert_eq!(bridge.last_establish_error(), None); + assert_eq!(bridge.last_offer_error(), None); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/reticulum-sidecar/src/stack/topology.rs b/reticulum-sidecar/src/stack/topology.rs index 4918addd9..fff9bd81a 100644 --- a/reticulum-sidecar/src/stack/topology.rs +++ b/reticulum-sidecar/src/stack/topology.rs @@ -235,6 +235,7 @@ pub fn overlay_contact_display_names( } /// Prefer a real stored name; else a non-hash-prefix cache label. +#[allow(dead_code)] // used via upsert_contact_with_name_cache (test / future explicit API) pub fn resolve_contact_name_for_upsert( hash: &str, stored_name: Option<&str>, diff --git a/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh index 88d2968be..18bc1d4ab 100755 --- a/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh +++ b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # Apply mesh-client rsLXMF LinkDeliveryManager::has_pending_to for rns-stack builds. -# Serializes packed Propagated deposits vs a second LinkRequest to the same PN -# (pinned rsLXMF lacks this query helper). +# Serializes packed Propagated deposits vs a second LinkRequest to the same PN. set -euo pipefail -RS_LXMF_REF="${RS_LXMF_REF:-68ad7c835187c052c763bb28c41b04a655f35c64}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch" -LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" LINK_RS="${LXMF_DIR}/crates/lxmf-core/src/link_delivery.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then @@ -32,36 +32,7 @@ if overlay_already_present; then exit 0 fi -if ! git -C "${LXMF_DIR}" diff --quiet || ! git -C "${LXMF_DIR}" diff --cached --quiet; then - echo "warning: ${LXMF_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${LXMF_DIR}" apply --check "${PATCH_FILE}" - git -C "${LXMF_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${LXMF_DIR}" "${PATCH_FILE}" "link-delivery-has-pending-to"; then exit 0 fi - -echo "has_pending_to patch did not apply on current HEAD; checking out pinned ref ${RS_LXMF_REF:0:12}" -if [[ -n "$(git -C "${LXMF_DIR}" status --porcelain)" ]]; then - echo "error: ${LXMF_DIR} has uncommitted changes; cannot checkout ${RS_LXMF_REF:0:12} to apply overlay" >&2 - echo "Stash/commit sibling changes, or ensure has_pending_to is already present." >&2 - exit 1 -fi -current_head="$(git -C "${LXMF_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_LXMF_REF}" ]]; then - git -C "${LXMF_DIR}" fetch origin --tags - git -C "${LXMF_DIR}" checkout "${RS_LXMF_REF}" -fi - -if overlay_already_present; then - echo "link-delivery has_pending_to overlay already present on rsLXMF @ ${RS_LXMF_REF:0:12}" - exit 0 -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsLXMF @ ${RS_LXMF_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsLXMF-propagation-node-policy-setters.sh b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh index 00d4dd026..6af13b808 100755 --- a/scripts/apply-rsLXMF-propagation-node-policy-setters.sh +++ b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh @@ -4,11 +4,12 @@ # policy updates can mutate a running local node (upstream only has set_min_stamp_cost). set -euo pipefail -RS_LXMF_REF="${RS_LXMF_REF:-68ad7c835187c052c763bb28c41b04a655f35c64}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch" -LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" NODE_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_node.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then @@ -34,36 +35,7 @@ if overlay_already_present; then exit 0 fi -if ! git -C "${LXMF_DIR}" diff --quiet || ! git -C "${LXMF_DIR}" diff --cached --quiet; then - echo "warning: ${LXMF_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${LXMF_DIR}" apply --check "${PATCH_FILE}" - git -C "${LXMF_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${LXMF_DIR}" "${PATCH_FILE}" "propagation-node-policy-setters"; then exit 0 fi - -echo "propagation-node policy setters patch did not apply on current HEAD; checking out pinned ref ${RS_LXMF_REF:0:12}" -if [[ -n "$(git -C "${LXMF_DIR}" status --porcelain)" ]]; then - echo "error: ${LXMF_DIR} has uncommitted changes; cannot checkout ${RS_LXMF_REF:0:12} to apply overlay" >&2 - echo "Stash/commit sibling changes, or ensure the policy setters are already present." >&2 - exit 1 -fi -current_head="$(git -C "${LXMF_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_LXMF_REF}" ]]; then - git -C "${LXMF_DIR}" fetch origin --tags - git -C "${LXMF_DIR}" checkout "${RS_LXMF_REF}" -fi - -if overlay_already_present; then - echo "propagation-node policy setters overlay already present on rsLXMF @ ${RS_LXMF_REF:0:12}" - exit 0 -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsLXMF @ ${RS_LXMF_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsLXMF-propagation-sync-peering.sh b/scripts/apply-rsLXMF-propagation-sync-peering.sh index 06910de10..b31e86689 100755 --- a/scripts/apply-rsLXMF-propagation-sync-peering.sh +++ b/scripts/apply-rsLXMF-propagation-sync-peering.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash # Apply mesh-client rsLXMF PropagationSyncTask peering/identity overlay for rns-stack builds. +# On current rsLXMF main, LinkIdentify lives as set_identity()/send_identify() (API rewrite); +# the historical set_local_identity overlay is only needed on older checkouts. set -euo pipefail -RS_LXMF_REF="${RS_LXMF_REF:-68ad7c835187c052c763bb28c41b04a655f35c64}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-sync-peering.patch" -LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" SYNC_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_sync.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then @@ -20,31 +23,16 @@ if [[ ! -f "${PATCH_FILE}" ]]; then exit 1 fi -if [[ -f "${SYNC_RS}" ]] && grep -q 'fn set_local_identity' "${SYNC_RS}"; then - echo "propagation-sync peering overlay already applied on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" +# Tip (post API-parity rewrite) or historical overlay. +if [[ -f "${SYNC_RS}" ]] && { + grep -qE 'fn set_identity\(' "${SYNC_RS}" \ + || grep -qE 'fn set_local_identity\(' "${SYNC_RS}" +}; then + echo "propagation-sync peering/identity capability present on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" exit 0 fi -if ! git -C "${LXMF_DIR}" diff --quiet || ! git -C "${LXMF_DIR}" diff --cached --quiet; then - echo "warning: ${LXMF_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${LXMF_DIR}" apply --check "${PATCH_FILE}" - git -C "${LXMF_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${LXMF_DIR}" "${PATCH_FILE}" "propagation-sync-peering"; then exit 0 fi - -echo "propagation-sync peering patch did not apply on current HEAD; checking out pinned ref ${RS_LXMF_REF:0:12}" -current_head="$(git -C "${LXMF_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_LXMF_REF}" ]]; then - git -C "${LXMF_DIR}" fetch origin --tags - git -C "${LXMF_DIR}" checkout "${RS_LXMF_REF}" -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsLXMF @ ${RS_LXMF_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsReticulum-auto-beacon-utun.sh b/scripts/apply-rsReticulum-auto-beacon-utun.sh index 575d44cd3..64567da42 100755 --- a/scripts/apply-rsReticulum-auto-beacon-utun.sh +++ b/scripts/apply-rsReticulum-auto-beacon-utun.sh @@ -2,11 +2,12 @@ # Apply mesh-client rsReticulum AutoInterface beacon overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" AUTO_RS="${RNS_DIR}/crates/rns-interface/src/auto.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -26,36 +27,7 @@ if [[ -f "${AUTO_RS}" ]] && grep -q 'beacon_tx_backoff_secs' "${AUTO_RS}"; then exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" - exit 0 -fi - -if git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" 2>&1 | grep -q 'patch does not apply'; then - echo "auto-beacon utun overlay not needed (already upstream or incompatible with current rsReticulum HEAD)" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "auto-beacon-utun"; then exit 0 fi - -echo "auto-beacon patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" - exit 0 -fi - -echo "auto-beacon utun overlay not needed (already upstream or incompatible with pinned rsReticulum ref)" -exit 0 +exit 1 diff --git a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh index 815ed2435..7bb8df624 100755 --- a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +++ b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh @@ -4,11 +4,12 @@ # re-fired the OS passkey dialog while the user was typing; wait 30s instead. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" BLE_RNODE_RS="${RNS_DIR}/crates/rns-interface/src/ble_rnode.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -35,31 +36,7 @@ if overlay_already_present; then exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "ble-rnode-pairing-transition-debounce"; then exit 0 fi - -echo "ble_rnode pairing-transition debounce patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -if overlay_already_present; then - echo "ble_rnode pairing-transition debounce overlay already present on rsReticulum @ ${RS_RETICULUM_REF:0:12}" - exit 0 -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsReticulum-discovery-announce-egress.sh b/scripts/apply-rsReticulum-discovery-announce-egress.sh index cade74d0a..c76baa0c7 100755 --- a/scripts/apply-rsReticulum-discovery-announce-egress.sh +++ b/scripts/apply-rsReticulum-discovery-announce-egress.sh @@ -5,11 +5,12 @@ # (BLE RNode late bring-up). Upstream: https://github.com/ratspeak/rsReticulum/pull/19 set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" RETICULUM_RS="${RNS_DIR}/crates/rns-runtime/src/reticulum.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -39,36 +40,7 @@ if overlay_already_present; then exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "discovery-announce-egress"; then exit 0 fi - -echo "discovery-announce egress patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -if [[ -n "$(git -C "${RNS_DIR}" status --porcelain)" ]]; then - echo "error: ${RNS_DIR} has uncommitted changes; cannot checkout ${RS_RETICULUM_REF:0:12} to apply overlay" >&2 - echo "Stash/commit sibling changes, or ensure the discovery announce fix is already present." >&2 - exit 1 -fi -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -if overlay_already_present; then - echo "discovery-announce egress overlay already present on rsReticulum @ ${RS_RETICULUM_REF:0:12}" - exit 0 -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsReticulum-link-client-nomad.sh b/scripts/apply-rsReticulum-link-client-nomad.sh index fa302481d..5c1355fbb 100755 --- a/scripts/apply-rsReticulum-link-client-nomad.sh +++ b/scripts/apply-rsReticulum-link-client-nomad.sh @@ -2,11 +2,12 @@ # Apply mesh-client rsReticulum LinkClient Nomad overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" MESSAGES_RS="${RNS_DIR}/crates/rns-transport/src/messages.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -26,36 +27,7 @@ if [[ -f "${MESSAGES_RS}" ]] && grep -q 'RecallDestinationPublicKey' "${MESSAGES exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" - exit 0 -fi - -if git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" 2>&1 | grep -q 'patch does not apply'; then - echo "link-client nomad overlay not needed (already upstream or incompatible with current rsReticulum HEAD)" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "link-client-nomad"; then exit 0 fi - -echo "link-client nomad patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" - exit 0 -fi - -echo "link-client nomad overlay not needed (already upstream or incompatible with pinned rsReticulum ref)" -exit 0 +exit 1 diff --git a/scripts/apply-rsReticulum-packet-tap.sh b/scripts/apply-rsReticulum-packet-tap.sh index 09b3a8efc..bfed9bce3 100755 --- a/scripts/apply-rsReticulum-packet-tap.sh +++ b/scripts/apply-rsReticulum-packet-tap.sh @@ -2,11 +2,12 @@ # Apply mesh-client rsReticulum packet-tap overlay for rns-stack local builds. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-packet-tap.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" RETICULUM_RS="${RNS_DIR}/crates/rns-runtime/src/reticulum.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -25,26 +26,7 @@ if [[ -f "${RETICULUM_RS}" ]] && grep -q 'register_packet_tap' "${RETICULUM_RS}" exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "packet-tap"; then exit 0 fi - -echo "packet-tap patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - -apply_patch -echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" +exit 1 diff --git a/scripts/apply-rsReticulum-path-medium-slots.sh b/scripts/apply-rsReticulum-path-medium-slots.sh index f69f29777..8d153a2da 100755 --- a/scripts/apply-rsReticulum-path-medium-slots.sh +++ b/scripts/apply-rsReticulum-path-medium-slots.sh @@ -3,11 +3,12 @@ # Keeps up to 3 ranked path slots per destination and RF/network preference. set -euo pipefail -RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" MARKER="${RNS_DIR}/crates/rns-transport/src/constants.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then @@ -26,28 +27,9 @@ if [[ -f "${MARKER}" ]] && grep -q 'MAX_PATH_SLOTS' "${MARKER}"; then exit 0 fi -if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then - echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 -fi - -apply_patch() { - git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" - git -C "${RNS_DIR}" apply "${PATCH_FILE}" -} - -if apply_patch 2> /dev/null; then - echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" - exit 0 -fi - -echo "path-medium-slots patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" -current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" -if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then - git -C "${RNS_DIR}" fetch origin --tags - git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" -fi - # Prerequisites (packet-tap, discovery egress, …) must already be applied by # ensure-rsReticulum-patches.sh / clone-ratspeak-stack.sh before this script. -apply_patch -echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "path-medium-slots"; then + exit 0 +fi +exit 1 diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index fcaa4f493..46c01a1f5 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -907,6 +907,44 @@ export const RETICULUM_OTHER_PEERS_COLLEAGUE_RES = [ { re: /\bcolleagues\b/i, hint: 'use networking "peers", not office "colleagues"' }, ]; +/** + * Broader office-colleague false friends for History/Contacts empty copy only + * (too noisy for stack transport strings that legitimately reuse some lemmas). + */ +export const HISTORY_EMPTY_PEER_COLLEAGUE_RES = [ + ...RETICULUM_OTHER_PEERS_COLLEAGUE_RES, + { re: /\bkoleg/i, hint: 'use networking "peers", not Czech/Slovak office colleague "kolega"' }, + { re: /\bcompañer/i, hint: 'use networking "peers", not Spanish office "compañero"' }, + { re: /\bcollegh/i, hint: 'use networking "peers", not Italian office "colleghi"' }, + { re: /\bcollega'?s?\b/i, hint: 'use networking "peers", not Dutch office "collega"' }, + // Unicode-aware left boundary: ASCII \b is false between non-ASCII letters. + { + re: /(?:^|\P{L})równieśnik/iu, + hint: 'use networking "peer", not Polish schoolmate "rówieśnik"', + }, + { + re: /(?:^|\P{L})коллег/iu, + hint: 'use networking "peers/пиры", not Russian office "коллега"', + }, + { + re: /(?:^|\P{L})колег/iu, + hint: 'use networking "peers/вузли", not Ukrainian office "колега"', + }, + { re: /同僚/, hint: 'use networking ピア, not office colleague 同僚' }, + { re: /동료/, hint: 'use networking 피어, not office colleague 동료' }, + { re: /\brekan\b/i, hint: 'use networking "peer", not Indonesian coworker "rekan"' }, + { re: /同行/, hint: 'use networking 对等节点, not office 同行' }, +]; + +/** UI History tab must mean message history, not school historiography / calendar date. */ +export const HISTORY_TAB_FALSE_FRIEND_RES = [ + { re: /^Dějepis$/i, hint: 'use UI "Historie", not school subject "Dějepis"' }, + { re: /^Geschichte$/i, hint: 'use message history "Verlauf", not general "Geschichte"' }, + { re: /^歴史$/, hint: 'use message history 履歴, not general history 歴史' }, + { re: /^Tarih$/i, hint: 'use message history "Geçmiş", not calendar date "Tarih"' }, + { re: /^Storia$/i, hint: 'use message history "Cronologia", not general "Storia"' }, +]; + /** Leaf keys allowed to stay identical to English in Reticulum UI copy. */ export const RETICULUM_IDENTICAL_OK_LEAF_KEYS = new Set([ 'reticulumNetworkUnknown', @@ -1195,6 +1233,36 @@ function checkReticulumConnectionPanelIssues(ctx) { } } + if ( + flatKey === 'peerListPanel.emptyHistory' || + flatKey === 'peerListPanel.emptyContacts' || + flatKey === 'nodeListPanel.emptyHistory' + ) { + for (const { re, hint } of HISTORY_EMPTY_PEER_COLLEAGUE_RES) { + if (re.test(val)) { + issues.push(`History/Contacts peers false friend: ${hint}`); + } + } + } + + if (flatKey === 'peerListPanel.tabHistory' || flatKey === 'nodeListPanel.tabHistory') { + for (const { re, hint } of HISTORY_TAB_FALSE_FRIEND_RES) { + if (re.test(val.trim())) { + issues.push(`History tab false friend: ${hint}`); + } + } + } + + if ( + (flatKey === 'peerDetailModal.removeContact' || + flatKey === 'peerDetailModal.removeContactConfirmTitle') && + /Kontaktlinsen|lentilles de contact/i.test(val) + ) { + issues.push( + 'peerDetailModal remove-contact false friend: contact lens wording instead of saved contact', + ); + } + for (const issue of reticulumConnectionPanelLiteralIssues(enVal, val)) { issues.push(issue); } diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index d07e2c479..a171b1584 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -1,19 +1,31 @@ #!/usr/bin/env bash -# Clone rsReticulum (pinned ref + mesh-client patches), rsLXMF, and rsNomad for rns-stack sidecar builds. +# Clone rsReticulum + rsLXMF + rsNomad (float origin/main by default), +# then apply mesh-client overlays for rns-stack sidecar builds. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "${REPO_ROOT}/.." && pwd)}" +# shellcheck source=lib/ratspeak-overlay-apply-list.sh +source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" RNS_DIR="${WORKSPACE_ROOT}/rsReticulum" LXMF_DIR="${WORKSPACE_ROOT}/rsLXMF" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" +# So apply-*.sh targets the same siblings as this script (WORKSPACE_ROOT may differ from ..). +export RS_RETICULUM_DIR="${RNS_DIR}" +export RS_LXMF_DIR="${LXMF_DIR}" + +# Optional bisect / known-good overrides. Unset or empty → float to origin/main. +RS_RETICULUM_REF="${RS_RETICULUM_REF:-}" +RS_LXMF_REF="${RS_LXMF_REF:-}" +RS_NOMAD_REF="${RS_NOMAD_REF:-}" + # Ensure an existing checkout has the correct origin remote and is on the -# pinned ref. If the directory does not exist, clone it first. +# requested ref (or floated origin/main). If the directory does not exist, clone it first. ensure_repo() { - local dir="$1" expected_origin="$2" pinned_ref="$3" label="$4" + local dir="$1" expected_origin="$2" ref_or_empty="$3" label="$4" if [[ ! -d "${dir}/.git" ]]; then git clone "${expected_origin}" "${dir}" fi @@ -23,47 +35,92 @@ ensure_repo() { echo "info: ${label} origin is ${actual_origin}; updating to ${expected_origin}" git -C "${dir}" remote set-url origin "${expected_origin}" fi - if [[ -n "${pinned_ref}" ]]; then - local current_head - current_head="$(git -C "${dir}" rev-parse HEAD 2> /dev/null || true)" - if [[ "${current_head}" != "${pinned_ref}" ]]; then - if [[ -n "$(git -C "${dir}" status --porcelain)" ]]; then - echo "warning: ${dir} has uncommitted changes; skipping pin to ${pinned_ref:0:12}" >&2 - else - git -C "${dir}" fetch --quiet origin "${pinned_ref}" 2> /dev/null \ - || git -C "${dir}" fetch --quiet origin - git -C "${dir}" checkout --quiet "${pinned_ref}" + + git -C "${dir}" fetch --quiet origin + + local target_ref='' target_sha='' current_head + current_head="$(git -C "${dir}" rev-parse HEAD 2> /dev/null || true)" + + if [[ -n "${ref_or_empty}" ]]; then + target_ref="${ref_or_empty}" + git -C "${dir}" fetch --quiet origin "${ref_or_empty}" 2> /dev/null || true + target_sha="$(git -C "${dir}" rev-parse --verify "${ref_or_empty}^{commit}" 2> /dev/null || true)" + if [[ -z "${target_sha}" ]]; then + # Branch/tag pins often resolve only as origin/ after fetch. + target_sha="$( + git -C "${dir}" rev-parse --verify "origin/${ref_or_empty}^{commit}" 2> /dev/null || true + )" + if [[ -n "${target_sha}" ]]; then + target_ref="origin/${ref_or_empty}" fi fi + if [[ -z "${target_sha}" ]]; then + echo "error: ${label}: cannot resolve pin ${ref_or_empty:0:12}" >&2 + exit 1 + fi + else + # Float to tip of origin/main (fallback: origin/master). + if git -C "${dir}" rev-parse --verify --quiet 'origin/main' > /dev/null; then + target_ref='origin/main' + elif git -C "${dir}" rev-parse --verify --quiet 'origin/master' > /dev/null; then + target_ref='origin/master' + else + echo "error: ${label}: neither origin/main nor origin/master after fetch" >&2 + exit 1 + fi + target_sha="$(git -C "${dir}" rev-parse --verify "${target_ref}^{commit}")" + fi + + if [[ -n "$(git -C "${dir}" status --porcelain)" ]]; then + # Overlays leave siblings dirty after a successful float — allow that when already + # on the target tip. Refuse only when checkout would rewrite a dirty tree. + if [[ "${current_head}" == "${target_sha}" ]]; then + echo "warning: ${dir} has uncommitted changes; already at ${target_ref} (${target_sha:0:12}), skipping checkout" >&2 + return 0 + fi + echo "error: ${dir} has uncommitted changes; refuse to float/pin to ${target_ref} (${target_sha:0:12}) from ${current_head:0:12} (stash or reset, then re-run)" >&2 + git -C "${dir}" status --short >&2 || true + exit 1 + fi + + if [[ "${current_head}" != "${target_sha}" ]]; then + if [[ -n "${ref_or_empty}" ]]; then + git -C "${dir}" checkout --quiet "${ref_or_empty}" + else + git -C "${dir}" checkout --quiet --detach "${target_ref}" + fi fi } +echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad float origin/main unless RS_*_REF set)..." ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ - '9928abed269a83ec5a7ef165ff1142d938cad706' 'rsReticulum' + "${RS_RETICULUM_REF}" 'rsReticulum' -"${SCRIPT_DIR}/apply-rsReticulum-packet-tap.sh" -"${SCRIPT_DIR}/apply-rsReticulum-auto-beacon-utun.sh" -"${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" -"${SCRIPT_DIR}/apply-rsReticulum-link-client-proof-budget.sh" -"${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" -"${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" -"${SCRIPT_DIR}/apply-rsReticulum-path-medium-slots.sh" +apply_ratspeak_rns_overlays "${SCRIPT_DIR}" ensure_repo "${LXMF_DIR}" 'https://github.com/ratspeak/rsLXMF.git' \ - '68ad7c835187c052c763bb28c41b04a655f35c64' 'rsLXMF' + "${RS_LXMF_REF}" 'rsLXMF' -"${SCRIPT_DIR}/apply-rsLXMF-propagation-sync-peering.sh" -"${SCRIPT_DIR}/apply-rsLXMF-propagation-node-policy-setters.sh" -"${SCRIPT_DIR}/apply-rsLXMF-link-delivery-has-pending-to.sh" +apply_ratspeak_lxmf_overlays "${SCRIPT_DIR}" -# Pin rsNomad so CI/release do not float on an unreviewed main tip. -# Override with RS_NOMAD_REF=... or skip with RS_NOMAD_SKIP_PIN=1 (local hardening work). -RS_NOMAD_REF="${RS_NOMAD_REF:-6e3b288fbc6931b1e2633d986cf0d49608d578b7}" +# Float Colorado-Mesh/rsNomad to origin/main (override via RS_NOMAD_REF above). +ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' "${RS_NOMAD_REF}" 'rsNomad' -if [[ "${RS_NOMAD_SKIP_PIN:-}" != "1" ]]; then - ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' "${RS_NOMAD_REF}" 'rsNomad' -else - ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' '' 'rsNomad' +rns_mode='floated origin/main' +lxmf_mode='floated origin/main' +nomad_mode='floated origin/main' +if [[ -n "${RS_RETICULUM_REF}" ]]; then + rns_mode="pinned ${RS_RETICULUM_REF:0:12}" +fi +if [[ -n "${RS_LXMF_REF}" ]]; then + lxmf_mode="pinned ${RS_LXMF_REF:0:12}" +fi +if [[ -n "${RS_NOMAD_REF}" ]]; then + nomad_mode="pinned ${RS_NOMAD_REF:0:12}" fi -echo "Ratspeak stack ready: rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD), rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD), rsNomad @ $(git -C "${NOMAD_DIR}" rev-parse --short HEAD)" +rns_sha="$(git -C "${RNS_DIR}" rev-parse HEAD)" +lxmf_sha="$(git -C "${LXMF_DIR}" rev-parse HEAD)" +nomad_sha="$(git -C "${NOMAD_DIR}" rev-parse HEAD)" +echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode})" +echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha}" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs new file mode 100644 index 000000000..5d15a000a --- /dev/null +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const cloneScriptPath = fileURLToPath(new URL('./clone-ratspeak-stack.sh', import.meta.url)); +const cloneScript = readFileSync(cloneScriptPath, 'utf8'); + +describe('clone-ratspeak-stack.sh float policy', () => { + it('floats rsReticulum and rsLXMF to origin/main by default', () => { + expect(cloneScript).toContain("target_ref='origin/main'"); + expect(cloneScript).toContain('checkout --quiet --detach'); + expect(cloneScript).toMatch(/RS_RETICULUM_REF="\$\{RS_RETICULUM_REF:-\}"/); + expect(cloneScript).toMatch(/RS_LXMF_REF="\$\{RS_LXMF_REF:-\}"/); + expect(cloneScript).toContain('export RS_RETICULUM_DIR='); + expect(cloneScript).toContain('export RS_LXMF_DIR='); + expect(cloneScript).toContain('refuse to float/pin'); + expect(cloneScript).toContain('already at'); + expect(cloneScript).toContain('skipping checkout'); + expect(cloneScript).toContain('origin/${ref_or_empty}'); + expect(cloneScript).toContain('Ratspeak stack SHAs (full)'); + expect(cloneScript).toContain('ratspeak-overlay-apply-list.sh'); + expect(cloneScript).not.toMatch(/9928abed269a83ec5a7ef165ff1142d938cad706/); + expect(cloneScript).not.toMatch(/68ad7c835187c052c763bb28c41b04a655f35c64/); + }); + + it('floats rsNomad to origin/main with optional RS_NOMAD_REF pin', () => { + expect(cloneScript).toMatch(/RS_NOMAD_REF="\$\{RS_NOMAD_REF:-\}"/); + expect(cloneScript).toContain('Colorado-Mesh/rsNomad.git'); + expect(cloneScript).toContain("nomad_mode='floated origin/main'"); + expect(cloneScript).not.toContain('6e3b288fbc6931b1e2633d986cf0d49608d578b7'); + expect(cloneScript).not.toContain('RS_NOMAD_SKIP_PIN'); + }); + + it('applies rsReticulum and rsLXMF overlays after checkout via shared list', () => { + expect(cloneScript).toContain('apply_ratspeak_rns_overlays'); + expect(cloneScript).toContain('apply_ratspeak_lxmf_overlays'); + const rnsEnsure = cloneScript.indexOf('ensure_repo "${RNS_DIR}"'); + const rnsApply = cloneScript.indexOf('apply_ratspeak_rns_overlays'); + const lxmfEnsure = cloneScript.indexOf('ensure_repo "${LXMF_DIR}"'); + const lxmfApply = cloneScript.indexOf('apply_ratspeak_lxmf_overlays'); + expect(rnsEnsure).toBeGreaterThanOrEqual(0); + expect(rnsApply).toBeGreaterThan(rnsEnsure); + expect(lxmfEnsure).toBeGreaterThan(rnsApply); + expect(lxmfApply).toBeGreaterThan(lxmfEnsure); + + const listPath = fileURLToPath( + new URL('./lib/ratspeak-overlay-apply-list.sh', import.meta.url), + ); + const listScript = readFileSync(listPath, 'utf8'); + expect(listScript).toContain('apply-rsReticulum-packet-tap.sh'); + expect(listScript).toContain('apply-rsLXMF-propagation-node-policy-setters.sh'); + }); +}); diff --git a/scripts/ensure-rsReticulum-patches.sh b/scripts/ensure-rsReticulum-patches.sh index 26248d86c..19b80748e 100755 --- a/scripts/ensure-rsReticulum-patches.sh +++ b/scripts/ensure-rsReticulum-patches.sh @@ -4,6 +4,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/ratspeak-overlay-apply-list.sh +source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" @@ -12,19 +14,11 @@ if [[ ! -d "${RNS_DIR}/.git" ]]; then exit 0 fi -"${SCRIPT_DIR}/apply-rsReticulum-packet-tap.sh" -"${SCRIPT_DIR}/apply-rsReticulum-auto-beacon-utun.sh" -"${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" -"${SCRIPT_DIR}/apply-rsReticulum-link-client-proof-budget.sh" -"${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" -"${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" -"${SCRIPT_DIR}/apply-rsReticulum-path-medium-slots.sh" +apply_ratspeak_rns_overlays "${SCRIPT_DIR}" if [[ ! -d "${LXMF_DIR}/.git" ]]; then echo "rsLXMF not found at ${LXMF_DIR}; skipping lxmf overlay apply" exit 0 fi -"${SCRIPT_DIR}/apply-rsLXMF-propagation-sync-peering.sh" -"${SCRIPT_DIR}/apply-rsLXMF-propagation-node-policy-setters.sh" -"${SCRIPT_DIR}/apply-rsLXMF-link-delivery-has-pending-to.sh" +apply_ratspeak_lxmf_overlays "${SCRIPT_DIR}" diff --git a/scripts/lib/apply-ratspeak-overlay.sh b/scripts/lib/apply-ratspeak-overlay.sh new file mode 100644 index 000000000..a2419edbf --- /dev/null +++ b/scripts/lib/apply-ratspeak-overlay.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Shared fail-loud overlay apply helper (keeps git apply stderr on failure). +# shellcheck shell=bash + +# apply_ratspeak_overlay_or_die REPO_DIR PATCH_FILE ERROR_LABEL +# ERROR_LABEL is a short name used in the error line (e.g. "packet-tap"). +apply_ratspeak_overlay_or_die() { + local repo_dir="$1" + local patch_file="$2" + local error_label="$3" + local check_out='' apply_out='' + local short_head + short_head="$(git -C "${repo_dir}" rev-parse --short HEAD 2> /dev/null || echo '?')" + + if ! check_out="$(git -C "${repo_dir}" apply --check "${patch_file}" 2>&1)"; then + echo "error: ${error_label} patch did not apply on $(basename "${repo_dir}") @ ${short_head}" >&2 + if [[ -n "${check_out}" ]]; then + printf '%s\n' "${check_out}" >&2 + fi + echo "error: regenerate overlay (reticulum-sidecar/patches/README.md) or set RS_*_REF to a compatible commit and re-run clone-ratspeak-stack.sh" >&2 + return 1 + fi + if ! apply_out="$(git -C "${repo_dir}" apply "${patch_file}" 2>&1)"; then + echo "error: ${error_label} patch apply failed on $(basename "${repo_dir}") @ ${short_head}" >&2 + if [[ -n "${apply_out}" ]]; then + printf '%s\n' "${apply_out}" >&2 + fi + echo "error: regenerate overlay (reticulum-sidecar/patches/README.md) or set RS_*_REF to a compatible commit and re-run clone-ratspeak-stack.sh" >&2 + return 1 + fi + echo "applied ${patch_file} on $(basename "${repo_dir}") @ ${short_head}" + return 0 +} diff --git a/scripts/lib/ratspeak-overlay-apply-list.sh b/scripts/lib/ratspeak-overlay-apply-list.sh new file mode 100644 index 000000000..d51977dd5 --- /dev/null +++ b/scripts/lib/ratspeak-overlay-apply-list.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Single source of apply-script order for clone-ratspeak-stack / ensure-rsReticulum-patches. +# Keep patch basenames in sync with RATSPEAK_PATCH_ENTRIES in scripts/update.sh. +# shellcheck shell=bash + +RS_RETICULUM_APPLY_SCRIPTS=( + apply-rsReticulum-packet-tap.sh + apply-rsReticulum-auto-beacon-utun.sh + apply-rsReticulum-link-client-nomad.sh + apply-rsReticulum-link-client-proof-budget.sh + apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh + apply-rsReticulum-discovery-announce-egress.sh + apply-rsReticulum-path-medium-slots.sh +) + +RS_LXMF_APPLY_SCRIPTS=( + apply-rsLXMF-propagation-sync-peering.sh + apply-rsLXMF-propagation-node-policy-setters.sh + apply-rsLXMF-link-delivery-has-pending-to.sh +) + +apply_ratspeak_rns_overlays() { + local script_dir="$1" + local s + for s in "${RS_RETICULUM_APPLY_SCRIPTS[@]}"; do + "${script_dir}/${s}" + done +} + +apply_ratspeak_lxmf_overlays() { + local script_dir="$1" + local s + for s in "${RS_LXMF_APPLY_SCRIPTS[@]}"; do + "${script_dir}/${s}" + done +} diff --git a/scripts/lib/ratspeak-overlay-apply-list.test.mjs b/scripts/lib/ratspeak-overlay-apply-list.test.mjs new file mode 100644 index 000000000..f88ae85b9 --- /dev/null +++ b/scripts/lib/ratspeak-overlay-apply-list.test.mjs @@ -0,0 +1,90 @@ +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const listPath = fileURLToPath(new URL('./ratspeak-overlay-apply-list.sh', import.meta.url)); +const helperPath = fileURLToPath(new URL('./apply-ratspeak-overlay.sh', import.meta.url)); +const updatePath = fileURLToPath(new URL('../update.sh', import.meta.url)); + +/** @type {string[]} */ +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // catch-no-log-ok best-effort temp cleanup + } + } +}); + +describe('ratspeak overlay apply list', () => { + it('lists every apply-rs script used by clone/ensure', () => { + const list = readFileSync(listPath, 'utf8'); + expect(list).toContain('RS_RETICULUM_APPLY_SCRIPTS'); + expect(list).toContain('RS_LXMF_APPLY_SCRIPTS'); + expect(list).toContain('apply_ratspeak_rns_overlays'); + expect(list).toContain('apply-rsReticulum-path-medium-slots.sh'); + expect(list).toContain('apply-rsLXMF-link-delivery-has-pending-to.sh'); + }); + + it('keeps apply helper fail-loud with stderr capture', () => { + const helper = readFileSync(helperPath, 'utf8'); + expect(helper).toContain('apply_ratspeak_overlay_or_die'); + expect(helper).toContain('apply --check'); + // Must not swallow git-apply diagnostics (rev-parse may still redirect). + expect(helper).not.toMatch(/git -C .* apply .*2>\s*\/dev\/null/); + }); + + it('patch basenames in update.sh match apply-list overlays', () => { + const list = readFileSync(listPath, 'utf8'); + const update = readFileSync(updatePath, 'utf8'); + const applyNames = [...list.matchAll(/apply-(rs(?:Reticulum|LXMF)-[a-z0-9-]+)\.sh/g)].map( + (m) => `${m[1]}.patch`, + ); + expect(applyNames.length).toBeGreaterThanOrEqual(10); + for (const patch of applyNames) { + expect(update).toContain(patch); + } + }); + + it('stops rns overlay apply on first failure without invoking later stubs', () => { + const work = mkdtempSync(path.join(os.tmpdir(), 'mesh-overlay-apply-')); + tempDirs.push(work); + const logPath = path.join(work, 'invocations.log'); + const list = readFileSync(listPath, 'utf8'); + const names = [...list.matchAll(/apply-rsReticulum-[a-z0-9-]+\.sh/g)].map((m) => m[0]); + expect(names.length).toBeGreaterThanOrEqual(3); + const failAt = 1; + for (let i = 0; i < names.length; i++) { + const scriptPath = path.join(work, names[i]); + const exitCode = i === failAt ? 1 : 0; + writeFileSync( + scriptPath, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$(basename "$0")" >> ${JSON.stringify(logPath)} +exit ${exitCode} +`, + 'utf8', + ); + chmodSync(scriptPath, 0o755); + } + const result = spawnSync( + 'bash', + [ + '-c', + `set -euo pipefail; source ${JSON.stringify(listPath)}; apply_ratspeak_rns_overlays ${JSON.stringify(work)}`, + ], + { encoding: 'utf8' }, + ); + expect(result.status).not.toBe(0); + const invoked = readFileSync(logPath, 'utf8').trim().split('\n'); + expect(invoked).toEqual(names.slice(0, failAt + 1)); + }); +}); diff --git a/scripts/update.sh b/scripts/update.sh index 7b22ecea4..e00a63795 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -203,7 +203,8 @@ process.stdin.on("end", () => { } # Warn when local Ratspeak overlays may be obsolete after upstream merges. -# Keep in sync with reticulum-sidecar/patches/*.patch and patches/README.md. +# Keep patch basenames in sync with scripts/lib/ratspeak-overlay-apply-list.sh +# and reticulum-sidecar/patches/*.patch / patches/README.md. check_ratspeak_patches() { # Format: "patch-basename|github-owner/repo|pr-number-or-empty|display-label|review-url" local RATSPEAK_PATCH_ENTRIES=( @@ -320,6 +321,220 @@ check_ratspeak_patches() { fi } +# GET GitHub API path (gh preferred, curl fallback). Body on stdout. +# Exit 0 = body (may be empty), exit 2 = rate-limit payload detected (empty body). +# Callers must handle exit 2 in the parent shell (command substitution drops side effects). +github_api_get() { + local api_path="$1" + local body='' + if command -v gh > /dev/null 2>&1; then + body="$(gh api "${api_path}" 2> /dev/null || true)" + elif command -v curl > /dev/null 2>&1; then + # Do not use curl -f: rate-limit JSON lives on non-2xx and must be inspectable. + local resp + resp="$( + curl -sSL \ + -H 'Accept: application/vnd.github+json' \ + -H 'User-Agent: mesh-client-update' \ + -w $'\n%{http_code}' \ + "https://api.github.com/${api_path}" 2> /dev/null || true + )" + # Strip trailing HTTP status line written by -w; keep error JSON body for detection. + body="${resp%$'\n'*}" + else + printf '' + return 0 + fi + if [[ -n "${body}" ]] && printf '%s' "${body}" | grep -qiE 'rate limit exceeded|API rate limit|secondary rate limit'; then + printf '' + return 2 + fi + printf '%s' "${body}" + return 0 +} + +warn_github_api_rate_limit_once() { + if [[ "${GITHUB_API_RATE_LIMIT_WARNED:-0}" != '1' ]]; then + echo -e " ${YELLOW}GitHub API rate limit:${NC} further Ratspeak upstream checks may be incomplete (retry later or use authenticated gh)." + GITHUB_API_RATE_LIMIT_WARNED=1 + HAS_WARNING=1 + fi +} + +# Latest release summary: "tag|published_at|first_body_line" or empty. +github_latest_release_summary() { + local repo="$1" + local json='' + local api_rc=0 + json="$(github_api_get "repos/${repo}/releases/latest")" || api_rc=$? + if [ "${api_rc}" -eq 2 ]; then + warn_github_api_rate_limit_once + echo '' + return 0 + fi + if [ -z "${json}" ]; then + echo '' + return 0 + fi + printf '%s' "${json}" | node -e ' +let s = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => { s += c; }); +process.stdin.on("end", () => { + try { + const j = JSON.parse(s); + if (j.message === "Not Found" || (!j.tag_name && !j.name)) { + process.stdout.write(""); + return; + } + const tag = String(j.tag_name || j.name || "").replace(/\|/g, "/"); + const published = String(j.published_at || "").slice(0, 10); + const body = String(j.body || "").split(/\r?\n/).find((l) => l.trim()) || ""; + const first = body + .replace(/[\u0000-\u001F\u007F]/g, "") + .replace(/\|/g, "/") + .slice(0, 120); + process.stdout.write(`${tag}|${published}|${first}`); + } catch { + process.stdout.write(""); + } +}); +' 2> /dev/null || echo '' +} + +# Curated release watch + known org repos (keep in sync when adopting new ratspeak libs). +# Format: "owner/repo|stub-kind-or-empty|display-label" +# stub-kind: voice|games → warn while mesh-client still has sidecar stubs only. +RATSPEAK_RELEASE_WATCH_ENTRIES=( + 'ratspeak/rsLXST|voice|rsLXST voice (sidecar stub)' + 'ratspeak/lrgp-rs|games|lrgp-rs games (sidecar stub)' + 'ratspeak/Ratspeak||Ratspeak client (reference)' + 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)' +) + +RATSPEAK_KNOWN_ORG_REPOS=( + '.github' + 'C6-Reticulum-ASM' + 'LXMFace' + 'Ratspeak' + 'lrgp-py' + 'lrgp-rs' + 'microReticulum' + 'ratkey' + 'rathole' + 'ratspeak-docs' + 'ratspeak-website' + 'revanity-go' + 'rsCardputer' + 'rsDeck' + 'rsLXMF' + 'rsLXST' + 'rsPager' + 'rsReticulum' +) + +print_ratspeak_upstream_catalog() { + local entry + echo 'RATSPEAK_RELEASE_WATCH_ENTRIES:' + for entry in "${RATSPEAK_RELEASE_WATCH_ENTRIES[@]}"; do + echo " ${entry}" + done + echo 'RATSPEAK_KNOWN_ORG_REPOS:' + for entry in "${RATSPEAK_KNOWN_ORG_REPOS[@]}"; do + echo " ${entry}" + done +} + +# Surface new Ratspeak library releases and brand-new org repos (stack floats via clone). +check_ratspeak_upstream() { + local has_upstream_warning=0 + local entry repo stub label summary tag published first url + + echo '' + echo 'Checking Ratspeak upstream releases and new org repos...' + echo ' (rsReticulum/rsLXMF float to origin/main via clone-ratspeak-stack.sh)' + + for entry in "${RATSPEAK_RELEASE_WATCH_ENTRIES[@]}"; do + IFS='|' read -r repo stub label <<< "${entry}" + url="https://github.com/${repo}/releases" + summary="$(github_latest_release_summary "${repo}")" + if [ -z "${summary}" ]; then + echo " ${label}: no GitHub release (or query failed) — ${url}" + continue + fi + IFS='|' read -r tag published first <<< "${summary}" + echo " ${label}: ${tag} (${published}) — ${first}" + if [ "${stub}" = 'voice' ] || [ "${stub}" = 'games' ]; then + warn_box "${label}" "sidecar stub" "${tag} available" "${url}" + echo " Reason tracked: mesh-client still stubs this feature; review integrating ${repo} @ ${tag}" + has_upstream_warning=1 + HAS_WARNING=1 + fi + done + + local repos_json='' + local repos_rc=0 + repos_json="$(github_api_get 'orgs/ratspeak/repos?per_page=100&sort=created&direction=desc')" || repos_rc=$? + if [ "${repos_rc}" -eq 2 ]; then + warn_github_api_rate_limit_once + fi + if [ -z "${repos_json}" ]; then + echo ' Could not list ratspeak org repos (install gh or check network) — skip new-repo scan.' + else + local new_repos + new_repos="$( + printf '%s' "${repos_json}" | node -e ' +const known = new Set(process.argv.slice(2)); +let s = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => { s += c; }); +process.stdin.on("end", () => { + try { + const repos = JSON.parse(s); + if (!Array.isArray(repos)) return; + const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; + for (const r of repos) { + const name = String(r.name || ""); + const created = Date.parse(r.created_at || ""); + if (!name || !Number.isFinite(created) || created < cutoff) continue; + if (known.has(name)) continue; + process.stdout.write(`${name}\t${String(r.created_at || "").slice(0, 10)}\t${r.html_url || ""}\n`); + } + } catch { + // ignore parse errors + } +}); +' "${RATSPEAK_KNOWN_ORG_REPOS[@]}" + )" + if [ -n "${new_repos}" ]; then + while IFS=$'\t' read -r name created url; do + [ -n "${name}" ] || continue + warn_box "ratspeak/${name} (new org repo)" "unknown" "created ${created}" "${url}" + echo " Reason tracked: created within ~90 days and not in RATSPEAK_KNOWN_ORG_REPOS — review for mesh-client use" + has_upstream_warning=1 + HAS_WARNING=1 + done <<< "${new_repos}" + else + echo ' No unfamiliar ratspeak org repos created in the last ~90 days.' + fi + fi + + if [ "${has_upstream_warning}" -eq 0 ]; then + echo ' Ratspeak upstream watch complete (no stub/new-repo warnings).' + fi +} + +if [ "${UPDATE_SH_TEST_HOOK:-}" = 'upstream-catalog-only' ]; then + print_ratspeak_upstream_catalog + exit 0 +fi + +# Test hook: exercise check_ratspeak_upstream (fake gh/curl via PATH). +if [ "${UPDATE_SH_TEST_HOOK:-}" = 'upstream-check-only' ]; then + check_ratspeak_upstream + exit 0 +fi + # --- Guard: must be project root --- if [ ! -f "${LOCKFILE}" ]; then echo "Error: ${LOCKFILE} not found. Run this script from the project root." >&2 @@ -411,6 +626,7 @@ for i in "${!KEYS[@]}"; do done check_ratspeak_patches +check_ratspeak_upstream if [ "${HAS_WARNING}" -eq 0 ]; then echo 'No updates to watched packages — safe to proceed.' diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index 02173cbe8..f1c5cf352 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -93,6 +93,133 @@ describe('update.sh Reticulum stack functionality check', () => { expect(result.stderr).toContain('Usage: scripts/update.sh [--clean-target]'); }); + it('prints Ratspeak upstream catalog (upstream-catalog-only)', () => { + const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); + expect(result.stdout).toContain('ratspeak/rsLXST|voice|'); + expect(result.stdout).toContain('ratspeak/lrgp-rs|games|'); + expect(result.stdout).toContain('ratspeak/Ratspeak||'); + expect(result.stdout).toContain('ratspeak/LXMFace||'); + expect(result.stdout).toContain('RATSPEAK_KNOWN_ORG_REPOS:'); + expect(result.stdout).toContain(' rsReticulum'); + expect(result.stdout).toContain(' rsLXMF'); + expect(result.stdout).toContain(' rsLXST'); + expect(result.stdout).toContain(' lrgp-rs'); + }); + + it('wires check_ratspeak_upstream after overlay PR checks', () => { + expect(updateScript).toContain('check_ratspeak_upstream()'); + expect(updateScript).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES'); + expect(updateScript).toContain('RATSPEAK_KNOWN_ORG_REPOS'); + expect(updateScript).toContain('warn_github_api_rate_limit_once'); + expect(updateScript).toContain('return 2'); + expect(updateScript).toContain('\\u0000-\\u001F\\u007F'); + const patchesCall = updateScript.lastIndexOf('\ncheck_ratspeak_patches\n'); + const upstreamCall = updateScript.lastIndexOf('\ncheck_ratspeak_upstream\n'); + expect(patchesCall).toBeGreaterThanOrEqual(0); + expect(upstreamCall).toBeGreaterThan(patchesCall); + }); + + /** + * @param {'release' | 'rate-limit' | 'malformed' | 'missing'} mode + */ + function prepareUpstreamGhFixture(mode) { + const work = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-upstream-')); + tempDirs.push(work); + const binDir = path.join(work, 'bin'); + mkdirSync(binDir, { recursive: true }); + const releasePath = path.join(work, 'release.json'); + const reposPath = path.join(work, 'repos.json'); + if (mode === 'release') { + writeFileSync( + releasePath, + JSON.stringify({ + tag_name: 'v9.9.9', + published_at: '2026-08-01T00:00:00Z', + body: 'First line\nSecond', + }), + ); + writeFileSync(reposPath, '[]'); + } else if (mode === 'rate-limit') { + writeFileSync(releasePath, JSON.stringify({ message: 'API rate limit exceeded for ...' })); + writeFileSync(reposPath, JSON.stringify({ message: 'API rate limit exceeded' })); + } else if (mode === 'malformed') { + writeFileSync(releasePath, '{not-json'); + writeFileSync(reposPath, '[]'); + } else { + writeFileSync(releasePath, JSON.stringify({ message: 'Not Found' })); + writeFileSync(reposPath, '[]'); + } + const ghPath = path.join(binDir, 'gh'); + writeFileSync( + ghPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" != "api" ]]; then + echo "unexpected gh args: $*" >&2 + exit 1 +fi +path="\${2:-}" +if [[ "$path" == repos/*/releases/latest ]]; then + cat ${JSON.stringify(releasePath)} + exit 0 +fi +if [[ "$path" == orgs/ratspeak/repos* ]]; then + cat ${JSON.stringify(reposPath)} + exit 0 +fi +printf '%s' '{}' +exit 0 +`, + 'utf8', + ); + chmodSync(ghPath, 0o755); + return { work, binDir }; + } + + it('upstream-check-only parses a valid release non-fatally', () => { + const fixture = prepareUpstreamGhFixture('release'); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('v9.9.9'); + expect(result.stdout).toContain('First line'); + expect(result.stdout).not.toContain('GitHub API rate limit:'); + }); + + it('upstream-check-only warns on rate-limit without failing', () => { + const fixture = prepareUpstreamGhFixture('rate-limit'); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('GitHub API rate limit:'); + }); + + it('upstream-check-only tolerates malformed repository JSON', () => { + const fixture = prepareUpstreamGhFixture('malformed'); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('no GitHub release (or query failed)'); + }); + + it('upstream-check-only tolerates missing releases', () => { + const fixture = prepareUpstreamGhFixture('missing'); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('no GitHub release (or query failed)'); + }); + it('runs cargo clean after a successful rebuild when CLEAN_SIDECAR_TARGET=1', () => { const fixture = prepareRebuildFixture({ buildExit: 0 }); const result = runUpdate( diff --git a/src/main/db-schema-sync.test.ts b/src/main/db-schema-sync.test.ts index 7f26e6452..eb179f343 100644 --- a/src/main/db-schema-sync.test.ts +++ b/src/main/db-schema-sync.test.ts @@ -282,6 +282,42 @@ describe('runSchemaUpgrade', { timeout: 30_000 }, () => { db.close(); }); + it('v48 backfill promotes last_heard rows to is_contact when upgrading from <48', () => { + dir = mkdtempSync(join(tmpdir(), 'mesh-schema-v48-backfill-')); + const db = new NodeSqliteDB(join(dir, 'test.db')); + db.pragma('journal_mode = WAL'); + runSchemaUpgrade(db); + + const hash = 'ab'.repeat(16); + db.prepareOnce( + `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, is_contact, favorited) + VALUES (?, 'Legacy', 1700000000, 0, 0)`, + ).run(hash); + db.pragma('user_version = 47'); + + runSchemaUpgrade(db); + + const row = db + .prepareOnce( + 'SELECT is_contact, last_heard FROM reticulum_destinations WHERE destination_hash = ?', + ) + .get(hash) as { is_contact: number; last_heard: number }; + expect(row.is_contact).toBe(1); + expect(row.last_heard).toBe(1700000000); + expect(db.pragma('user_version', { simple: true })).toBe(CURRENT_SCHEMA_VERSION); + + // Already at v48: clearing is_contact must not be re-promoted on startup. + db.prepareOnce( + `UPDATE reticulum_destinations SET is_contact = 0 WHERE destination_hash = ?`, + ).run(hash); + runSchemaUpgrade(db); + const after = db + .prepareOnce('SELECT is_contact FROM reticulum_destinations WHERE destination_hash = ?') + .get(hash) as { is_contact: number }; + expect(after.is_contact).toBe(0); + db.close(); + }); + it('rejects database newer than CURRENT_SCHEMA_VERSION without mutating schema', () => { dir = mkdtempSync(join(tmpdir(), 'mesh-schema-too-new-')); const db = new NodeSqliteDB(join(dir, 'too-new.db')); diff --git a/src/main/db-schema-sync.ts b/src/main/db-schema-sync.ts index d71fe012d..d058d159a 100644 --- a/src/main/db-schema-sync.ts +++ b/src/main/db-schema-sync.ts @@ -19,7 +19,7 @@ import { sanitizeLogMessage } from './log-service'; import { ensureMessageFtsTables } from './messageFts'; /** Bumped when ensureSchema behavior changes in a non-idempotent way (rare). */ -export const CURRENT_SCHEMA_VERSION = 47; +export const CURRENT_SCHEMA_VERSION = 48; /** Thrown when on-disk `user_version` exceeds this build's {@link CURRENT_SCHEMA_VERSION}. */ export class DatabaseSchemaTooNewError extends Error { @@ -195,6 +195,7 @@ export const CANONICAL_TABLES_DDL = ` display_name TEXT, last_heard INTEGER, favorited INTEGER DEFAULT 0, + is_contact INTEGER DEFAULT 0, icon_name TEXT, icon_color TEXT, verified INTEGER DEFAULT 0, @@ -502,6 +503,7 @@ export const DESIRED_COLUMNS: Readonly ); }); + it('db:listMeshtasticDmPeers and db:listMeshcoreDmPeers assert IPC sender', () => { + expect(INDEX_SOURCE).toMatch( + /ipcMain\.handle\('db:listMeshtasticDmPeers'[\s\S]*?assertIpcSender\(event, 'db:listMeshtasticDmPeers'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /ipcMain\.handle\('db:listMeshcoreDmPeers'[\s\S]*?assertIpcSender\(event, 'db:listMeshcoreDmPeers'\)/, + ); + }); + it('http:preflight and http:connect validate IPC sender before executing', () => { expect(INDEX_SOURCE).toMatch( /ipcMain\.handle\('http:preflight'[\s\S]*?validateIpcSender\(event\)/, diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index 354db3cc7..c71e9ab8f 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -456,6 +456,8 @@ describe('privileged IPC sender validation (source contract)', () => { 'db:getMessageChannels', 'db:getNodeNote', 'db:getMeshcoreMessages', + 'db:listMeshtasticDmPeers', + 'db:listMeshcoreDmPeers', 'db:searchMessages', 'db:searchMeshcoreMessages', 'db:getMeshcoreContacts', @@ -649,6 +651,8 @@ describe('db mutator IPC sender validation (source contract, H3)', () => { const dbReadChannels = [ 'db:getNodeNote', 'db:getMeshcoreMessages', + 'db:listMeshtasticDmPeers', + 'db:listMeshcoreDmPeers', 'db:searchMessages', 'db:searchMeshcoreMessages', 'db:getMeshcoreContacts', diff --git a/src/main/index.ts b/src/main/index.ts index 52c5b333c..f2e480f11 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -117,6 +117,7 @@ import { formatBluetoothctlSpawnError, linuxWebBluetoothDeviceSelection, } from './linuxWebBluetoothDeviceSelection'; +import { listMeshcoreDmPeersFromDb, listMeshtasticDmPeersFromDb } from './listDmPeers'; import { clearLogFile, exportLogTo, @@ -657,6 +658,18 @@ function validateSaveMeshcoreMessage(msg: unknown): asserts msg is Record>> 0; + } + if (m.to_node != null) { + const rawTo = Number(m.to_node); + if (!Number.isFinite(rawTo)) + throw new Error('db:saveMeshcoreMessage: to_node must be a finite number'); + m.to_node = rawTo >>> 0; + } } function validateSaveMeshcoreContact(contact: unknown): asserts contact is Record< @@ -3839,6 +3852,30 @@ ipcMain.handle('db:getMessages', (event, channel?: number, limit = 200) => { } }); +ipcMain.handle('db:listMeshtasticDmPeers', (event, ownNodeId: unknown, limit?: unknown) => { + try { + assertIpcSender(event, 'db:listMeshtasticDmPeers'); + if (typeof ownNodeId !== 'number' || !Number.isFinite(ownNodeId)) return []; + const db = getDbForIpc('db:listMeshtasticDmPeers'); + if (!db) return []; + return listMeshtasticDmPeersFromDb(db, ownNodeId, limit); + } catch (err) { + return finishDbIpcReadHandler('db:listMeshtasticDmPeers', err, []); + } +}); + +ipcMain.handle('db:listMeshcoreDmPeers', (event, ownNodeId: unknown, limit?: unknown) => { + try { + assertIpcSender(event, 'db:listMeshcoreDmPeers'); + if (typeof ownNodeId !== 'number' || !Number.isFinite(ownNodeId)) return []; + const db = getDbForIpc('db:listMeshcoreDmPeers'); + if (!db) return []; + return listMeshcoreDmPeersFromDb(db, ownNodeId, limit); + } catch (err) { + return finishDbIpcReadHandler('db:listMeshcoreDmPeers', err, []); + } +}); + ipcMain.handle('db:saveNode', (event, node) => { if (!validateIpcSender(event)) throw new Error('db:saveNode: unauthorized sender'); try { @@ -5120,7 +5157,7 @@ ipcMain.handle('db:saveMeshcoreMessage', (event, message) => { const replyPreviewSender = typeof m.reply_preview_sender === 'string' ? m.reply_preview_sender.slice(0, 64) : null; const rowParams = { - sender_id: m.sender_id != null ? Number(m.sender_id) : null, + sender_id: m.sender_id != null ? Number(m.sender_id) >>> 0 : null, sender_name: typeof m.sender_name === 'string' ? m.sender_name : null, payload: m.payload as string, channel_idx: m.channel_idx != null ? Math.trunc(Number(m.channel_idx)) : 0, @@ -5129,7 +5166,7 @@ ipcMain.handle('db:saveMeshcoreMessage', (event, message) => { packet_id: m.packet_id != null ? Number(m.packet_id) : null, emoji: m.emoji != null ? (sanitizeUnicodeReactionScalar(m.emoji) ?? null) : null, reply_id: replyId, - to_node: m.to_node != null ? Number(m.to_node) : null, + to_node: m.to_node != null ? Number(m.to_node) >>> 0 : null, received_via, rx_packet_fingerprint: rxFp, reply_preview_text: replyPreviewText, diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index 8d2f45062..fea424c04 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -106,10 +106,14 @@ describe('reticulum-db-handlers SQL contracts', () => { expect(source).toContain('.replace(/[\\r\\n]+/g'); expect(source).toContain('canonicalizeReticulumDestinationHash(rawHash)'); expect(source).toContain('WHEN ? = 1 THEN excluded.favorited'); + expect(source).toContain('WHEN ? = 1 THEN excluded.is_contact'); // Age prune must use Unix-seconds cutoff (destinations store seconds, not ms). expect(source).toMatch( /deleteReticulumDestinationsByAge[\s\S]*?Math\.floor\(Date\.now\(\) \/ 1000\) - safeDays \* 86_400/, ); + expect(source).toMatch( + /deleteReticulumDestinationsByAge[\s\S]*?is_contact IS NULL OR is_contact = 0/, + ); }); }); @@ -368,6 +372,71 @@ describe('reticulum destination / activity prune IPC', () => { expect(row.icon_color).toBe('cyan'); }); + it('upsertReticulumDestination history stamp does not clear is_contact', () => { + const upsert = handlers.get('db:upsertReticulumDestination'); + const hash = 'cafebabedeadbeef0123456789abcdef'; + upsert?.(event, { + destination_hash: hash, + display_name: 'Saved', + is_contact: true, + last_heard: 1_700_000_000, + }); + upsert?.(event, { + destination_hash: hash, + last_heard: 1_700_000_100, + }); + const row = db! + .prepareOnce('SELECT * FROM reticulum_destinations WHERE destination_hash = ?') + .get(hash) as Record; + expect(row.is_contact).toBe(1); + expect(row.last_heard).toBe(1_700_000_100); + }); + + it('deleteReticulumDestinationsByAge keeps is_contact rows', () => { + const nowSec = Math.floor(Date.now() / 1000); + db! + .prepareOnce( + `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, favorited, is_contact) + VALUES (?, ?, ?, ?, ?)`, + ) + .run('ff'.repeat(16), 'Contact', nowSec - 40 * 86_400, 0, 1); + db! + .prepareOnce( + `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, favorited, is_contact) + VALUES (?, ?, ?, ?, ?)`, + ) + .run('ee'.repeat(16), 'History', nowSec - 40 * 86_400, 0, 0); + const result = handlers.get('db:deleteReticulumDestinationsByAge')?.(event, 30) as { + changes: number; + }; + expect(result.changes).toBe(1); + const remaining = db! + .prepareOnce('SELECT destination_hash FROM reticulum_destinations') + .all() as { destination_hash: string }[]; + expect(remaining.map((r) => r.destination_hash)).toEqual(['ff'.repeat(16)]); + }); + + it('clearReticulumContactDestinations clears is_contact and keeps last_heard', () => { + const upsert = handlers.get('db:upsertReticulumDestination'); + const hash = 'aabbccddeeff00112233445566778899'; + upsert?.(event, { + destination_hash: hash, + last_heard: 1_700_000_000, + is_contact: true, + }); + const result = handlers.get('db:clearReticulumContactDestinations')?.(event) as { + changes: number; + }; + expect(result.changes).toBe(1); + const row = db! + .prepareOnce( + 'SELECT is_contact, last_heard FROM reticulum_destinations WHERE destination_hash = ?', + ) + .get(hash) as { is_contact: number; last_heard: number }; + expect(row.is_contact).toBe(0); + expect(row.last_heard).toBe(1_700_000_000); + }); + it('upsertReticulumDestination rejects stripped/malformed hashes', () => { const upsert = handlers.get('db:upsertReticulumDestination'); expect(() => diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index 2a1a7515f..46cf087c3 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -396,9 +396,11 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): if (!db) return { changes: 0 }; const favoritedProvided = Object.prototype.hasOwnProperty.call(r, 'favorited'); const favoritedForInsert = r.favorited === true || r.favorited === 1 ? 1 : 0; + const isContactProvided = Object.prototype.hasOwnProperty.call(r, 'is_contact'); + const isContactForInsert = r.is_contact === true || r.is_contact === 1 ? 1 : 0; db.prepareOnce( - `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, favorited, icon_name, icon_color) - VALUES (?, ?, ?, ?, ?, ?) + `INSERT INTO reticulum_destinations (destination_hash, display_name, last_heard, favorited, is_contact, icon_name, icon_color) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(destination_hash) DO UPDATE SET display_name = CASE WHEN excluded.display_name IS NOT NULL @@ -412,6 +414,10 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): WHEN ? = 1 THEN excluded.favorited ELSE reticulum_destinations.favorited END, + is_contact = CASE + WHEN ? = 1 THEN excluded.is_contact + ELSE reticulum_destinations.is_contact + END, icon_name = COALESCE(excluded.icon_name, reticulum_destinations.icon_name), icon_color = COALESCE(excluded.icon_color, reticulum_destinations.icon_color)`, ).run( @@ -425,9 +431,11 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): ? Math.trunc(Number(r.last_heard)) : null, favoritedForInsert, + isContactForInsert, typeof r.icon_name === 'string' ? r.icon_name.slice(0, 64) : null, typeof r.icon_color === 'string' ? r.icon_color.slice(0, 32) : null, favoritedProvided ? 1 : 0, + isContactProvided ? 1 : 0, ); return { changes: 1 }; } catch (err) { @@ -480,7 +488,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): } }); - /** Clear LXMF contact marker (last_heard); keeps display_name / favorite / icon peer meta. */ + /** Clear saved-contact flag; keeps History last_heard / display_name / favorite / icon. */ ipcMain.handle('db:clearReticulumContactDestinations', (event) => { try { assertIpcSender(event, 'db:clearReticulumContactDestinations'); @@ -488,7 +496,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): if (!db) return { changes: 0 }; const result = db .prepareOnce( - 'UPDATE reticulum_destinations SET last_heard = NULL WHERE last_heard IS NOT NULL', + 'UPDATE reticulum_destinations SET is_contact = 0 WHERE is_contact IS NOT NULL AND is_contact != 0', ) .run(); return { changes: result.changes ?? 0 }; @@ -537,7 +545,10 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): const deletable = ( db .prepareOnce( - 'SELECT COUNT(*) as cnt FROM reticulum_destinations WHERE (favorited IS NULL OR favorited = 0) AND last_heard IS NOT NULL', + `SELECT COUNT(*) as cnt FROM reticulum_destinations + WHERE (favorited IS NULL OR favorited = 0) + AND (is_contact IS NULL OR is_contact = 0) + AND last_heard IS NOT NULL`, ) .get() as { cnt: number } ).cnt; @@ -547,7 +558,9 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): .prepareOnce( `DELETE FROM reticulum_destinations WHERE destination_hash IN ( SELECT destination_hash FROM reticulum_destinations - WHERE (favorited IS NULL OR favorited = 0) AND last_heard IS NOT NULL + WHERE (favorited IS NULL OR favorited = 0) + AND (is_contact IS NULL OR is_contact = 0) + AND last_heard IS NOT NULL ORDER BY last_heard ASC LIMIT ? )`, ) @@ -569,13 +582,14 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): const db = getDbForIpc('db:deleteReticulumDestinationsByAge'); if (!db) return { changes: 0 }; const safeDays = typeof days === 'number' && days > 0 ? Math.floor(days) : 30; - // reticulum_destinations.last_heard is Unix seconds (see persistReticulumContactFromPayload). + // reticulum_destinations.last_heard is Unix seconds (history stamp / contact activity). const cutoff = Math.floor(Date.now() / 1000) - safeDays * 86_400; const result = db .prepareOnce( `DELETE FROM reticulum_destinations WHERE last_heard IS NOT NULL AND last_heard < ? - AND (favorited IS NULL OR favorited = 0)`, + AND (favorited IS NULL OR favorited = 0) + AND (is_contact IS NULL OR is_contact = 0)`, ) .run(cutoff); if (result.changes > 0) { diff --git a/src/main/listDmPeers.test.ts b/src/main/listDmPeers.test.ts new file mode 100644 index 000000000..7acd694a4 --- /dev/null +++ b/src/main/listDmPeers.test.ts @@ -0,0 +1,170 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { NodeSqliteDB } from './db-compat'; +import { runSchemaUpgrade } from './db-schema-sync'; +import { + clampDmPeerLimit, + listMeshcoreDmPeersFromDb, + listMeshtasticDmPeersFromDb, +} from './listDmPeers'; + +describe('listDmPeers', () => { + let dir: string | undefined; + let db: NodeSqliteDB | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mesh-dm-peers-')); + db = new NodeSqliteDB(join(dir, 'test.db')); + db.pragma('journal_mode = WAL'); + runSchemaUpgrade(db); + }); + + afterEach(() => { + db?.close(); + db = undefined; + if (dir) { + rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it('clampDmPeerLimit bounds values', () => { + expect(clampDmPeerLimit(undefined)).toBe(2000); + expect(clampDmPeerLimit(0)).toBe(1); + expect(clampDmPeerLimit(99999)).toBe(5000); + }); + + it('list helpers tolerate empty tables', () => { + expect(listMeshtasticDmPeersFromDb(db!, Number.NaN)).toEqual([]); + expect(listMeshcoreDmPeersFromDb(db!, 1)).toEqual([]); + }); + + it('listMeshtasticDmPeersFromDb returns distinct DM peers ordered by latest', () => { + const own = 1; + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'a', 'hi', 0, ?, ?)`, + ) + .run(2, 1000, own); + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'me', 'yo', 0, ?, ?)`, + ) + .run(own, 3000, 2); + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'me', 'bye', 0, ?, ?)`, + ) + .run(own, 2000, 4); + // channel broadcast — ignored + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'c', 'ch', 0, ?, NULL)`, + ) + .run(5, 9000); + // broadcast dest — ignored + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'me', 'all', 0, ?, ?)`, + ) + .run(own, 8000, 0xffffffff); + + const rows = listMeshtasticDmPeersFromDb(db!, own); + expect(rows.map((r) => r.node_id)).toEqual([2, 4]); + expect(rows[0]?.last_message_at).toBe(3000); + expect(rows[1]?.last_message_at).toBe(2000); + }); + + it('listMeshcoreDmPeersFromDb uses channel_idx = -1 only', () => { + const own = 1; + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'a', 'hi', -1, ?, ?)`, + ) + .run(9, 1000, own); + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'me', 'yo', -1, ?, ?)`, + ) + .run(own, 2000, 10); + // room channel — ignored + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'room', 'post', -2, ?, ?)`, + ) + .run(11, 9000, own); + + const rows = listMeshcoreDmPeersFromDb(db!, own); + expect(rows.map((r) => r.node_id).sort((a, b) => a - b)).toEqual([9, 10]); + }); + + it('listMeshtasticDmPeersFromDb excludes self and respects limit', () => { + const own = 1; + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'me', 'loop', 0, ?, ?)`, + ) + .run(own, 9000, own); + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'a', 'hi', 0, ?, ?)`, + ) + .run(2, 1000, own); + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'b', 'hi', 0, ?, ?)`, + ) + .run(3, 2000, own); + db! + .prepareOnce( + `INSERT INTO messages (sender_id, sender_name, payload, channel, timestamp, to_node) + VALUES (?, 'c', 'hi', 0, ?, ?)`, + ) + .run(4, 3000, own); + + const rows = listMeshtasticDmPeersFromDb(db!, own, 2); + expect(rows.map((r) => r.node_id)).toEqual([4, 3]); + expect(rows.every((r) => r.node_id !== own)).toBe(true); + }); + + it('listMeshcoreDmPeersFromDb includes null to_node inbound and orders by latest', () => { + const own = 1; + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'a', 'hi', -1, ?, NULL)`, + ) + .run(9, 1000); + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'b', 'hi', -1, ?, ?)`, + ) + .run(10, 3000, own); + db! + .prepareOnce( + `INSERT INTO meshcore_messages (sender_id, sender_name, payload, channel_idx, timestamp, to_node) + VALUES (?, 'me', 'loop', -1, ?, ?)`, + ) + .run(own, 9000, own); + + const rows = listMeshcoreDmPeersFromDb(db!, own); + expect(rows.map((r) => r.node_id)).toEqual([10, 9]); + expect(rows.every((r) => r.node_id !== own)).toBe(true); + }); +}); diff --git a/src/main/listDmPeers.ts b/src/main/listDmPeers.ts new file mode 100644 index 000000000..3b49515ea --- /dev/null +++ b/src/main/listDmPeers.ts @@ -0,0 +1,100 @@ +import type { DmPeerRow } from '../shared/electron-api.types'; +import { MESHTASTIC_BROADCAST_NODE_NUM } from '../shared/nodeNameUtils'; +import type { NodeSqliteDB } from './db-compat'; + +export type { DmPeerRow }; + +const DEFAULT_LIMIT = 2000; +const MAX_LIMIT = 5000; + +export function clampDmPeerLimit(limit: unknown): number { + if (typeof limit !== 'number' || !Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); +} + +/** + * Distinct Meshtastic DM peers for `ownNodeId` (sender or recipient of a directed message). + * Broadcast `to_node` is excluded. + */ +export function listMeshtasticDmPeersFromDb( + db: NodeSqliteDB, + ownNodeId: number, + limit: unknown = DEFAULT_LIMIT, +): DmPeerRow[] { + const own = ownNodeId >>> 0; + const safeLimit = clampDmPeerLimit(limit); + const broadcast = MESHTASTIC_BROADCAST_NODE_NUM >>> 0; + const rows = db + .prepare( + `SELECT peer_id AS node_id, MAX(timestamp) AS last_message_at + FROM ( + SELECT + CASE + WHEN sender_id = ? THEN to_node + ELSE sender_id + END AS peer_id, + timestamp + FROM messages + WHERE to_node IS NOT NULL + AND to_node != ? + AND (sender_id = ? OR to_node = ?) + ) + WHERE peer_id IS NOT NULL + AND peer_id != ? + AND peer_id != ? + GROUP BY peer_id + ORDER BY last_message_at DESC + LIMIT ?`, + ) + .all(own, broadcast, own, own, own, broadcast, safeLimit) as { + node_id: number; + last_message_at: number; + }[]; + return rows.map((r) => ({ + node_id: r.node_id >>> 0, + last_message_at: r.last_message_at || 0, + })); +} + +/** + * Distinct MeshCore DM peers (`channel_idx = -1`) for `ownNodeId`. + * Room-server posts (`channel_idx = -2`) are not included. + */ +export function listMeshcoreDmPeersFromDb( + db: NodeSqliteDB, + ownNodeId: number, + limit: unknown = DEFAULT_LIMIT, +): DmPeerRow[] { + const own = ownNodeId >>> 0; + const safeLimit = clampDmPeerLimit(limit); + const rows = db + .prepare( + `SELECT peer_id AS node_id, MAX(timestamp) AS last_message_at + FROM ( + SELECT + CASE + WHEN sender_id = ? AND to_node IS NOT NULL AND to_node != ? THEN to_node + WHEN to_node = ? AND sender_id IS NOT NULL AND sender_id != ? THEN sender_id + WHEN to_node IS NULL AND sender_id IS NOT NULL AND sender_id != ? THEN sender_id + ELSE NULL + END AS peer_id, + timestamp + FROM meshcore_messages + WHERE channel_idx = -1 + AND (sender_id = ? OR to_node = ? OR (to_node IS NULL AND sender_id IS NOT NULL)) + ) + WHERE peer_id IS NOT NULL + AND peer_id != ? + GROUP BY peer_id + ORDER BY last_message_at DESC + LIMIT ?`, + ) + .all(own, own, own, own, own, own, own, own, safeLimit) as { + node_id: number; + last_message_at: number; + }[]; + return rows.map((r) => ({ + node_id: r.node_id >>> 0, + last_message_at: r.last_message_at || 0, + })); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 58094a90f..95e1274e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,6 +45,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getMessages: (channel?: number, limit?: number) => ipcRenderer.invoke('db:getMessages', channel, limit), + listMeshtasticDmPeers: (ownNodeId: number, limit?: number) => + ipcRenderer.invoke('db:listMeshtasticDmPeers', ownNodeId, limit), saveNode: (node: MeshNode) => ipcRenderer.invoke('db:saveNode', node), @@ -116,6 +118,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getMeshcoreMessages: (channelIdx?: number, limit?: number) => ipcRenderer.invoke('db:getMeshcoreMessages', channelIdx, limit), + listMeshcoreDmPeers: (ownNodeId: number, limit?: number) => + ipcRenderer.invoke('db:listMeshcoreDmPeers', ownNodeId, limit), searchMessages: (query: string, limit?: number) => ipcRenderer.invoke('db:searchMessages', query, limit), searchMeshcoreMessages: (query: string, limit?: number) => diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 0571e5cc9..8fae2fb1b 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -10,6 +10,7 @@ import { getDistFromChatBottom, VIRTUALIZER_SCROLL_END_THRESHOLD } from '../lib/ import { messageRecordsToChatMessages } from '../lib/storeRecordAdapters'; import type { ChatMessage, MeshNode } from '../lib/types'; import type { MessageRecord } from '../stores/messageStore'; +import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; import ChatPanel from './ChatPanel'; import { ToastProvider } from './Toast'; @@ -4210,6 +4211,134 @@ describe('ChatPanel reticulum dm-only chat', () => { expect(onNodeClick).not.toHaveBeenCalled(); }); + it('shows LXMFace on DM tab and sender row when destination hash is known', () => { + const peerHash = 'a7b3c9d1e5f20681943ab2de77fc8e01'; + const peerId = parseInt(peerHash.slice(0, 12), 16) >>> 0; + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + reticulum_destination_hash: peerHash, + long_name: 'Face Peer', + short_name: 'FP', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + ]); + const messages: ChatMessage[] = [ + { + sender_id: peerId, + sender_name: 'Face Peer', + payload: 'with face', + channel: 0, + to: 1, + reticulum_sender_hash: peerHash, + timestamp: Date.now(), + status: 'acked', + }, + ]; + const { container } = render( + + + , + ); + const faceImgs = container.querySelectorAll('img[src^="data:image/svg+xml"]'); + expect(faceImgs.length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByRole('button', { name: 'Face Peer' }).length).toBeGreaterThanOrEqual(1); + expect( + screen.getByRole('button', { name: /Open peer details for Face Peer/i }), + ).toBeInTheDocument(); + }); + + it('opens peer detail from DM header Peer details control', async () => { + const user = userEvent.setup(); + const peerHash = 'a7b3c9d1e5f20681943ab2de77fc8e01'; + const peerId = parseInt(peerHash.slice(0, 12), 16) >>> 0; + const onPeerClick = vi.fn(); + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + reticulum_destination_hash: peerHash, + long_name: 'Detail Peer', + short_name: 'DP', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + ]); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: /Open peer details for Detail Peer/i })); + expect(onPeerClick).toHaveBeenCalledExactlyOnceWith(peerHash); + }); + + it('prefers custom Lucide appearance over LXMFace on DM tab', () => { + const peerHash = 'ffffffffffffffffffffffffffffffff'; + const peerId = parseInt(peerHash.slice(0, 12), 16) >>> 0; + useReticulumPeerStore.setState({ + peerAppearanceByHash: new Map([[peerHash, { icon_name: 'star', icon_color: 'cyan' }]]), + }); + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + reticulum_destination_hash: peerHash, + long_name: 'Star Peer', + short_name: 'SP', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + ]); + const { container } = render( + + + , + ); + const tabBtn = screen.getByRole('button', { name: 'Star Peer' }); + expect(tabBtn.querySelector('img')).toBeNull(); + expect(tabBtn.querySelector('svg')).toBeTruthy(); + expect(container.querySelector('img[src^="data:image/svg+xml"]')).toBeNull(); + }); + it('does not call onNodeClick or onPeerClick when Reticulum sender hash cannot be resolved', async () => { const user = userEvent.setup(); const peerId = 0xabcdef01; diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index e5d3bc490..15a50a5a4 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -51,13 +51,10 @@ import { formatReticulumViaBadgeLabel, parseReticulumViaAtoms, } from '@/renderer/lib/reticulum/classifyReticulumVia'; -import { - normalizeReticulumNodeId, - registerReticulumDestinationHash, - resolveReticulumDestinationHash, -} from '@/renderer/lib/reticulum/destHash'; +import { normalizeReticulumNodeId } from '@/renderer/lib/reticulum/destHash'; import { parseReticulumAttachmentPayload } from '@/renderer/lib/reticulum/parseReticulumAttachmentPayload'; import { reticulumMessageMatchesDmPeer } from '@/renderer/lib/reticulum/reticulumChatDmFilter'; +import { resolveReticulumDmFaceHash } from '@/renderer/lib/reticulum/reticulumChatFaceHash'; import { openReticulumDmFromHash, parseReticulumDestinationInput, @@ -71,7 +68,6 @@ import { isUnreasonablyFutureMessageTimestampMs, } from '@/shared/messageTimestampSkew'; import { formatMeshtasticNodeId, isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; -import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; import { CHAT_COMPACT_CONTINUATION_TIME_GAP_MS } from '@/shared/timeConstants'; import type { OutboxEntry } from '../../shared/electron-api.types'; @@ -79,6 +75,7 @@ import { isMeshcoreRoomChatMessage } from '../hooks/meshcore/meshcoreHookPreambl import { useChatOutbox } from '../hooks/useChatOutbox'; import { useNowMs } from '../hooks/useNowMs'; import { useReticulumDmPathProbe } from '../hooks/useReticulumDmPathProbe'; +import { chatDmPeerMessageCounts } from '../lib/chatDmPeerIndex'; import { playMessageNotification } from '../lib/chatNotifications'; import { dismissedDmTabsStorageKey, @@ -144,7 +141,7 @@ import { } from '../lib/storeRecordAdapters'; import type { ChatMessage, MeshNode, MeshProtocol } from '../lib/types'; import type { RequestStoreForwardHistoryResult } from '../runtime/useMeshtasticRuntime'; -import { reticulumHashForNodeId, useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ChatComposer, type ChatComposerSendOpts } from './ChatComposer'; import { ChatPayloadText } from './ChatPayloadText'; @@ -159,6 +156,7 @@ import { ReticulumDmPathReachabilityBadge, } from './ReticulumDmPathReachabilityBadge'; import { ReticulumMessageStatusBadge } from './ReticulumMessageStatusBadge'; +import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { ReticulumPropagationNotice } from './ReticulumPropagationNotice'; import { useToast } from './Toast'; @@ -830,15 +828,10 @@ function ChatPanel({ return groupChatReactionsByParentKey(filtered); }, [displayMessages, protocol]); - const inferredDmTabs = useMemo(() => { - const peers = new Map(); - for (const msg of regularMessages) { - const peer = resolveDmPeer(msg); - if (peer == null) continue; - peers.set(peer, (peers.get(peer) ?? 0) + 1); - } - return peers; - }, [regularMessages, resolveDmPeer]); + const inferredDmTabs = useMemo( + () => chatDmPeerMessageCounts(regularMessages, ownNodeIdSet, protocol, chatUnreadDmOptions), + [chatUnreadDmOptions, ownNodeIdSet, protocol, regularMessages], + ); /** Incoming DM messages per peer newer than persisted last-read for `dm:${peer}` (channel unread map skips DMs). */ const dmUnreadCounts = useMemo( @@ -1732,20 +1725,18 @@ function ChatPanel({ const reticulumDmDestinationHash = useMemo(() => { if (protocol !== 'reticulum' || activeDmNode == null) return null; - const fromNode = nodes.get(activeDmNode)?.reticulum_destination_hash?.trim(); - if (fromNode) { - registerReticulumDestinationHash(activeDmNode, fromNode); - return fromNode; - } - return ( - reticulumHashForNodeId(activeDmNode) ?? resolveReticulumDestinationHash(activeDmNode) ?? null + return resolveReticulumDmFaceHash( + activeDmNode, + nodes.get(activeDmNode)?.reticulum_destination_hash, ); }, [activeDmNode, nodes, protocol]); + const peerAppearanceByHash = useReticulumPeerStore((s) => s.peerAppearanceByHash); + const reticulumDmPeerHops = useReticulumPeerStore((s) => { if (!reticulumDmDestinationHash) return null; const key = reticulumDmDestinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); - const peer = s.contacts.get(key) ?? s.peers.get(key); + const peer = s.contacts.get(key) ?? s.history.get(key) ?? s.peers.get(key); return peer?.hops ?? null; }); @@ -1810,6 +1801,11 @@ function ChatPanel({ const dmUnread = dmUnreadCounts.get(nodeNum) ?? 0; const showDmUnreadBadge = dmUnread > 0 && !(viewMode === 'dm' && activeDmNode === nodeNum); + const faceHash = + protocol === 'reticulum' + ? resolveReticulumDmFaceHash(nodeNum, nodes.get(nodeNum)?.reticulum_destination_hash) + : null; + const appearance = faceHash ? peerAppearanceByHash.get(faceHash) : undefined; return (
- {getDmLabel(nodeNum)} + {protocol === 'reticulum' ? ( + + ) : null} + {getDmLabel(nodeNum)} + ) : null; + if (!pathBadge && !dmNode && !rncpControl && !peerDetailsControl) return null; return (
{pathBadge} {pathActions} + {peerDetailsControl} {dmNode ? : null} {rncpControl}
@@ -2492,86 +2521,101 @@ function ChatPanel({ }`} > {/* Header: sender name (clickable) + DM indicator + time */} - {!isContinuation && ( -
- - {!isOwn && ( - - )} - {isDm && ( - - DM - - )} - - {formatTime(msg.timestamp)} - - {channels.length > 1 && !isDm && ( - ch{msg.channel} - )} -
- )} + {!isContinuation && + (() => { + const senderFaceHash = + protocol === 'reticulum' + ? resolveReticulumDmFaceHash( + msg.sender_id, + msg.reticulum_sender_hash ?? + nodes.get(msg.sender_id)?.reticulum_destination_hash, + ) + : null; + const senderAppearance = senderFaceHash + ? peerAppearanceByHash.get(senderFaceHash) + : undefined; + return ( +
+ {senderFaceHash ? ( + + ) : null} + + {!isOwn && ( + + )} + {isDm && ( + + DM + + )} + + {formatTime(msg.timestamp)} + + {channels.length > 1 && !isDm && ( + + ch{msg.channel} + + )} +
+ ); + })()} {showContinuationTime && (
diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 442d0a0e9..e60acd70e 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -10,7 +10,9 @@ import { MESHTASTIC_CONTACT_GROUP_BUILTIN_GPS, MESHTASTIC_CONTACT_GROUP_BUILTIN_RF_MQTT, } from '../lib/meshtasticContactGroupUtils'; +import { OFFLINE_MESHTASTIC_IDENTITY_ID } from '../lib/offlineProtocolIdentities'; import type { MeshNode } from '../lib/types'; +import { addMessage, useMessageStore } from '../stores/messageStore'; import NodeListPanel from './NodeListPanel'; const HYBRID_MQTT_PATH_ARIA = 'RF and MQTT path'; @@ -795,3 +797,324 @@ describe('NodeListPanel virtualization', () => { expect(source).toMatch(/nodeList\.length\s*>\s*100/); }); }); + +describe('NodeListPanel History tab', () => { + beforeEach(() => { + useMessageStore.setState({ messages: {} }); + vi.mocked(window.electronAPI.db.listMeshtasticDmPeers).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.listMeshcoreDmPeers).mockResolvedValue([]); + }); + + it('has no axe violations on All and History list toggles', async () => { + const user = userEvent.setup(); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'OnlyNode', last_heard: Date.now() })], + ]); + const { container } = render( + , + ); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true'); + await user.click(screen.getByRole('button', { name: 'History' })); + expect(screen.getByRole('button', { name: 'History' })).toHaveAttribute('aria-pressed', 'true'); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('shows empty History state when there are no DMs', async () => { + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByText('OnlyNode')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'History' })); + expect( + screen.getByText('No direct messages yet — send or receive a DM to see peers here.'), + ).toBeInTheDocument(); + expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument(); + }); + + it('lists DM peers on History and keeps non-DM nodes on All', async () => { + const user = userEvent.setup(); + addMessage(OFFLINE_MESHTASTIC_IDENTITY_ID, { + id: 'dm-1', + from: 2, + senderName: 'Alice', + to: 1, + payload: 'hello', + channelIndex: 0, + timestamp: 1_700_000_000_000, + status: 'acked', + }); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'Me' })], + [2, makeNode({ node_id: 2, long_name: 'Alice' })], + [3, makeNode({ node_id: 3, long_name: 'NeverMessaged' })], + ]); + render( + , + ); + expect(screen.getByText('NeverMessaged')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + expect(screen.queryByText('NeverMessaged')).not.toBeInTheDocument(); + }); + + it('merges SQLite DM peers into History when not in the message window', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.db.listMeshtasticDmPeers).mockResolvedValue([ + { node_id: 9, last_message_at: 1_700_000_100_000 }, + ]); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'Me' })], + [9, makeNode({ node_id: 9, long_name: 'FromDb' })], + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('FromDb')).toBeInTheDocument(); + }); + }); + + it('excludes MeshCore Room nodes from History even if listed by SQLite', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.db.listMeshcoreDmPeers).mockResolvedValue([ + { node_id: 11, last_message_at: 1_700_000_100_000 }, + { node_id: 12, last_message_at: 1_700_000_200_000 }, + ]); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'Me', hw_model: 'Chat' })], + [11, makeNode({ node_id: 11, long_name: 'RoomServer', hw_model: 'Room' })], + [12, makeNode({ node_id: 12, long_name: 'DmPeer', hw_model: 'Chat' })], + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('DmPeer')).toBeInTheDocument(); + }); + expect(screen.queryByText('RoomServer')).not.toBeInTheDocument(); + }); + + it('shows a stub History row when the DM peer is missing from NodeDB', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.db.listMeshtasticDmPeers).mockResolvedValue([ + { node_id: 9, last_message_at: 1_700_000_100_000 }, + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + // Stub uses hex id as both Node ID cell and display name. + expect(screen.getAllByText('!00000009').length).toBeGreaterThanOrEqual(1); + }); + }); + + it('sorts History peers by latest DM activity descending', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.db.listMeshtasticDmPeers).mockResolvedValue([ + { node_id: 2, last_message_at: 1_000 }, + { node_id: 3, last_message_at: 3_000 }, + { node_id: 4, last_message_at: 2_000 }, + ]); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'Me' })], + [2, makeNode({ node_id: 2, long_name: 'Oldest' })], + [3, makeNode({ node_id: 3, long_name: 'Newest' })], + [4, makeNode({ node_id: 4, long_name: 'Middle' })], + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('Newest')).toBeInTheDocument(); + }); + const names = screen + .getAllByRole('row') + .slice(1) + .map((row) => row.textContent ?? ''); + const newestIdx = names.findIndex((t) => t.includes('Newest')); + const middleIdx = names.findIndex((t) => t.includes('Middle')); + const oldestIdx = names.findIndex((t) => t.includes('Oldest')); + expect(newestIdx).toBeGreaterThanOrEqual(0); + expect(newestIdx).toBeLessThan(middleIdx); + expect(middleIdx).toBeLessThan(oldestIdx); + }); + + it('filters History peers with search and restores All after switching back', async () => { + const user = userEvent.setup(); + addMessage(OFFLINE_MESHTASTIC_IDENTITY_ID, { + id: 'dm-search-1', + from: 2, + senderName: 'Alice', + to: 1, + payload: 'hello', + channelIndex: 0, + timestamp: 1_700_000_000_000, + status: 'acked', + }); + addMessage(OFFLINE_MESHTASTIC_IDENTITY_ID, { + id: 'dm-search-2', + from: 3, + senderName: 'Bob', + to: 1, + payload: 'hi', + channelIndex: 0, + timestamp: 1_700_000_100_000, + status: 'acked', + }); + const nodes = new Map([ + [1, makeNode({ node_id: 1, long_name: 'Me' })], + [2, makeNode({ node_id: 2, long_name: 'Alice' })], + [3, makeNode({ node_id: 3, long_name: 'Bob' })], + [4, makeNode({ node_id: 4, long_name: 'NeverMessaged' })], + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText('Search nodes'), { target: { value: 'bob' } }); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search nodes'), { target: { value: '' } }); + await user.click(screen.getByRole('button', { name: 'All' })); + expect(screen.getByText('NeverMessaged')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('does not apply distance filter on History (All-only)', async () => { + const user = userEvent.setup(); + addMessage(OFFLINE_MESHTASTIC_IDENTITY_ID, { + id: 'dm-far-1', + from: 2, + senderName: 'FarPeer', + to: 1, + payload: 'hello', + channelIndex: 0, + timestamp: 1_700_000_000_000, + status: 'acked', + }); + const nodes = new Map([ + [ + 1, + makeNode({ + node_id: 1, + long_name: 'Me', + latitude: 40, + longitude: -105, + }), + ], + [ + 2, + makeNode({ + node_id: 2, + long_name: 'FarPeer', + latitude: 0, + longitude: 0, + }), + ], + [ + 3, + makeNode({ + node_id: 3, + long_name: 'NearNoDm', + latitude: 40.01, + longitude: -105.01, + }), + ], + ]); + render( + , + ); + // FarPeer has coords at 0,0 — filtered out of All when distance filter is on + // (nodes without GPS stay; FarPeer has GPS far away). + expect(screen.queryByText('FarPeer')).not.toBeInTheDocument(); + expect(screen.getByText('NearNoDm')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'History' })); + await waitFor(() => { + expect(screen.getByText('FarPeer')).toBeInTheDocument(); + }); + expect(screen.queryByText('NearNoDm')).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index 7ec26a332..f6da8c116 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -9,7 +9,7 @@ import { TriangleAlert, User, } from 'lucide-react-motion'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useIconTrigger, useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; @@ -25,6 +25,13 @@ import { type OffloadContactsFromRadioFn, useMeshcoreContactCapacity, } from '../hooks/useMeshcoreContactCapacity'; +import { useMessages } from '../hooks/useMessages'; +import { + buildChatDmPeerIndex, + type ChatDmPeerDbRow, + type ChatDmPeerIndexEntry, + mergeChatDmPeerDbRows, +} from '../lib/chatDmPeerIndex'; import { formatCoordColumns, latestPositionHistoryPoint, @@ -37,7 +44,9 @@ import { import { translateRoutingRowDescription } from '../lib/diagnostics/diagnosticsLabels'; import { snrMeaningfulForNodeDiagnostics } from '../lib/diagnostics/snrMeaningfulForNodeDiagnostics'; import { downloadBlob } from '../lib/downloadBlob'; +import { errLikeToLogString } from '../lib/errLikeToLogString'; import { formatRelativeOrIsoDate } from '../lib/formatRelativeOrIsoDate'; +import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { getMapOverlayColors, MAP_BASEMAPS } from '../lib/mapBasemapUtils'; import { isMeshcoreOffloadAbortError, @@ -63,8 +72,10 @@ import { import { nodeHealthScore, nodeHealthTier } from '../lib/nodeHealthScore'; import { getNodeTypeIcon } from '../lib/nodeIcons'; import { getNodeStatus, haversineDistanceKm, normalizeLastHeardMs } from '../lib/nodeStatus'; +import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; import { useRadioProvider } from '../lib/radio/providerFactory'; import { RoleDisplay } from '../lib/roleInfo'; +import { messageRecordsToChatMessages } from '../lib/storeRecordAdapters'; import type { MeshNode, MeshProtocol } from '../lib/types'; import { useCoordFormatStore } from '../stores/coordFormatStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; @@ -99,6 +110,25 @@ type SortField = | 'altitude' | 'redundancy'; +type NodeListTab = 'all' | 'history'; + +function stubDmHistoryNode(nodeId: number, lastMessageAt: number, mode: MeshProtocol): MeshNode { + const hex = formatMeshtasticNodeId(nodeId).replace(/^!/, ''); + return { + node_id: nodeId, + long_name: mode === 'meshcore' ? hex : `!${hex}`, + short_name: hex.slice(-4), + hw_model: mode === 'meshcore' ? 'Chat' : '', + snr: 0, + battery: 0, + last_heard: lastMessageAt, + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }; +} + const BUILTIN_TYPE_FILTERS = [ { group_id: -1, typeKey: 'nodeListPanel.meshcoreTypeChat' as const, hw_model: 'Chat' }, { group_id: -2, typeKey: 'nodeListPanel.meshcoreTypeRepeater' as const, hw_model: 'Repeater' }, @@ -221,12 +251,70 @@ export default function NodeListPanel({ ); const ignoreMqttEnabled = useDiagnosticsStore((s) => s.ignoreMqttEnabled); const nodeRedundancy = useDiagnosticsStore((s) => s.nodeRedundancy); + const [listTab, setListTab] = useState('all'); + const [dbDmPeers, setDbDmPeers] = useState([]); const [sortField, setSortField] = useState('last_heard'); const [sortAsc, setSortAsc] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [importLoading, setImportLoading] = useState(false); const [refreshLoading, setRefreshLoading] = useState(false); const [advertLoading, setAdvertLoading] = useState(false); + + const identityId = getIdentityIdForProtocol(mode) ?? getOfflineIdentityIdForProtocol(mode); + const identityMessages = useMessages(identityId); + const ownNodeIdSet = useMemo(() => new Set([myNodeNum >>> 0]), [myNodeNum]); + const meshcoreRoomPeerIds = useMemo(() => { + if (mode !== 'meshcore') return null; + const roomIds = new Set(); + for (const [peerId, node] of nodes) { + if (node.hw_model === 'Room') roomIds.add(peerId); + } + return roomIds; + }, [mode, nodes]); + const excludeDmPeer = useCallback( + (peer: number) => meshcoreRoomPeerIds?.has(peer) === true, + [meshcoreRoomPeerIds], + ); + const chatUnreadDmOptions = useMemo( + () => (mode === 'meshcore' ? { excludeDmPeer } : undefined), + [excludeDmPeer, mode], + ); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const rows = + mode === 'meshcore' + ? await window.electronAPI.db.listMeshcoreDmPeers(myNodeNum) + : await window.electronAPI.db.listMeshtasticDmPeers(myNodeNum); + if (!cancelled) { + const next = Array.isArray(rows) ? rows : []; + setDbDmPeers((prev) => (prev.length === 0 && next.length === 0 ? prev : next)); + } + } catch (e) { + console.warn('[NodeListPanel] listDmPeers ' + errLikeToLogString(e)); + if (!cancelled) { + setDbDmPeers((prev) => (prev.length === 0 ? prev : [])); + } + } + })(); + return () => { + cancelled = true; + }; + }, [mode, myNodeNum, identityMessages.length]); + + const dmPeerIndex = useMemo(() => { + const chatMessages = messageRecordsToChatMessages(identityMessages); + const fromMemory = buildChatDmPeerIndex(chatMessages, ownNodeIdSet, mode, chatUnreadDmOptions); + const merged = mergeChatDmPeerDbRows(fromMemory, dbDmPeers); + if (mode !== 'meshcore' || !meshcoreRoomPeerIds) return merged; + // Drop Room servers even if SQLite still has DM-shaped rows. + for (const peer of [...merged.keys()]) { + if (meshcoreRoomPeerIds.has(peer)) merged.delete(peer); + } + return merged; + }, [chatUnreadDmOptions, dbDmPeers, identityMessages, meshcoreRoomPeerIds, mode, ownNodeIdSet]); const { contactCount, loading: offloadLoading, @@ -353,7 +441,26 @@ export default function NodeListPanel({ }; const nodeList = useMemo(() => { - let list = Array.from(nodes.values()); + let list: MeshNode[]; + const historyActivity = new Map(); + + if (listTab === 'history') { + list = []; + for (const [peerId, entry] of dmPeerIndex) { + historyActivity.set(peerId, entry); + const existing = nodes.get(peerId); + if (existing) { + list.push({ + ...existing, + last_heard: Math.max(existing.last_heard ?? 0, entry.lastMessageAt), + }); + } else { + list.push(stubDmHistoryNode(peerId, entry.lastMessageAt, mode)); + } + } + } else { + list = Array.from(nodes.values()); + } // Filter by search if (searchQuery.trim()) { @@ -370,7 +477,7 @@ export default function NodeListPanel({ } // Filter by group membership or built-in filters (MeshCore: contact type; Meshtastic: GPS / RF+MQTT) - if (selectedGroupId != null) { + if (listTab === 'all' && selectedGroupId != null) { if (mode === 'meshcore') { if (selectedGroupId < 0) { const typeFilter = BUILTIN_TYPE_FILTERS.find((f) => f.group_id === selectedGroupId); @@ -392,12 +499,12 @@ export default function NodeListPanel({ } // Filter MQTT-only nodes - if (locationFilter.hideMqttOnly) { + if (listTab === 'all' && locationFilter.hideMqttOnly) { list = list.filter((n) => !n.heard_via_mqtt_only); } // Filter by distance - if (locationFilter.enabled) { + if (listTab === 'all' && locationFilter.enabled) { const homeNode = myNodeNum ? nodes.get(myNodeNum) : undefined; const homeHasLocation = homeNode?.latitude != null && @@ -426,6 +533,11 @@ export default function NodeListPanel({ // Sort list.sort((a, b) => { + if (listTab === 'history') { + const aTs = historyActivity.get(a.node_id)?.lastMessageAt ?? a.last_heard ?? 0; + const bTs = historyActivity.get(b.node_id)?.lastMessageAt ?? b.last_heard ?? 0; + if (aTs !== bTs) return sortAsc ? aTs - bTs : bTs - aTs; + } // Self-node always first if (a.node_id === myNodeNum) return -1; if (b.node_id === myNodeNum) return 1; @@ -502,6 +614,8 @@ export default function NodeListPanel({ return list; }, [ + dmPeerIndex, + listTab, nodes, sortField, sortAsc, @@ -554,7 +668,7 @@ export default function NodeListPanel({ ).length; return { hidden: totalWithGps - visibleWithGps }; }, [locationFilter, myNodeNum, nodes, nodeList]); - const totalNodeCount = nodes.size; + const totalNodeCount = listTab === 'history' ? dmPeerIndex.size : nodes.size; const visibleNodeCount = nodeList.length; const headerCountLabel = visibleNodeCount === totalNodeCount @@ -567,12 +681,44 @@ export default function NodeListPanel({ return (
+
+ + +
+ {/* 1fr | auto | 1fr keeps the search visually centered on wide screens (matches MeshCore’s title | search | import row). */}

- {mode === 'meshcore' - ? t('nodeListPanel.headingContacts') - : t('nodeListPanel.headingNodeDatabase')}{' '} + {listTab === 'history' + ? t('nodeListPanel.tabHistory') + : mode === 'meshcore' + ? t('nodeListPanel.headingContacts') + : t('nodeListPanel.headingNodeDatabase')}{' '} ({headerCountLabel})

)} - {/* Group filter (MeshCore + Meshtastic when contactGroupsEnabled) */} - {contactGroupsEnabled && onManageGroups && ( + {/* Group filter (MeshCore + Meshtastic when contactGroupsEnabled) — All tab only */} + {listTab === 'all' && contactGroupsEnabled && onManageGroups && (
{ - patch('enforce_stamps', e.target.checked); - }} - aria-label={t('networkPanel.reticulumPnHosting.enforceStamps')} - /> - {t('networkPanel.reticulumPnHosting.enforceStamps')} - -
+

+ {t('networkPanel.reticulumPnHosting.enforceUnavailableTip')} +