diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..aa6426f63 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b3cc55683 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.icns binary +*.woff binary +*.woff2 binary +*.wasm binary diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 51b61fa25..d8c5895cc 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -147,6 +147,12 @@ else printf 'pre-commit: skip check:reticulum-interface-modes (no interface-mode paths staged)\n' >&2 fi +if staged_match '^(reticulum-sidecar/src/stack/pn_hosting_policy\.rs|src/shared/pnHostingPolicy\.ts|scripts/check-pn-hosting-policy\.mjs)'; then + pnpm run check:pn-hosting-policy || fail 'pnpm run check:pn-hosting-policy' +else + printf 'pre-commit: skip check:pn-hosting-policy (no pn-hosting-policy paths staged)\n' >&2 +fi + if staged_match '^(reticulum-sidecar/src/stack/config\.rs|src/shared/reticulumDecommissionedHubs\.ts|scripts/check-reticulum-decommissioned-hubs\.mjs)'; then pnpm run check:reticulum-decommissioned-hubs || fail 'pnpm run check:reticulum-decommissioned-hubs' else diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4d0b9a0e6..851089f6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,9 @@ version: 2 updates: + # Dependabot PRs intentionally disabled (limit 0) — the project manages npm + # and Actions updates via `pnpm run update` (scripts/update.sh) which runs + # dedupe, patch checks, and Ratspeak/rsReticulum verification in one pass. + # See AGENTS.md §6 and docs/ci-cd.md. - package-ecosystem: 'npm' directory: '/' schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 227704e8c..2516b4014 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,9 +32,29 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Format check + run: pnpm run format:check + + - name: Markdown lint + run: pnpm run lint:md + - name: Lint run: pnpm run lint + - name: License check + run: pnpm run check:licenses + + - name: Install actionlint + run: pnpm run setup:actionlint + + - name: Actionlint + run: | + export PATH="${GITHUB_WORKSPACE}/.githooks/bin:${PATH}" + actionlint + + - name: pnpm audit (high+) + run: pnpm audit --audit-level=high + - name: Install yamllint run: pip install yamllint diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml index f9682f85e..0150ec202 100644 --- a/.github/workflows/reticulum-sidecar.yaml +++ b/.github/workflows/reticulum-sidecar.yaml @@ -14,7 +14,7 @@ on: - 'scripts/build-reticulum-sidecar-release.mjs' - 'scripts/clone-ratspeak-stack.sh' - 'scripts/ensure-rsReticulum-patches.sh' - - 'scripts/apply-rsLXMF-propagation-sync-peering.sh' + - 'scripts/apply-rsLXMF-*.sh' pull_request: paths: - 'reticulum-sidecar/**' @@ -22,7 +22,7 @@ on: - 'scripts/build-reticulum-sidecar-release.mjs' - 'scripts/clone-ratspeak-stack.sh' - 'scripts/ensure-rsReticulum-patches.sh' - - 'scripts/apply-rsLXMF-propagation-sync-peering.sh' + - 'scripts/apply-rsLXMF-*.sh' jobs: lint: diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index e33884c1b..2d904b439 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -2,9 +2,10 @@ "ignores": [ "node_modules/**", ".cursor/**", + ".hermes/**", "site/**", "release/**", "dist/**", - "out/**" + "out/**", ], -} \ No newline at end of file +} diff --git a/.markdownlint.json b/.markdownlint.json index e837c279e..de532fb9c 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -2,7 +2,9 @@ "MD001": false, "MD013": false, "MD022": false, - "MD024": false, + "MD024": { + "siblings_only": true + }, "MD025": false, "MD031": false, "MD032": false, diff --git a/.prettierignore b/.prettierignore index fc35a50d1..b7acd7b35 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,8 @@ tmp/ # Files unsupported by prettier parser .npmrc +.editorconfig +.gitattributes patches/ *.plist *.txt diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..c0ca380e1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "davidanson.vscode-markdownlint", + "rust-lang.rust-analyzer" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 297621ca4..3705417da 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ "eslint.validate": ["javascript", "typescript", "javascriptreact", "typescriptreact"], "eslint.format.enable": true, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" @@ -19,6 +19,6 @@ "editor.defaultFormatter": "vscode.json-language-features" }, "[typescriptreact]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/AGENTS.md b/AGENTS.md index 0588b1a7b..957f16248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ This file is self-contained. ARCHITECTURE.md and CONTRIBUTING.md are human refer ## 1. Scope & Workflow - Only change what was asked. No drive-by refactors, reformatting, or types/comments outside scope. +- **Credits ↔ package.json:** When adding or renaming a person under **Authors** or **Contributors** in [`docs/credits.md`](docs/credits.md), also add/update the matching entry in root `package.json` `contributors` (same order as credits). Format: `"DisplayName https://github.com/handle"` when a GitHub URL exists, otherwise the credits display name/callsign only (e.g. `"megabear - KD5IHC"`). Do **not** put Colorado Mesh org thanks, Acknowledgements projects, or dependency/binary attribution tables into `contributors`. - **Testing:** Ship a passing test for behavioral changes; do not call the task done without it. - **Stateful/I/O code:** Preserve integrity on failure; document failure point, fallback, and logging where it matters. - **Pre-commit patience:** Pre-commit runs staged-related Vitest (`pnpm run test:staged`), staged ESLint, full typecheck, and path-gated `check:*` scripts. Typical small commits are much faster than a full suite; vitest infra / lockfile changes still force a full Vitest run. Be patient — do not interrupt or force-skip. **PR CI** ([`tests.yaml`](.github/workflows/tests.yaml)) always runs the **full** Vitest suite (`pnpm run test:run`) — never `test:staged` / `test:changed` / `vitest related`. i18n is gated via `locale-quality.test.ts` (subprocess of `check:i18n`). **`pnpm run release`** (`scripts/release.sh`) runs full Vitest **plus ungated `check:*` scanners** (including a direct `check:i18n`). Green pre-commit ≠ green CI or release. @@ -142,14 +143,15 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **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), `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) - **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 +- **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` +- **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)) - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [docs/reticulum.md#interface-modes](docs/reticulum.md#interface-modes). - **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; room history → 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`, `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` (skips `propagated` rows so link-timeout does not kill PN fallback), `markStaleReticulumOutbound.ts`. Propagated Completes UI: **Stored at propagation node**. +- **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) +- **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. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). @@ -157,7 +159,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` - **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` -- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start +- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` only after `markRncpReceiveDestSharePending` (from `sendRncpRequestEnable`) within TTL; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start - **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` - **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) - **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** below. @@ -253,6 +255,7 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel - **Locale files:** `src/renderer/locales/{en,es,uk,de,zh,pt-BR,fr,it,pl,cs,ja,ru,nl,ko,tr,id}/translation.json` — English is source of truth (`pnpm run check:i18n` reports key count). - **Locale persistence:** `locale` key in `app_settings` SQLite table (canonical) and `mesh-client:appSettings` localStorage (fast startup read); reconciled in `App.tsx` on mount. - **Reduce motion:** `reduceMotion` boolean in the same `app_settings` / localStorage bundle; toggled in **App → Appearance** ([`AppPanel.tsx`](src/renderer/components/AppPanel.tsx)). When true, non-essential UI motion (animated icons, decorative CSS pulses) is suppressed; loading spinners and connection status pulses remain. Does not auto-sync to OS `prefers-reduced-motion` after first-run init — see [`docs/accessibility-checklist.md`](docs/accessibility-checklist.md). +- **24-hour time:** `use24HourTime` beside Reduce motion in **App → Appearance** (`timeFormatStore`, `formatDisplayTime`; SQLite `app_settings` + `mesh-client:appSettings` localStorage). When on, chat/diagnostics clocks force 24-hour; when off, follow system locale. - **Adding strings:** add to `src/renderer/locales/en/translation.json`, use `t('your.key')` in components; `check:i18n` enforces all call sites resolve to English keys and **fails on unused English keys** (no static `t()`, registered dynamic prefix, quoted literal in `src/`, or `tabs.*` from `TAB_SLOT_IDS`). - **Removing strings:** delete the key from `en/translation.json` and run `pnpm run i18n:prune-unused -- --write` to drop it from every locale (or remove manually). `check:i18n` blocks orphaned English keys. - **Auto-translate:** `pnpm run i18n:auto-translate` uses MyMemory (default) or LibreTranslate (`LIBRETRANSLATE_URL`). With git, the default run **only** fills keys that are **new in English vs `HEAD`** and still missing from each locale (pre-commit uses this). Use **`pnpm run i18n:auto-translate --all`** or **`I18N_TRANSLATE_ALL=1`** to backfill every key missing from a locale vs English. Use **`--audit`** (or `I18N_AUDIT=1`) to additionally retranslate any key whose locale value is still identical to English (i.e. never actually translated). Existing translated entries are never overwritten. MyMemory sends contact `info@coloradomesh.org` by default for the 50 k words/day quota; override with `MYMEMORY_EMAIL` if needed. diff --git a/docs/accessibility-checklist.md b/docs/accessibility-checklist.md index 246f5be13..72e238731 100644 --- a/docs/accessibility-checklist.md +++ b/docs/accessibility-checklist.md @@ -46,6 +46,7 @@ This is a living document. Check items against VoiceOver (macOS), NVDA (Windows) - [ ] **Setting on — still allowed (essential feedback)**: Loading spinners (`animate-spin`, `Loader`), connection header status pulses (`animate-pulse` on MQTT/device labels and status dots). - [ ] Toggle persists across restart (SQLite + localStorage reconcile on mount, same pattern as `locale` / `chatCompactMode`). - [x] Optional first-run: if `reduceMotion` key is absent, initializer may default from `matchMedia('(prefers-reduced-motion: reduce)')` once; thereafter only the App toggle applies (not live-synced to OS changes). Implemented by `initReduceMotionDefaultIfAbsent()` in `reduceMotionPreference.ts` (called from `main.tsx` before React mount). +- [ ] **24-hour time (in-app setting)**: **App → Appearance → Use 24-hour time** (`use24HourTime` via `timeFormatStore` / `formatDisplayTime`; SQLite + localStorage, same bundle as Reduce motion). When on, chat and other display clocks force 24-hour format; when off, follow the system locale. --- diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 047fc30c8..4b970fede 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -20,19 +20,24 @@ Mesh-Client uses GitHub Actions for continuous integration and deployment. ## CI Build (`ci.yaml`) -Runs on every push and pull request to `main`: +Runs on every push and pull request to `main` (and `workflow_dispatch`): 1. Checkout code 2. Setup pnpm 3. Setup Node 22 4. Install dependencies (`pnpm install --frozen-lockfile`) -5. Run lint (`pnpm run lint`) -6. Run typecheck (`pnpm run typecheck`) -7. Run build (`pnpm run build`) -8. Run `yamllint` on workflow/config YAML -9. Run `check:flatpak`, `check:flatpak-offline-pnpm` (needs `flatpak-node-generator`), `desktop-file-validate`, and `appstreamcli validate` on Flatpak metadata - -All steps must pass before a PR can be merged. +5. Format check (`pnpm run format:check`) +6. Markdown lint (`pnpm run lint:md`) +7. Run lint (`pnpm run lint`) +8. License check (`pnpm run check:licenses`) +9. actionlint (via `pnpm run setup:actionlint`) +10. `pnpm audit --audit-level=high` (non-blocking warning) +11. Run `yamllint` on workflow/config YAML +12. Run typecheck (`pnpm run typecheck`) +13. Run build (`pnpm run build`) +14. Run `check:flatpak`, `check:flatpak-offline-pnpm` (needs `flatpak-node-generator`), `desktop-file-validate`, and `appstreamcli validate` on Flatpak metadata + +All blocking steps must pass before a PR can be merged. --- @@ -119,7 +124,9 @@ Automated dependency updates are configured in `.github/dependabot.yml`: - **Schedule:** Weekly on Saturdays - **npm dependencies:** Grouped PRs (Electron separate, all other deps together) - **GitHub Actions:** Grouped into one PR -- **Limit:** 10 open PRs maximum +- **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. ### Testing Dependabot PRs locally diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index af393e15f..16e249cb6 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -91,16 +91,42 @@ The Connection tab UI edits a subset: **name** and **mode** for all types; **hos **WS `rmap.discovery`:** sidecar polls DiscoveryStore every **10s**; emits full `{ discovered: [...] }` snapshot when JSON fingerprint changes. Stub builds return `{ discovered: [] }`. | GET | `/api/v1/packets` | `?limit=500` (1–2500) | `{ packets: [] }` — recent wire tap ring buffer | | DELETE | `/api/v1/packets` | | `{ ok }` — clear wire tap buffer | -| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec }` — `local-prop` rows include `message_count`, `storage_bytes` when live | +| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec, pn_hosting_policy }` — `local-prop` rows include `message_count`, `storage_bytes` when live | | GET | `/api/v1/propagation/discovered` | | `{ discovered: DiscoveredPropagationRow[] }` — heard `lxmf.propagation` announces (not auto-configured) | -| POST | `/api/v1/propagation/add` | `{ destination_hash, name? }` | `{ ok, node }` — add a remote propagation node by hash | +| POST | `/api/v1/propagation/add` | `{ destination_hash, name?, skip_probe? }` | `{ ok, node }` or `{ ok: false, error }` — probes `/offer` unless `skip_probe`; may return `PROPAGATION_OFFER_UNSUPPORTED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, identity/path errors | +| POST | `/api/v1/propagation/hosting-policy` | `PnHostingPolicy` | `{ ok }` — persist + apply local PN hosting / peering policy | | PUT | `/api/v1/propagation/{id}` | `{ name }` | `{ ok }` — rename a remote node (`local-prop` rejected) | | DELETE | `/api/v1/propagation/{id}` | | `{ ok }` — remove a remote node (`local-prop` rejected; clears preferred if that id) | -| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` | -| POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` | +| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` — for `local-prop`, starts PN serve + announce | +| POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` — for `local-prop`, stops PN serve + announce | | POST | `/api/v1/propagation/{id}/preferred` | | `{ ok }` | | POST | `/api/v1/propagation/sync` | | `{ ok }` | | POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` | +| POST | `/api/v1/propagation/auto-sync-interval` | `{ interval_sec }` | `{ ok }` — `0` disables periodic sync; persists with stack | + +**`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` | | ### Nomad Network @@ -226,7 +252,7 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds, **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`). Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. -**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 (Establishing ≈10, Offering ≈25, …, Complete ≈100). Sticky success after HaveAll emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. +**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 (Establishing ≈10, Offering ≈25, …, Complete ≈100). Sticky success after HaveAll emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. Add may return `PROPAGATION_OFFER_UNSUPPORTED` / probe timeout failures. SQLite chat history uses separate `db:*` handlers (`getReticulumMessages`, `saveReticulumMessage`, `searchReticulumMessages`, `deleteReticulumMessage`, destination upserts), not sidecar HTTP. Remote saved addresses / inbound policy and RRC room history also use dedicated `db:*` handlers (not sidecar HTTP). diff --git a/docs/reticulum.md b/docs/reticulum.md index 05315cada..58497a59b 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -32,12 +32,12 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | LXMF chat | DM-only text and reactions (outbound LXMF file/voice attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files) | | 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 inbox ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set. 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 | | 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. | -| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer), optional **local propagation inbox**, configurable **auto-sync interval** | +| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **Advanced PN hosting** policy | | Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | | Admin | RNode firmware flasher (Web Serial), stack factory reset | | Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | @@ -108,7 +108,7 @@ The **Map** tab shows **local** RMAP v4 discovery data — interfaces your stack ### App → Retention & limits -Reticulum destination age prune is enabled by default at **30 days** and affects only non-favorited destinations. The destination count cap is also enabled by default at **10,000** (maximum **50,000**); favorites are preserved. Reticulum message retention is independently configurable and defaults to keeping the newest **4,000** messages. RRC room history retention is independently enabled by default (newest **10,000** messages; **30-day** age prune) and is controlled from App → Retention (`rrcMessageRetentionEnabled` / `rrcMessageRetentionCount`). +Reticulum destination age prune is enabled by default at **30 days** and affects only non-favorited destinations. The destination count cap is also enabled by default at **10,000** (maximum **50,000**); favorites are preserved. Reticulum message retention is independently configurable and defaults to keeping the newest **4,000** messages. RRC room history retention is independently enabled by default (newest **10,000** messages; **30-day** age prune) and is controlled from App → Retention (`rrcMessageRetentionEnabled` / `rrcMessageRetentionCount`). Per-room UI hydrate loads at most **500** newest rows (`RRC_ROOM_HISTORY_LOAD_COUNT` via `rrcRoomHistory.ts`) — older retained SQLite rows stay on disk until prune. **Config audit kinds:** `rmap_missing_coordinates`, `rmap_no_tcp_hub`, `rmap_transport_disabled`, `rmap_i2p_not_connectable`. @@ -233,7 +233,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Config validate:** Electron IPC `reticulum:validateConfig` → one-shot sidecar `validate-config --json` against `userData/reticulum/config` - **Announces:** interval (`announce_interval_sec`, 0–86400; default **3600** s / 1 h when unset; `0` = startup-only) persisted in rnsd config. The live sidecar sends an **LXMF delivery** announce shortly after stack start and on that interval (Ratspeak/lxmd parity). **Announce now** (`POST /api/v1/announces`) forces an immediate delivery announce. **Clear announces** (`DELETE /api/v1/announces`) clears the stub peer cache; the live path table may refill on the next peer refresh. Per-interface `announce_interval_min` (RMAP/discoverable interfaces) is separate. - **Inbound LXMF:** the sidecar registers `lxmf.delivery` with the transport (`RegisterDestination` + `LinkManager`) and feeds decrypted link/resource payloads into the delivery callback (WS `lxmf_message`). Without this registration, peer DMs never appear in Chat even when paths exist. -- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local propagation inbox**, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. +- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get`), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. --- @@ -254,19 +254,21 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions. - Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. - Unintended link drops enter **reconnecting** (backoff 2–30 s), preserve desired rooms (including join keys), and rejoin after WELCOME. Explicit **Disconnect** / **Cancel** clears that hub (`will_reconnect: false`). +- **Involuntary PART:** hub/self `PARTED` while the room is still desired queues a silent re-JOIN; UI banner uses neutral `rrc.moderation.hubParted` (not kick/ban wording). Member-fanout `PARTED` (another peer left) updates the nicklist only — must not be treated as self-leave. - @mention unread badges and inactive-tab toasts; muted views use the shared Chat mute storage keyed as `rrc::`. - Sidecar modules: `rrc_codec`, `rrc_link`, `rrc_session`, `rrc_defaults`; REST under `/api/v1/rrc/*` (see [sidecar IPC](reticulum-sidecar-ipc.md)). +- History: persist via `rrcMessagePersist.ts`; hydrate/clear via `rrcRoomHistory.ts` (UI load cap **500**/room; SQLite retention default **10,000**). ### Delivery modes -| Path table | Propagation node | Routing / UI | -| ------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Destination present | — | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | -| Destination present | Preferred **remote** PN set | Same Direct-first attempt; if Direct **fails**, sidecar **one-shot retries via preferred remote PN** (not Local inbox). UI switches to **PN** / **Stored at propagation node** on PN Complete | -| Destination absent | Preferred PN set | **Propagated** via preferred propagation node; **PN** badge = store-and-forward — Completes as **Stored at propagation node** (not recipient-delivered) | -| Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | +| Path table | Propagation node | Routing / UI | +| ------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Destination present | — | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | +| Destination present | Preferred **remote** PN set | Same Direct-first attempt; if Direct **fails**, sidecar **one-shot retries via preferred remote PN** (not local PN hosting). UI switches to **PN** / **Stored at propagation node** on PN Complete | +| Destination absent | Preferred PN set | **Propagated** via preferred propagation node; **PN** badge = store-and-forward — Completes as **Stored at propagation node** (not recipient-delivered) | +| Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node. **Local propagation (offline inbox)** is this device’s inbox / optional local serving — it does **not** deposit outbound DMs for unreachable peers. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node. **Local PN hosting** is this device’s optional local serving / inbox — it does **not** deposit outbound DMs for unreachable peers. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. --- @@ -276,6 +278,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - **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). - **`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) @@ -362,7 +365,7 @@ curl -s http://127.0.0.1:19437/api/v1/status CI matrix (stub + full stack): [`.github/workflows/reticulum-sidecar.yaml`](../.github/workflows/reticulum-sidecar.yaml). Flatpak release builds bundle the full-stack binary into `resources/reticulum-sidecar/`. -Patch overlays (packet tap, AutoInterface utun fix): [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md). +Patch overlays (packet tap, AutoInterface utun, discovery-announce-egress, rsLXMF policy-setters, …): [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md). --- @@ -390,14 +393,14 @@ Implementation: sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`no Wire protocols are stock Reticulum utilities — mesh-client is a client (and rncp receive listener), not a private dialect. -| Scenario | Peer side | mesh-client side | -| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Shell | `rnsh` / `rnsh-rs` listen; allow our identity (`-a` / allow-list) | Remote → Shell → paste `rnsh` destination hash → connect | -| Send file | `rncp -l -a ` (or mesh-client inbound Ask) | Remote → Transfer → Send to peer `rncp.receive` hash | -| Receive file | `rncp file ` | Remote → Settings → inbound Ask/allow-list; copy **My rncp receive destination** | -| Fetch | Peer `rncp -l -F -j -a ` | Remote → Transfer → Fetch remote path | -| Auth fail | Peer allow-list omits us | Error shows **not allowed** + copy our identity hash | -| Request enable | Second mesh-client | Chat/Transfer **Request enable** LXMF DM (sentinel `mesh-client:request-rncp-receive:v1`) | +| Scenario | Peer side | mesh-client side | +| -------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shell | `rnsh` / `rnsh-rs` listen; allow our identity (`-a` / allow-list) | Remote → Shell → paste `rnsh` destination hash → connect | +| Send file | `rncp -l -a ` (or mesh-client inbound Ask) | Remote → Transfer / Chat DM → peer `rncp.receive` hash (not LXMF) | +| Receive file | `rncp file ` | Remote → Settings → inbound Ask/allow-list; copy **My rncp receive destination** | +| Fetch | Peer `rncp -l -F -j -a ` | Remote → Transfer → Fetch remote path | +| Auth fail | Peer allow-list omits us | Error shows **not allowed** + copy our identity hash | +| Request enable | Second mesh-client | Chat/Transfer **Request enable** (`mesh-client:request-rncp-receive:v1`); peer replies with `mesh-client:rncp-receive-dest:v1:` so the sender autofills | Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destinations are refused locally before a link opens. There is no byte-level resume — Retry restarts the full file. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1f7d89ff7..a50ed2bc4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -888,6 +888,22 @@ AGPL Rust sidecar (`mesh-client-reticulum`), interfaces, LXMF, RRC, and RNode Wi 2. **Explicit Disconnect / Cancel** (`local_disconnect` or `will_reconnect: false`): that hub session is removed from the UI. Reconnect manually or rely on hub auto-join when the stack starts. 3. Failed initial connect also clears the hub slot so it cannot exhaust the 8-session cap. +### RRC false self-PART / hubParted banner + +**Symptoms**: Busy rooms show repeated “Left the room (hub parted you)” / self leave when other members part; or an involuntary hub PART looks like a kick/ban. + +**Cause**: Older logic treated member-fanout `PARTED` as self-leave. Sidecar now classifies actor-facing self PARTED vs other-member fanout (`parted_concerns_self`); involuntary self-PART while the room is still desired queues silent rejoin and the UI uses neutral `rrc.moderation.hubParted` (not kick/ban copy). + +**What to do**: Upgrade / restart the sidecar. If the banner appears after a true hub PART, re-join the room (or wait for auto-rejoin when still desired). Kick/ban wording is reserved for moderation notice paths only. + +### RRC history shows fewer messages than Retention + +**Symptoms**: App → Retention keeps **10,000** RRC messages, but opening a room only shows ~**500**. + +**Cause**: Per-room UI hydrate caps at `RRC_ROOM_HISTORY_LOAD_COUNT` (**500**) via `rrcRoomHistory.ts`. SQLite may still hold up to the retention count; older rows are not all loaded into the session store. + +**What to do**: This is expected. Retention prune (`db:pruneRrcMessagesByCount` / age) controls disk; the 500 cap is a session/UI hydrate limit, not a wipe. + ### Reticulum sidecar won't start or health poll times out **Symptoms**: Connection tab **Start stack** fails; logs show `[ReticulumSidecar]` health poll timeout; `reticulum:getStatus` reports `lastError`. Identity **Generate** / **Import** errors with `Reticulum sidecar is not running`. @@ -1120,7 +1136,35 @@ Unrecognized codes pass through unchanged. - Transfer-phase hangs use a renderer hard ceiling (~180s) plus lxmf-core’s own timeouts. - Auto-sync interval counts from the last _successful_ sync; failed attempts only apply a short cooldown (~2 min) so they do not postpone the next scheduled sync forever. -**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `[propagation-sync]` / offer errors. +**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `[propagation-sync]` / offer errors. If Add fails with **offer unsupported**, the destination does not speak LXMF `/offer`. If Sync/Add fails with **peering cost exceeds max**, raise **Network → Advanced PN hosting → Max peering cost**. + +### Reticulum local PN hosting not discoverable + +**Symptoms**: Local Host propagation node is enabled but peers never hear your PN announce / cannot `/offer` or `/get`. + +**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop. + +**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]`. Tune announce interval under **Advanced PN hosting**. + +### Reticulum PN hosting policy apply fails + +**Symptoms**: Saving **Network → Advanced PN hosting** (peering cost, storage limits, static peers, announce interval) fails or reverts; Device log shows hosting-policy errors. + +**Cause / checks**: + +- Sidecar rejected the policy (`peering_cost_exceeds_max`, `stamp_flex_exceeds_cost`, range limits, or invalid 32-hex static peer). The renderer now validates the same rules before PUT; failure surfaces via the panel error path. +- Stack/identity not ready (hosting apply needs a live sidecar). BLE/USB hubs themselves are unrelated — policy is local lxmf-core config, but the stack must be running to persist it. +- Invalid `node_name` (control characters or longer than 128 characters). + +**Fix**: Fix the invalid field (keep peering cost ≤ max; stamp flex ≤ stamp cost; static peers as lowercase 32-hex). Confirm Reticulum stack is **running**, then re-apply. Check logs for `[reticulumPropagationStore] hosting policy` / sidecar `hosting-policy` responses. + +### Reticulum last synced time looks wrong after update + +**Symptoms**: Propagation UI shows a far-future or absurdly old “last synced” time after a sidecar upgrade or clock skew. + +**Cause**: `last_propagation_sync_at` comes from the sidecar as Unix seconds. A future clock (or bad stamp) was previously accepted wholesale; refresh now clamps future values to local `Date.now()`. + +**Fix**: Run **Refresh** / reopen Propagation after fixing the system clock. Trigger a successful **Sync** to rewrite a sane stamp. ### MeshCore Colorado Mesh / LetsMesh won't connect after upgrade @@ -1171,7 +1215,7 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. 3. Set **Preferred** (manual mode) or leave **Auto** when multiple nodes are listed. -4. **Local propagation only** is this device’s offline inbox — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” +4. **Local propagation hosting** stores messages for peers that sync with you — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” **Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails and a preferred **remote** PN is configured, the sidecar retries once via that PN (Ratspeak-style store-and-forward). Without a remote preferred PN, the row stays **Failed** even if Ratspeak on the same machine deposits successfully. @@ -1257,6 +1301,10 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). 2. For `path_constrained`, prefer a faster interface or wait for a better path; large files over slow links may not be attempted. 3. Check sidecar logs for `rnsh`/`rncp` link errors; the `reticulum:rncpSend` / `rncpFetch` IPC returns the reason key surfaced in the toast. +**Chat DM note**: the destination field is the peer's **`rncp.receive`** hash, not their LXMF/Chat hash. Prefer **Request enable** (mesh-client peers share the receive hash after they accept) or paste from their Remote → **My rncp receive destination**. + +**Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:` reply is applied only if a pending mark exists (`rncpReceiveDestSharePending`, TTL); shares without a prior request-enable from this session are ignored. + ### Reticulum Remote inbound rncp blocked (Ask mode / policy) **Symptoms**: Incoming file offers never arrive, or an offer is auto-declined; a peer reports their send was rejected. diff --git a/mkdocs.yml b/mkdocs.yml index 6ae7d9b54..3c6906ade 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,10 @@ nav: - Accessibility: accessibility-checklist.md - CI/CD: ci-cd.md - Release Process: release-process.md + - Localization: localization.md + - Key Backup & Crypto: key-backup-and-crypto.md + - Meshtastic Telemetry: meshtastic-telemetry-local-client.md + - Nomad Hosting Interop: nomad-hosting-interop.md - Meshtastic & MeshCore Parity: meshcore-meshtastic-parity.md - Reticulum: reticulum.md - Reticulum Sidecar IPC: reticulum-sidecar-ipc.md diff --git a/package.json b/package.json index a8538999c..dda1c5d19 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "contributors": [ "Joey Stanford https://github.com/rinchen", "dude.eth https://github.com/defidude", - "megabear - KD5IHC" + "megabear - KD5IHC", + "Soord https://github.com/soord", + "WB3IHY https://github.com/WB3IHY", + "Letark https://github.com/Letark" ], "main": "dist-electron/main/index.js", "scripts": { @@ -62,6 +65,7 @@ "check:log-injection": "node scripts/check-log-injection.mjs", "check:log-panel-filter": "node scripts/check-log-panel-filter.mjs", "check:log-service-sinks": "node scripts/check-log-service-sinks.mjs", + "check:pn-hosting-policy": "node scripts/check-pn-hosting-policy.mjs", "check:protocol-string-gates": "node scripts/check-protocol-string-gates.mjs", "check:reticulum-decommissioned-hubs": "node scripts/check-reticulum-decommissioned-hubs.mjs", "check:reticulum-interface-modes": "node scripts/check-reticulum-interface-modes.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89782b210..b45d1017f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,7 @@ overrides: cacheable-request: ^10.0.0 form-data: ^4.0.6 app-builder-lib: ^26.15.0 - brace-expansion@<1.1.16: 1.1.16 - brace-expansion@>=2.0.0 <2.1.2: 2.1.2 - brace-expansion@>=3.0.0 <5.0.7: 5.0.7 + brace-expansion: 5.0.8 builder-util-runtime: 9.7.0 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' @@ -1661,9 +1659,6 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1689,12 +1684,6 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - brace-expansion@5.0.8: resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} engines: {node: 20 || >=22} @@ -1837,9 +1826,6 @@ packages: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -6183,8 +6169,6 @@ snapshots: axobject-query@4.1.0: {} - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -6207,15 +6191,6 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@1.1.16: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.2: - dependencies: - balanced-match: 1.0.2 - brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -6367,8 +6342,6 @@ snapshots: compare-version@0.1.2: {} - concat-map@0.0.1: {} - concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -8178,15 +8151,15 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 5.0.8 minimatch@5.1.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 5.0.8 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 5.0.8 minimist@1.2.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6f1625012..441ec40b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,11 +39,12 @@ overrides: form-data: ^4.0.6 # Security floors (Dependabot GHSA-52cp-r559-cp3m / GHSA-395f-4hp3-45gv / # GHSA-w8wr-v893-vjvp / GHSA-3jxr-9vmj-r5cp / GHSA-r28c-9q8g-f849 / - # GHSA-p2f4-r6v6-j797 / GHSA-7g7r-gx96-252g). Keep majors separate for brace-expansion. + # GHSA-p2f4-r6v6-j797 / GHSA-7g7r-gx96-252g / GHSA-mh99-v99m-4gvg). + # brace-expansion: keep a single 5.0.8 floor. Major-scoped pins (1.1.17 / + # 2.1.3) still fail `pnpm audit` because GHSA-mh99-v99m-4gvg’s advisory + # range is `<=5.0.7` (only >=5.0.8 counts as patched). CI audit is blocking. app-builder-lib: ^26.15.0 - 'brace-expansion@<1.1.16': 1.1.16 - 'brace-expansion@>=2.0.0 <2.1.2': 2.1.2 - 'brace-expansion@>=3.0.0 <5.0.7': 5.0.7 + brace-expansion: 5.0.8 builder-util-runtime: 9.7.0 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index e139a79ac..38815d1bd 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -32,7 +32,9 @@ Apply overlays (required for `rns-stack` until upstream merges): ./scripts/apply-rsReticulum-link-client-nomad.sh ./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +./scripts/apply-rsReticulum-discovery-announce-egress.sh ./scripts/apply-rsLXMF-propagation-sync-peering.sh +./scripts/apply-rsLXMF-propagation-node-policy-setters.sh ``` See [patches/README.md](patches/README.md) for base SHA and regen steps. diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 45d08ae21..180373ec9 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -2,6 +2,31 @@ Patches applied on top of pinned [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) checkouts for mesh-client `rns-stack` builds. +## 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 + +**First-time setup:** + +```bash +# From mesh-client repo root — clones/pins siblings and applies known overlays +./scripts/clone-ratspeak-stack.sh +# Or ensure patches on an existing sibling tree: +./scripts/ensure-rsReticulum-patches.sh +``` + +Apply a single overlay when developing that patch: + +```bash +./scripts/apply-rsReticulum-discovery-announce-egress.sh +git -C ../rsReticulum status --short +``` + +If a patch is skipped or conflicts after an upstream bump, CI/`ensure-rsReticulum-patches.sh` will fail. Rebase the overlay, regenerate the `.patch` file per the section below, then re-run the apply script. + ## rsReticulum-packet-tap.patch Wire packet tap API for the Reticulum Stats/Sniffer panel (`wire_packet` WebSocket events, `GET /api/v1/packets`). @@ -209,6 +234,7 @@ Apply after the other rsReticulum overlays when rebuilding a pinned checkout: ./scripts/apply-rsReticulum-link-client-nomad.sh ./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +./scripts/apply-rsReticulum-discovery-announce-egress.sh ``` ### Regenerate @@ -223,6 +249,56 @@ git diff -- crates/rns-interface/src/ble_rnode.rs \ When upstream ships an equivalent debounce (or a passkey-window pause), remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +## rsReticulum-discovery-announce-egress.patch + +Register `rnstransport.discovery.interface` as a local destination before announcing, and defer `Announcer::register` until the discoverable interface online latch is true. Without this, Boundary hubs such as **rmap.world** silently drop discovery announces (non-local + no path), and BLE RNode can consume a multi-hour `announce_interval` on a no-op TX while still connecting. + +| Field | Value | +| ----- | ----- | +| **Base commit** | `6d2b28475321bc15c8f60796513d8878b47ed3ab` (after prior overlays) | +| **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/19 | + +**Modifies (3 files):** + +- `crates/rns-runtime/src/reticulum.rs` — online latch on `LocalDiscoveryInterface`, `take_online_discovery_interfaces`, `discovery_local_destination_registration`, deferred announcer +- `crates/rns-transport/src/actor/mod.rs` — outbound discovery announce egress regression tests +- `crates/rns-transport/src/discovery/announcer.rs` — RateLimit-after-discard regression test + +### Apply locally + +From mesh-client repo root (sibling `../rsReticulum` required): + +```bash +./scripts/apply-rsReticulum-discovery-announce-egress.sh +``` + +Apply **after** the other rsReticulum overlays (packet-tap also touches `reticulum.rs`): + +```bash +./scripts/apply-rsReticulum-packet-tap.sh +./scripts/apply-rsReticulum-auto-beacon-utun.sh +./scripts/apply-rsReticulum-link-client-nomad.sh +./scripts/apply-rsReticulum-rnode-tcp-activity-keepalive.sh +./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +./scripts/apply-rsReticulum-discovery-announce-egress.sh +``` + +### Regenerate + +```bash +# After applying prior overlays on the pin, implement the discovery fix, then: +cd ../rsReticulum +git diff -- \ + crates/rns-runtime/src/reticulum.rs \ + crates/rns-transport/src/actor/mod.rs \ + crates/rns-transport/src/discovery/announcer.rs \ + > ../mesh-client/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch +``` + +### 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`. + ## rsLXMF-propagation-sync-peering.patch LinkIdentify + peering stamp before LXMF `/offer`, sticky offer/finish fields, plus Establishing diagnostics (`last_establish_error` + warn when LRPROOF is ignored for missing identity or invalid proof) so mesh-client can complete remote PN sync and surface non-generic failures. @@ -258,3 +334,73 @@ git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- crates/lxmf-core/src/propag ### 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`. + +## 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. + +| Field | Value | +| ----- | ----- | +| **Base commit** | `68ad7c835187c052c763bb28c41b04a655f35c64` | +| **Upstream PR** | https://github.com/ratspeak/rsLXMF/pull/6 | + +**Modifies (1 file):** + +- `crates/lxmf-core/src/propagation_node.rs` — three policy setters on `PropagationNode` + +### Apply locally + +From mesh-client repo root (sibling `../rsLXMF` required): + +```bash +./scripts/apply-rsLXMF-propagation-node-policy-setters.sh +``` + +`clone-ratspeak-stack.sh` and `ensure-rsReticulum-patches.sh` invoke this automatically. + +### Regenerate + +```bash +cd ../rsLXMF +git fetch origin +git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- 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`. + +## 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. + +| Field | Value | +| ----- | ----- | +| **Base commit** | `68ad7c835187c052c763bb28c41b04a655f35c64` | +| **Upstream PR** | (none yet — mesh-client local API) | + +**Modifies (1 file):** + +- `crates/lxmf-core/src/link_delivery.rs` — `has_pending_to(&[u8; 16]) -> bool` + +### Apply locally + +```bash +./scripts/apply-rsLXMF-link-delivery-has-pending-to.sh +``` + +`clone-ratspeak-stack.sh` and `ensure-rsReticulum-patches.sh` invoke this automatically. + +### Regenerate + +```bash +cd ../rsLXMF +git fetch origin +git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- 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. diff --git a/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch b/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch new file mode 100644 index 000000000..893ce3f7f --- /dev/null +++ b/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch @@ -0,0 +1,17 @@ +diff --git a/crates/lxmf-core/src/link_delivery.rs b/crates/lxmf-core/src/link_delivery.rs +index fc12604..30c84ae 100644 +--- a/crates/lxmf-core/src/link_delivery.rs ++++ b/crates/lxmf-core/src/link_delivery.rs +@@ -2301,6 +2301,12 @@ 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. ++ pub fn has_pending_to(&self, dest_hash: &[u8; 16]) -> bool { ++ self.pending.values().any(|d| d.dest_hash == *dest_hash) ++ } ++ + pub fn direct_link_snapshot(&self, dest_hash: [u8; 16]) -> Option { + let link_id = *self.direct_links.get(&dest_hash)?; + let delivery = self.pending.get(&link_id)?; diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch b/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch new file mode 100644 index 000000000..ba805fb39 --- /dev/null +++ b/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch @@ -0,0 +1,23 @@ +diff --git a/crates/lxmf-core/src/propagation_node.rs b/crates/lxmf-core/src/propagation_node.rs +index 5032269..33772f0 100644 +--- a/crates/lxmf-core/src/propagation_node.rs ++++ b/crates/lxmf-core/src/propagation_node.rs +@@ -172,6 +172,18 @@ impl PropagationNode { + self.config.min_stamp_cost = cost; + } + ++ pub fn set_peering_cost(&mut self, cost: u8) { ++ self.config.peering_cost = cost; ++ } ++ ++ pub fn set_max_storage(&mut self, max: usize) { ++ self.config.max_storage = max; ++ } ++ ++ pub fn set_max_message_size(&mut self, max: usize) { ++ self.config.max_message_size = max; ++ } ++ + /// Disk-backed node. Loads existing messages from `storage_path` on startup. + pub fn with_storage( + config: PropagationNodeConfig, diff --git a/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch b/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch new file mode 100644 index 000000000..6489780df --- /dev/null +++ b/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch @@ -0,0 +1,396 @@ +diff --git a/crates/rns-runtime/src/reticulum.rs b/crates/rns-runtime/src/reticulum.rs +index 5e73442..f466dd7 100644 +--- a/crates/rns-runtime/src/reticulum.rs ++++ b/crates/rns-runtime/src/reticulum.rs +@@ -104,10 +104,13 @@ impl Default for DiscoveryRuntime { + } + } + +-#[derive(Debug, Clone)] ++#[derive(Clone)] + struct LocalDiscoveryInterface { + id: u64, + config: DiscoveryInterfaceConfig, ++ /// Shared online flag from the interface driver. When `None`, treat as ++ /// always online (interfaces that never expose a readiness latch). ++ online: Option>, + } + + impl ReticulumHandle { +@@ -1298,6 +1301,7 @@ pub async fn init( + Ok(iface_handles) => { + for iface_handle in iface_handles { + let registered_id = iface_handle.id; ++ let online = iface_handle.online.clone(); + register_interface_with_post_init( + &transport_tx, + iface_handle, +@@ -1311,6 +1315,7 @@ pub async fn init( + LocalDiscoveryInterface { + id: registered_id, + config: cfg.clone(), ++ online: Some(online), + }, + ); + } +@@ -2338,15 +2343,69 @@ async fn start_on_network_discovery(handle: ReticulumHandle) { + } + } + ++/// Whether a discoverable interface is ready for `Announcer::register`. ++/// ++/// `None` online latch means always ready (drivers that never expose readiness). ++fn discovery_interface_is_online(local: &LocalDiscoveryInterface) -> bool { ++ local ++ .online ++ .as_ref() ++ .map(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)) ++ .unwrap_or(true) ++} ++ ++/// Move online discoverable interfaces out of `pending` and return those newly ++/// ready for announcer registration. Offline ones stay pending. Already- ++/// registered ids are ignored. ++fn take_online_discovery_interfaces( ++ pending: &mut Vec, ++ registered: &mut std::collections::HashSet, ++) -> Vec { ++ let mut ready = Vec::new(); ++ pending.retain(|local| { ++ if !discovery_interface_is_online(local) { ++ return true; ++ } ++ if registered.insert(local.id) { ++ ready.push(local.clone()); ++ } ++ false ++ }); ++ ready ++} ++ ++/// Hash + `RegisterDestination` that marks the discovery aspect as instance-local ++/// so outbound announces pass `interface_allows_announce` (non-local + no path ++/// is blocked on Boundary / Roaming). ++fn discovery_local_destination_registration( ++ identity_hash: &[u8; 16], ++) -> ([u8; 16], TransportMessage) { ++ let hash = rns_identity::destination::Destination::hash_from_name_and_identity( ++ rns_transport::discovery::DISCOVERY_ASPECT_FILTER, ++ Some(identity_hash), ++ ); ++ ( ++ hash, ++ TransportMessage::RegisterDestination { ++ hash, ++ app_name: rns_transport::discovery::DISCOVERY_ASPECT_FILTER.to_string(), ++ delivery_tx: None, ++ }, ++ ) ++} ++ + async fn run_discovery_announcer( + handle: ReticulumHandle, + stamper: Arc, + locals: Vec, + ) { + let mut announcer = Announcer::new(stamper); +- for local in locals { +- announcer.register(local.id, handle.transport_identity.hash, local.config); +- } ++ // Defer Announcer::register until the driver marks the interface online. ++ // BLE RNode (and similar) come up seconds after init — registering and ++ // announcing immediately rate-limits the next publish for announce_interval ++ // (often hours) even though the first packet left before TX was ready. ++ let mut pending: Vec = locals; ++ let mut registered: std::collections::HashSet = std::collections::HashSet::new(); + + let announce_identity = handle + .network_identity +@@ -2355,13 +2414,38 @@ async fn run_discovery_announcer( + let encrypt_identity = handle.network_identity.clone(); + let tick_interval = Duration::from_secs(rns_transport::discovery::ANNOUNCE_JOB_INTERVAL_SECS); + ++ let (discovery_dest, register_msg) = ++ discovery_local_destination_registration(&announce_identity.hash); ++ let _ = handle.transport_tx.send(register_msg).await; ++ tracing::info!( ++ dest = %hex::encode(discovery_dest), ++ "discovery destination registered as local for announce egress" ++ ); ++ + loop { ++ for local in take_online_discovery_interfaces(&mut pending, &mut registered) { ++ tracing::info!( ++ iface_id = local.id, ++ name = %local.config.name, ++ "discovery interface online — starting announces" ++ ); ++ announcer.register( ++ local.id, ++ handle.transport_identity.hash, ++ local.config.clone(), ++ ); ++ } ++ + let encrypt = |plaintext: &[u8]| { + encrypt_identity + .as_ref() + .and_then(|identity| identity.encrypt(plaintext, None).ok()) + }; +- let (requests, _skips) = announcer.tick(unix_now(), Some(&encrypt)); ++ let (requests, _skips) = if announcer.is_empty() { ++ (Vec::new(), Vec::new()) ++ } else { ++ announcer.tick(unix_now(), Some(&encrypt)) ++ }; + for request in requests { + match build_announce_packet( + &announce_identity, +@@ -2369,15 +2453,16 @@ async fn run_discovery_announcer( + Some(&request.app_data), + ) { + Ok(raw) => { ++ let destination_hash = ++ rns_identity::destination::Destination::hash_from_name_and_identity( ++ rns_transport::discovery::DISCOVERY_ASPECT_FILTER, ++ Some(&announce_identity.hash), ++ ); + let _ = handle + .transport_tx + .send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), +- destination_hash: +- rns_identity::destination::Destination::hash_from_name_and_identity( +- rns_transport::discovery::DISCOVERY_ASPECT_FILTER, +- Some(&announce_identity.hash), +- ), ++ destination_hash, + })) + .await; + } +@@ -2389,7 +2474,14 @@ async fn run_discovery_announcer( + + tokio::select! { + _ = handle.shutdown.wait() => break, +- _ = tokio::time::sleep(tick_interval) => {} ++ // While waiting for offline discoverable interfaces, poll frequently ++ // so the first announce fires soon after BLE/serial comes up rather ++ // than waiting a full announce-job minute. ++ _ = tokio::time::sleep(if pending.is_empty() { ++ tick_interval ++ } else { ++ Duration::from_secs(1) ++ }) => {} + } + } + } +@@ -4130,6 +4222,77 @@ loglevel = 7 + assert!(h.discovery_enabled().await); + } + ++ #[test] ++ fn discovery_local_destination_registration_uses_discovery_aspect() { ++ let identity_hash = [0x2e; 16]; ++ let (dest, msg) = discovery_local_destination_registration(&identity_hash); ++ let expected = rns_identity::destination::Destination::hash_from_name_and_identity( ++ rns_transport::discovery::DISCOVERY_ASPECT_FILTER, ++ Some(&identity_hash), ++ ); ++ assert_eq!(dest, expected); ++ match msg { ++ TransportMessage::RegisterDestination { ++ hash, ++ app_name, ++ delivery_tx, ++ } => { ++ assert_eq!(hash, expected); ++ assert_eq!(app_name, rns_transport::discovery::DISCOVERY_ASPECT_FILTER); ++ assert!(delivery_tx.is_none()); ++ } ++ other => panic!("expected RegisterDestination, got {other:?}"), ++ } ++ } ++ ++ #[test] ++ fn take_online_discovery_interfaces_defers_offline_until_flag_flips() { ++ let online = Arc::new(AtomicBool::new(false)); ++ let cfg = DiscoveryInterfaceConfig::backbone("NV0N".into(), "unused".into(), 0); ++ let mut pending = vec![LocalDiscoveryInterface { ++ id: 7, ++ config: cfg.clone(), ++ online: Some(online.clone()), ++ }]; ++ let mut registered = std::collections::HashSet::new(); ++ ++ let ready = take_online_discovery_interfaces(&mut pending, &mut registered); ++ assert!(ready.is_empty(), "offline iface must stay pending"); ++ assert_eq!(pending.len(), 1); ++ assert!(registered.is_empty()); ++ ++ online.store(true, std::sync::atomic::Ordering::SeqCst); ++ let ready = take_online_discovery_interfaces(&mut pending, &mut registered); ++ assert_eq!(ready.len(), 1); ++ assert_eq!(ready[0].id, 7); ++ assert!(pending.is_empty()); ++ assert!(registered.contains(&7)); ++ ++ // Idempotent: already-registered id is not returned again even if re-queued. ++ pending.push(LocalDiscoveryInterface { ++ id: 7, ++ config: cfg, ++ online: Some(online), ++ }); ++ let ready = take_online_discovery_interfaces(&mut pending, &mut registered); ++ assert!(ready.is_empty()); ++ assert!(pending.is_empty()); ++ } ++ ++ #[test] ++ fn take_online_discovery_interfaces_treats_missing_latch_as_online() { ++ let cfg = DiscoveryInterfaceConfig::backbone("hub".into(), "10.0.0.1".into(), 4242); ++ let mut pending = vec![LocalDiscoveryInterface { ++ id: 1, ++ config: cfg, ++ online: None, ++ }]; ++ let mut registered = std::collections::HashSet::new(); ++ let ready = take_online_discovery_interfaces(&mut pending, &mut registered); ++ assert_eq!(ready.len(), 1); ++ assert!(pending.is_empty()); ++ } ++ + #[tokio::test] + async fn enable_overrides_previous_stamper_without_error() { + let h = dummy_handle(); +diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs +index 612cc04..710a657 100644 +--- a/crates/rns-transport/src/actor/mod.rs ++++ b/crates/rns-transport/src/actor/mod.rs +@@ -6425,6 +6425,87 @@ mod tests { + let _ = std::fs::remove_dir_all(&dir); + } + ++ /// Local discovery announces use `on_outbound` → `broadcast_local_announce_on_interfaces`. ++ /// Without RegisterDestination (and with no path), Boundary is gated off — ++ /// RMAP hubs never see TX even though outbound routing logs ran. ++ /// (On this pin, Full/Gateway still allow non-local with no path; tip main ++ /// also gates those via a mode-independent local/path check.) ++ #[test] ++ fn outbound_discovery_announce_silent_on_boundary_without_local_destination() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ ++ let (mut boundary, mut boundary_rx) = make_test_interface("RMAP World"); ++ boundary.mode = InterfaceMode::Boundary; ++ actor.interfaces.insert(1, boundary); ++ ++ let (mut ap, mut ap_rx) = make_test_interface("RNode"); ++ ap.mode = InterfaceMode::AccessPoint; ++ actor.interfaces.insert(3, ap); ++ ++ let (raw, dest) = make_valid_announce("rnstransport.discovery.interface", 0); ++ actor.on_outbound(OutboundRequest { ++ raw, ++ destination_hash: dest, ++ }); ++ ++ assert!( ++ boundary_rx.try_recv().is_err(), ++ "non-local discovery announce must not TX on Boundary" ++ ); ++ assert!( ++ ap_rx.try_recv().is_err(), ++ "AccessPoint must never receive announces" ++ ); ++ } ++ ++ /// After RegisterDestination, locally originated discovery announces egress ++ /// Boundary + Full (e.g. rmap.world hub) but never AccessPoint (discoverable ++ /// RNode mode autocorrect). ++ #[test] ++ fn outbound_discovery_announce_egresses_boundary_and_full_when_local() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ ++ let (mut boundary, mut boundary_rx) = make_test_interface("RMAP World"); ++ boundary.mode = InterfaceMode::Boundary; ++ actor.interfaces.insert(1, boundary); ++ ++ let (mut full, mut full_rx) = make_test_interface("full"); ++ full.mode = InterfaceMode::Full; ++ actor.interfaces.insert(2, full); ++ ++ let (mut ap, mut ap_rx) = make_test_interface("RNode"); ++ ap.mode = InterfaceMode::AccessPoint; ++ actor.interfaces.insert(3, ap); ++ ++ let (raw, dest) = make_valid_announce("rnstransport.discovery.interface", 0); ++ actor.handle_message(TransportMessage::RegisterDestination { ++ hash: dest, ++ app_name: "rnstransport.discovery.interface".to_string(), ++ delivery_tx: None, ++ }); ++ assert!(actor.local_destinations.contains(&dest)); ++ ++ actor.on_outbound(OutboundRequest { ++ raw, ++ destination_hash: dest, ++ }); ++ ++ assert!( ++ boundary_rx.try_recv().is_ok(), ++ "local discovery announce must TX on Boundary (RMAP hub)" ++ ); ++ assert!( ++ full_rx.try_recv().is_ok(), ++ "local discovery announce must TX on Full" ++ ); ++ assert!( ++ ap_rx.try_recv().is_err(), ++ "AccessPoint must not receive local or relayed announces" ++ ); ++ } ++ + #[test] + fn outbound_announce_respects_access_point_mode() { + let (mut actor, _tx) = TransportActor::new(); +diff --git a/crates/rns-transport/src/discovery/announcer.rs b/crates/rns-transport/src/discovery/announcer.rs +index 2062718..abdc204 100644 +--- a/crates/rns-transport/src/discovery/announcer.rs ++++ b/crates/rns-transport/src/discovery/announcer.rs +@@ -561,4 +561,35 @@ mod tests { + assert_eq!(second[0].interface_id, 2); + assert!(matches!(skips[0].1, SkipReason::RateLimited { .. })); + } ++ ++ /// Document why the runtime must defer `Announcer::register` until the ++ /// discoverable interface is online: `tick` stamps `last_announce_at` as ++ /// soon as it emits a request, even if the caller never puts the packet ++ /// on the wire (e.g. BLE RNode still coming up). The next attempt is then ++ /// RateLimited for the full announce_interval (often hours). ++ #[test] ++ fn discarding_tick_request_still_rate_limits() { ++ let mut a = Announcer::new(static_stamper()); ++ let mut cfg = sample_backbone(); ++ cfg.announce_interval_secs = 3600; ++ a.register(1, [0x11; 16], cfg); ++ ++ let (requests, skips) = a.tick(1_000.0, None); ++ assert_eq!(requests.len(), 1); ++ assert!(skips.is_empty()); ++ drop(requests); // simulate "queued" then never TX'd ++ ++ let (requests, skips) = a.tick(1_060.0, None); ++ assert!(requests.is_empty(), "must not re-emit within interval"); ++ assert_eq!(skips.len(), 1); ++ match &skips[0].1 { ++ SkipReason::RateLimited { remaining_secs } => { ++ assert!( ++ *remaining_secs > 3000, ++ "discarded request must still burn nearly the full interval, got {remaining_secs}" ++ ); ++ } ++ other => panic!("expected RateLimited, got {other:?}"), ++ } ++ } + } diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 8305e96f9..05fac2674 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -139,6 +139,10 @@ pub fn router(stack: Arc) -> Router { "/api/v1/propagation/auto-sync-interval", post(propagation::set_propagation_auto_sync_interval), ) + .route( + "/api/v1/propagation/hosting-policy", + post(propagation::set_pn_hosting_policy), + ) .route( "/api/v1/propagation/{id}/enable", post(propagation::enable_propagation), diff --git a/reticulum-sidecar/src/api/propagation.rs b/reticulum-sidecar/src/api/propagation.rs index 10bc64706..7e25f5bd9 100644 --- a/reticulum-sidecar/src/api/propagation.rs +++ b/reticulum-sidecar/src/api/propagation.rs @@ -20,6 +20,8 @@ pub struct PropagationSyncBody { pub struct AddPropagationBody { pub destination_hash: String, pub name: Option, + #[serde(default)] + pub skip_probe: bool, } #[derive(Debug, Deserialize)] @@ -27,12 +29,22 @@ pub struct RenamePropagationBody { pub name: String, } +pub async fn set_pn_hosting_policy( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.set_pn_hosting_policy(body).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + pub async fn add_propagation_node( State(stack): State>, Json(body): Json, ) -> Json { match stack - .add_propagation_node(&body.destination_hash, body.name) + .add_propagation_node(&body.destination_hash, body.name, body.skip_probe) .await { Ok(res) => Json(res), diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 56814ef6b..9708c4ff1 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -48,7 +48,11 @@ use super::packet_log::{ }; use super::path_speed; use super::persistence::PersistedState; +use super::pn_hosting_apply::{apply_pn_hosting_policy_to_node, apply_pn_hosting_policy_to_router}; +use super::pn_hosting_policy::PnHostingPolicy; +use super::propagation_announce::PropagationAnnounceLoop; use super::propagation_bridge::PropagationBridge; +use super::propagation_serve::PropagationServeHandle; use super::rncp_transfer::RncpTransferManager; use super::rnsh_session::RnshSessionManager; use super::rrc_defaults::RRC_HUB_ASPECT; @@ -93,6 +97,9 @@ pub struct LiveBridge { display_name_cache: Arc>>, outbound: Arc>, propagation: Arc, + prop_serve: Arc, + prop_announce: Arc, + pn_hosting_policy: Arc>, /// Per-run cancel token; replaced on each new sync so stale emitters cannot reset it. sync_cancel: Mutex>, /// Generation for the active sync emitter; stale emitters must not cancel/clear pins. @@ -274,7 +281,12 @@ impl LiveBridge { contacts_to_name_map(&state.contacts) })); + let pn_hosting_policy = { + let state = inner.read().await; + state.pn_hosting_policy.clone() + }; let mut router = LxmRouter::new(lxmf_core::router::RouterConfig::default()); + apply_pn_hosting_policy_to_router(&mut router, &pn_hosting_policy); router.set_transport(handle.transport_tx.clone()); let cache_for_cb = peer_via_cache.clone(); @@ -404,7 +416,11 @@ impl LiveBridge { lxmf_propagation_dest_hash, storage_dir.join("propagation"), &identity, + &pn_hosting_policy, )?), + prop_serve: Arc::new(PropagationServeHandle::new()), + prop_announce: Arc::new(PropagationAnnounceLoop::new()), + pn_hosting_policy: Arc::new(Mutex::new(pn_hosting_policy)), sync_cancel: Mutex::new(Arc::new(AtomicBool::new(false))), sync_run_id: Arc::new(AtomicU64::new(0)), discovered_propagation: Arc::new(Mutex::new(HashMap::new())), @@ -1089,6 +1105,7 @@ impl LiveBridge { /// Register handler for LXMF propagation-node announces (`lxmf.propagation`). /// /// Upserts an in-memory discovered list (not auto-added to configured PNs). + /// When local hosting + autopeer are on, also feeds `LxmRouter::autopeer`. pub fn register_propagation_announce_handler(&self) { const LXMF_PROPAGATION_ASPECT: &str = "lxmf.propagation"; const MAX_DISCOVERED_PROPAGATION: usize = 200; @@ -1096,6 +1113,9 @@ impl LiveBridge { let event_tx = self.event_tx.clone(); let discovered = Arc::clone(&self.discovered_propagation); let outbound = Arc::clone(&self.outbound); + let router = Arc::clone(&self.router); + let propagation = Arc::clone(&self.propagation); + let pn_hosting_policy = Arc::clone(&self.pn_hosting_policy); tokio::spawn(async move { let (callback_tx, mut callback_rx) = tokio::sync::mpsc::channel::(64); @@ -1121,6 +1141,17 @@ impl LiveBridge { let Some(parsed) = lxmf_core::handlers::parse_pn_announce_data(app_data) else { continue; }; + // lxmd parity: cache PN stamp cost for outbound Propagated packing. + { + let mut router = router.lock().await; + router.set_stamp_cost(evt.destination_hash, parsed.stamp_cost); + tracing::debug!( + target: "propagation-discovered", + dest = %hex::encode(evt.destination_hash), + stamp_cost = parsed.stamp_cost, + "learned propagation-node stamp cost from announce" + ); + } let hash_hex = hex::encode(evt.destination_hash); let identity_hash_hex = evt.identity_hash.map(hex::encode); let display_name = lxmf_core::handlers::pn_name_from_app_data(app_data); @@ -1175,6 +1206,40 @@ impl LiveBridge { let frame = serde_json::json!({ "type": "propagation.discovered", "payload": payload }); let _ = event_tx.send(frame.to_string()); + + // Autopeer only while hosting a local PN (lxmd parity). + if propagation.is_local_serving() { + let autopeer_on = pn_hosting_policy + .lock() + .ok() + .map(|p| p.autopeer) + .unwrap_or(true); + if autopeer_on && parsed.node_state { + let mut router = router.lock().await; + // AutopeerCandidate uses f64; announce wire fields are i64/u64. + // Precision loss is fine for timebase/limits (KB-scale / unix seconds). + #[allow(clippy::cast_precision_loss)] + let peered = router.autopeer(lxmf_core::router::AutopeerCandidate { + destination_hash: evt.destination_hash, + timebase: parsed.timebase as f64, + transfer_limit: Some(parsed.transfer_limit as f64), + sync_limit: Some(parsed.sync_limit as f64), + stamp_cost: Some(parsed.stamp_cost), + stamp_flexibility: Some(parsed.stamp_flex), + peering_cost: Some(parsed.peering_cost), + hops: Some(evt.hops), + }); + if !peered { + tracing::debug!( + target: "propagation-discovered", + dest = %hash_hex, + hops = evt.hops, + peering_cost = parsed.peering_cost, + "autopeer declined candidate" + ); + } + } + } } }); } @@ -1983,6 +2048,147 @@ impl LiveBridge { pub async fn set_local_propagation_serving(&self, enabled: bool) { let mut router = self.router.lock().await; self.propagation.set_local_serving(enabled, &mut router); + drop(router); + + if enabled { + let policy = self + .pn_hosting_policy + .lock() + .ok() + .map(|p| p.clone()) + .unwrap_or_default(); + if let Err(e) = self.prop_serve.start( + &self.handle.transport_tx, + &self.identity, + self.propagation.local_dest_hash_bytes(), + self.propagation.local_node(), + ) { + tracing::error!(target: "propagation-serve", "failed to start serve: {e}"); + let mut router = self.router.lock().await; + self.propagation.set_local_serving(false, &mut router); + return; + } + self.prop_announce.start( + self.handle.transport_tx.clone(), + self.identity.clone(), + self.propagation.local_dest_hash_bytes(), + Arc::clone(&self.pn_hosting_policy), + policy.announce_at_start, + ); + } else { + self.prop_announce.stop(); + self.prop_serve.stop(); + } + } + + pub async fn apply_pn_hosting_policy(&self, policy: &PnHostingPolicy) -> Result<(), String> { + { + let mut slot = self + .pn_hosting_policy + .lock() + .map_err(|_| "pn_hosting_policy_mutex_poisoned".to_string())?; + *slot = policy.clone(); + } + { + let mut router = self.router.lock().await; + apply_pn_hosting_policy_to_router(&mut router, policy); + } + if let Ok(mut node) = self.propagation.local_node().lock() { + apply_pn_hosting_policy_to_node(&mut node, policy); + } + // Restart announce loop with updated interval / name when serving. + if self.propagation.is_local_serving() { + self.prop_announce.start( + self.handle.transport_tx.clone(), + self.identity.clone(), + self.propagation.local_dest_hash_bytes(), + Arc::clone(&self.pn_hosting_policy), + false, + ); + } + Ok(()) + } + + /// Lightweight `/offer` capability probe before persisting a remote PN. + /// + /// Returns `Ok(())` when the remote answers `/offer` (including LXMF offer + /// errors that prove the handler ran). Hard-fails with + /// `PROPAGATION_OFFER_UNSUPPORTED` only when the offer response is + /// unrecognized (`Unknown`). + pub async fn probe_propagation_offer(&self, destination_hash: &str) -> Result<(), String> { + let dest_hex = destination_hash.trim().to_lowercase(); + let hash = parse_hash16(&dest_hex)?; + self.cancel_propagation_sync().await; + self.rehydrate_propagation_identities_from_persisted(); + let identity_ok = self.ensure_identity_for_direct(&dest_hex).await; + if !self.ensure_path_for_direct(&dest_hex, true).await { + return Err("PROPAGATION_PATH_UNKNOWN".into()); + } + let identity_known_after = self + .outbound + .lock() + .map(|d| d.identity_known_for(&dest_hex)) + .unwrap_or(false); + if !identity_ok || !identity_known_after { + return Err("PROPAGATION_IDENTITY_UNKNOWN".into()); + } + let target_class = self.classify_propagation_sync_target(&dest_hex).await; + if target_class == "delivery" || target_class == "other" { + return Err("PROPAGATION_TARGET_NOT_PN".into()); + } + let peering = self.resolve_propagation_peering(&dest_hex).await?; + // Claim outbound sync target so PN deposits defer during the offer probe + // (same latch as start_propagation_sync). cancel_propagation_sync clears it + // on every polling exit; release immediately if start_sync never begins. + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(Some(hash)); + } + if !self.propagation.start_sync(hash, Some(peering)) { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } + return Err("PROPAGATION_OFFER_PROBE_FAILED".into()); + } + + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if Instant::now() >= deadline { + self.cancel_propagation_sync().await; + return Err("PROPAGATION_OFFER_PROBE_TIMEOUT".into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + if let Some(err) = self.propagation.last_offer_error() { + self.cancel_propagation_sync().await; + return if err == "Unknown" { + Err("PROPAGATION_OFFER_UNSUPPORTED".into()) + } else { + // Handler ran (NoIdentity / InvalidKey / …) — path exists. + Ok(()) + }; + } + if let Some(err) = self.propagation.last_establish_error() { + self.cancel_propagation_sync().await; + return Err(format!("propagation establish failed: {err}")); + } + let progress = self.propagation.sync_progress(); + // Offering / later stages prove /offer was accepted enough to proceed. + if progress >= 25.0 { + self.cancel_propagation_sync().await; + return Ok(()); + } + if !self.propagation.sync_active() && progress <= 0.0 { + if let Some(ok) = self.propagation.last_finished_ok() { + self.cancel_propagation_sync().await; + return if ok { + Ok(()) + } else if self.propagation.last_offer_error() == Some("Unknown") { + Err("PROPAGATION_OFFER_UNSUPPORTED".into()) + } else { + Err("PROPAGATION_OFFER_PROBE_FAILED".into()) + }; + } + } + } } pub fn propagation_local_stats(&self) -> (usize, usize) { @@ -2024,6 +2230,18 @@ impl LiveBridge { let dest_hex = destination_hash.to_lowercase(); // Cancel any in-flight sync/emitter before starting a new one. self.cancel_propagation_sync().await; + // Claim the PN Link early so outbound packed deposits defer instead of racing. + if let Ok(mut driver) = self.outbound.lock() { + if driver.has_inflight_delivery_to(&hash) { + tracing::info!( + target: "propagation-sync", + dest = %dest_hex, + "deferring propagation sync — outbound PN deposit already in flight" + ); + return Err("PROPAGATION_SYNC_OUTBOUND_BUSY".into()); + } + driver.set_propagation_sync_target(Some(hash)); + } // Re-apply persisted PN pubkey before identity wait (announce flood may have evicted it). self.rehydrate_propagation_identities_from_persisted(); // Link proofs are ignored unless the destination pubkey is in known_identities. @@ -2037,13 +2255,27 @@ impl LiveBridge { .unwrap_or(false); let target_class = self.classify_propagation_sync_target(&dest_hex).await; if !identity_ok || !identity_known_after { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } return Err("PROPAGATION_IDENTITY_UNKNOWN".into()); } // Only hard-reject destinations positively classified as non-PN. if target_class == "delivery" || target_class == "other" { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } return Err("PROPAGATION_TARGET_NOT_PN".into()); } - let peering = self.resolve_propagation_peering(&dest_hex).await?; + let peering = match self.resolve_propagation_peering(&dest_hex).await { + Ok(p) => p, + Err(e) => { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } + return Err(e); + } + }; // Fresh LXMF delivery announce so the PN can return LRPROOF (reverse path). let announced = self .ensure_lxmf_announce_for_propagation_sync(&dest_hex) @@ -2057,6 +2289,23 @@ impl LiveBridge { ); tokio::time::sleep(PROPAGATION_SYNC_ANNOUNCE_SETTLE).await; } + // After settle: bail if an outbound deposit claimed the PN Link. + let outbound_busy = self + .outbound + .lock() + .map(|d| d.has_inflight_delivery_to(&hash)) + .unwrap_or(false); + if outbound_busy { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } + tracing::info!( + target: "propagation-sync", + dest = %dest_hex, + "deferring propagation sync — outbound PN deposit in flight" + ); + return Err("PROPAGATION_SYNC_OUTBOUND_BUSY".into()); + } let hops = self.hops_to_destination(&dest_hex).await; // Pin PN pubkey for the duration of Establishing so announce-flood eviction // cannot drop it before LRPROOF validation (see known_identities cap). @@ -2080,9 +2329,15 @@ impl LiveBridge { // Fresh cancel token + generation so a prior emitter cannot cancel/clear this run. let (cancel, run_id) = { let Ok(_lifecycle) = self.propagation.lock_sync_lifecycle() else { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } return Err("propagation sync unavailable".into()); }; let Ok(mut slot) = self.sync_cancel.lock() else { + if let Ok(mut driver) = self.outbound.lock() { + driver.set_propagation_sync_target(None); + } return Err("propagation sync unavailable".into()); }; let cancel = Arc::new(AtomicBool::new(false)); @@ -2093,6 +2348,7 @@ impl LiveBridge { if !self.propagation.start_sync(hash, Some(peering)) { if let Ok(mut driver) = self.outbound.lock() { driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); } return Err("propagation sync unavailable".into()); } @@ -2100,6 +2356,7 @@ impl LiveBridge { let on_terminal: Arc = Arc::new(move || { if let Ok(mut driver) = outbound.lock() { driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); } }); self.propagation.spawn_sync_progress_emitter( @@ -2131,9 +2388,19 @@ impl LiveBridge { let peer_id = peer_identity.hash; let local_id = self.identity.hash; let peering_cost = self - .pn_announce_peering_cost(destination_hex) + .refresh_pn_announce_costs(destination_hex) .await + .map(|(_, peering)| peering) .unwrap_or(lxmf_core::constants::PEERING_COST); + let max_cost = self + .pn_hosting_policy + .lock() + .ok() + .map(|p| p.max_peering_cost) + .unwrap_or(lxmf_core::constants::MAX_PEERING_COST); + if peering_cost > max_cost { + return Err("PROPAGATION_PEER_COST_EXCEEDS_MAX".into()); + } let precomputed = if peering_cost == 0 { Some(Vec::new()) } else { @@ -2156,7 +2423,9 @@ impl LiveBridge { Ok((local_id, peer_id, peering_cost, precomputed)) } - async fn pn_announce_peering_cost(&self, destination_hex: &str) -> Option { + /// Learn PN stamp/peering costs from a recent `lxmf.propagation` announce (lxmd parity). + /// Returns `(stamp_cost, peering_cost)` when found. + async fn refresh_pn_announce_costs(&self, destination_hex: &str) -> Option<(u8, u8)> { let resp = self .query_control_timed(TransportQuery::GetRecentAnnounces) .await; @@ -2168,11 +2437,20 @@ impl LiveBridge { if hex::encode(entry.dest_hash).to_lowercase() != key { continue; } - return entry + let parsed = entry .app_data .as_deref() - .and_then(lxmf_core::handlers::parse_pn_announce_data) - .map(|d| d.peering_cost); + .and_then(lxmf_core::handlers::parse_pn_announce_data)?; + let mut router = self.router.lock().await; + router.set_stamp_cost(entry.dest_hash, parsed.stamp_cost); + tracing::info!( + target: "propagation-sync", + dest = %key, + stamp_cost = parsed.stamp_cost, + peering_cost = parsed.peering_cost, + "cached PN announce stamp/peering costs" + ); + return Some((parsed.stamp_cost, parsed.peering_cost)); } None } @@ -2193,6 +2471,7 @@ impl LiveBridge { self.propagation.cancel_sync(); if let Ok(mut driver) = self.outbound.lock() { driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); } } @@ -2202,6 +2481,10 @@ impl LiveBridge { if let Ok(mut driver) = self.outbound.lock() { driver.set_propagation_node(&mut router, hash); } + drop(router); + if let Some(hex) = destination_hash { + let _ = self.refresh_pn_announce_costs(hex).await; + } } pub async fn fetch_interfaces(&self) -> Result, String> { @@ -2359,6 +2642,10 @@ impl LiveBridge { router.outbound_propagation_node.map(hex::encode) }; let preferred_pn_set = preferred_pn_hash.is_some(); + // Ensure preferred PN stamp cost is cached before any Direct→Propagated fallback pack. + if let Some(ref pn_hex) = preferred_pn_hash { + let _ = self.refresh_pn_announce_costs(pn_hex).await; + } // Prefer Direct when a path can be discovered — do not immediately park on // the preferred PN just because the local path table was empty at click time. diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index a4849fe92..45bce1f8c 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -3,8 +3,12 @@ use std::collections::{HashMap, HashSet}; use bytes::Bytes; -use lxmf_core::constants::DeliveryMethod; -use lxmf_core::link_delivery::{DeliveryResult, LinkDeliveryManager}; +use lxmf_core::constants::{ + DELIVERY_RETRY_WAIT, DeliveryMethod, MAX_DELIVERY_ATTEMPTS, PATH_REQUEST_WAIT, +}; +use lxmf_core::link_delivery::{ + DeliveryResult, LinkDeliveryManager, is_retryable_link_delivery_failure, +}; use lxmf_core::message::LxMessage; use lxmf_core::router::{ DirectDeliveryPlan, DirectDeliveryPlanInput, DirectReusableLinkState, DirectRouteSnapshot, @@ -98,6 +102,8 @@ pub struct LxmfOutboundDriver { path_request_gate: PathRequestGate, /// Message hashes that already consumed the one-shot Direct→PN fallback. pn_fallback_attempted: HashSet<[u8; 32]>, + /// When set, remote propagation sync holds a Link to this dest — do not race deposits. + propagation_sync_target: Option<[u8; 16]>, self_lxmf_hash: String, self_display_name: String, } @@ -123,6 +129,7 @@ impl LxmfOutboundDriver { path_table_hashes: HashSet::new(), path_request_gate: PathRequestGate::new(), pn_fallback_attempted: HashSet::new(), + propagation_sync_target: None, self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -163,6 +170,16 @@ impl LxmfOutboundDriver { self.pinned_identities.clear(); } + /// Mark (or clear) the remote PN currently owned by an in-flight propagation sync. + pub fn set_propagation_sync_target(&mut self, dest: Option<[u8; 16]>) { + self.propagation_sync_target = dest; + } + + /// True when a packed deposit / Direct session already holds a Link to `dest`. + pub fn has_inflight_delivery_to(&self, dest: &[u8; 16]) -> bool { + self.link_delivery.has_pending_to(dest) + } + pub fn known_identities_for_propagation(&self) -> HashMap { let mut out = self.known_identities.clone(); for (k, v) in &self.pinned_identities { @@ -313,7 +330,39 @@ impl LxmfOutboundDriver { prop_hash: [u8; 16], ) { let prop_hex = hex::encode(prop_hash); + // Avoid racing a second LinkRequest to the same PN (sync or another deposit). + let sync_blocks = self.propagation_sync_target == Some(prop_hash); + let pending_blocks = self.link_delivery.has_pending_to(&prop_hash); + if should_defer_propagated_for_pn_link(sync_blocks, pending_blocks) { + let now = now_f64(); + message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); + tracing::debug!( + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + sync_blocks, + pending_blocks, + attempts = message.delivery_attempts, + "DeliverPropagated: deferring — PN link busy" + ); + if let Some(hash) = message.hash.or(message.message_id) { + emit_outbound_status_with_via( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + None, + "sending", + Some("propagated"), + None, + ); + } + router.send(message); + return; + } if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { + tracing::debug!( + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + "DeliverPropagated: PN identity unknown — requesting path" + ); self.request_path_gated( router, event_tx, @@ -325,21 +374,57 @@ impl LxmfOutboundDriver { ); return; } - let Some(packed) = self.pack_for_propagation(&mut message, prop_hash) else { + let Some(packed) = self.pack_for_propagation( + &mut message, + prop_hash, + router.get_stamp_cost(&prop_hash).unwrap_or(0), + ) else { + tracing::warn!( + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + "DeliverPropagated: pack_for_propagation failed — requeue" + ); router.send(message); return; }; + // lxmd parity: count the attempt before packed link delivery so Failed can budget retries. + let attempts = mark_propagated_delivery_attempt(&mut message); + if attempts >= MAX_DELIVERY_ATTEMPTS { + tracing::warn!( + prop = %prop_hex, + attempts, + max_attempts = MAX_DELIVERY_ATTEMPTS, + "propagated delivery attempt budget reached; deferring terminal failure" + ); + router.send(message); + return; + } let hops = route_hops_for(&self.route_hops, prop_hash); + tracing::debug!( + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + hops, + packed_len = packed.len(), + attempts, + "DeliverPropagated: starting packed delivery" + ); if let Err(err) = self .link_delivery .start_packed_delivery(message, prop_hash, hops, packed, false) { + let reason = err.error.to_string(); tracing::warn!( prop = %prop_hex, - error = %err.error, + error = %reason, "propagated link delivery start failed" ); - router.send(*err.message); + self.requeue_propagated_after_link_failure( + router, + event_tx, + *err.message, + prop_hash, + &reason, + ); } } @@ -478,17 +563,18 @@ impl LxmfOutboundDriver { mut message: LxMessage, ) { message.mark_failed(); + let method = delivery_method_label(message.method); + tracing::warn!( + dest = %hex::encode(message.destination_hash), + method, + attempts = message.delivery_attempts, + "LXMF outbound delivery failed" + ); if let Some(hash) = message.hash.or(message.message_id) { self.pn_fallback_attempted.remove(&hash); let _ = router.mark_outbound_failed(&hash); - emit_outbound_status_by_hash( - event_tx, - &hash, - "failed", - Some(delivery_method_label(message.method)), - ); + emit_outbound_status_by_hash(event_tx, &hash, "failed", Some(method)); } - let method = delivery_method_label(message.method); let payload = lxmf_payload_from_message( &message, &self.self_lxmf_hash, @@ -562,10 +648,11 @@ impl LxmfOutboundDriver { &self, message: &mut LxMessage, prop_hash: [u8; 16], + target_cost: u8, ) -> Option> { let dest_hex = hex::encode(message.destination_hash); - let target_cost = message.stamp_cost.unwrap_or(0); - let (packed, _, _) = message + // lxmd parity: stamp against the *propagation node* cost, not the DM peer. + let (packed, _, stamp_value) = message .pack_propagated_encrypted_with_stamp( |plaintext| { self.encrypt_for_destination(&dest_hex, plaintext) @@ -578,7 +665,14 @@ impl LxmfOutboundDriver { target_cost, ) .ok()?; - let _ = prop_hash; + tracing::debug!( + dest = %dest_hex, + prop = %hex::encode(prop_hash), + target_cost, + stamp_value, + packed_len = packed.len(), + "prepared propagation wrapper" + ); Some(packed) } @@ -607,13 +701,89 @@ impl LxmfOutboundDriver { emit_outbound_status_by_hash(event_tx, &hash, "delivered", method); } } - DeliveryResult::Rejected { message, .. } | DeliveryResult::Failed { message, .. } => { + DeliveryResult::Rejected { + message, reason, .. + } => { + tracing::warn!( + dest = %hex::encode(message.destination_hash), + method = %delivery_method_label(message.method), + reason = %reason, + "LXMF delivery Rejected" + ); + // Peer/PN rejected the resource — do not retry; only Direct→PN once. match self.try_requeue_via_propagation(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } } + DeliveryResult::Failed { + message, + reason, + dest_hash, + .. + } => { + tracing::warn!( + dest = %hex::encode(message.destination_hash), + link_dest = %hex::encode(dest_hash), + method = %delivery_method_label(message.method), + reason = %reason, + attempts = message.delivery_attempts, + "LXMF delivery Failed" + ); + // lxmd parity: Propagated "link closed"/timeout stay eligible for rediscovery. + if should_retry_propagated_link_failure( + message.method, + &reason, + message.delivery_attempts, + ) { + self.requeue_propagated_after_link_failure( + router, event_tx, message, dest_hash, &reason, + ); + return; + } + match self.try_requeue_via_propagation(router, event_tx, message) { + Ok(()) => {} + Err(message) => self.emit_outbound_failed(router, event_tx, *message), + } + } + } + } + + /// Re-queue a Propagated deposit after a retryable link failure (lxmd parity). + fn requeue_propagated_after_link_failure( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + mut message: LxMessage, + prop_hash: [u8; 16], + reason: &str, + ) { + let now = now_f64(); + message.method = DeliveryMethod::Propagated; + message.last_delivery_attempt = now; + message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); + let _ = try_queue_path_request(&self.transport_tx, prop_hash, false, reason); + let msg_hash = message.hash.or(message.message_id); + tracing::warn!( + dest = %hex::encode(message.destination_hash), + prop = %hex::encode(prop_hash), + msg = %msg_hash.map(hex::encode).unwrap_or_else(|| "none".into()), + attempts = message.delivery_attempts, + reason, + "re-queuing Propagated LXMF after retryable link failure" + ); + if let Some(hash) = msg_hash { + // Keep chat UI in sending/propagated while PN rediscovery proceeds. + emit_outbound_status_with_via( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + None, + "sending", + Some("propagated"), + None, + ); } + router.send(message); } } @@ -626,6 +796,30 @@ fn delivery_method_label(method: DeliveryMethod) -> &'static str { } } +fn mark_propagated_delivery_attempt(message: &mut LxMessage) -> u32 { + let now = now_f64(); + message.delivery_attempts += 1; + message.last_delivery_attempt = now; + message.next_delivery_attempt = now + f64::from(DELIVERY_RETRY_WAIT as u32); + message.delivery_attempts +} + +/// Whether a Propagated link `Failed` should requeue instead of going terminal. +pub(crate) fn should_retry_propagated_link_failure( + method: DeliveryMethod, + reason: &str, + delivery_attempts: u32, +) -> bool { + method == DeliveryMethod::Propagated + && is_retryable_link_delivery_failure(reason) + && delivery_attempts <= MAX_DELIVERY_ATTEMPTS +} + +/// Defer starting a packed PN deposit when sync or another delivery owns that dest Link. +pub(crate) fn should_defer_propagated_for_pn_link(sync_blocks: bool, pending_blocks: bool) -> bool { + sync_blocks || pending_blocks +} + /// Decide Direct vs Propagated for an LXMF send (path/pubkey/PN). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LxmfSendRoute { @@ -1012,4 +1206,41 @@ mod tests { false, )); } + + #[test] + fn should_retry_propagated_link_closed_while_attempts_remain() { + assert!(should_retry_propagated_link_failure( + DeliveryMethod::Propagated, + "link closed", + 1, + )); + assert!(should_retry_propagated_link_failure( + DeliveryMethod::Propagated, + "link establishment timeout", + MAX_DELIVERY_ATTEMPTS, + )); + assert!(!should_retry_propagated_link_failure( + DeliveryMethod::Propagated, + "link closed", + MAX_DELIVERY_ATTEMPTS + 1, + )); + assert!(!should_retry_propagated_link_failure( + DeliveryMethod::Direct, + "link closed", + 1, + )); + assert!(!should_retry_propagated_link_failure( + DeliveryMethod::Propagated, + "resource rejected", + 1, + )); + } + + #[test] + fn should_defer_propagated_when_sync_or_pending_owns_pn_link() { + assert!(should_defer_propagated_for_pn_link(true, false)); + assert!(should_defer_propagated_for_pn_link(false, true)); + assert!(should_defer_propagated_for_pn_link(true, true)); + assert!(!should_defer_propagated_for_pn_link(false, false)); + } } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 86b1e1c97..b0620b7db 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -16,6 +16,9 @@ mod nomad_timeouts; mod packet_log; mod path_speed; mod persistence; +#[cfg(feature = "rns-stack")] +mod pn_hosting_apply; +mod pn_hosting_policy; pub mod rf_profiles; mod rmap_discovery; mod rrc_codec; @@ -33,8 +36,12 @@ mod lxmf_delivery; #[cfg(feature = "rns-stack")] mod nomad_server; #[cfg(feature = "rns-stack")] +mod propagation_announce; +#[cfg(feature = "rns-stack")] mod propagation_bridge; #[cfg(feature = "rns-stack")] +mod propagation_serve; +#[cfg(feature = "rns-stack")] mod rncp_transfer; #[cfg(feature = "rns-stack")] mod rnsh_session; @@ -50,6 +57,7 @@ use std::sync::Arc; pub use config::{ImportMode, ImportResult, StackSettings, UpdateInterfacePatch}; use packet_log::{MAX_WIRE_PACKET_LOG, PacketLogBuffer, WirePacketRow}; use persistence::PersistedState; +pub use pn_hosting_policy::PnHostingPolicy; use tokio::sync::{Mutex, RwLock, broadcast}; pub use types::{ AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, LxmfReactionRequest, @@ -848,6 +856,7 @@ impl StackHandle { let inner = self.inner.read().await; let preferred_id = inner.preferred_propagation_id.clone(); let auto_sync_interval_sec = inner.auto_sync_interval_sec; + let pn_hosting_policy = inner.pn_hosting_policy.clone(); #[cfg(feature = "rns-stack")] let local_stats = if let Some(live) = &self.live { let (count, bytes) = live.propagation_local_stats(); @@ -905,6 +914,7 @@ impl StackHandle { "propagation": propagation, "preferred_id": preferred_id, "auto_sync_interval_sec": auto_sync_interval_sec, + "pn_hosting_policy": pn_hosting_policy, }) } @@ -943,6 +953,26 @@ impl StackHandle { Ok(()) } + pub async fn set_pn_hosting_policy(&self, policy: PnHostingPolicy) -> Result<(), String> { + let policy = { + let mut inner = self.inner.write().await; + // Snapshot for rollback if durable save fails after in-memory mutate. + let snapshot = inner.pn_hosting_policy.clone(); + inner.set_pn_hosting_policy(policy)?; + let policy = inner.pn_hosting_policy.clone(); + if let Err(e) = inner.save(&self.config_dir, &self.storage_dir) { + inner.pn_hosting_policy = snapshot; + return Err(e); + } + policy + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.apply_pn_hosting_policy(&policy).await?; + } + Ok(()) + } + pub async fn start_propagation_sync(&self, propagation_id: &str) -> Result<(), String> { let prop_hash = { let inner = self.inner.read().await; @@ -1032,6 +1062,7 @@ impl StackHandle { &self, destination_hash: &str, name: Option, + skip_probe: bool, ) -> Result { let hash = destination_hash.trim().to_lowercase(); // Prefer live known key / discovered announce metadata before persist. @@ -1057,9 +1088,16 @@ impl StackHandle { } #[cfg(not(feature = "rns-stack"))] { + let _ = skip_probe; (None, None) } }; + #[cfg(feature = "rns-stack")] + if !skip_probe { + if let Some(live) = &self.live { + live.probe_propagation_offer(&hash).await?; + } + } let mut inner = self.inner.write().await; let mut row = inner.add_propagation_node(destination_hash, name)?; if pub_hex.is_some() || id_hex.is_some() { diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 7a1c7adac..8aaddd069 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use serde::Deserialize; +use super::pn_hosting_policy::PnHostingPolicy; use super::types::{ AddInterfaceRequest, ContactRow, InterfaceRow, LxmfReactionRequest, LxmfSendRequest, NomadNodeRow, PeerRow, PropagationRow, RrcHubRow, StackIdentity, @@ -28,6 +29,8 @@ pub struct PersistedState { pub primary_local_serial_interface_id: Option, pub propagation_sync: serde_json::Value, pub auto_sync_interval_sec: u32, + /// LXMF local PN hosting / peering policy (defaults match rsLXMF / lxmd). + pub pn_hosting_policy: PnHostingPolicy, pub nomad_nodes: Vec, pub rrc_hubs: Vec, /// User preference: start Nomad page hosting when the live stack is up. @@ -77,6 +80,7 @@ impl PersistedState { primary_local_serial_interface_id: None, propagation_sync: serde_json::Value::Null, auto_sync_interval_sec: 3600, + pn_hosting_policy: PnHostingPolicy::default(), nomad_nodes: Vec::new(), rrc_hubs: Vec::new(), nomad_serving_enabled: false, @@ -96,7 +100,7 @@ impl PersistedState { if self.propagation.is_empty() { self.propagation.push(PropagationRow { id: "local-prop".into(), - name: "Local propagation (offline inbox)".to_string(), + name: "Local propagation node".to_string(), hops: Some(0), enabled: false, status: "unknown".into(), @@ -391,6 +395,12 @@ impl PersistedState { self.auto_sync_interval_sec = sec; } + pub fn set_pn_hosting_policy(&mut self, policy: PnHostingPolicy) -> Result<(), String> { + let policy = policy.sanitized()?; + self.pn_hosting_policy = policy; + Ok(()) + } + pub fn upsert_nomad_node( &mut self, hash: &str, @@ -770,7 +780,7 @@ impl serde::Serialize for PersistedState { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("PersistedState", 24)?; + let mut s = serializer.serialize_struct("PersistedState", 25)?; s.serialize_field("identity", &self.identity)?; s.serialize_field("interfaces", &self.interfaces)?; s.serialize_field("contacts", &self.contacts)?; @@ -786,6 +796,7 @@ impl serde::Serialize for PersistedState { )?; s.serialize_field("propagation_sync", &self.propagation_sync)?; s.serialize_field("auto_sync_interval_sec", &self.auto_sync_interval_sec)?; + s.serialize_field("pn_hosting_policy", &self.pn_hosting_policy)?; s.serialize_field("nomad_nodes", &self.nomad_nodes)?; s.serialize_field("rrc_hubs", &self.rrc_hubs)?; s.serialize_field("nomad_serving_enabled", &self.nomad_serving_enabled)?; @@ -833,6 +844,8 @@ impl<'de> serde::Deserialize<'de> for PersistedState { #[serde(default)] auto_sync_interval_sec: u32, #[serde(default)] + pn_hosting_policy: PnHostingPolicy, + #[serde(default)] nomad_nodes: Vec, #[serde(default)] rrc_hubs: Vec, @@ -875,6 +888,7 @@ impl<'de> serde::Deserialize<'de> for PersistedState { raw.propagation_sync }, auto_sync_interval_sec: raw.auto_sync_interval_sec, + pn_hosting_policy: raw.pn_hosting_policy, nomad_nodes: raw.nomad_nodes, rrc_hubs: raw.rrc_hubs, nomad_serving_enabled: raw.nomad_serving_enabled, @@ -1087,6 +1101,55 @@ mod tests { assert!(legacy_state.nomad_serving_content_source.is_none()); } + #[test] + fn pn_hosting_policy_round_trip_and_default_when_absent() { + let mut state = PersistedState::default_empty(); + let policy = PnHostingPolicy { + peering_cost: 20, + max_peering_cost: 26, + node_name: Some("Test PN".into()), + static_peers: vec!["aabbccddeeff00112233445566778899".into()], + ..PnHostingPolicy::default() + }; + state + .set_pn_hosting_policy(policy.clone()) + .expect("set valid policy"); + let json = serde_json::to_string(&state).expect("serialize"); + let loaded: PersistedState = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(loaded.pn_hosting_policy.peering_cost, 20); + assert_eq!( + loaded.pn_hosting_policy.node_name.as_deref(), + Some("Test PN") + ); + assert_eq!( + loaded.pn_hosting_policy.static_peers, + vec!["aabbccddeeff00112233445566778899"] + ); + + let mut value: serde_json::Value = serde_json::from_str(&json).expect("value"); + let obj = value.as_object_mut().expect("object"); + obj.remove("pn_hosting_policy"); + let legacy_state: PersistedState = + serde_json::from_value(value).expect("legacy without pn_hosting_policy"); + assert_eq!(legacy_state.pn_hosting_policy, PnHostingPolicy::default()); + } + + #[test] + fn set_pn_hosting_policy_rejects_invalid() { + let mut state = PersistedState::default_empty(); + let before = state.pn_hosting_policy.clone(); + let bad = PnHostingPolicy { + peering_cost: 30, + max_peering_cost: 26, + ..PnHostingPolicy::default() + }; + assert_eq!( + state.set_pn_hosting_policy(bad).unwrap_err(), + "peering_cost_exceeds_max" + ); + assert_eq!(state.pn_hosting_policy, before); + } + #[test] fn rncp_listener_fields_round_trip_and_default_when_absent() { 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 new file mode 100644 index 000000000..783655d91 --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_hosting_apply.rs @@ -0,0 +1,102 @@ +//! Apply [`PnHostingPolicy`] to a live `LxmRouter` + `PropagationNode`. + +use lxmf_core::peer::LxmPeer; +use lxmf_core::propagation_node::PropagationNode; +use lxmf_core::router::LxmRouter; + +use super::pn_hosting_policy::PnHostingPolicy; + +pub fn apply_pn_hosting_policy_to_router(router: &mut LxmRouter, policy: &PnHostingPolicy) { + router.set_autopeer(policy.autopeer); + router.set_max_peers(policy.max_peers); + router.set_propagation_limit(policy.propagation_limit_kb); + 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); + + router.config.sync_limit_kb = policy.sync_limit_kb; + router.config.delivery_limit_kb = policy.delivery_limit_kb; + 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; + router.config.ext.from_static_only = policy.from_static_only; + router.config.ext.name = policy.node_name.clone(); + + router.static_peers.clear(); + let mut desired_static = std::collections::HashSet::new(); + for peer in &policy.static_peers { + if let Ok(bytes) = hex::decode(peer) + && let Ok(hash) = <[u8; 16]>::try_from(bytes.as_slice()) + { + desired_static.insert(hash); + if !router.static_peers.contains(&hash) { + router.static_peers.push(hash); + } + let entry = router + .peers + .entry(hash) + .or_insert_with(|| LxmPeer::new(hash)); + entry.is_static = true; + } else { + tracing::debug!( + target: "pn-hosting-apply", + peer = %peer, + "skipping invalid static peer hash" + ); + } + } + // Drop peers that exist only because of a prior static config entry. + router + .peers + .retain(|hash, peer| !peer.is_static || desired_static.contains(hash)); +} + +pub fn apply_pn_hosting_policy_to_node(node: &mut PropagationNode, policy: &PnHostingPolicy) { + node.set_min_stamp_cost(policy.min_stamp_cost()); + node.set_peering_cost(policy.peering_cost); + node.set_max_storage(policy.message_storage_limit_bytes()); + node.set_max_message_size(policy.propagation_limit_kb.saturating_mul(1024)); +} + +#[cfg(test)] +mod tests { + use super::*; + use lxmf_core::router::RouterConfig; + + fn hash_from_hex(hex: &str) -> [u8; 16] { + let bytes = ::hex::decode(hex).expect("hex"); + <[u8; 16]>::try_from(bytes.as_slice()).expect("16 bytes") + } + + #[test] + fn apply_prunes_stale_static_peers_and_keeps_discovered() { + let mut router = LxmRouter::new(RouterConfig::default()); + let keep = hash_from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let drop = hash_from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let discovered = hash_from_hex("cccccccccccccccccccccccccccccccc"); + + let mut keep_peer = LxmPeer::new(keep); + keep_peer.is_static = true; + router.peers.insert(keep, keep_peer); + let mut drop_peer = LxmPeer::new(drop); + drop_peer.is_static = true; + router.peers.insert(drop, drop_peer); + router.peers.insert(discovered, LxmPeer::new(discovered)); + router.static_peers = vec![keep, drop]; + + let policy = PnHostingPolicy { + static_peers: vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()], + ..Default::default() + }; + apply_pn_hosting_policy_to_router(&mut router, &policy); + + assert!(router.peers.contains_key(&keep)); + assert!(router.peers.get(&keep).is_some_and(|p| p.is_static)); + assert!(!router.peers.contains_key(&drop)); + assert!(router.peers.contains_key(&discovered)); + assert!(!router.peers.get(&discovered).is_some_and(|p| p.is_static)); + assert_eq!(router.static_peers, vec![keep]); + } +} diff --git a/reticulum-sidecar/src/stack/pn_hosting_policy.rs b/reticulum-sidecar/src/stack/pn_hosting_policy.rs new file mode 100644 index 000000000..d7a63166e --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_hosting_policy.rs @@ -0,0 +1,220 @@ +//! Persisted LXMF propagation-node hosting / peering policy. + +use serde::{Deserialize, Serialize}; + +/// Defaults match rsLXMF `RouterConfig` / `RouterConfigExt` / lxmd `[propagation]`. +pub const DEFAULT_PEERING_COST: u8 = 18; +pub const DEFAULT_MAX_PEERING_COST: u8 = 26; +pub const DEFAULT_AUTOPEER: bool = true; +pub const DEFAULT_AUTOPEER_MAXDEPTH: usize = 4; +pub const DEFAULT_MAX_PEERS: usize = 20; +pub const DEFAULT_PROPAGATION_STAMP_COST: u8 = 16; +pub const DEFAULT_PROPAGATION_STAMP_FLEX: u8 = 3; +pub const DEFAULT_MESSAGE_STORAGE_LIMIT_MB: u32 = 256; +pub const DEFAULT_PROPAGATION_LIMIT_KB: usize = 256; +pub const DEFAULT_SYNC_LIMIT_KB: usize = 10_240; +pub const DEFAULT_DELIVERY_LIMIT_KB: usize = 1000; +pub const DEFAULT_PN_ANNOUNCE_INTERVAL_SEC: u32 = 360; +pub const DEFAULT_ANNOUNCE_AT_START: bool = true; + +const MAX_AUTOPEER_MAXDEPTH: usize = 64; +const MAX_MAX_PEERS: usize = 256; +/// Cap static peer list size (mirrors TS `MAX_STATIC_PEERS`). +pub const MAX_STATIC_PEERS: usize = 256; +const MAX_STORAGE_MB: u32 = 10_240; +const MAX_LIMIT_KB: usize = 102_400; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] // mirrors independent LXMF router prefs +pub struct PnHostingPolicy { + pub peering_cost: u8, + pub max_peering_cost: u8, + pub autopeer: bool, + pub autopeer_maxdepth: usize, + pub max_peers: usize, + pub propagation_stamp_cost: u8, + pub propagation_stamp_flex: u8, + pub message_storage_limit_mb: u32, + pub propagation_limit_kb: usize, + pub sync_limit_kb: usize, + pub delivery_limit_kb: usize, + pub from_static_only: bool, + pub auth_required: bool, + pub enforce_stamps: bool, + pub enforce_ratchets: bool, + pub static_peers: Vec, + pub node_name: Option, + pub pn_announce_interval_sec: u32, + pub announce_at_start: bool, +} + +impl Default for PnHostingPolicy { + fn default() -> Self { + Self { + peering_cost: DEFAULT_PEERING_COST, + max_peering_cost: DEFAULT_MAX_PEERING_COST, + autopeer: DEFAULT_AUTOPEER, + autopeer_maxdepth: DEFAULT_AUTOPEER_MAXDEPTH, + max_peers: DEFAULT_MAX_PEERS, + propagation_stamp_cost: DEFAULT_PROPAGATION_STAMP_COST, + propagation_stamp_flex: DEFAULT_PROPAGATION_STAMP_FLEX, + message_storage_limit_mb: DEFAULT_MESSAGE_STORAGE_LIMIT_MB, + propagation_limit_kb: DEFAULT_PROPAGATION_LIMIT_KB, + sync_limit_kb: DEFAULT_SYNC_LIMIT_KB, + delivery_limit_kb: DEFAULT_DELIVERY_LIMIT_KB, + from_static_only: false, + auth_required: false, + enforce_stamps: false, + enforce_ratchets: false, + static_peers: Vec::new(), + node_name: None, + pn_announce_interval_sec: DEFAULT_PN_ANNOUNCE_INTERVAL_SEC, + announce_at_start: DEFAULT_ANNOUNCE_AT_START, + } + } +} + +impl PnHostingPolicy { + pub fn validate(&self) -> Result<(), String> { + if self.peering_cost > self.max_peering_cost { + return Err("peering_cost_exceeds_max".into()); + } + if self.propagation_stamp_flex > self.propagation_stamp_cost { + return Err("stamp_flex_exceeds_cost".into()); + } + if self.autopeer_maxdepth > MAX_AUTOPEER_MAXDEPTH { + return Err("autopeer_maxdepth_out_of_range".into()); + } + if self.max_peers == 0 || self.max_peers > MAX_MAX_PEERS { + return Err("max_peers_out_of_range".into()); + } + if self.message_storage_limit_mb == 0 || self.message_storage_limit_mb > MAX_STORAGE_MB { + return Err("message_storage_limit_out_of_range".into()); + } + if self.propagation_limit_kb == 0 || self.propagation_limit_kb > MAX_LIMIT_KB { + return Err("propagation_limit_out_of_range".into()); + } + if self.sync_limit_kb == 0 || self.sync_limit_kb > MAX_LIMIT_KB { + return Err("sync_limit_out_of_range".into()); + } + if self.delivery_limit_kb == 0 || self.delivery_limit_kb > MAX_LIMIT_KB { + return Err("delivery_limit_out_of_range".into()); + } + if self.pn_announce_interval_sec > 86_400 { + return Err("pn_announce_interval_out_of_range".into()); + } + if self.static_peers.len() > MAX_STATIC_PEERS { + return Err("static_peers_too_many".into()); + } + for peer in &self.static_peers { + validate_static_peer_hash(peer)?; + } + if let Some(name) = &self.node_name { + let trimmed = name.trim(); + if trimmed.chars().any(char::is_control) { + return Err("node_name_invalid".into()); + } + if trimmed.chars().count() > 128 { + return Err("node_name_too_long".into()); + } + } + Ok(()) + } + + /// Clamp and normalize; returns a validated policy or an error for semantic violations. + pub fn sanitized(mut self) -> Result { + self.static_peers = self + .static_peers + .into_iter() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(name) = self.node_name.take() { + let trimmed = name.trim().to_string(); + self.node_name = if trimmed.is_empty() { + None + } else { + Some(trimmed) + }; + } + self.validate()?; + Ok(self) + } + + pub fn message_storage_limit_bytes(&self) -> usize { + (self.message_storage_limit_mb as usize).saturating_mul(1024 * 1024) + } + + pub fn min_stamp_cost(&self) -> u8 { + self.propagation_stamp_cost + .saturating_sub(self.propagation_stamp_flex) + } +} + +fn validate_static_peer_hash(hash: &str) -> Result<(), String> { + let trimmed = hash.trim().to_lowercase(); + if trimmed.len() != 32 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("static_peer_invalid:{trimmed}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_validate() { + assert!(PnHostingPolicy::default().validate().is_ok()); + } + + #[test] + fn rejects_peering_cost_above_max() { + let policy = PnHostingPolicy { + peering_cost: 30, + max_peering_cost: 26, + ..Default::default() + }; + assert_eq!(policy.validate().unwrap_err(), "peering_cost_exceeds_max"); + } + + #[test] + fn rejects_bad_static_peer() { + let policy = PnHostingPolicy { + static_peers: vec!["abcd".into()], + ..Default::default() + }; + assert!( + policy + .validate() + .unwrap_err() + .starts_with("static_peer_invalid:") + ); + } + + #[test] + fn rejects_too_many_static_peers() { + let peer = "aabbccddeeff00112233445566778899".to_string(); + let policy = PnHostingPolicy { + static_peers: vec![peer; MAX_STATIC_PEERS + 1], + ..Default::default() + }; + assert_eq!(policy.validate().unwrap_err(), "static_peers_too_many"); + } + + #[test] + fn serde_round_trip_defaults() { + let json = serde_json::to_string(&PnHostingPolicy::default()).unwrap(); + let parsed: PnHostingPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, PnHostingPolicy::default()); + } + + #[test] + fn serde_missing_fields_use_defaults() { + let parsed: PnHostingPolicy = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed.peering_cost, DEFAULT_PEERING_COST); + assert_eq!(parsed.max_peering_cost, DEFAULT_MAX_PEERING_COST); + assert!(parsed.autopeer); + } +} diff --git a/reticulum-sidecar/src/stack/propagation_announce.rs b/reticulum-sidecar/src/stack/propagation_announce.rs new file mode 100644 index 000000000..4af893959 --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_announce.rs @@ -0,0 +1,192 @@ +//! Periodic `lxmf.propagation` announces for local PN hosting. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use lxmf_core::handlers::{PropagationNodeAnnounceData, get_propagation_node_app_data}; +use rns_identity::announce::AnnounceData; +use rns_identity::identity::Identity; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use rns_wire::context::PacketContext; +use rns_wire::flags::{DestinationType, HeaderType, PacketFlags, PacketType, TransportType}; +use rns_wire::header::PacketHeader; +use tokio::sync::mpsc; + +use super::pn_hosting_policy::PnHostingPolicy; +use super::propagation_serve::LXMF_PROPAGATION_APP; + +pub fn build_propagation_announce_packet( + identity: &Identity, + propagation_dest_hash: [u8; 16], + policy: &PnHostingPolicy, + node_state: bool, +) -> Result, String> { + let mut pn_data = PropagationNodeAnnounceData::new( + node_state && !policy.from_static_only, + policy.propagation_limit_kb as u64, + policy.sync_limit_kb as u64, + policy.propagation_stamp_cost, + policy.propagation_stamp_flex, + policy.peering_cost, + ); + if let Some(ref name) = policy.node_name { + pn_data.set_name(name); + } + let app_data = get_propagation_node_app_data(&pn_data); + let announce = AnnounceData::create( + identity, + LXMF_PROPAGATION_APP, + Some(app_data.as_slice()), + None, + ) + .map_err(|e| format!("Failed to create propagation announce: {e}"))?; + let flags = PacketFlags { + header_type: HeaderType::Header1, + context_flag: false, + transport_type: TransportType::Broadcast, + destination_type: DestinationType::Single, + packet_type: PacketType::Announce, + }; + let header = PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: propagation_dest_hash, + context: PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&announce.pack()); + Ok(raw) +} + +pub async fn send_propagation_announce( + transport_tx: &mpsc::Sender, + identity: &Identity, + propagation_dest_hash: [u8; 16], + policy: &PnHostingPolicy, + node_state: bool, +) -> Result<(), String> { + let raw = + build_propagation_announce_packet(identity, propagation_dest_hash, policy, node_state)?; + transport_tx + .send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: propagation_dest_hash, + })) + .await + .map_err(|e| format!("Failed to send propagation announce: {e}")) +} + +pub struct PropagationAnnounceLoop { + running: AtomicBool, + stop_tx: Mutex>>, +} + +impl PropagationAnnounceLoop { + pub fn new() -> Self { + Self { + running: AtomicBool::new(false), + stop_tx: Mutex::new(None), + } + } + + pub fn stop(&self) { + self.running.store(false, Ordering::SeqCst); + if let Ok(mut slot) = self.stop_tx.lock() + && let Some(tx) = slot.take() + { + let _ = tx.send(()); + } + } + + pub fn start( + &self, + transport_tx: mpsc::Sender, + identity: Identity, + propagation_dest_hash: [u8; 16], + policy: Arc>, + announce_at_start: bool, + ) { + self.stop(); + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + if let Ok(mut slot) = self.stop_tx.lock() { + *slot = Some(stop_tx); + } + self.running.store(true, Ordering::SeqCst); + + tokio::spawn(async move { + if announce_at_start { + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + if let Err(e) = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + true, + ) + .await + { + tracing::warn!(target: "propagation-announce", "startup announce failed: {e}"); + } + } + + loop { + let interval_sec = policy + .lock() + .ok() + .map(|p| p.pn_announce_interval_sec) + .unwrap_or(360); + let wait = if interval_sec == 0 { + Duration::from_secs(360) + } else { + Duration::from_secs(u64::from(interval_sec)) + }; + tokio::select! { + () = tokio::time::sleep(wait) => { + if interval_sec == 0 { + continue; + } + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + if let Err(e) = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + true, + ) + .await + { + tracing::warn!(target: "propagation-announce", "periodic announce failed: {e}"); + } + } + _ = &mut stop_rx => { + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + if let Err(e) = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + false, + ) + .await + { + tracing::warn!( + target: "propagation-announce", + "shutdown announce failed: {e}" + ); + } + break; + } + } + } + }); + } +} + +impl Default for PropagationAnnounceLoop { + fn default() -> Self { + Self::new() + } +} diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index f16525139..f779d92c7 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -28,15 +28,19 @@ impl PropagationBridge { local_dest_hash: [u8; 16], storage_dir: PathBuf, identity: &Identity, + policy: &super::pn_hosting_policy::PnHostingPolicy, ) -> Result { std::fs::create_dir_all(&storage_dir).map_err(|e| e.to_string())?; + let node_config = PropagationNodeConfig { + max_storage: policy.message_storage_limit_bytes(), + max_message_age: lxmf_core::constants::MESSAGE_EXPIRY, + min_stamp_cost: policy.min_stamp_cost(), + peering_cost: policy.peering_cost, + max_message_size: policy.propagation_limit_kb.saturating_mul(1024), + }; let local_node = Arc::new(Mutex::new( - PropagationNode::with_storage( - PropagationNodeConfig::default(), - local_dest_hash, - storage_dir, - ) - .map_err(|e| format!("propagation storage init: {e}"))?, + PropagationNode::with_storage(node_config, local_dest_hash, storage_dir) + .map_err(|e| format!("propagation storage init: {e}"))?, )); let mut sync_task = PropagationSyncTask::with_shared_node(transport_tx, local_node.clone()); let signing_key = identity @@ -52,10 +56,18 @@ impl PropagationBridge { }) } + pub fn local_node(&self) -> Arc> { + self.local_node.clone() + } + pub fn local_dest_hash_hex(&self) -> String { hex::encode(self.local_dest_hash) } + pub fn local_dest_hash_bytes(&self) -> [u8; 16] { + self.local_dest_hash + } + pub fn set_local_serving(&self, enabled: bool, router: &mut LxmRouter) { self.local_serving.store(enabled, Ordering::SeqCst); router.set_propagation_enabled(enabled); @@ -351,8 +363,14 @@ mod tests { 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).expect("bridge"); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); let active = AtomicU64::new(1); let mut ran = false; assert!(bridge.run_if_current(&active, 1, || { @@ -376,8 +394,14 @@ mod tests { 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).expect("bridge"); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); bridge.cancel_sync(); assert_eq!(bridge.last_finished_ok(), Some(false)); assert!(!bridge.sync_active()); diff --git a/reticulum-sidecar/src/stack/propagation_serve.rs b/reticulum-sidecar/src/stack/propagation_serve.rs new file mode 100644 index 000000000..40eceee24 --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_serve.rs @@ -0,0 +1,152 @@ +//! Network-visible LXMF propagation-node serve path (`/offer` + `/get`). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use lxmf_core::handlers::PropagationRequestHandler; +use lxmf_core::propagation_node::PropagationNode; +use rns_identity::destination::Destination; +use rns_identity::identity::Identity; +use rns_runtime::link_manager::{LinkManager, register_destination}; +use rns_transport::messages::TransportMessage; +use tokio::sync::mpsc; + +pub const LXMF_PROPAGATION_APP: &str = "lxmf.propagation"; + +/// Owns the inbound LinkManager task for local PN hosting. +pub struct PropagationServeHandle { + active: AtomicBool, + stop_tx: Mutex>>, +} + +impl PropagationServeHandle { + pub fn new() -> Self { + Self { + active: AtomicBool::new(false), + stop_tx: Mutex::new(None), + } + } + + pub fn stop(&self) { + self.active.store(false, Ordering::SeqCst); + if let Ok(mut slot) = self.stop_tx.lock() + && let Some(tx) = slot.take() + { + let _ = tx.send(()); + } + } + + /// Register `lxmf.propagation` and spawn LinkManager with `/offer` + `/get` handlers. + pub fn start( + &self, + transport_tx: &mpsc::Sender, + identity: &Identity, + propagation_dest_hash: [u8; 16], + local_node: Arc>, + ) -> Result<(), String> { + self.stop(); + + let delivery_rx = + register_destination(transport_tx, propagation_dest_hash, LXMF_PROPAGATION_APP); + + let prop_signing_key = identity + .get_signing_key() + .ok_or_else(|| "propagation serve: identity has no signing key".to_string())?; + + let mut prop_link_mgr = LinkManager::with_destination( + transport_tx.clone(), + delivery_rx, + identity, + LXMF_PROPAGATION_APP, + Some(prop_signing_key), + ); + + let (resource_tx, _resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + prop_link_mgr.set_resource_completed_channel(resource_tx); + + let pn_for_handler = local_node; + let offer_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::OFFER_REQUEST_PATH.as_bytes()); + let get_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::MESSAGE_GET_PATH.as_bytes()); + let link_identities = prop_link_mgr.link_identities_handle(); + let local_identity_hash = identity.hash; + prop_link_mgr.set_request_handler(move |link_id, path_hash, data| { + let remote_identity_hash = link_identities + .lock() + .ok() + .and_then(|ids| ids.get(&link_id).copied()); + let remote_identity_ref = remote_identity_hash.as_ref(); + let client_dest_hash = remote_identity_hash + .map(|identity_hash| { + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&identity_hash)) + }) + .unwrap_or([0; 16]); + let handler = PropagationRequestHandler::new(local_identity_hash); + if path_hash == offer_path_hash { + tracing::info!(target: "propagation-serve", "handling /offer request"); + let Ok(mut node) = pn_for_handler.lock() else { + tracing::warn!( + target: "propagation-serve", + "pn lock failed; dropping /offer request" + ); + return None; + }; + Some(handler.handle_offer_request(remote_identity_ref, &data, &mut node)) + } else if path_hash == get_path_hash { + tracing::info!(target: "propagation-serve", "handling /get request"); + let action = { + let Ok(mut node) = pn_for_handler.lock() else { + tracing::warn!( + target: "propagation-serve", + "pn lock failed; dropping /get request" + ); + return None; + }; + handler.handle_message_get_request( + remote_identity_ref, + &client_dest_hash, + &data, + &mut node, + ) + }; + Some(action.into_response()) + } else { + tracing::debug!( + target: "propagation-serve", + path = %hex::encode(path_hash), + "unknown request path" + ); + None + } + }); + + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + if let Ok(mut slot) = self.stop_tx.lock() { + *slot = Some(stop_tx); + } + self.active.store(true, Ordering::SeqCst); + + tokio::spawn(async move { + tokio::select! { + () = prop_link_mgr.run() => { + tracing::warn!( + target: "propagation-serve", + "LinkManager run completed unexpectedly (not stop-requested)" + ); + } + _ = &mut stop_rx => { + tracing::info!(target: "propagation-serve", "LinkManager stop requested"); + } + } + }); + + Ok(()) + } +} + +impl Default for PropagationServeHandle { + fn default() -> Self { + Self::new() + } +} diff --git a/reticulum-sidecar/src/stack/rrc_session.rs b/reticulum-sidecar/src/stack/rrc_session.rs index 775662495..7fe7a36ca 100644 --- a/reticulum-sidecar/src/stack/rrc_session.rs +++ b/reticulum-sidecar/src/stack/rrc_session.rs @@ -28,6 +28,7 @@ use super::rrc_link::{RrcLinkError, RrcLinkEvent, RrcLinkHandle, open_rrc_link}; const CLIENT_NAME: &str = "mesh-client"; const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + const WELCOME_TIMEOUT: Duration = Duration::from_secs(20); const RECONNECT_BASE_MS: u64 = 2_000; const RECONNECT_MAX_MS: u64 = 30_000; @@ -71,6 +72,8 @@ struct RrcSessionInner { rooms: HashMap, /// Normalized room name → optional join key retained for reconnect. desired_rooms: HashMap>, + /// Wire room + join key queued after involuntary hub PARTED while still desired. + pending_rejoins: Vec<(String, Option)>, last_error: Option, identity_hash: [u8; 16], capabilities: RrcWelcomeCapabilities, @@ -84,6 +87,7 @@ impl RrcSessionInner { nickname: None, rooms: HashMap::new(), desired_rooms: HashMap::new(), + pending_rejoins: Vec::new(), last_error: None, identity_hash, capabilities: RrcWelcomeCapabilities::default(), @@ -107,6 +111,18 @@ impl RrcSessionInner { } } } + + /// Queue a silent re-JOIN; dedupe by normalized room name. + fn queue_pending_rejoin(&mut self, room: String, join_key: Option) { + let key = normalize_room(&room); + let already_pending = self + .pending_rejoins + .iter() + .any(|(pending_room, _)| normalize_room(pending_room) == key); + if !already_pending { + self.pending_rejoins.push((room, join_key)); + } + } } /// Handle to one hub's session task: a command channel for actions that must @@ -605,6 +621,7 @@ async fn session_loop( g.hub_name = None; g.rooms.clear(); g.desired_rooms.clear(); + g.pending_rejoins.clear(); g.last_error = None; g.capabilities = RrcWelcomeCapabilities::default(); } @@ -641,6 +658,7 @@ async fn session_loop( g.status = RrcSessionStatus::Disconnected; g.rooms.clear(); g.desired_rooms.clear(); + g.pending_rejoins.clear(); g.hub_name = None; } emit( @@ -761,19 +779,14 @@ async fn session_loop( ) .await; if let Err(e) = rejoin { - warn!("rrc rejoin {room} failed: {e}"); - { - let mut g = inner.lock().await; - g.rooms.remove(&normalize_room(&room)); - } - emit( + handle_rejoin_failure( + &inner, &event_tx, - "rrc.error", - json!({ - "message": format!("rejoin {room} failed: {e}"), - "hub_dest_hash": hub_hex, - }), - ); + &hub_hex, + &room, + &e, + ) + .await; } } if let Some(reply) = reply { @@ -870,10 +883,31 @@ async fn session_loop( warn!("rrc PONG send failed: {e}"); } } - Err(e) => warn!("rrc PONG encode failed: {e}"), + Err(e) => { + warn!("rrc PONG encode failed: {e}"); + } } } } + // True self-PARTED while room still desired (e.g. multi-link + // edge case) — re-JOIN without emitting rrc.room.parted. + let rejoins = { + let mut g = inner.lock().await; + std::mem::take(&mut g.pending_rejoins) + }; + for (room, key) in rejoins { + let rejoin = send_room_control( + &mut link, + &inner, + Some(room.clone()), + msg_type::JOIN, + key, + ) + .await; + if let Err(e) = rejoin { + handle_rejoin_failure(&inner, &event_tx, &hex, &room, &e).await; + } + } } Some(RrcLinkEvent::Closed { reason }) => { link = None; @@ -1032,6 +1066,37 @@ async fn establish_session( } } +/// Shared JOIN-failure cleanup for reconnect rejoin and pending_rejoins. +/// Removes the room from desired + live maps and notifies the renderer. +async fn handle_rejoin_failure( + inner: &Arc>, + event_tx: &broadcast::Sender, + hub_hex: &str, + room: &str, + err: &str, +) { + warn!("rrc rejoin {room} failed: {err}"); + { + let mut g = inner.lock().await; + let key = normalize_room(room); + g.desired_rooms.remove(&key); + g.rooms.remove(&key); + } + emit( + event_tx, + "rrc.room.parted", + json!({ "hub_dest_hash": hub_hex, "room": room }), + ); + emit( + event_tx, + "rrc.error", + json!({ + "hub_dest_hash": hub_hex, + "message": format!("rejoin {room} failed: {err}"), + }), + ); +} + async fn send_room_control( link: &mut Option, inner: &Arc>, @@ -1132,15 +1197,54 @@ async fn handle_inbound( msg_type::PARTED => { let room = env.room_name.clone().unwrap_or_default(); let key = normalize_room(&room); - { + let parting_peers = parse_joined_members(env.body.as_ref()); + let (about_self, auto_rejoin) = { let mut g = inner.lock().await; - g.rooms.remove(&key); + let our_hash = hex::encode(g.identity_hash); + let about_self = parted_concerns_self( + env.body.as_ref(), + env.nickname.as_deref(), + &our_hash, + g.nickname.as_deref(), + ); + if about_self { + // We left (or hub says we did). Voluntary PART already cleared + // desired_rooms; if still desired, queue silent re-JOIN. + g.rooms.remove(&key); + let auto_rejoin = match g.desired_rooms.get(&key).cloned() { + Some(join_key) => { + g.queue_pending_rejoin(room.clone(), join_key); + true + } + None => false, + }; + (true, auto_rejoin) + } else { + // Fanout: another member left — update roster only. + if let Some(state) = g.rooms.get_mut(&key) { + if !parting_peers.is_empty() { + for (h, _) in &parting_peers { + state.members.retain(|(mh, _)| !mh.eq_ignore_ascii_case(h)); + } + } else if let Some(n) = env.nickname.as_deref() { + let n = n.trim(); + state.members.retain(|(_, mn)| { + mn.as_ref() + .map(|m| !m.trim().eq_ignore_ascii_case(n)) + .unwrap_or(true) + }); + } + } + (false, false) + } + }; + if about_self && !auto_rejoin { + emit( + event_tx, + "rrc.room.parted", + json!({ "hub_dest_hash": hub_dest_hash, "room": room }), + ); } - emit( - event_tx, - "rrc.room.parted", - json!({ "hub_dest_hash": hub_dest_hash, "room": room }), - ); None } msg_type::MSG | msg_type::NOTICE | msg_type::ACTION => { @@ -1213,6 +1317,35 @@ fn normalize_room(room: &str) -> String { room.trim().to_lowercase() } +/// Decide whether an inbound PARTED means *we* left the room. +/// +/// Stock rrcd: +/// - Actor-facing PART ack: no `K_NICK`; optional body `[our_hash]`. +/// - Member fanout when someone else leaves: `K_NICK` = their nick; optional +/// body `[their_hash]`. +/// +/// Treating fanout as self-leave made busy rooms look like constant hub kicks. +fn parted_concerns_self( + body: Option<&ciborium::value::Value>, + nick: Option<&str>, + our_hash_hex: &str, + our_nick: Option<&str>, +) -> bool { + let peers = parse_joined_members(body); + if !peers.is_empty() { + return peers + .iter() + .any(|(h, _)| h.eq_ignore_ascii_case(our_hash_hex)); + } + match nick.map(str::trim).filter(|s| !s.is_empty()) { + Some(n) => our_nick + .map(|o| o.trim().eq_ignore_ascii_case(n)) + .unwrap_or(false), + // No body hashes and no nick → actor-facing self PARTED. + None => true, + } +} + async fn resolve_reconnect_nickname( inner: &Arc>, intent_nick: &str, @@ -1284,4 +1417,85 @@ mod tests { fn normalize_room_trims_and_lowercases() { assert_eq!(normalize_room(" #General "), "#general"); } + + #[test] + fn involuntary_parted_queues_rejoin_when_desired() { + let mut inner = RrcSessionInner::new([0u8; 16]); + inner.remember_desired_room("general", None); + inner.rooms.insert( + "general".into(), + RrcRoomState { + name: "general".into(), + members: vec![], + }, + ); + let key = normalize_room("general"); + inner.rooms.remove(&key); + match inner.desired_rooms.get(&key) { + Some(join_key) => { + inner.queue_pending_rejoin("general".into(), join_key.clone()); + } + None => panic!("expected desired room"), + } + assert_eq!(inner.pending_rejoins.len(), 1); + assert!(inner.desired_rooms.contains_key("general")); + // Duplicate involuntary PART (case / whitespace variants) must not queue twice. + inner.queue_pending_rejoin(" General ".into(), None); + assert_eq!(inner.pending_rejoins.len(), 1); + // Voluntary PART removes desired first — no rejoin queue. + inner.desired_rooms.remove("general"); + inner.pending_rejoins.clear(); + assert!(!inner.desired_rooms.contains_key("general")); + } + + #[test] + fn parted_fanout_other_nick_is_not_self() { + assert!(!parted_concerns_self( + None, + Some("pmow"), + "128eb883f0c94439bdb2069947319022", + Some("valerius"), + )); + } + + #[test] + fn parted_actor_ack_without_nick_is_self() { + assert!(parted_concerns_self( + None, + None, + "128eb883f0c94439bdb2069947319022", + Some("me"), + )); + } + + #[test] + fn parted_matching_nick_is_self() { + assert!(parted_concerns_self( + None, + Some("Me"), + "128eb883f0c94439bdb2069947319022", + Some("me"), + )); + } + + #[test] + fn parted_body_hash_distinguishes_self() { + use ciborium::value::Value; + let our = hex::decode("128eb883f0c94439bdb2069947319022").unwrap(); + let other = hex::decode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); + let self_body = Value::Array(vec![Value::Bytes(our)]); + let other_body = Value::Array(vec![Value::Bytes(other)]); + assert!(parted_concerns_self( + Some(&self_body), + Some("ignored-when-body"), + "128eb883f0c94439bdb2069947319022", + Some("me"), + )); + assert!(!parted_concerns_self( + Some(&other_body), + Some("pmow"), + "128eb883f0c94439bdb2069947319022", + Some("me"), + )); + } } diff --git a/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh new file mode 100755 index 000000000..88d2968be --- /dev/null +++ b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh @@ -0,0 +1,67 @@ +#!/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). +set -euo pipefail + +RS_LXMF_REF="${RS_LXMF_REF:-68ad7c835187c052c763bb28c41b04a655f35c64}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch" +LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +LINK_RS="${LXMF_DIR}/crates/lxmf-core/src/link_delivery.rs" + +if [[ ! -d "${LXMF_DIR}/.git" ]]; then + echo "error: rsLXMF not found at ${LXMF_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsLXMF.git ${LXMF_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +overlay_already_present() { + [[ -f "${LINK_RS}" ]] || return 1 + grep -qE 'fn has_pending_to\(' "${LINK_RS}" +} + +if overlay_already_present; then + echo "link-delivery has_pending_to overlay already 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)" + 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}" diff --git a/scripts/apply-rsLXMF-propagation-node-policy-setters.sh b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh new file mode 100755 index 000000000..00d4dd026 --- /dev/null +++ b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Apply mesh-client rsLXMF PropagationNode live policy setters for rns-stack builds. +# Adds set_peering_cost / set_max_storage / set_max_message_size so PN hosting +# 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)" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch" +LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +NODE_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_node.rs" + +if [[ ! -d "${LXMF_DIR}/.git" ]]; then + echo "error: rsLXMF not found at ${LXMF_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsLXMF.git ${LXMF_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +overlay_already_present() { + [[ -f "${NODE_RS}" ]] || return 1 + grep -qE 'fn set_peering_cost\(' "${NODE_RS}" \ + && grep -qE 'fn set_max_storage\(' "${NODE_RS}" \ + && grep -qE 'fn set_max_message_size\(' "${NODE_RS}" +} + +if overlay_already_present; then + echo "propagation-node policy setters overlay already 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)" + 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}" diff --git a/scripts/apply-rsReticulum-discovery-announce-egress.sh b/scripts/apply-rsReticulum-discovery-announce-egress.sh new file mode 100755 index 000000000..6add80f8b --- /dev/null +++ b/scripts/apply-rsReticulum-discovery-announce-egress.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Apply mesh-client rsReticulum discovery-announce egress overlay for rns-stack builds. +# Registers rnstransport.discovery.interface as a local destination and defers +# Announcer::register until the discoverable interface online latch is true +# (BLE RNode late bring-up). Upstream: https://github.com/ratspeak/rsReticulum/pull/19 +set -euo pipefail + +RS_RETICULUM_REF="${RS_RETICULUM_REF:-6d2b28475321bc15c8f60796513d8878b47ed3ab}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch" +RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +RETICULUM_RS="${RNS_DIR}/crates/rns-runtime/src/reticulum.rs" + +if [[ ! -d "${RNS_DIR}/.git" ]]; then + echo "error: rsReticulum not found at ${RNS_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsReticulum.git ${RNS_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +overlay_already_present() { + [[ -f "${RETICULUM_RS}" ]] || return 1 + # Accept extracted helpers (overlay / upstream) or the older inline fix form. + if grep -qE 'fn take_online_discovery_interfaces\(' "${RETICULUM_RS}" \ + && grep -qE 'fn discovery_local_destination_registration\(' "${RETICULUM_RS}"; then + return 0 + fi + grep -qE 'discovery destination registered as local for announce egress' "${RETICULUM_RS}" \ + && grep -qE 'discovery interface online — starting announces' "${RETICULUM_RS}" +} + +if overlay_already_present; then + echo "discovery-announce egress overlay already present on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" + 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 "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}" diff --git a/scripts/check-pn-hosting-policy.mjs b/scripts/check-pn-hosting-policy.mjs new file mode 100644 index 000000000..c8be77d81 --- /dev/null +++ b/scripts/check-pn-hosting-policy.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * Pre-commit / CI check: TS PN hosting policy stays aligned with sidecar Rust. + * + * Compares: + * - DEFAULT numeric / boolean constants + * - MAX_* caps (including MAX_STATIC_PEERS = 256) + * - validation error token strings that appear in both + * + * Sources: + * - src/shared/pnHostingPolicy.ts + * - reticulum-sidecar/src/stack/pn_hosting_policy.rs + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +const RUST_FILE = path.join(ROOT, 'reticulum-sidecar', 'src', 'stack', 'pn_hosting_policy.rs'); +const TS_FILE = path.join(ROOT, 'src', 'shared', 'pnHostingPolicy.ts'); + +const CHECK = 'check-pn-hosting-policy'; + +/** TS DEFAULT_PN_HOSTING_POLICY field → Rust DEFAULT_* const name. */ +const DEFAULT_FIELD_TO_RUST = { + peering_cost: 'DEFAULT_PEERING_COST', + max_peering_cost: 'DEFAULT_MAX_PEERING_COST', + autopeer: 'DEFAULT_AUTOPEER', + autopeer_maxdepth: 'DEFAULT_AUTOPEER_MAXDEPTH', + max_peers: 'DEFAULT_MAX_PEERS', + propagation_stamp_cost: 'DEFAULT_PROPAGATION_STAMP_COST', + propagation_stamp_flex: 'DEFAULT_PROPAGATION_STAMP_FLEX', + message_storage_limit_mb: 'DEFAULT_MESSAGE_STORAGE_LIMIT_MB', + propagation_limit_kb: 'DEFAULT_PROPAGATION_LIMIT_KB', + sync_limit_kb: 'DEFAULT_SYNC_LIMIT_KB', + delivery_limit_kb: 'DEFAULT_DELIVERY_LIMIT_KB', + pn_announce_interval_sec: 'DEFAULT_PN_ANNOUNCE_INTERVAL_SEC', + announce_at_start: 'DEFAULT_ANNOUNCE_AT_START', +}; + +/** Named MAX_* expected in TS (and named or literal-equivalent in Rust). */ +const REQUIRED_MAX_NAMES = [ + 'MAX_AUTOPEER_MAXDEPTH', + 'MAX_MAX_PEERS', + 'MAX_STATIC_PEERS', + 'MAX_STORAGE_MB', + 'MAX_LIMIT_KB', + 'MAX_PN_ANNOUNCE_INTERVAL_SEC', + 'MAX_NODE_NAME_CHARS', +]; + +/** Validation error tokens that must appear in both TS and Rust sources. */ +const SHARED_ERROR_TOKENS = [ + 'peering_cost_exceeds_max', + 'stamp_flex_exceeds_cost', + 'autopeer_maxdepth_out_of_range', + 'max_peers_out_of_range', + 'message_storage_limit_out_of_range', + 'propagation_limit_out_of_range', + 'sync_limit_out_of_range', + 'delivery_limit_out_of_range', + 'pn_announce_interval_out_of_range', + 'static_peers_too_many', + 'static_peer_invalid', + 'node_name_invalid', + 'node_name_too_long', +]; + +function read(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`${CHECK}: missing ${filePath}`); + process.exit(1); + } + return fs.readFileSync(filePath, 'utf8'); +} + +function parseNumericLiteral(raw) { + const cleaned = String(raw).replace(/_/g, '').trim(); + if (cleaned === 'true') return true; + if (cleaned === 'false') return false; + const n = Number(cleaned); + if (!Number.isFinite(n)) { + throw new Error(`unparseable literal: ${raw}`); + } + return n; +} + +/** Extract `pub const NAME: T = VALUE` / `const NAME: T = VALUE` from Rust. */ +function extractRustConsts(src) { + const out = {}; + for (const line of src.split('\n')) { + const trimmed = line.trim(); + const pub = trimmed.startsWith('pub const ') ? 'pub const ' : null; + const prefix = pub ?? (trimmed.startsWith('const ') ? 'const ' : null); + if (!prefix) continue; + const rest = trimmed.slice(prefix.length); + const colon = rest.indexOf(':'); + const eq = rest.indexOf('='); + const semi = rest.indexOf(';'); + if (colon < 0 || eq < 0 || semi < 0 || eq < colon) continue; + const name = rest.slice(0, colon).trim(); + if (!(name.startsWith('DEFAULT_') || name.startsWith('MAX_'))) continue; + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) continue; + out[name] = parseNumericLiteral(rest.slice(eq + 1, semi).trim()); + } + return out; +} + +/** Extract `const MAX_* = N` from TypeScript. */ +function extractTsMaxConsts(src) { + const out = {}; + for (const line of src.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('const MAX_')) continue; + const eq = trimmed.indexOf('='); + const semi = trimmed.indexOf(';'); + if (eq < 0 || semi < 0) continue; + const name = trimmed.slice('const '.length, eq).trim(); + if (!name.startsWith('MAX_') || !/^[A-Z][A-Z0-9_]*$/.test(name)) continue; + out[name] = parseNumericLiteral(trimmed.slice(eq + 1, semi).trim()); + } + return out; +} + +/** Extract field values from `export const DEFAULT_PN_HOSTING_POLICY`. */ +function extractTsDefaults(src) { + const block = src.match( + /export const DEFAULT_PN_HOSTING_POLICY:\s*PnHostingPolicy\s*=\s*\{([\s\S]*?)\n\};/, + ); + if (!block) { + throw new Error('DEFAULT_PN_HOSTING_POLICY not found in pnHostingPolicy.ts'); + } + const out = {}; + const re = /(\w+)\s*:\s*([^,\n]+)/g; + let m; + while ((m = re.exec(block[1])) !== null) { + const key = m[1]; + const raw = m[2].trim(); + if (raw === 'null' || raw === '[]') continue; + if (raw === 'true' || raw === 'false' || /^-?[\d_]+$/.test(raw)) { + out[key] = parseNumericLiteral(raw); + } + } + return out; +} + +/** + * Rust may use a literal instead of a named MAX_* for announce interval / node name. + * Fall back to the comparison in `validate`. + */ +function extractRustMaxFallbacks(src, named) { + const out = { ...named }; + if (out.MAX_PN_ANNOUNCE_INTERVAL_SEC == null) { + const m = src.match(/pn_announce_interval_sec\s*>\s*([\d_]+)/); + if (m) out.MAX_PN_ANNOUNCE_INTERVAL_SEC = parseNumericLiteral(m[1]); + } + if (out.MAX_NODE_NAME_CHARS == null) { + const m = src.match(/chars\(\)\.count\(\)\s*>\s*([\d_]+)/); + if (m) out.MAX_NODE_NAME_CHARS = parseNumericLiteral(m[1]); + } + return out; +} + +const rustSrc = read(RUST_FILE); +const tsSrc = read(TS_FILE); + +let failed = false; + +try { + const rustConsts = extractRustConsts(rustSrc); + const tsDefaults = extractTsDefaults(tsSrc); + const tsMax = extractTsMaxConsts(tsSrc); + const rustMax = extractRustMaxFallbacks(rustSrc, rustConsts); + + // Defaults: TS object fields vs Rust DEFAULT_* consts. + for (const [field, rustName] of Object.entries(DEFAULT_FIELD_TO_RUST)) { + if (!(field in tsDefaults)) { + console.error(`${CHECK}: TS DEFAULT_PN_HOSTING_POLICY missing field ${field}`); + failed = true; + continue; + } + if (!(rustName in rustConsts)) { + console.error(`${CHECK}: Rust missing ${rustName}`); + failed = true; + continue; + } + if (tsDefaults[field] !== rustConsts[rustName]) { + console.error( + `${CHECK}: default ${field} diverge (ts=${tsDefaults[field]} rust=${rustConsts[rustName]})`, + ); + failed = true; + } + } + + // MAX_* caps + for (const name of REQUIRED_MAX_NAMES) { + if (!(name in tsMax)) { + console.error(`${CHECK}: TS missing ${name}`); + failed = true; + continue; + } + if (!(name in rustMax)) { + console.error(`${CHECK}: Rust missing ${name} (named const or validate literal)`); + failed = true; + continue; + } + if (tsMax[name] !== rustMax[name]) { + console.error(`${CHECK}: ${name} diverge (ts=${tsMax[name]} rust=${rustMax[name]})`); + failed = true; + } + } + + if (tsMax.MAX_STATIC_PEERS !== 256 || rustMax.MAX_STATIC_PEERS !== 256) { + console.error( + `${CHECK}: MAX_STATIC_PEERS must be 256 (ts=${tsMax.MAX_STATIC_PEERS} rust=${rustMax.MAX_STATIC_PEERS})`, + ); + failed = true; + } + + for (const tok of SHARED_ERROR_TOKENS) { + if (!tsSrc.includes(tok)) { + console.error(`${CHECK}: TS missing validation error token ${tok}`); + failed = true; + } + if (!rustSrc.includes(tok)) { + console.error(`${CHECK}: Rust missing validation error token ${tok}`); + failed = true; + } + } +} catch (e) { + console.error(`${CHECK}: ${e instanceof Error ? e.message : e}`); + process.exit(1); +} + +if (failed) { + process.exit(1); +} + +console.log(`${CHECK}: ok`); diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index 9a71ef524..c13458d75 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -46,11 +46,14 @@ ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" "${SCRIPT_DIR}/apply-rsReticulum-rnode-tcp-activity-keepalive.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" +"${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" ensure_repo "${LXMF_DIR}" 'https://github.com/ratspeak/rsLXMF.git' \ '68ad7c835187c052c763bb28c41b04a655f35c64' '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" # 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). diff --git a/scripts/ensure-rsReticulum-patches.sh b/scripts/ensure-rsReticulum-patches.sh index 43483e6aa..5edf66552 100755 --- a/scripts/ensure-rsReticulum-patches.sh +++ b/scripts/ensure-rsReticulum-patches.sh @@ -17,6 +17,7 @@ fi "${SCRIPT_DIR}/apply-rsReticulum-link-client-nomad.sh" "${SCRIPT_DIR}/apply-rsReticulum-rnode-tcp-activity-keepalive.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" +"${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" if [[ ! -d "${LXMF_DIR}/.git" ]]; then echo "rsLXMF not found at ${LXMF_DIR}; skipping lxmf overlay apply" @@ -24,3 +25,5 @@ if [[ ! -d "${LXMF_DIR}/.git" ]]; then 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" diff --git a/scripts/release.sh b/scripts/release.sh index 904d08563..58d9121c1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -335,6 +335,11 @@ if ! pnpm run check:reticulum-interface-modes; then exit 1 fi +if ! pnpm run check:pn-hosting-policy; then + print_error "PN hosting policy catalog check failed." + exit 1 +fi + if ! pnpm run check:reticulum-decommissioned-hubs; then print_error "Reticulum decommissioned hub catalog check failed." exit 1 diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 6b30b0def..19e803d3f 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -19,6 +19,7 @@ const REQUIRED_PNPM_CHECKS = [ 'check:db-migrations', 'check:ipc-contract', 'check:reticulum-interface-modes', + 'check:pn-hosting-policy', 'check:reticulum-decommissioned-hubs', 'check:console-log', 'check:silent-catches', diff --git a/scripts/update.sh b/scripts/update.sh index 6f7e37719..7fbcfe778 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -212,7 +212,10 @@ check_ratspeak_patches() { 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' 'rsReticulum-rnode-tcp-activity-keepalive.patch|ratspeak/rsReticulum|15|rsReticulum RNode TCP activity keepalive|https://github.com/ratspeak/rsReticulum/pull/15' 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum||rsReticulum BLE RNode pairing-transition debounce|' + 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' + 'rsLXMF-propagation-node-policy-setters.patch|ratspeak/rsLXMF|6|rsLXMF PropagationNode policy setters|https://github.com/ratspeak/rsLXMF/pull/6' + 'rsLXMF-link-delivery-has-pending-to.patch|ratspeak/rsLXMF||rsLXMF LinkDeliveryManager has_pending_to|' ) local patches_dir='reticulum-sidecar/patches' local has_ratspeak_warning=0 diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index 9b587cefb..8d2f45062 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -212,6 +212,66 @@ describe('reticulum destination / activity prune IPC', () => { expect(result.changes).toBe(0); }); + it('saveReticulumMessage does not demote delivered to sending', () => { + const identityId = 'id-rt-status'; + const messageHash = 'ab'.repeat(32); + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: 'hello', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'delivered', + }); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: 'hello', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'sending', + }); + const row = db! + .prepareOnce( + 'SELECT delivery_status FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', + ) + .get(identityId, messageHash) as { delivery_status: string }; + expect(row.delivery_status).toBe('delivered'); + }); + + it('saveReticulumMessage still allows failed → sending on retry', () => { + const identityId = 'id-rt-retry'; + const messageHash = 'cd'.repeat(32); + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: 'retry', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'failed', + }); + save?.(event, { + identity_id: identityId, + sender_id: 'cc'.repeat(16), + sender_name: 'Me', + payload: 'retry', + timestamp: 1_700_000_000_000, + message_hash: messageHash, + delivery_status: 'sending', + }); + const row = db! + .prepareOnce( + 'SELECT delivery_status FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', + ) + .get(identityId, messageHash) as { delivery_status: string }; + expect(row.delivery_status).toBe('sending'); + }); + it('pruneReticulumIdentityActivityByAge deletes stale millisecond last_seen rows', () => { const nowMs = Date.now(); db! diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index 508ad0883..cb2d6e476 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -19,6 +19,60 @@ import { assertIpcSender } from '../validate-ipc-sender'; const REMOTE_ADDRESS_SERVICES = new Set(['rnsh', 'rncp']); const REMOTE_INBOUND_DECISIONS = new Set(['allow', 'block']); +interface ParsedRemoteAddressUpsert { + id: string; + label: string; + service: RemoteAddressService; + destinationHash: string; + identityHash: string | null; + lxmfPeerHash: string | null; + lastUsedAt: number | null; +} + +function parseRemoteAddressUpsertRow(row: unknown): ParsedRemoteAddressUpsert { + if (!row || typeof row !== 'object') { + throw new Error('db:upsertReticulumRemoteAddress: row must be an object'); + } + const r = row as Record; + const destinationHash = canonicalizeHash32(r.destination_hash); + if (!destinationHash) { + throw new Error('db:upsertReticulumRemoteAddress: destination_hash invalid'); + } + const service = r.service; + if ( + typeof service !== 'string' || + !REMOTE_ADDRESS_SERVICES.has(service as RemoteAddressService) + ) { + throw new Error('db:upsertReticulumRemoteAddress: service invalid'); + } + const label = typeof r.label === 'string' ? r.label.trim().slice(0, 128) : ''; + if (!label) { + throw new Error('db:upsertReticulumRemoteAddress: label required'); + } + const identityHash = r.identity_hash != null ? canonicalizeHash32(r.identity_hash) : null; + const lxmfPeerHash = + r.lxmf_peer_hash != null && r.lxmf_peer_hash !== '' + ? canonicalizeHash32(r.lxmf_peer_hash) + : null; + if (r.lxmf_peer_hash != null && r.lxmf_peer_hash !== '' && !lxmfPeerHash) { + throw new Error('db:upsertReticulumRemoteAddress: lxmf_peer_hash invalid'); + } + const lastUsedAt = + r.last_used_at != null && Number.isFinite(Number(r.last_used_at)) + ? Math.trunc(Number(r.last_used_at)) + : null; + const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim().slice(0, 64) : randomUUID(); + return { + id, + label, + service: service as RemoteAddressService, + destinationHash, + identityHash, + lxmfPeerHash, + lastUsedAt, + }; +} + /** 32-hex identity/destination hash — delegates to shared helper (matches sidecar `parse_hash16()`). */ function canonicalizeHash32(raw: unknown): string | null { return typeof raw === 'string' ? canonicalizeReticulumDestinationHash(raw) : null; @@ -146,14 +200,27 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): ) .get(identityId, messageHash) as { id?: number } | undefined; if (existing?.id != null) { + // Never demote a delivered Completes back to in-flight (retry/echo saves). db.prepareOnce( `UPDATE reticulum_messages - SET delivery_status = COALESCE(?, delivery_status), + SET delivery_status = CASE + WHEN delivery_status = 'delivered' + AND ? IN ('sending', 'pending', 'queued') + THEN delivery_status + ELSE COALESCE(?, delivery_status) + END, received_via = COALESCE(?, received_via), sender_name = COALESCE(?, sender_name), delivery_method = COALESCE(?, delivery_method) WHERE id = ?`, - ).run(deliveryStatus, receivedVia, senderName, deliveryMethod, existing.id); + ).run( + deliveryStatus, + deliveryStatus, + receivedVia, + senderName, + deliveryMethod, + existing.id, + ); return { changes: 1 }; } } @@ -729,33 +796,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): ipcMain.handle('db:upsertReticulumRemoteAddress', (event, row: unknown) => { try { assertIpcSender(event, 'db:upsertReticulumRemoteAddress'); - if (!row || typeof row !== 'object') { - throw new Error('db:upsertReticulumRemoteAddress: row must be an object'); - } - const r = row as Record; - const destinationHash = canonicalizeHash32(r.destination_hash); - if (!destinationHash) { - throw new Error('db:upsertReticulumRemoteAddress: destination_hash invalid'); - } - const service = r.service; - if ( - typeof service !== 'string' || - !REMOTE_ADDRESS_SERVICES.has(service as RemoteAddressService) - ) { - throw new Error('db:upsertReticulumRemoteAddress: service invalid'); - } - const label = typeof r.label === 'string' ? r.label.trim().slice(0, 128) : ''; - if (!label) { - throw new Error('db:upsertReticulumRemoteAddress: label required'); - } - const identityHash = r.identity_hash != null ? canonicalizeHash32(r.identity_hash) : null; - const lxmfPeerHash = - typeof r.lxmf_peer_hash === 'string' ? r.lxmf_peer_hash.slice(0, 128) : null; - const lastUsedAt = - r.last_used_at != null && Number.isFinite(Number(r.last_used_at)) - ? Math.trunc(Number(r.last_used_at)) - : null; - const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim().slice(0, 64) : randomUUID(); + const parsed = parseRemoteAddressUpsertRow(row); const now = Date.now(); const db = getDbForIpc('db:upsertReticulumRemoteAddress'); if (!db) return { changes: 0 }; @@ -769,7 +810,17 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): lxmf_peer_hash = COALESCE(excluded.lxmf_peer_hash, reticulum_remote_addresses.lxmf_peer_hash), updated_at = excluded.updated_at, last_used_at = COALESCE(excluded.last_used_at, reticulum_remote_addresses.last_used_at)`, - ).run(id, label, service, destinationHash, identityHash, lxmfPeerHash, now, now, lastUsedAt); + ).run( + parsed.id, + parsed.label, + parsed.service, + parsed.destinationHash, + parsed.identityHash, + parsed.lxmfPeerHash, + now, + now, + parsed.lastUsedAt, + ); return { changes: 1 }; } catch (err) { finishDbIpcHandler('db:upsertReticulumRemoteAddress', err); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c3be9b22e..270978fa3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1819,7 +1819,8 @@ function AppContent() { (msg: ChatMessage) => { const replyTo = msg.reticulum_reply_to_hash ?? (msg.replyId != null ? String(msg.replyId) : undefined); - sendMessage(msg.payload, msg.channel, msg.to ?? undefined, replyTo); + const retryOfStoreId = msg.reticulum_message_hash ?? msg.storeId; + sendMessage(msg.payload, msg.channel, msg.to ?? undefined, replyTo, retryOfStoreId); }, [sendMessage], ); diff --git a/src/renderer/appHandleResend.test.ts b/src/renderer/appHandleResend.test.ts index 41d946ba9..fbe991fb8 100644 --- a/src/renderer/appHandleResend.test.ts +++ b/src/renderer/appHandleResend.test.ts @@ -1,6 +1,6 @@ /** - * Contract: App `handleResend` must pass `msg.replyId` into `device.sendMessage` so Meshtastic - * and MeshCore retries preserve thread metadata. Keep this aligned with App.tsx handleResend. + * Contract: App `handleResend` must match App.tsx — forward reply metadata and, for + * Reticulum, the prior store id so retry reuses the failed bubble. */ import { describe, expect, it, vi } from 'vitest'; @@ -8,13 +8,22 @@ import type { ChatMessage } from '@/renderer/lib/types'; function handleResendContract( msg: ChatMessage, - sendMessage: (text: string, channel: number, destination?: number, replyId?: number) => void, + sendMessage: ( + text: string, + channel: number, + destination?: number, + replyTo?: string, + retryOfStoreId?: string, + ) => void, ) { - sendMessage(msg.payload, msg.channel, msg.to ?? undefined, msg.replyId); + const replyTo = + msg.reticulum_reply_to_hash ?? (msg.replyId != null ? String(msg.replyId) : undefined); + const retryOfStoreId = msg.reticulum_message_hash ?? msg.storeId; + sendMessage(msg.payload, msg.channel, msg.to ?? undefined, replyTo, retryOfStoreId); } describe('App handleResend (contract)', () => { - it('forwards replyId as the fourth argument when present', () => { + it('forwards replyId as a string when present', () => { const sendMessage = vi.fn(); const msg: ChatMessage = { sender_id: 1, @@ -26,7 +35,7 @@ describe('App handleResend (contract)', () => { replyId: 4242, }; handleResendContract(msg, sendMessage); - expect(sendMessage).toHaveBeenCalledWith('retry body', 0, undefined, 4242); + expect(sendMessage).toHaveBeenCalledWith('retry body', 0, undefined, '4242', undefined); }); it('passes undefined replyId when the failed message was not a reply', () => { @@ -41,6 +50,46 @@ describe('App handleResend (contract)', () => { to: 0xabc, }; handleResendContract(msg, sendMessage); - expect(sendMessage).toHaveBeenCalledWith('plain', -1, 0xabc, undefined); + expect(sendMessage).toHaveBeenCalledWith('plain', -1, 0xabc, undefined, undefined); + }); + + it('passes Reticulum message hash as retryOfStoreId so resend reuses the row', () => { + const sendMessage = vi.fn(); + const hash = 'aa'.repeat(32); + const msg: ChatMessage = { + sender_id: 1, + sender_name: 'Me', + payload: 'lxmf retry', + channel: 0, + timestamp: 1, + status: 'failed', + to: 0x1234, + reticulum_message_hash: hash, + reticulum_reply_to_hash: 'bb'.repeat(32), + }; + handleResendContract(msg, sendMessage); + expect(sendMessage).toHaveBeenCalledWith('lxmf retry', 0, 0x1234, 'bb'.repeat(32), hash); + }); + + it('falls back to storeId when Reticulum hash is not set yet', () => { + const sendMessage = vi.fn(); + const msg: ChatMessage = { + sender_id: 1, + sender_name: 'Me', + payload: 'pending failed', + channel: 0, + timestamp: 1, + status: 'failed', + to: 0x1234, + storeId: 'reticulum-pending-9', + }; + handleResendContract(msg, sendMessage); + expect(sendMessage).toHaveBeenCalledWith( + 'pending failed', + 0, + 0x1234, + undefined, + 'reticulum-pending-9', + ); }); }); diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index ccb23dbdd..a8f485467 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -22,11 +22,11 @@ import type { OurPosition } from '../lib/gpsSource'; import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { DEFAULT_MESSAGE_RETENTION, - fetchMessageRetention, MESSAGE_RETENTION_KEYS, MESSAGE_RETENTION_MAX_COUNT, MESSAGE_RETENTION_MIN_COUNT, type MessageRetentionSettings, + parseMessageRetention, } from '../lib/messageRetention'; import { getNodeStatus, haversineDistanceKm } from '../lib/nodeStatus'; import { parseStoredJson } from '../lib/parseStoredJson'; @@ -49,6 +49,7 @@ import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useNodeStore } from '../stores/nodeStore'; import { usePositionHistoryStore } from '../stores/positionHistoryStore'; import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ConfirmModal } from './ConfirmModal'; import { HelpTooltip } from './HelpTooltip'; import { ReticulumAppPanelSection } from './ReticulumAppPanelSection'; @@ -164,6 +165,7 @@ interface AppSettings { storeForwardHistoryProfile: 'conservative' | 'aggressive'; shareLocationSendWaypoint: boolean; reduceMotion: boolean; + use24HourTime: boolean; meshcoreOpenWireCompatEnabled: boolean; meshcorePathHashMode: 0 | 1 | 2; } @@ -381,13 +383,18 @@ export default function AppPanel({ console.warn('[AppPanel] reduceMotion persist failed ' + errLikeToLogString(err)); }); } + if (key === 'use24HourTime') { + void window.electronAPI.appSettings + .set('use24HourTime', value ? 'true' : 'false') + .catch((err: unknown) => { + console.warn('[AppPanel] use24HourTime persist failed ' + errLikeToLogString(err)); + }); + } }; - // ─── DB-backed message retention (issue #387) ───────────────── - // Source of truth lives in SQLite (`app_settings` KV table). Hydrate on - // mount; debounce writes through IPC. Two independent caps gated by the - // currently selected protocol — pruning still runs for both tables on - // startup (see App.tsx) since both stacks may be active simultaneously. + // ─── DB-backed settings hydrate (message retention + 24h clock) ─ + // Source of truth lives in SQLite (`app_settings` KV table). One getAll() + // on mount so tests that mockResolvedValueOnce still see retention keys. const [retention, setRetention] = useState({ ...DEFAULT_MESSAGE_RETENTION, }); @@ -396,14 +403,24 @@ export default function AppPanel({ useEffect(() => { let cancelled = false; - fetchMessageRetention() - .then((loaded) => { + void window.electronAPI.appSettings + .getAll() + .then((raw) => { if (cancelled) return; + const use24 = raw?.use24HourTime; + if (use24 === 'true' || use24 === 'false') { + const enabled = use24 === 'true'; + useTimeFormatStore.getState().hydrateFromSqlite(enabled); + setSettings((prev) => + prev.use24HourTime === enabled ? prev : { ...prev, use24HourTime: enabled }, + ); + } + const loaded = parseMessageRetention(raw); setRetention(loaded); lastSavedRetentionRef.current = loaded; }) - .catch((e: unknown) => { - console.warn('[AppPanel] fetchMessageRetention failed ' + errLikeToLogString(e)); + .catch((err: unknown) => { + console.warn('[AppPanel] app settings hydrate failed ' + errLikeToLogString(err)); }); return () => { cancelled = true; @@ -1886,6 +1903,23 @@ export default function AppPanel({ +
+ { + updateSetting('use24HourTime', e.target.checked); + useTimeFormatStore.getState().setUse24HourTime(e.target.checked); + }} + aria-label={t('appPanel.use24HourTime')} + className="accent-brand-green" + /> + + +
{t('appPanel.colorScheme')} diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 84e35846d..2d5a155f7 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -1674,6 +1674,32 @@ describe('ChatPanel StatusBadge', () => { }); }); + it('does not show Resend for Reticulum messages that are still sending', () => { + render( + + + , + ); + expect(screen.queryByTitle('Resend message')).not.toBeInTheDocument(); + }); + + it('shows Resend for failed Reticulum messages', () => { + render( + + + , + ); + expect(screen.getByTitle('Resend message')).toBeInTheDocument(); + }); + it('renders "BT ✓" with a space for BLE acked messages', () => { render( diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 22a011bbd..8790c98f0 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -38,6 +38,7 @@ import { import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatShortRelativeAgo } from '@/renderer/lib/formatShortRelativeAgo'; import { useIconTrigger, useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { withMeshcoreFloodScopeOverride } from '@/renderer/lib/meshcoreFloodScopeSend'; @@ -144,6 +145,7 @@ import { import type { ChatMessage, MeshNode, MeshProtocol } from '../lib/types'; import type { RequestStoreForwardHistoryResult } from '../runtime/useMeshtasticRuntime'; import { reticulumHashForNodeId, useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ChatComposer, type ChatComposerSendOpts } from './ChatComposer'; import { ChatPayloadText } from './ChatPayloadText'; import { HelpTooltip } from './HelpTooltip'; @@ -547,6 +549,7 @@ function ChatPanel({ onSendLocationWaypoint, }: ChatPanelProps) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const parentIconTrigger = useParentIconTrigger(); const { addToast } = useToast(); const ownNodeIdSet = useMemo(() => { @@ -1601,10 +1604,7 @@ function ChatPanel({ ); function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); + return formatDisplayTime(ts, { use24Hour: use24HourTime }); } function formatFullTimestamp(ts: number): string { @@ -2703,27 +2703,25 @@ function ChatPanel({ {/* Delivery status for own messages */} {isOwn && (msg.status || msg.mqttStatus) && (
- {isOwn && - (msg.status === 'failed' || - (protocol === 'reticulum' && msg.status === 'sending')) && ( - - )} + {isOwn && msg.status === 'failed' && ( + + )} {showLxmfDeliveryStatus && msg.status ? ( s.use24HourTime); const letsMeshUsernameSyncTimerRef = useRef | null>(null); const [reticulumStackError, setReticulumStackError] = useState(null); @@ -2993,7 +2996,7 @@ export default function ConnectionPanel({
{t('connectionPanel.lastData')} - {new Date(state.lastDataReceived).toLocaleTimeString()} + {formatDisplayTime(state.lastDataReceived, { use24Hour: use24HourTime })}
)} diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index e0df3fd8f..6d07bed63 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -13,6 +13,7 @@ import { } from 'recharts'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { SpinnerIcon } from '@/renderer/lib/icons/spinnerIcon'; @@ -22,6 +23,7 @@ import { isRfForeignLoraHeard, useDiagnosticsStore, } from '@/renderer/stores/diagnosticsStore'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { formatMeshtasticNodeId, meshtasticNodeIdMatchesHexQuery } from '@/shared/nodeNameUtils'; @@ -181,6 +183,7 @@ export default function DiagnosticsPanel({ onRefreshReticulumDiagnostics, }: Props) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const formatRowTime = useCallback( (ts: number) => { if (!ts) return t('common.emDash'); @@ -939,10 +942,7 @@ export default function DiagnosticsPanel({ const chartData = samples .filter((s) => s.t >= cutoff) .map((s) => ({ - time: new Date(s.t).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }), + time: formatDisplayTime(s.t, { use24Hour: use24HourTime }), cu: Math.round(s.cu * 10) / 10, })); if (chartData.length < 2) return null; diff --git a/src/renderer/components/LogAnalyzeModal.tsx b/src/renderer/components/LogAnalyzeModal.tsx index be04f6b27..4faccb753 100644 --- a/src/renderer/components/LogAnalyzeModal.tsx +++ b/src/renderer/components/LogAnalyzeModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import { analyzeLogs, @@ -45,10 +46,11 @@ export default function LogAnalyzeModal({ }: LogAnalyzeModalProps) { const { t } = useTranslation(); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const dialogRef = useRef(null); const result = analyzeLogs(entries, protocol); - const timeRange = formatTimeRange(result.oldestTs, result.newestTs); + const timeRange = formatTimeRange(result.oldestTs, result.newestTs, use24HourTime); const dedupedRecs = dedupeRecommendations(result.categories); useEffect(() => { diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 91895ed1b..49b162627 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { getIdentityIdForProtocol } from '@/renderer/lib/identityByProtocol'; import { @@ -64,6 +65,7 @@ import { useCoordFormatStore } from '../stores/coordFormatStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useNodeStore } from '../stores/nodeStore'; import { usePathHistoryStore } from '../stores/pathHistoryStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { useWatchedNodesStore } from '../stores/watchedNodesStore'; import { HelpTooltip } from './HelpTooltip'; import { MeshcoreRepeaterPasswordControls } from './MeshcoreRepeaterPasswordControls'; @@ -254,6 +256,7 @@ export default function NodeDetailModal({ }: NodeDetailModalProps) { const { t } = useTranslation(); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const { ensureRepeaterAuth, promptRepeaterPassword, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); const { ensureRoomAuth, RemoteAuthModal: RoomAuthModal } = useMeshcoreRoomAuth(); @@ -935,7 +938,9 @@ export default function NodeDetailModal({
- {new Date(meshcoreNodeTelemetry.fetchedAt).toLocaleTimeString()} + {formatDisplayTime(meshcoreNodeTelemetry.fetchedAt, { + use24Hour: use24HourTime, + })}
)} @@ -783,7 +786,9 @@ export default function NodeInfoBody({ )} - {new Date(meshcoreTraceFirst.timestamp).toLocaleTimeString()} + {formatDisplayTime(meshcoreTraceFirst.timestamp, { + use24Hour: use24HourTime, + })} {meshcoreTraceHistory.length > 1 && ( {t('nodeInfoBody.olderCount', { count: meshcoreTraceHistory.length - 1 })} diff --git a/src/renderer/components/ReticulumMapPanel.tsx b/src/renderer/components/ReticulumMapPanel.tsx index 7850a6504..e2cf00cda 100644 --- a/src/renderer/components/ReticulumMapPanel.tsx +++ b/src/renderer/components/ReticulumMapPanel.tsx @@ -17,6 +17,7 @@ import { } from '@/renderer/components/map/leafletMapControls'; import { CHAT_SCROLL_END_THRESHOLD } from '@/renderer/lib/chatScrollUtils'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayDateTime } from '@/renderer/lib/formatDisplayTime'; import { readStoredStaticGps } from '@/renderer/lib/gpsSource'; import { DEFAULT_MAP_BASEMAP_ID, @@ -38,6 +39,7 @@ import { useMapLayerStore } from '@/renderer/stores/mapLayerStore'; import { useMapViewportStore } from '@/renderer/stores/mapViewportStore'; import { useReticulumDiscoveryMapStore } from '@/renderer/stores/reticulumDiscoveryMapStore'; import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; const REFRESH_MS = 30_000; const DEFAULT_CENTER: [number, number] = [20, 0]; @@ -116,6 +118,7 @@ export default function ReticulumMapPanel({ onOpenAppGpsSettings, }: ReticulumMapPanelProps) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const basemapId = useMapLayerStore((s) => s.basemapId); const basemap = MAP_BASEMAPS[basemapId] ?? MAP_BASEMAPS[DEFAULT_MAP_BASEMAP_ID]; const overlayColors = getMapOverlayColors(basemap.isDark); @@ -428,7 +431,9 @@ export default function ReticulumMapPanel({ )}
{t('reticulumMap.lastHeard', { - time: new Date(row.last_heard * 1000).toLocaleString(), + time: formatDisplayDateTime(row.last_heard * 1000, { + use24Hour: use24HourTime, + }), })}
diff --git a/src/renderer/components/ReticulumNetworkPanel.tsx b/src/renderer/components/ReticulumNetworkPanel.tsx index 3483c4f8a..1636e60a0 100644 --- a/src/renderer/components/ReticulumNetworkPanel.tsx +++ b/src/renderer/components/ReticulumNetworkPanel.tsx @@ -33,6 +33,7 @@ import { IdentityVaultPanel } from './IdentityVaultPanel'; import QrCodeImage from './QrCodeImage'; import QrIngestControl from './QrIngestControl'; import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; +import ReticulumPnHostingDangerZone from './ReticulumPnHostingDangerZone'; import ReticulumPropagationSection from './ReticulumPropagationSection'; import { ReticulumRmapDiscoveryControls } from './ReticulumRmapDiscoveryControls'; import { useToast } from './Toast'; @@ -799,6 +800,8 @@ export function ReticulumNetworkPanel({ > + + ) : null} diff --git a/src/renderer/components/ReticulumPeerDetailModal.test.tsx b/src/renderer/components/ReticulumPeerDetailModal.test.tsx index 3b9abaebd..4548682a0 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.test.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +const addToast = vi.fn(); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => { @@ -12,6 +14,10 @@ vi.mock('react-i18next', () => ({ }), })); +vi.mock('./Toast', () => ({ + useToast: () => ({ addToast }), +})); + vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ requestReticulumPeerPath: vi.fn(), probeReticulumPeer: vi.fn(), @@ -26,8 +32,10 @@ const PEER_HASH = 'abcdef1234567890abcdef1234567890'; describe('ReticulumPeerDetailModal — copy hash', () => { beforeEach(() => { + addToast.mockClear(); vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined); useReticulumPeerStore.setState({ peers: new Map([ [ @@ -41,6 +49,7 @@ describe('ReticulumPeerDetailModal — copy hash', () => { ], ]), contacts: new Map(), + peerAppearanceByHash: new Map(), lastRefreshAt: null, }); }); @@ -57,3 +66,96 @@ describe('ReticulumPeerDetailModal — copy hash', () => { expect(writeText).toHaveBeenCalledWith(PEER_HASH); }); }); + +describe('ReticulumPeerDetailModal — avatar icon', () => { + beforeEach(() => { + addToast.mockClear(); + vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined); + useReticulumPeerStore.setState({ + peers: new Map([ + [ + PEER_HASH, + { + destination_hash: PEER_HASH, + display_name: 'Test Peer', + hops: 2, + last_seen: Date.now() / 1000, + }, + ], + ]), + contacts: new Map(), + peerAppearanceByHash: new Map(), + lastRefreshAt: null, + }); + }); + + it('selects People and persists icon_name user', async () => { + const user = userEvent.setup(); + const upsert = vi.mocked(window.electronAPI.db.upsertReticulumDestination); + + render( + , + ); + + const select = screen.getByLabelText('reticulumProfileIcon.iconNameAria'); + await user.selectOptions(select, 'user'); + + await waitFor(() => { + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + destination_hash: PEER_HASH, + icon_name: 'user', + icon_color: 'green', + }), + ); + }); + expect(useReticulumPeerStore.getState().peerAppearanceByHash.get(PEER_HASH)).toEqual({ + icon_name: 'user', + icon_color: 'green', + }); + expect(select).toHaveValue('user'); + }); + + it('loads wire people icon into People select option', async () => { + vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([ + { + destination_hash: PEER_HASH, + icon_name: 'people', + icon_color: 'cyan', + }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByLabelText('reticulumProfileIcon.iconNameAria')).toHaveValue('user'); + }); + }); + + it('toasts and reverts when upsert fails', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockRejectedValue( + new Error('db down'), + ); + + render( + , + ); + + const select = screen.getByLabelText('reticulumProfileIcon.iconNameAria'); + await user.selectOptions(select, 'user'); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('reticulumProfileIcon.iconSaveFailed', 'error'); + }); + expect(select).toHaveValue('circle'); + expect(useReticulumPeerStore.getState().peerAppearanceByHash.get(PEER_HASH)).toEqual({ + icon_name: 'circle', + icon_color: 'green', + }); + }); +}); diff --git a/src/renderer/components/ReticulumPeerDetailModal.tsx b/src/renderer/components/ReticulumPeerDetailModal.tsx index c4cdce13a..49ee26d78 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.tsx @@ -12,6 +12,10 @@ import { registerReticulumDestinationHash, reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; +import { + isDefaultReticulumProfileIcon, + resolveReticulumProfileIconName, +} from '@/renderer/lib/reticulum/reticulumIconAppearance'; import { formatReticulumPeerPathToast, formatReticulumPeerProbeToast, @@ -31,11 +35,7 @@ import { formatReticulumIdentityFingerprint } from '@/shared/reticulumIdentityFi import { ConfirmModal } from './ConfirmModal'; import QrCodeImage from './QrCodeImage'; -import { - RETICULUM_PROFILE_ICON_NAMES, - ReticulumProfileIcon, - type ReticulumProfileIconName, -} from './ReticulumProfileIcon'; +import { type ReticulumProfileIconName, ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { useToast } from './Toast'; export interface ReticulumPeerDetailModalProps { @@ -157,12 +157,15 @@ export default function ReticulumPeerDetailModal({ typeof r.destination_hash === 'string' && canonicalizeReticulumDestinationHash(r.destination_hash) === key, ); - if (row?.icon_color) setIconColor(row.icon_color); - if ( - row?.icon_name && - RETICULUM_PROFILE_ICON_NAMES.includes(row.icon_name as ReticulumProfileIconName) - ) { - setIconName(row.icon_name as ReticulumProfileIconName); + if (!row) return; + const resolvedName = resolveReticulumProfileIconName(row.icon_name); + const color = row.icon_color?.trim() || 'green'; + if (isDefaultReticulumProfileIcon(resolvedName, color)) { + setIconName('circle'); + setIconColor('green'); + } else { + setIconName(resolvedName); + setIconColor(color); } } catch (err) { console.warn( @@ -176,18 +179,57 @@ export default function ReticulumPeerDetailModal({ }, [peerHash]); const saveIconAppearance = async (patch: { icon_color?: string; icon_name?: string }) => { - if (patch.icon_color != null) setIconColor(patch.icon_color); - if (patch.icon_name != null) setIconName(patch.icon_name as ReticulumProfileIconName); + const nextName = (patch.icon_name as ReticulumProfileIconName | undefined) ?? iconName; + const nextColor = patch.icon_color ?? iconColor; + const cleared = isDefaultReticulumProfileIcon(nextName, nextColor); + const persistPatch = cleared + ? { icon_name: 'circle', icon_color: 'green' } + : { + icon_name: nextName, + icon_color: nextColor, + }; + + const previousName = iconName; + const previousColor = iconColor; + const previousAppearance = useReticulumPeerStore + .getState() + .peerAppearanceByHash.get( + canonicalizeReticulumDestinationHash(peerHash) ?? + peerHash.replace(/[^0-9a-f]/gi, '').toLowerCase(), + ); + + setIconName(persistPatch.icon_name as ReticulumProfileIconName); + setIconColor(persistPatch.icon_color); + const key = canonicalizeReticulumDestinationHash(peerHash); - if (!key) return; + if (!key) { + setIconName(previousName); + setIconColor(previousColor); + console.warn('[ReticulumPeerDetailModal] icon appearance: invalid destination hash'); + addToast(t('reticulumProfileIcon.iconSaveFailed'), 'error'); + return; + } + + useReticulumPeerStore.getState().patchPeerAppearance(key, persistPatch); + try { await window.electronAPI.db.upsertReticulumDestination({ destination_hash: key, - ...patch, + ...persistPatch, }); - useReticulumPeerStore.getState().patchPeerAppearance(key, patch); } catch (e) { + setIconName(previousName); + setIconColor(previousColor); + if (previousAppearance) { + useReticulumPeerStore.getState().patchPeerAppearance(key, previousAppearance); + } else { + useReticulumPeerStore.getState().patchPeerAppearance(key, { + icon_name: 'circle', + icon_color: 'green', + }); + } console.warn('[ReticulumPeerDetailModal] icon appearance ' + errLikeToLogString(e)); + addToast(t('reticulumProfileIcon.iconSaveFailed'), 'error'); } }; @@ -358,7 +400,7 @@ export default function ReticulumPeerDetailModal({ ) : (
- +

{ - void saveIconAppearance({ icon_name: e.target.value }); + const name = e.target.value as ReticulumProfileIconName; + if (name === 'circle') { + void saveIconAppearance({ icon_name: 'circle', icon_color: 'green' }); + } else { + void saveIconAppearance({ icon_name: name }); + } }} > - + diff --git a/src/renderer/components/ReticulumPeerListPanel.test.tsx b/src/renderer/components/ReticulumPeerListPanel.test.tsx index 466ad1984..4ea8918c1 100644 --- a/src/renderer/components/ReticulumPeerListPanel.test.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.test.tsx @@ -161,6 +161,29 @@ describe('ReticulumPeerListPanel', () => { expect(screen.getByText('peerListPanel.contactYes')).toBeInTheDocument(); }); + it('shows empty outline avatar when peer has no custom icon', () => { + render( + , + ); + const label = screen.getByText('Alpha Peer'); + const rowLabel = label.closest('span.inline-flex'); + expect(rowLabel?.querySelector('.border-dashed')).toBeTruthy(); + expect(rowLabel?.querySelector('svg')).toBeNull(); + }); + + it('shows people icon when peer has user appearance', () => { + useReticulumPeerStore.setState({ + peerAppearanceByHash: new Map([['abc', { icon_name: 'user', icon_color: 'green' }]]), + }); + render( + , + ); + const label = screen.getByText('Alpha Peer'); + const rowLabel = label.closest('span.inline-flex'); + expect(rowLabel?.querySelector('.border-dashed')).toBeNull(); + expect(rowLabel?.querySelector('svg')).toBeTruthy(); + }); + it('renders contacts tab with last heard column', async () => { const user = userEvent.setup(); render( diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index 59263a0a4..a3ef845b5 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -49,7 +49,7 @@ import { resolveReticulumPeerLabel, useReticulumPeerStore, } from '../stores/reticulumPeerStore'; -import { hasCustomReticulumProfileIcon, ReticulumProfileIcon } from './ReticulumProfileIcon'; +import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { useToast } from './Toast'; type PeerListTab = 'peers' | 'contacts' | 'favorites'; @@ -87,7 +87,6 @@ interface PeerTableRowProps { busy: boolean; contacted: boolean; verified: boolean; - showIcon: boolean; iconName?: string | null; iconColor?: string | null; displayLabel: string; @@ -104,7 +103,6 @@ const PeerTableRow = memo(function PeerTableRow({ busy, contacted, verified, - showIcon, iconName, iconColor, displayLabel, @@ -124,9 +122,7 @@ const PeerTableRow = memo(function PeerTableRow({ > - {showIcon ? ( - - ) : null} + {displayLabel} {verified ? ( ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const addToast = vi.fn(); +vi.mock('./Toast', () => ({ + useToast: () => ({ addToast }), +})); + +import ReticulumPnHostingDangerZone from './ReticulumPnHostingDangerZone'; + +describe('ReticulumPnHostingDangerZone', () => { + const original = { + hostingPolicy: useReticulumPropagationStore.getState().hostingPolicy, + refreshFromSidecar: useReticulumPropagationStore.getState().refreshFromSidecar, + setHostingPolicyOnSidecar: useReticulumPropagationStore.getState().setHostingPolicyOnSidecar, + }; + + beforeEach(() => { + addToast.mockReset(); + useReticulumPropagationStore.setState({ + hostingPolicy: { ...DEFAULT_PN_HOSTING_POLICY }, + refreshFromSidecar: vi.fn().mockResolvedValue(undefined), + setHostingPolicyOnSidecar: vi.fn().mockResolvedValue(true), + }); + }); + + afterEach(() => { + useReticulumPropagationStore.setState(original); + }); + + it('renders yellow danger zone and saves hosting policy payload', async () => { + const user = userEvent.setup(); + const setHostingPolicyOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setHostingPolicyOnSidecar, + ); + + render(); + + expect(screen.getByText('networkPanel.reticulumPnHosting.title')).toBeInTheDocument(); + + const maxPeering = screen.getByLabelText('networkPanel.reticulumPnHosting.maxPeeringCost'); + await user.clear(maxPeering); + await user.type(maxPeering, '30'); + + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveAria' }), + ); + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveConfirm' }), + ); + + await waitFor(() => { + expect(setHostingPolicyOnSidecar).toHaveBeenCalled(); + }); + const saved = setHostingPolicyOnSidecar.mock.calls[0]?.[0]; + expect(saved?.max_peering_cost).toBe(30); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('networkPanel.reticulumPnHosting.saveOk', 'success'); + }); + }); + + it('toasts failure when save fails', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + setHostingPolicyOnSidecar: vi.fn().mockResolvedValue(false), + lastHostingPolicyError: 'networkPanel.reticulumPnHosting.saveFailed', + }); + + render(); + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveAria' }), + ); + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveConfirm' }), + ); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('networkPanel.reticulumPnHosting.saveFailed', 'error'); + }); + }); +}); diff --git a/src/renderer/components/ReticulumPnHostingDangerZone.tsx b/src/renderer/components/ReticulumPnHostingDangerZone.tsx new file mode 100644 index 000000000..efd6c717b --- /dev/null +++ b/src/renderer/components/ReticulumPnHostingDangerZone.tsx @@ -0,0 +1,377 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { type PnHostingPolicy } from '@/shared/pnHostingPolicy'; + +import { ConfirmModal } from './ConfirmModal'; +import { useToast } from './Toast'; + +interface ReticulumPnHostingDangerZoneProps { + disabled?: boolean; +} + +function NumberField({ + id, + label, + value, + min, + max, + onChange, + disabled, +}: Readonly<{ + id: string; + label: string; + value: number; + min: number; + max: number; + onChange: (n: number) => void; + disabled?: boolean; +}>) { + return ( + + ); +} + +/** + * Yellow advanced danger zone for LXMF PN hosting / peering policy. + * Collapsed by default; lives on Network after Propagation. + */ +export default function ReticulumPnHostingDangerZone({ + disabled = false, +}: Readonly) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const hostingPolicy = useReticulumPropagationStore((s) => s.hostingPolicy); + const setHostingPolicyOnSidecar = useReticulumPropagationStore( + (s) => s.setHostingPolicyOnSidecar, + ); + const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); + + const [draft, setDraft] = useState(hostingPolicy); + const [policySnapshot, setPolicySnapshot] = useState(hostingPolicy); + const [draftDirty, setDraftDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [pendingSave, setPendingSave] = useState(false); + + if (hostingPolicy !== policySnapshot && !draftDirty) { + setPolicySnapshot(hostingPolicy); + setDraft(hostingPolicy); + } + + useEffect(() => { + void refreshFromSidecar(); + }, [refreshFromSidecar]); + + const patch = (key: K, value: PnHostingPolicy[K]) => { + setDraftDirty(true); + setDraft((prev) => ({ ...prev, [key]: value })); + }; + + return ( + <> +
+ + {t('networkPanel.reticulumPnHosting.title')} + +

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

+
+ { + patch('peering_cost', n); + }} + /> + { + patch('max_peering_cost', n); + }} + /> + { + patch('autopeer_maxdepth', n); + }} + /> + { + patch('max_peers', n); + }} + /> + { + patch('propagation_stamp_cost', n); + }} + /> + { + patch('propagation_stamp_flex', n); + }} + /> + { + patch('message_storage_limit_mb', n); + }} + /> + { + patch('propagation_limit_kb', n); + }} + /> + { + patch('sync_limit_kb', n); + }} + /> + { + patch('delivery_limit_kb', n); + }} + /> + { + patch('pn_announce_interval_sec', n); + }} + /> +
+
+ + + + + + +
+ +