diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index ab0a43c26..000000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# Branch-scoped Vitest before push: tests related to files changed since merge-base with origin/main. -# Full suite remains `pnpm run test:run` / `pnpm run check:pr`. Skip with git push --no-verify. - -REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) - -fail() { - printf 'pre-push: FAILED — %s\n' "$1" >&2 - exit 1 -} - -if ! git rev-parse --verify origin/main > /dev/null 2>&1; then - printf 'pre-push: skip Vitest --changed (origin/main not available; fetch or run pnpm run check:pr)\n' >&2 - exit 0 -fi - -MERGE_BASE=$(git merge-base HEAD origin/main) || fail 'git merge-base HEAD origin/main' -printf 'pre-push: vitest run --changed %s (merge-base with origin/main)\n' "$MERGE_BASE" >&2 - -cd "$REPO_ROOT" || fail 'cd repo root' -pnpm exec vitest run --changed "$MERGE_BASE" || fail 'vitest run --changed (pre-push)' - -printf 'pre-push: OK\n' >&2 -exit 0 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 717925600..63faf1e5b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -100,7 +100,7 @@ jobs: - name: Build Reticulum sidecar for Electron packaging env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: node scripts/build-reticulum-sidecar-release.mjs --platform ${{ matrix.sidecar_platform }} - name: Verify staged Reticulum sidecars diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index 2c4c8ed70..adba2a02c 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -66,7 +66,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF) env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6e69f13dc..163427c9d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -158,7 +158,7 @@ jobs: - name: Build Reticulum sidecar for Electron packaging env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: node scripts/build-reticulum-sidecar-release.mjs --platform ${{ matrix.sidecar_platform }} - name: Verify staged Reticulum sidecars diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml index cabe34831..ef4bd6075 100644 --- a/.github/workflows/reticulum-sidecar.yaml +++ b/.github/workflows/reticulum-sidecar.yaml @@ -33,7 +33,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF, rsNomad) shell: bash env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -41,11 +41,11 @@ jobs: - name: Install Linux BLE build deps run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config - name: cargo fmt --check (rsNomad) - working-directory: ${{ github.workspace }}/../rsNomad + working-directory: ${{ github.workspace }}/.rsstack/rsNomad # Format only workspace members — `--all` also walks path deps into rsReticulum. run: cargo fmt -p nomad-core -- --check - name: cargo clippy (rsNomad) - working-directory: ${{ github.workspace }}/../rsNomad + working-directory: ${{ github.workspace }}/.rsstack/rsNomad run: cargo clippy --workspace --all-targets -- -D warnings - name: cargo fmt --check working-directory: reticulum-sidecar @@ -74,11 +74,9 @@ jobs: steps: - uses: actions/checkout@v6 # Optional rns-stack path deps must exist on disk even for the default stub build. - # checkout@v6 only allows paths under GITHUB_WORKSPACE; Cargo expects Ratspeak siblings. + # Repo-local .rsstack workspace must stay under GITHUB_WORKSPACE so Cargo path deps resolve. - name: Clone Ratspeak stack (rsReticulum, rsLXMF, rsNomad) shell: bash - env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -116,7 +114,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF, rsNomad) shell: bash env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -147,7 +145,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF, rsNomad) shell: bash env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -172,7 +170,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF, rsNomad) shell: bash env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f3fd1d35c..dc51568db 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -100,7 +100,7 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF) shell: bash env: - WORKSPACE_ROOT: ${{ github.workspace }}/.. + WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: diff --git a/.gitignore b/.gitignore index 643127943..9c9ee8040 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ coverage/ .context-os .hermes/ .rtk +# Repo-local Ratspeak overlay workspace (clone-ratspeak-stack.sh target) +.rsstack/ .githooks/bin/ flatpak/generated-sources.json # Written by scripts/write-flatpak-ci-build-info.mjs in Flatpak CI diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 2d904b439..a8e9349ed 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,6 +1,7 @@ { "ignores": [ "node_modules/**", + ".rsstack/**", ".cursor/**", ".hermes/**", "site/**", diff --git a/.prettierignore b/.prettierignore index da483e25b..df880fd0d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,12 +8,16 @@ coverage # Dependencies node_modules +# Repo-local Ratspeak overlay workspace (cloned upstream repos; gitignored) +.rsstack + # Logs & temp *.log tmp/ # Files unsupported by prettier parser .npmrc +.yamllint .editorconfig .gitattributes patches/ diff --git a/.yamllint b/.yamllint index 852bf0a8e..d4f387bc8 100644 --- a/.yamllint +++ b/.yamllint @@ -8,3 +8,4 @@ rules: ignore: | pnpm-lock.yaml node_modules/ + .rsstack/ diff --git a/AGENTS.md b/AGENTS.md index 1a6b72e9d..abf3356c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This file is self-contained. ARCHITECTURE.md and CONTRIBUTING.md are human refer - **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, path-gated `typecheck:strict-shared` when `src/shared/` is staged, 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. **Pre-push** runs `vitest run --changed` against the merge-base with `origin/main` (branch-scoped; skip with `--no-verify`). **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 check:pr`** (hand, before opening/updating a PR) runs full lint + typecheck + `typecheck:strict-shared` + `test:run` (+ full-feature sidecar check when the branch touches sidecar). **`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. +- **Pre-commit patience:** Pre-commit runs staged-related Vitest (`pnpm run test:staged`), staged ESLint, full typecheck, path-gated `typecheck:strict-shared` when `src/shared/` is staged, 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 check:pr`** (hand, before opening/updating a PR) runs full lint + typecheck + `typecheck:strict-shared` + `test:run` (+ full-feature sidecar check when the branch touches sidecar). **`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. - **Fresh clone:** Before other setup, run `node scripts/check-environment.mjs` (works before pnpm is installed). After `pnpm install`, re-run `pnpm run check:environment`. Fix required failures using printed hints and `setup:*` scripts; optional warnings can wait. Wrong/outdated pnpm is blocked by `scripts/check-package-manager.mjs` on `preinstall` and `pnpm run dev` (prints Corepack/`npm install -g pnpm@…` steps; Node 25+ needs Corepack installed separately). ### Platform parity @@ -132,8 +132,6 @@ Adding a cross-boundary feature: 8. `pnpm audit` only when dependency manifests staged; `actionlint` / `yamllint` when workflows / YAML staged 9. `pnpm run test:staged` → `scripts/precommit-tests.mjs` (staged-only `vitest related`; full suite for vitest config/setup/deps; skip when no source/test staged) -**Pre-push:** `.githooks/pre-push` runs `vitest run --changed ` when `origin/main` exists. - Before PR: `pnpm run check:pr` (lint + typecheck + `typecheck:strict-shared` + full `test:run` + path-aware sidecar). Release pre-flight (`pnpm run release`) always uses `test:run` + full `check:*` (no path-gating / soft-skips). ## 7. Git & PR Workflow @@ -144,13 +142,13 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). ### Reticulum -- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. +- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps under repo-local `.rsstack/` via `scripts/clone-ratspeak-stack.sh` — `rsReticulum`/`rsLXMF`/`rsNomad`/`rsLXST`/`lrgp-rs`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. - **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. -- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, Propagation mode Off/Auto/Manual + rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled -- **Propagation sync:** `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation — `refreshFromSidecar` must **not** clear the active attempt while `sync.active` +- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs the best Discovered PN by destination hash (no Add, no Preferred write) via `startPropagationSyncCascade` + sidecar `destination_hash` sync, then configured remotes, then local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `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. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. - **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)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **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` @@ -158,7 +156,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local inbox); 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 cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node**; local-prop Completes: local inbox (not peer-delivered). **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local inbox); 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 cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: local inbox, not peer-delivered (PN + amber house). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). diff --git a/README.md b/README.md index 8fef253fc..7de8cc8a6 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - Generate or import LXMF identity (mnemonic); **identity vault** (passcode) and encrypted identity export; import/export rnsd-style config from standard system paths - Stack settings (`enable_transport`, `share_instance`, log level), announce interval, **Clear announces**, **RMAP v4 discovery** publish controls -- **Propagation nodes**: preferred node for offline DMs, per-node sync, optional **local propagation inbox** / Advanced PN hosting policy +- **Propagation nodes** (Network tab): **Propagation mode** Off (default) / Auto / Manual, Preferred node for offline DMs, per-node sync, optional **local propagation inbox** / Advanced PN hosting policy **Messaging (Chat + encrypted paper + RRC)** @@ -538,7 +538,7 @@ Enter your broker URL, topic, and optional credentials in the MQTT section of th | Windows | Yes | Yes | Yes | Yes | Yes | Yes | | Linux | Yes | Yes | Yes | Yes | Yes | Yes | -Sidecar dev build: `pnpm run reticulum:sidecar:build` ([Rust](https://rustup.rs/) required). Full stack needs sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, `../rsLXST`, and `../lrgp-rs` — see [docs/reticulum.md](docs/reticulum.md#building-the-sidecar). +Sidecar dev build: `pnpm run reticulum:sidecar:build` ([Rust](https://rustup.rs/) required). Full stack lives in the repo-local `.rsstack/` workspace (`rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, `lrgp-rs`) — see [docs/reticulum.md](docs/reticulum.md#building-the-sidecar). ### Tech Stack diff --git a/docs/ci-cd.md b/docs/ci-cd.md index e119535e1..94bb025a2 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -49,7 +49,7 @@ Runs on every push and pull request to `main`: 1. Checkout code, setup pnpm + Node 22, install dependencies 2. **Parallel matrix** — coverage per Vitest project (`renderer-ui`, `renderer-logic`, `main`) with blob reporter (`VITEST_COVERAGE_SHARD=1` skips per-shard threshold checks) 3. **Merge job** — downloads blob artifacts, runs `pnpm run test:coverage:merge` (enforces global coverage thresholds) -4. **`reticulum-sidecar-coverage`** (when `reticulum-sidecar/**` or related scripts change, via `paths-filter`) — clones Ratspeak siblings, runs `cargo llvm-cov --fail-under-lines 45` on ubuntu-latest; uploads `lcov.info` artifact (no Codecov upload on free org plan) +4. **`reticulum-sidecar-coverage`** (when `reticulum-sidecar/**` or related scripts change, via `paths-filter`) — clones the `.rsstack/` workspace, runs `cargo llvm-cov --fail-under-lines 45` on ubuntu-latest; uploads `lcov.info` artifact (no Codecov upload on free org plan) 5. Upload Cobertura coverage to GitHub Code Coverage (non-fork PRs / pushes) — Vitest merge job only 6. Upload merged test results artifact (retained 7 days) @@ -76,7 +76,7 @@ Path-filtered on `reticulum-sidecar/**` and related scripts: 1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`) 2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs) -CI clones Ratspeak siblings via `scripts/clone-ratspeak-stack.sh` and **no longer hardcodes `RS_RETICULUM_REF`** — rsReticulum / rsLXMF / rsNomad / rsLXST / lrgp-rs float to `origin/main` (overlays must apply). Override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` only for local bisect. +CI and local **dev** clones float the `.rsstack/` workspace via `scripts/clone-ratspeak-stack.sh` to `origin/main` (overlays must apply; override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). **Release** packaging (`scripts/build-reticulum-sidecar-release.mjs`) runs the same clone and records the resolved commit SHAs for all five crates in `.rsstack/RESOLVED_SHAS.txt` so artifacts retain the exact source revisions used — pin via `RS_*_REF` when a release must not float. Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). @@ -277,7 +277,6 @@ The pre-commit hook (`.githooks/pre-commit`) runs checks beyond what GitHub Acti - `pnpm dedupe` when dependency manifests are staged - `pnpm run i18n:auto-translate` when `en/translation.json` is staged (fills new English keys vs `HEAD`) + re-stages locales - Staged ESLint (`--cache`) + full `typecheck`; path-gated `typecheck:strict-shared` when shared paths staged; always-on cheap `check:*` scanners; path-gated flatpak / DB / IPC / reticulum catalog / full-feature sidecar checks (sidecar also requires `cargo` on `PATH` when sidecar paths are staged; `check:i18n` when English locale staged, else `check:i18n:branch`) -- Pre-push: `vitest run --changed` vs merge-base with `origin/main` when available - Before PR: `pnpm run check:pr` (full lint + typecheck + strict-shared + `test:run` + path-aware sidecar) - `pnpm audit` only when dependency manifests staged; `actionlint` / `yamllint` only when relevant files are staged - `pnpm run test:staged` (`scripts/precommit-tests.mjs`: staged-only `vitest related`; full suite when vitest config/setup mocks or dependency manifests change; skip when no source/test staged) diff --git a/docs/development-environment.md b/docs/development-environment.md index 94ca27100..394bdb508 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -119,9 +119,9 @@ pnpm run reticulum:sidecar:build This writes `reticulum-sidecar/target/debug/mesh-client-reticulum` (macOS/Linux) or `.exe` on Windows. -**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, `../rsLXST`, and `../lrgp-rs`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` to a SHA or ref before running the clone script. +**First-time / recover the stack workspace:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` to a SHA or ref before running the clone script. -When those siblings already exist, `pnpm run reticulum:sidecar:build` applies required overlays via `scripts/ensure-rsReticulum-patches.sh` before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. +When those `.rsstack/` checkouts already exist, `pnpm run reticulum:sidecar:build` applies required overlays via `scripts/ensure-rsReticulum-patches.sh` before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. **First run in Electron dev:** **Reticulum** → **Connection** → **Start stack** will run `cargo build` automatically if that binary is missing (first compile can take a few minutes). Pre-build with the command above to avoid waiting on the first click. @@ -472,7 +472,7 @@ flatpak run --command=flatpak-builder-lint org.freedesktop.Sdk \ | `reticulum:sidecar:dev` | Run sidecar standalone on `127.0.0.1:19437` | | `reticulum:sidecar:fmt` | `cargo fmt` in `reticulum-sidecar/` | | `reticulum:sidecar:fmt:check` | `cargo fmt --check` | -| `reticulum:sidecar:test` | Full-feature `cargo test` (clones Ratspeak siblings if needed) | +| `reticulum:sidecar:test` | Full-feature `cargo test` (clones the `.rsstack/` workspace if needed) | | `reticulum:sidecar:test:full` | Alias for `reticulum:sidecar:test` | | `setup:actionlint` | Install actionlint for GitHub workflow linting | | `setup:build-deps` | Install native build dependencies | @@ -589,7 +589,6 @@ Other useful commands: - `pnpm run test:staged` (pre-commit helper: Vitest related to **staged** files only) - `pnpm run test:changed` (one-shot tests for working-tree edits vs `HEAD`, including unstaged WIP) - `pnpm run check:pr` (before opening/updating a PR: full lint + typecheck + `typecheck:strict-shared` + full `test:run`) -- Pre-push hook: `vitest run --changed` vs merge-base with `origin/main` (branch-scoped; not full suite) - `pnpm run test:ui` / `test:logic` / `test:main` (single Vitest project) - `pnpm run test:coverage` (CI coverage report; used by `act:tests:native`) - `pnpm run test:coverage:merge` (merge sharded CI blob reports locally) @@ -649,7 +648,7 @@ After `pnpm install`, repo hooks are enabled via `core.hooksPath` (see the `prep ESLint: production `src/**` enforces `no-unsafe-*`; test files keep those off. `no-unnecessary-condition` is enforced for `src/shared/**` and `src/renderer/lib/**` only. -Green pre-commit does **not** replace PR CI: [`.github/workflows/tests.yaml`](../.github/workflows/tests.yaml) always runs the full Vitest suite with coverage. Use `pnpm run check:pr` before opening a PR. Pre-push runs branch `--changed` Vitest when `origin/main` is available. +Green pre-commit does **not** replace PR CI: [`.github/workflows/tests.yaml`](../.github/workflows/tests.yaml) always runs the full Vitest suite with coverage. Use `pnpm run check:pr` before opening a PR. Hook order (authoritative source: [`.githooks/pre-commit`](../.githooks/pre-commit)): @@ -665,8 +664,6 @@ Hook order (authoritative source: [`.githooks/pre-commit`](../.githooks/pre-comm 10. `pnpm audit --audit-level=high` only when dependency manifests staged; `actionlint` when `.github/workflows/*` staged; `yamllint` when any `*.yaml` / `*.yml` staged 11. `pnpm run test:staged` (`scripts/precommit-tests.mjs`: staged-only `vitest related`; full suite when vitest config/setup mocks or dependency manifests change; skip when no source/test staged) -**Pre-push:** [`.githooks/pre-push`](../.githooks/pre-push) runs `vitest run --changed ` when `origin/main` exists. - **Release / CI full suite:** `pnpm run release` (`scripts/release.sh`) and PR [`tests.yaml`](../.github/workflows/tests.yaml) always run `pnpm run test:run` (full Vitest) — never `test:staged`. Release also runs the ungated `check:*` set and requires actionlint + yamllint. Use `pnpm run check:pr` for the same Vitest/lint/typecheck surface locally before a PR. Install hook dependencies via [Helper scripts](#8-helper-scripts-auto-install-where-possible) (`setup:actionlint`, yamllint via pip/brew/apt). diff --git a/docs/nomad-hosting-interop.md b/docs/nomad-hosting-interop.md index bfafa1155..cbaf79882 100644 --- a/docs/nomad-hosting-interop.md +++ b/docs/nomad-hosting-interop.md @@ -4,7 +4,7 @@ Manual verification that mesh-client’s static Nomad host interops with other N ## Prerequisites -- Sibling `rsReticulum` / `rsLXMF` / `rsNomad` checkouts and an `rns-stack` sidecar build +- Repo-local `.rsstack/` checkouts (`./scripts/clone-ratspeak-stack.sh`) and an `rns-stack` sidecar build - Reticulum stack running in mesh-client (Connection → Reticulum) - Shared path to peers: TCP hub, I2P/Ygg, or RF — same network as the peer client - Optional: a site folder such as sibling `nomad-page` with `pages/*.mu` diff --git a/docs/reticulum.md b/docs/reticulum.md index 03a3e89c9..d0c29767d 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -24,25 +24,25 @@ After changing interfaces on a live network, **restart the stack** so RNS picks ## What is included -| Area | Shipped behavior | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | -| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | -| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | -| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | -| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | -| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | -| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (local inbox, not peer-delivered). **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Local inbox Completes ≠ peer delivery. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | -| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | -| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | -| 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 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.*`) | -| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | +| Area | Shipped behavior | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | +| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | +| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | +| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | +| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | +| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | +| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted, never persisted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**, PN + green check); local-prop Completes as `stored_locally` (local inbox, not peer-delivered — PN + amber house). Propagation mode **Off** disables the cascade entirely. **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Local inbox Completes ≠ peer delivery. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | +| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | +| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | +| 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 | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance — Off disables sync **and** the outbound PN cascade, Auto one-time syncs and deposits on the best Discovered PN by hash without adding it or changing Preferred, Manual uses Preferred or picks the closest added node for that sync then falls back through the other added nodes to the local inbox. Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting** (usable as the only PN), 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.*`) | +| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | **Not in Reticulum mode:** Meshtastic/MeshCore-style RF channel chat, MQTT broker card, Meshtastic/MeshCore LoRa node position map, Rooms BBS, TAK, Meshtastic PKI Security tab, Hop Goblins routing diagnostics. (RRC is hub room chat over Reticulum Links — not LoRa RF channels.) @@ -274,7 +274,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 PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), 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). Local-prop messagestore load is **deferred** off the live-ready path; serve/announce waits until that load finishes so peers are not syncing an empty store. 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. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable deposit/retrieve logs use targets `propagation-deposit` / `propagation-retrieve` (`message_hash`, `transient_id`, `pn_hash`). +- **Propagation:** **Propagation mode** (Network → Propagation nodes; **Off** default / **Auto** / **Manual**) — **Off** means **no propagation support**: no sync (periodic, bottom **Sync**, or per-node **Sync messages**) and no outbound Direct→PN cascade, so nothing is deposited on a remote PN or the local inbox; a saved Preferred row stays on disk and is re-armed only when you pick Auto/Manual (renderer pushes the mode to the sidecar via `POST /api/v1/propagation/mode`, persisted as `propagation_mode` in `mesh_client_stack.json`). **Auto** one-time syncs the best **Discovered** PN by destination hash (does **not** add it to the configured list or change Preferred), then tries configured remotes, then local-prop (skips remotes when no enabled interfaces). Auto also **deposits** outbound LXMF on Discovered PNs: `auto_discovered_candidates` (`pn_cascade.rs`) appends up to `MAX_AUTO_DISCOVERED_PN_CANDIDATES` (3) heard nodes — hop-sorted, skipping inactive announces, the self hash, already-configured hashes, and `peering_cost` above the hosting policy `max_peering_cost` — **after** the added remotes and **before** local-prop. Nothing is persisted; the announce handler and `refresh_pn_cascade_candidates` share one rebuild helper in `live.rs`, so a newly heard PN becomes cascade-eligible without a stack restart. Because Auto really uses them, the Chat **“No propagation node is configured”** banner hides in Auto as soon as one PN is discovered; Manual only counts nodes you added. The banner also has **Don't show again**, backed by **Show propagation reminder in Chat** in Network → Propagation nodes (`chatNoticeDismissed`, persisted in `mesh-client:reticulumPropagationNoticeDismissed`). **Manual** syncs Preferred; with no Preferred it picks the closest added remote **for that sync only** (no Preferred write), then falls back to the other added remotes, then local-prop. Every cascade step **waits for that attempt to settle** before deciding what to do next: `startSync` only reports that the sidecar _accepted_ the request, so the renderer awaits the terminal `propagation_sync` frame (or the stall / ceiling watchdog) through `awaitPropagationSyncSettled` and moves to the next candidate on failure — a node that accepts and then never establishes no longer ends the cascade at its first step. A user **Cancel** stops the chain instead of advancing. The remote half of the chain is capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min), after which it goes straight to the local inbox, and a target that just failed is omitted for 15 minutes (`reticulumPropagationSyncBackoff.ts`, session memory) so a hop-closest dead PN cannot monopolize every tick; each remote attempt is capped at ~60s (`PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS`) before the cascade advances, and local fallback re-reads sidecar nodes when local looks disabled. Because a cascade can now span several attempts, overlapping 30 s auto-sync ticks join the single in-flight run; an explicit per-row **Sync** supersedes it. **Local-only PN** is a supported setup for an always-on machine: enable local-prop with no remotes and every cascade/sync settles in the local inbox. 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; explicit **Add / Add & prefer** for list management), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), 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). Local-prop messagestore load is **deferred** off the live-ready path; serve/announce waits until that load finishes so peers are not syncing an empty store — while that load runs, `list_propagation` reports the local row as `status: "loading"` (not just disabled), the row renders **loading…**, and its per-node **Sync messages** is disabled. When a sync cascade finds nothing to contact (no Discovered PN, no added remotes, local inbox off or still loading) it reports **why** — `reticulumPropagation.syncLocalLoading` or `reticulumPropagation.syncNoTarget` — instead of the generic "node may be unreachable"; a real per-node error from an attempted node is never overwritten. Every sync attempt stamps `syncTargetId`, so the progress line, the inline error, and the Sync toasts **name the node** being tried (`resolveReticulumPropagationTargetLabel` — configured row name, announce name, or hash prefix); the cascade clears it when it contacted nobody, so "nothing to sync with" is never blamed on a node. The 30 s auto-sync tick re-reads `/api/v1/propagation` while no cascade candidate exists, so a fresh stack starts syncing on its own once an announce lands or the local store finishes loading. 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. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable deposit/retrieve logs use targets `propagation-deposit` / `propagation-retrieve` (`message_hash`, `transient_id`, `pn_hash`, and on Completes `cascade_step` + `delivery_method` so the actual deposit island is auditable). Developer support bundles always include `reticulum/mesh_client_stack.json` and `reticulum/lxmf-outbound.log` (placeholder when absent), and `debug-snapshot.json` carries a `propagationClient` slice (`mode`, `preferredId`, `resolvedSyncTargetId`, `autoTarget`, `lastSyncError`). --- @@ -283,10 +283,10 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **DM-only** on the Chat tab — no RF channel pills (RRC covers hub rooms separately) - Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash -- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). +- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). - Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct vs local-inbox labeling; early WS events before provisional id→hash rekey are buffered - **Optimistic pending rekey:** Chat send creates a `reticulum-pending-*` row; when the sidecar returns the real `message_hash`, ingest/SQLite upsert passes `replaces_message_hash` so the pending row is deleted atomically (avoids orphan Sending duplicates) -- Remote PN Completes (`delivered`) render as **Stored at propagation node** (PN badge); local-prop Completes (`stored_locally`) stay in the **local propagation inbox** — neither is recipient **Delivered** +- Remote PN Completes (`delivered`) render as **Stored at propagation node** — PN badge with a green check; local-prop Completes (`stored_locally`) render as **PN** with an amber **house** mark (`ReticulumMessageStatusBadge`) so the local propagation inbox is visually distinct from a peer-bound PN deposit — neither is recipient **Delivered** - **DM path reachability:** active DM header shows a reachability badge (`ReticulumDmPathReachabilityBadge` + `useReticulumDmPathProbe`) seeded from path-table/contact hops, then settled by peer probe; when settled, **Request path** / **Probe** use the same sidecar endpoints as the Peers tab. Chat **Probe** mirrors Peer List UX: stack-running check → `/probe` → toast → peer refresh; `onProbeSettled` / `applyProbeResult(forHash, …)` applies the result without a second `/probe` (stale hashes after DM switch are ignored); manual reprobe forces Checking… even when passive hops already look reachable ## RRC (Reticulum Relay Chat) @@ -306,16 +306,17 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): ### Delivery modes -| Path table | Propagation node | Routing / UI | -| ------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Destination present | — (no cascade candidates) | **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 | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **local inbox** (`stored_locally`) | -| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | -| Destination absent | Local-prop only | Completes as `stored_locally` in the **local propagation inbox** (not peer-delivered) | -| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop for inbox-only Completes) on Network tab | -| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | +| Path table | Propagation node | Routing / UI | +| ------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Destination present | — (no cascade candidates) | **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 | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → in **Auto** up to 3 Discovered PNs → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **local inbox** (`stored_locally`) | +| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | +| Destination absent | Local-prop only | Completes as `stored_locally` in the **local propagation inbox** (not peer-delivered) | +| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop for inbox-only Completes) on Network tab | +| Any | Propagation mode **Off** | Direct only — no cascade candidates and no armed outbound PN; Direct exhaustion is terminal (**Failed**) and offline peers need Auto/Manual or **Paper** | +| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff) for peer store-and-forward. **Local-prop** is last in the Direct→PN cascade and Completes as `stored_locally` (your inbox only — not peer delivery). Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop). LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff) for peer store-and-forward. **Local-prop** is last in the Direct→PN cascade and Completes as `stored_locally` (your inbox only — not peer delivery, badge **PN** + house). Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop) — in mode **Off** there is no capacity, so the bridge fails the row. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. --- @@ -323,7 +324,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before the **multi-PN cascade**. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. -**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → multi-PN cascade (preferred remote → other enabled remotes hop-sorted → local-prop last). It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). +**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → multi-PN cascade (preferred remote → other enabled remotes hop-sorted → in Auto, up to 3 Discovered PNs → local-prop last). It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). **Network → stack settings → Prefer path medium** sets the global bias: @@ -409,7 +410,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. +`rns-stack` builds need the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. @@ -419,9 +420,9 @@ End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and pnpm run reticulum:sidecar:build ``` -When sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, `../rsLXST`, and `../lrgp-rs` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice, LRGP games). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). +Cargo always needs the `.rsstack/` checkouts (`rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, `lrgp-rs`) as path dependencies — clone them with `./scripts/clone-ratspeak-stack.sh` (same as [reticulum-sidecar/README.md](../reticulum-sidecar/README.md)). With those trees present, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** for the **real mesh-I/O** stack (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice, LRGP games). Building **without** `--features rns-stack` still uses those checkouts but links the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). -**Electron dev:** **Start stack** auto-runs `cargo build` when the debug binary is missing, when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the binary, or when a stub binary is present but full-stack siblings exist. First compile can take several minutes — pre-build with the command above. +**Electron dev:** **Start stack** auto-runs `cargo build` when the debug binary is missing, when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the binary, or when a stub binary is present but the full `.rsstack/` workspace exists. First compile can take several minutes — pre-build with the command above. **Run sidecar alone:** diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 508528483..35f6b7333 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1050,20 +1050,20 @@ Keep Rust current with `pnpm run update` (runs `rustup update` and rebuilds the **Symptoms**: **Start stack** fails; logs show `RETICULUM_CARGO_BUILD_FAILED` or Rust errors such as `method not found in ReticulumHandle`, `register_packet_tap`, or `PacketTapEvent`. Electron may surface `RETICULUM_RNS_PATCH_MISSING` after upgrading mesh-client. -**Cause**: Full-stack (`rns-stack`) dev builds call `register_packet_tap` in the sidecar, but that API lives in a local rsReticulum overlay ([`reticulum-sidecar/patches/rsReticulum-packet-tap.patch`](../reticulum-sidecar/patches/rsReticulum-packet-tap.patch)) until [ratspeak/rsReticulum#10](https://github.com/ratspeak/rsReticulum/pull/10) merges. CI applies overlays via `clone-ratspeak-stack.sh`; a sibling `../rsReticulum` checkout without the overlay fails to compile. +**Cause**: Full-stack (`rns-stack`) dev builds call `register_packet_tap` in the sidecar, but that API lives in a local rsReticulum overlay ([`reticulum-sidecar/patches/rsReticulum-packet-tap.patch`](../reticulum-sidecar/patches/rsReticulum-packet-tap.patch)) until [ratspeak/rsReticulum#10](https://github.com/ratspeak/rsReticulum/pull/10) merges. CI applies overlays via `clone-ratspeak-stack.sh`; a `.rsstack/rsReticulum` checkout without the overlay fails to compile. **Fix** (canonical recover path): -1. From mesh-client repo root, re-float siblings and re-apply overlays: +1. From mesh-client repo root, re-float the `.rsstack/` workspace and re-apply overlays: ```bash ./scripts/clone-ratspeak-stack.sh pnpm run reticulum:sidecar:build ``` `clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` to `origin/main` (override with `RS_*_REF` for bisect) and fails if an overlay will not apply. -2. If siblings already exist and you only need overlays: `./scripts/ensure-rsReticulum-patches.sh` then `pnpm run reticulum:sidecar:build`. +2. If the `.rsstack/` checkouts already exist and you only need overlays: `./scripts/ensure-rsReticulum-patches.sh` then `pnpm run reticulum:sidecar:build`. 3. **Manual apply** (single overlay): ```bash - git -C ../rsReticulum apply reticulum-sidecar/patches/rsReticulum-packet-tap.patch + git -C .rsstack/rsReticulum apply ../../reticulum-sidecar/patches/rsReticulum-packet-tap.patch pnpm run reticulum:sidecar:build ``` 4. On **newer rsReticulum** checkouts that already include the auto-beacon utun fix upstream, only the packet-tap patch is required — `apply-rsReticulum-auto-beacon-utun.sh` is a no-op. @@ -1106,6 +1106,31 @@ Healthy Auto is left preferred (RNS default). Public hubs are never chosen by th **Manual workaround**: Connection → Interfaces → disable **Auto** → restart stack if prompted. Keep the private hub up; confirm it is not `ECONNREFUSED` in the log (`hostLink` TCP probe). +### Reticulum DM shows "Stored at propagation node" but the reply never arrives (PN island / preferred mismatch) + +**Symptoms**: A propagated DM Completes as **Stored at propagation node** on the sender, and the sender's periodic **Propagation sync** also Completes, yet the reply never lands in Chat. Direct (path-based) DMs between the same two apps work; only store-and-forward replies go missing. Often seen when two peers each prefer a **different** PN (e.g. one on `0e972735…`, the other syncing `11111111…`), or when an external app (Sideband, Columba, Retichat) reports "single checkmark / parked at PN". + +**Cause**: "Stored at PN" only means the message was deposited on the **sender's** chosen deposit node. The recipient only receives it if they **sync (or peer) that same PN island**. If the recipient's Preferred / sync target is a different PN, and those PNs are not peered/replicating, a successful sync on the recipient's node retrieves nothing for that deposit. Sync **Completing ≠ retrieving that specific deposit**. Shared, enabled backup PNs (e.g. both have `deadbeef` enabled) do **not** help when the cascade already succeeded on the first preferred remote and stopped there. + +**Diagnose**: + +1. On both sides, note the **Preferred** PN hash in **Network → Propagation nodes** (and mode: Off / Auto / Manual). For external apps, ask the peer for **their** preferred/inbox PN hash. +2. In a **Developer** support bundle: `debug-snapshot.json` → `propagationClient` shows each side's `mode`, `preferredId`, `resolvedSyncTargetId`, `autoTarget`, and `lastSyncError`; `reticulum/lxmf-outbound.log` shows `propagation-deposit … pn_hash=… cascade_step=… delivery_method=…` (the **actual deposit island**) and `propagation-retrieve` lines for what sync pulled. +3. Compare the sender's deposit `pn_hash` against the recipient's `resolvedSyncTargetId`. A mismatch with non-peered PNs is the island gap. + +**Fix**: Put both peers on a **shared** propagation node (same Preferred hash, or PNs known to peer/replicate), or switch mode to **Auto** so each side tracks the best commonly-reachable PN. When testing against external apps, record their preferred PN hash and align it with mesh-client's Preferred. + +**Repro matrix** (sender deposit island vs recipient sync target): + +| Sender Preferred | Recipient sync target | PNs peered? | Reply retrieved? | +| -------------------- | --------------------- | ----------- | --------------------- | +| `0e972735…` | `11111111…` | no | **No** (island gap) | +| `0e972735…` | `11111111…` | yes | Yes (peers replicate) | +| `deadbeef…` (shared) | `deadbeef…` (shared) | n/a | Yes (same island) | +| `0e972735…` | `0e972735…` | n/a | Yes (same node) | + +Force the propagated path (peer offline / Direct disabled) and compare the sender's `propagation-deposit … pn_hash` against the recipient's `propagation-retrieve` / `propagationClient.resolvedSyncTargetId` in a Developer bundle to confirm which row applies. + ### Reticulum Nomad Network or topology API returns 404 **Symptoms**: Device log shows `sidecar GET /api/v1/nomadnetwork/nodes failed: 404` or `/api/v1/topology` **404** while the sidecar process is running. Nomad Network tab may show **API unavailable**. @@ -1293,7 +1318,8 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the - Establishing with **no LRPROOF** often means the PN lacks a reverse path to your LXMF identity. Sync always sends an LXMF delivery announce and waits briefly before Linking; if that still stalls, use Network → **Announce now** and retry. - HaveAll / Complete is success (not failure). Cancel or Establishing stall (~45s) must not advance “last synced”. - 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. +- Auto/Manual **Sync** runs a multi-step cascade that waits for each attempt to settle (terminal WS frame or stall/ceiling). Failed remotes are omitted for ~15 minutes; the remote half of a cascade is capped (~5 min budget, ~60s per remote attempt) before falling through to local-prop. Soft defer `PROPAGATION_SYNC_OUTBOUND_BUSY` (outbound deposit owns the PN link) does **not** start that backoff — the next tick may retry the same node. +- 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. Nothing-to-sync (`syncNoTarget` / local messagestore still loading) is not treated as a failure. **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**. @@ -1388,10 +1414,10 @@ 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. +3. Pick a **Propagation mode** in the same section. Fresh installs default to **Off** (no automatic Preferred, no periodic sync). **Upgrades keep any saved mode** (including legacy **Auto**). Set **Preferred** manually and use **Manual** to sync that pin (or the closest added node when none is preferred), or use **Auto** to one-time sync the best **Discovered** node by hash (**without** adding it or changing Preferred), then configured remotes, then the local inbox. Set preferred / Add & prefer stay available in Auto. See [PN island / preferred mismatch](#reticulum-dm-shows-stored-at-propagation-node-but-the-reply-never-arrives-pn-island--preferred-mismatch) if both peers use different PNs. 4. **Local propagation hosting** stores messages for peers that sync with you and is **last** in the Direct→PN cascade (`stored_locally` — local inbox, not peer-delivered). Preferring Local shows a warning toast; it does **not** replace a remote PN for peer store-and-forward. -**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (inbox only). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). +**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (inbox only). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). **Not the same as transport:** Ratspeak TCP hubs (e.g. `rns.ratspeak.org:4242`) and [rathole](https://github.com/ratspeak/rathole) are **connectivity / transport** tools, not LXMF propagation. mesh-client does not ship a default community propagation hash. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 663812f0d..b0c8f9612 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1736,8 +1736,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001807: - resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3442,8 +3442,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4485,8 +4485,8 @@ packages: unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4693,8 +4693,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.2: - resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6194,10 +6194,10 @@ snapshots: browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.12 - caniuse-lite: 1.0.30001807 + caniuse-lite: 1.0.30001809 electron-to-chromium: 1.5.402 node-releases: 2.0.53 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + update-browserslist-db: 1.3.0(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -6265,7 +6265,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001807: {} + caniuse-lite@1.0.30001809: {} chai@6.2.2: {} @@ -8204,7 +8204,7 @@ snapshots: socks: 2.8.9 split2: 4.2.0 worker-timers: 8.0.34 - ws: 8.21.2 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color @@ -8215,7 +8215,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -8474,7 +8474,7 @@ snapshots: postcss@8.5.26: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9326,7 +9326,7 @@ snapshots: graceful-fs: 4.2.11 node-int64: 0.4.0 - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.0(browserslist@4.28.7): dependencies: browserslist: 4.28.7 escalade: 3.2.0 @@ -9557,7 +9557,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.2: {} + ws@8.21.3: {} xml-name-validator@5.0.0: {} diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index ac474f688..0b463d560 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -12,8 +12,9 @@ path = "src/main.rs" [features] default = [] -# Full stack: requires sibling checkouts at ../../rsReticulum, ../../rsLXMF, -# ../../rsNomad, ../../rsLXST, and ../../lrgp-rs (Ratspeak layout). +# Full stack: requires the repo-local overlay workspace at ../.rsstack/rsReticulum, +# ../.rsstack/rsLXMF, ../.rsstack/rsNomad, ../.rsstack/rsLXST, and ../.rsstack/lrgp-rs +# (provisioned by scripts/clone-ratspeak-stack.sh). rns-stack = [ "dep:rns-runtime", "dep:lxmf-core", @@ -62,70 +63,70 @@ notify = { version = "6.1", optional = true, default-features = true } [dependencies.rns-runtime] package = "rns-runtime" -path = "../../rsReticulum/crates/rns-runtime" +path = "../.rsstack/rsReticulum/crates/rns-runtime" optional = true features = ["serial"] [dependencies.rns-identity] package = "rns-identity" -path = "../../rsReticulum/crates/rns-identity" +path = "../.rsstack/rsReticulum/crates/rns-identity" optional = true [dependencies.rns-wire] package = "rns-wire" -path = "../../rsReticulum/crates/rns-wire" +path = "../.rsstack/rsReticulum/crates/rns-wire" optional = true features = ["std"] [dependencies.rns-ratkey] package = "rns-ratkey" -path = "../../rsReticulum/crates/rns-ratkey" +path = "../.rsstack/rsReticulum/crates/rns-ratkey" optional = true [dependencies.rns-transport] package = "rns-transport" -path = "../../rsReticulum/crates/rns-transport" +path = "../.rsstack/rsReticulum/crates/rns-transport" optional = true [dependencies.lxmf-core] package = "lxmf-core" -path = "../../rsLXMF/crates/lxmf-core" +path = "../.rsstack/rsLXMF/crates/lxmf-core" optional = true [dependencies.nomad-core] package = "nomad-core" -path = "../../rsNomad/crates/nomad-core" +path = "../.rsstack/rsNomad/crates/nomad-core" optional = true [dependencies.lxst-telephony] package = "lxst-telephony" -path = "../../rsLXST/crates/lxst-telephony" +path = "../.rsstack/rsLXST/crates/lxst-telephony" optional = true [dependencies.lxst-core] package = "lxst-core" -path = "../../rsLXST/crates/lxst-core" +path = "../.rsstack/rsLXST/crates/lxst-core" optional = true [dependencies.lrgp] package = "lrgp" -path = "../../lrgp-rs" +path = "../.rsstack/lrgp-rs" optional = true [dependencies.rns-interface] package = "rns-interface" -path = "../../rsReticulum/crates/rns-interface" +path = "../.rsstack/rsReticulum/crates/rns-interface" optional = true features = ["ble"] [dependencies.rns-link] package = "rns-link" -path = "../../rsReticulum/crates/rns-link" +path = "../.rsstack/rsReticulum/crates/rns-link" optional = true [dependencies.rns-crypto] package = "rns-crypto" -path = "../../rsReticulum/crates/rns-crypto" +path = "../.rsstack/rsReticulum/crates/rns-crypto" optional = true [dependencies.rusqlite] diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index 558ee9e56..1c1fed637 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -8,33 +8,34 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See ## Build -**First-time setup** — from the mesh-client repo root, clone/float siblings and apply overlays: +**First-time setup** — from the mesh-client repo root, clone/float the repo-local `.rsstack/` workspace and apply overlays: ```bash ./scripts/clone-ratspeak-stack.sh ``` -That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. +That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` under `.rsstack/` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. -**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): +**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires the `.rsstack/` checkouts on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): ```bash pnpm run reticulum:sidecar:build ``` -**Full rsReticulum + rsLXMF + rsNomad + rsLXST + lrgp-rs** — sibling checkout (Ratspeak layout + Colorado-Mesh rsNomad + LXST voice + LRGP games): +**Full rsReticulum + rsLXMF + rsNomad + rsLXST + lrgp-rs** — repo-local workspace (Ratspeak crates + Colorado-Mesh rsNomad + LXST voice + LRGP games): ``` -parent/ - rsReticulum/ - rsLXMF/ - rsLXST/ - lrgp-rs/ - rsNomad/ - mesh-client/reticulum-sidecar/ +mesh-client/ + .rsstack/ + rsReticulum/ + rsLXMF/ + rsLXST/ + lrgp-rs/ + rsNomad/ + reticulum-sidecar/ ``` -Prefer `./scripts/clone-ratspeak-stack.sh` (or `./scripts/ensure-rsReticulum-patches.sh` on an existing tree). Individual apply scripts remain for single-overlay work: +Prefer `./scripts/clone-ratspeak-stack.sh` (or `./scripts/ensure-rsReticulum-patches.sh` on an existing `.rsstack` tree). Individual apply scripts remain for single-overlay work: ```bash ./scripts/apply-rsReticulum-packet-tap.sh diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 58d10ac6f..b57d62f8e 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -1,23 +1,23 @@ # rsReticulum / rsLXMF overlays -Patches applied on top of [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) / [ratspeak/rsLXMF](https://github.com/ratspeak/rsLXMF) checkouts for mesh-client `rns-stack` builds (sibling [Colorado-Mesh/rsNomad](https://github.com/Colorado-Mesh/rsNomad) is also required for Nomad hosting; no mesh-client overlay today). +Patches applied on top of [ratspeak/rsReticulum](https://github.com/ratspeak/rsReticulum) / [ratspeak/rsLXMF](https://github.com/ratspeak/rsLXMF) checkouts for mesh-client `rns-stack` builds (`.rsstack/rsNomad` from [Colorado-Mesh/rsNomad](https://github.com/Colorado-Mesh/rsNomad) is also required for Nomad hosting; no mesh-client overlay today). Checkouts live in the repo-local `.rsstack/` gitignored workspace, keeping a standalone `rsReticulum` mirror (if present) pristine. -By default `scripts/clone-ratspeak-stack.sh` floats siblings to **`origin/main`** and applies these overlays (fails loud if a patch will not apply). Use `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to pin a known-good SHA for bisect. Per-overlay **Base commit** tables below record the last regeneration baseline, not a permanent pin — when regenerating, prefer floated `origin/main` and record the short SHA in the PR. +By default `scripts/clone-ratspeak-stack.sh` floats the `.rsstack/` checkouts to **`origin/main`** and applies these overlays (fails loud if a patch will not apply). Use `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to pin a known-good SHA for bisect. Per-overlay **Base commit** tables below record the last regeneration baseline, not a permanent pin — when regenerating, prefer floated `origin/main` and record the short SHA in the PR. ## Development — overlays/patches -Overlays require **git checkouts** of sibling repos next to this clone (not a bare Cargo cache path): +Overlays require **git checkouts** in the repo-local `.rsstack/` workspace (not a bare Cargo cache path): -- `../rsReticulum` — floated to `origin/main` unless `RS_RETICULUM_REF` is set -- `../rsLXMF` — floated to `origin/main` unless `RS_LXMF_REF` is set -- `../rsNomad` — floated to `origin/main` unless `RS_NOMAD_REF` is set +- `.rsstack/rsReticulum` — floated to `origin/main` unless `RS_RETICULUM_REF` is set +- `.rsstack/rsLXMF` — floated to `origin/main` unless `RS_LXMF_REF` is set +- `.rsstack/rsNomad` — floated to `origin/main` unless `RS_NOMAD_REF` is set **First-time setup:** ```bash -# From mesh-client repo root — clones/floats siblings and applies known overlays +# From mesh-client repo root — clones/floats the .rsstack workspace and applies known overlays ./scripts/clone-ratspeak-stack.sh -# Or ensure patches on an existing sibling tree: +# Or ensure patches on an existing .rsstack tree: ./scripts/ensure-rsReticulum-patches.sh ``` @@ -25,7 +25,7 @@ Apply a single overlay when developing that patch: ```bash ./scripts/apply-rsReticulum-discovery-announce-egress.sh -git -C ../rsReticulum status --short +git -C .rsstack/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. @@ -48,7 +48,7 @@ Wire packet tap API for the Reticulum Stats/Sniffer panel (`wire_packet` WebSock ### Apply locally -From mesh-client repo root (sibling `../rsReticulum` required): +From mesh-client repo root (`.rsstack/rsReticulum` required): ```bash ./scripts/apply-rsReticulum-packet-tap.sh @@ -59,7 +59,7 @@ From mesh-client repo root (sibling `../rsReticulum` required): Regenerate against floated `origin/main` (record the short SHA in the PR): ```bash -cd ../rsReticulum +cd .rsstack/rsReticulum git fetch origin && git checkout --detach origin/main # apply local packet-tap edits, then: git diff -- \ @@ -67,11 +67,11 @@ git diff -- \ crates/rns-transport/src/messages.rs \ crates/rns-transport/src/actor/mod.rs \ crates/rns-transport/src/actor/inbound.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch + > ../../reticulum-sidecar/patches/rsReticulum-packet-tap.patch # smoke-check on a clean tip clone: git -C /tmp/rsReticulum-patch-test fetch origin git -C /tmp/rsReticulum-patch-test checkout --detach origin/main -git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-packet-tap.patch +git -C /tmp/rsReticulum-patch-test apply --check ../../reticulum-sidecar/patches/rsReticulum-packet-tap.patch ``` ### Sunset @@ -93,7 +93,7 @@ Skip macOS/iOS VPN tunnel interfaces (`utun*`, `ipsec*`, `ppp*`) for AutoInterfa ### Apply locally -From mesh-client repo root (sibling `../rsReticulum` required): +From mesh-client repo root (`.rsstack/rsReticulum` required): ```bash ./scripts/apply-rsReticulum-auto-beacon-utun.sh @@ -111,14 +111,14 @@ Apply after the packet-tap patch when both overlays are needed: Regenerate against floated `origin/main` (record the short SHA in the PR): ```bash -cd ../rsReticulum +cd .rsstack/rsReticulum git fetch origin && git checkout --detach origin/main # after implementing the utun filter/backoff, then: git diff -- crates/rns-interface/src/auto.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch + > ../../reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch git -C /tmp/rsReticulum-patch-test fetch origin git -C /tmp/rsReticulum-patch-test checkout --detach origin/main -git -C /tmp/rsReticulum-patch-test apply --check ../mesh-client/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch +git -C /tmp/rsReticulum-patch-test apply --check ../../reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch ``` ### Sunset @@ -143,7 +143,7 @@ Recall cached destination public keys in `LinkClient` before waiting on path-res ### Apply locally -From mesh-client repo root (sibling `../rsReticulum` required): +From mesh-client repo root (`.rsstack/rsReticulum` required): ```bash ./scripts/apply-rsReticulum-link-client-nomad.sh @@ -211,7 +211,7 @@ Debounce BLE RNode reconnect after mid-SMP disconnect (`BLE pairing in progress` ### Apply locally -From mesh-client repo root (sibling `../rsReticulum` required): +From mesh-client repo root (`.rsstack/rsReticulum` required): ```bash ./scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh @@ -232,11 +232,11 @@ Apply after the other rsReticulum overlays when rebuilding against floated `orig Regenerate against floated `origin/main` (record the short SHA in the PR): ```bash -cd ../rsReticulum +cd .rsstack/rsReticulum git fetch origin && git checkout --detach origin/main # apply local debounce edit, then: git diff -- crates/rns-interface/src/ble_rnode.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch + > ../../reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch ``` ### Sunset @@ -280,7 +280,7 @@ Register `rnstransport.discovery.interface` as a local destination before announ ### Apply locally -From mesh-client repo root (sibling `../rsReticulum` required): +From mesh-client repo root (`.rsstack/rsReticulum` required): ```bash ./scripts/apply-rsReticulum-discovery-announce-egress.sh @@ -302,13 +302,13 @@ Regenerate against floated `origin/main` (record the short SHA in the PR): ```bash # After applying prior overlays on tip, implement the discovery fix, then: -cd ../rsReticulum +cd .rsstack/rsReticulum git fetch origin && git checkout --detach origin/main 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 + > ../../reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch ``` ### Sunset @@ -330,7 +330,7 @@ LinkIdentify + peering stamp before LXMF `/offer`, sticky offer/finish fields, p ### Apply locally -From mesh-client repo root (sibling `../rsLXMF` required): +From mesh-client repo root (`.rsstack/rsLXMF` required): ```bash ./scripts/apply-rsLXMF-propagation-sync-peering.sh @@ -343,11 +343,11 @@ From mesh-client repo root (sibling `../rsLXMF` required): On current floated `origin/main`, the apply script **no-ops** when `set_identity` is already present — regenerate only if you still need the overlay for an older pin: ```bash -cd ../rsLXMF +cd .rsstack/rsLXMF git fetch origin && git checkout --detach origin/main # only needed for older pins without set_identity / send_identify: git diff -- crates/lxmf-core/src/propagation_sync.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsLXMF-propagation-sync-peering.patch + > ../../reticulum-sidecar/patches/rsLXMF-propagation-sync-peering.patch ``` ### Sunset @@ -369,7 +369,7 @@ Live mutators for local PN hosting policy updates (`set_peering_cost`, `set_max_ ### Apply locally -From mesh-client repo root (sibling `../rsLXMF` required): +From mesh-client repo root (`.rsstack/rsLXMF` required): ```bash ./scripts/apply-rsLXMF-propagation-node-policy-setters.sh @@ -380,11 +380,11 @@ From mesh-client repo root (sibling `../rsLXMF` required): ### Regenerate ```bash -cd ../rsLXMF +cd .rsstack/rsLXMF git fetch origin && git checkout --detach origin/main # apply local setter edits, then: git diff -- crates/lxmf-core/src/propagation_node.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch + > ../../reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch ``` ### Sunset @@ -406,7 +406,7 @@ When [ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6) merges and f ### Apply locally -From mesh-client repo root (sibling `../rsLXMF` required; apply policy-setters first): +From mesh-client repo root (`.rsstack/rsLXMF` required; apply policy-setters first): ```bash ./scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh @@ -442,11 +442,11 @@ Expose `LinkDeliveryManager::has_pending_to` so the sidecar can serialize packed ### Regenerate ```bash -cd ../rsLXMF +cd .rsstack/rsLXMF git fetch origin && git checkout --detach origin/main # apply local has_pending_to edit, then: git diff -- crates/lxmf-core/src/link_delivery.rs \ - > ../mesh-client/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch + > ../../reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch ``` ### Sunset diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 89326d34c..00f99af19 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -162,6 +162,10 @@ pub fn router(stack: Arc) -> Router { "/api/v1/propagation/auto-sync-interval", post(propagation::set_propagation_auto_sync_interval), ) + .route( + "/api/v1/propagation/mode", + post(propagation::set_propagation_mode), + ) .route( "/api/v1/propagation/hosting-policy", post(propagation::set_pn_hosting_policy), diff --git a/reticulum-sidecar/src/api/propagation.rs b/reticulum-sidecar/src/api/propagation.rs index 7e25f5bd9..6639ea3e0 100644 --- a/reticulum-sidecar/src/api/propagation.rs +++ b/reticulum-sidecar/src/api/propagation.rs @@ -11,9 +11,21 @@ pub struct PropagationAutoSyncIntervalBody { pub interval_sec: u32, } +#[derive(Debug, Deserialize)] +pub struct PropagationModeBody { + /// `off` | `auto` | `manual` (renderer Network → Propagation nodes selector). + pub mode: String, +} + #[derive(Debug, Deserialize)] pub struct PropagationSyncBody { - pub propagation_id: String, + /// Configured list id (`local-prop` or `pn-…`). Mutually exclusive with `destination_hash`. + #[serde(default)] + pub propagation_id: Option, + /// One-time sync by LXMF propagation destination hash (32 hex). Does not add to the + /// configured list or change Preferred. Mutually exclusive with `propagation_id`. + #[serde(default)] + pub destination_hash: Option, } #[derive(Debug, Deserialize)] @@ -108,11 +120,41 @@ pub async fn set_propagation_auto_sync_interval( } } +pub async fn set_propagation_mode( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.set_propagation_mode(&body.mode).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + pub async fn start_propagation_sync( State(stack): State>, Json(body): Json, ) -> Json { - match stack.start_propagation_sync(&body.propagation_id).await { + let id = body + .propagation_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let hash = body + .destination_hash + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let result = match (id, hash) { + (Some(propagation_id), None) => stack.start_propagation_sync(propagation_id).await, + (None, Some(destination_hash)) => { + stack.start_propagation_sync_by_hash(destination_hash).await + } + (Some(_), Some(_)) => { + Err("provide exactly one of propagation_id or destination_hash".into()) + } + (None, None) => Err("propagation_id or destination_hash required".into()), + }; + match result { Ok(()) => Json(serde_json::json!({ "ok": true })), Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 5ee288d62..d2bba51bf 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -519,15 +519,21 @@ impl LiveBridge { })), }; + // Mode Off keeps Preferred on disk but must not arm an outbound PN at stack start. + let propagation_mode_off = inner.read().await.propagation_mode.is_off(); let preferred_prop_hash = { let state = inner.read().await; - state.preferred_propagation_id.as_ref().and_then(|id| { - state - .propagation - .iter() - .find(|p| p.id == *id) - .and_then(|p| p.destination_hash.clone()) - }) + if propagation_mode_off { + None + } else { + state.preferred_propagation_id.as_ref().and_then(|id| { + state + .propagation + .iter() + .find(|p| p.id == *id) + .and_then(|p| p.destination_hash.clone()) + }) + } }; bridge.spawn_maintenance(event_tx); @@ -546,6 +552,11 @@ impl LiveBridge { if let Some(hash_hex) = preferred_prop_hash { bridge.set_outbound_propagation_node(Some(&hash_hex)).await; + } else if propagation_mode_off { + tracing::info!( + target: "lxmf-outbound", + "propagation mode off — no outbound propagation node armed at stack start" + ); } else { tracing::warn!( target: "lxmf-outbound", @@ -1908,6 +1919,7 @@ impl LiveBridge { let router = Arc::clone(&self.router); let propagation = Arc::clone(&self.propagation); let pn_hosting_policy = Arc::clone(&self.pn_hosting_policy); + let persisted = Arc::clone(&self.persisted); tokio::spawn(async move { let (callback_tx, mut callback_rx) = tokio::sync::mpsc::channel::(64); @@ -1967,11 +1979,17 @@ impl LiveBridge { node_state: parsed.node_state, peering_cost: parsed.peering_cost, }; - let payload = { + let (payload, cascade_fields_changed) = { let Ok(mut cache) = discovered.lock() else { continue; }; - cache.insert(hash_hex.clone(), row.clone()); + let previous = cache.insert(hash_hex.clone(), row.clone()); + // Only rebuild when this announce can change the Auto cascade shortlist. + let changed = previous.is_none_or(|prev| { + prev.node_state != row.node_state + || prev.hops != row.hops + || prev.peering_cost != row.peering_cost + }); while cache.len() > MAX_DISCOVERED_PROPAGATION { // Evict oldest last_seen. let oldest = cache @@ -1984,7 +2002,7 @@ impl LiveBridge { break; } } - serde_json::json!({ + let payload = serde_json::json!({ "destination_hash": hash_hex, "identity_hash": identity_hash_hex, "public_key": public_key_hex, @@ -1993,12 +2011,23 @@ impl LiveBridge { "last_seen": last_seen, "node_state": parsed.node_state, "peering_cost": parsed.peering_cost, - }) + }); + (payload, changed) }; let frame = serde_json::json!({ "type": "propagation.discovered", "payload": payload }); let _ = event_tx.send(frame.to_string()); + if cascade_fields_changed { + rebuild_pn_cascade_candidates( + &persisted, + &discovered, + &outbound, + &pn_hosting_policy, + ) + .await; + } + // Autopeer only while hosting a local PN (lxmd parity). if propagation.is_local_serving() { let autopeer_on = pn_hosting_policy @@ -3718,6 +3747,11 @@ impl LiveBridge { self.propagation.wait_messagestore_loaded().await } + /// True while the local PN messagestore is still loading (serve is deferred until then). + pub fn propagation_messagestore_load_pending(&self) -> bool { + self.propagation.messagestore_load_pending() + } + #[allow(clippy::unused_async)] // async matches StackHandle propagation cancel API pub async fn cancel_propagation_sync(&self) { // Invalidate in-flight emitters before flipping cancel / clearing pins. @@ -3747,22 +3781,17 @@ impl LiveBridge { } /// Rebuild Direct→PN cascade candidate list from persisted propagation rows. + /// + /// Propagation mode `Off` yields an empty list, so Direct failures never deposit on a + /// remote PN or the local inbox. pub async fn refresh_pn_cascade_candidates(&self) { - use pn_cascade::candidates_from_propagation_rows; - let (rows, self_hash) = { - let state = self.persisted.read().await; - let rows: Vec<(String, bool, Option, Option)> = state - .propagation - .iter() - .map(|p| (p.id.clone(), p.enabled, p.destination_hash.clone(), p.hops)) - .collect(); - let self_hash = state.identity.lxmf_hash.clone(); - (rows, self_hash) - }; - let candidates = candidates_from_propagation_rows(&rows, &self_hash); - if let Ok(mut driver) = self.outbound.lock() { - driver.set_pn_cascade_candidates(candidates); - } + rebuild_pn_cascade_candidates( + &self.persisted, + &self.discovered_propagation, + &self.outbound, + &self.pn_hosting_policy, + ) + .await; } pub async fn fetch_interfaces(&self) -> Result, String> { @@ -5314,6 +5343,49 @@ fn path_table_added_hashes_capped(prev: &HashSet, next: &HashSet added } +/// Rebuild the Direct→PN cascade list from persisted rows plus, in Auto, heard announces. +/// +/// Takes the shared Arcs rather than `&self` so the propagation announce task can refresh +/// the list as soon as a new PN is heard — otherwise a discovered node would only become +/// cascade-eligible after a settings write or a stack restart. +async fn rebuild_pn_cascade_candidates( + persisted: &Arc>, + discovered_propagation: &Arc>>, + outbound: &Arc>, + pn_hosting_policy: &Arc>, +) { + use pn_cascade::{auto_discovered_candidates, candidates_for_propagation_mode}; + let (rows, self_hash, mode) = { + let state = persisted.read().await; + let rows: Vec<(String, bool, Option, Option)> = state + .propagation + .iter() + .map(|p| (p.id.clone(), p.enabled, p.destination_hash.clone(), p.hops)) + .collect(); + let self_hash = state.identity.lxmf_hash.clone(); + (rows, self_hash, state.propagation_mode) + }; + let mut candidates = candidates_for_propagation_mode(&rows, &self_hash, mode); + let discovered_rows: Vec = discovered_propagation + .lock() + .map(|cache| cache.values().cloned().collect()) + .unwrap_or_default(); + let max_peering_cost = pn_hosting_policy + .lock() + .map(|p| p.max_peering_cost) + .unwrap_or(super::pn_hosting_policy::DEFAULT_MAX_PEERING_COST); + candidates.extend(auto_discovered_candidates( + &discovered_rows, + &candidates, + &self_hash, + mode, + max_peering_cost, + )); + if let Ok(mut driver) = outbound.lock() { + driver.set_pn_cascade_candidates(candidates); + } +} + /// Compare route-relevant fields (ignore `last_seen` / display_name churn). fn peer_route_fields_equal(a: &PeerRow, b: &PeerRow) -> bool { a.hops == b.hops diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 575fd4cdd..4bdd2a7ee 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1076,6 +1076,15 @@ impl LxmfOutboundDriver { self.direct_path_failovers.remove(&hash); let _ = router.mark_outbound_delivered(&hash); if let Some((pn_hash, transient_id)) = pending_deposit { + // cascade_step disambiguates which island the deposit landed on: + // local inbox, a cascade fallback remote, or the first/preferred remote. + let cascade_step = if was_local { + "local" + } else if was_cascade { + "cascade_remote" + } else { + "preferred_remote" + }; tracing::info!( target: "propagation-deposit", message_hash = %hex::encode(hash), @@ -1085,6 +1094,8 @@ impl LxmfOutboundDriver { .unwrap_or_default(), pn_hash = %hex::encode(pn_hash), stored_locally = was_local, + cascade_step, + delivery_method = method.unwrap_or("unknown"), "outbound PN deposit Completes" ); } @@ -2131,18 +2142,21 @@ mod tests { PnCascadeCandidate { hash: preferred, is_local: false, + is_discovered: false, hops: Some(1), id: "pn-a".into(), }, PnCascadeCandidate { hash: next_remote, is_local: false, + is_discovered: false, hops: Some(2), id: "pn-b".into(), }, PnCascadeCandidate { hash: local, is_local: true, + is_discovered: false, hops: Some(0), id: "local-prop".into(), }, diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 6daa40558..b541109c7 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -26,6 +26,7 @@ mod pn_hosting_apply; mod pn_hosting_policy; #[cfg(feature = "rns-stack")] mod pn_inbound; +mod propagation_mode; pub mod rf_profiles; mod rmap_discovery; mod rrc_codec; @@ -73,6 +74,7 @@ use packet_log::{MAX_WIRE_PACKET_LOG, PacketLogBuffer, WirePacketRow}; pub use path_medium::{PathMediumPreferenceSetting, PathMediumSetting}; use persistence::PersistedState; pub use pn_hosting_policy::PnHostingPolicy; +pub use propagation_mode::parse_propagation_mode; use tokio::sync::{Mutex, RwLock, broadcast}; pub use types::{ AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, @@ -84,6 +86,37 @@ pub use types::{ const NOMAD_REQUIRES_STACK: &str = "Nomad serving requires an rns-stack sidecar build"; const NOMAD_DISPLAY_NAME_MAX_CHARS: usize = 128; +/// Live view of the local propagation node for the `local-prop` list row. +#[cfg(feature = "rns-stack")] +struct LocalPropagationStats { + count: usize, + bytes: usize, + /// Router is serving the local PN (deferred until the messagestore finishes loading). + serving: bool, + /// Background messagestore load has not finished yet. + load_pending: bool, + hash: String, +} + +/// Status label for the `local-prop` row. +/// +/// `loading` distinguishes "enabled but the messagestore is still being read from disk" +/// from a user-disabled node, so the renderer can say so instead of reporting a sync failure. +#[cfg(any(feature = "rns-stack", test))] +fn local_propagation_status( + serving: bool, + load_pending: bool, + persisted_enabled: bool, +) -> &'static str { + if serving { + return "active"; + } + if load_pending && persisted_enabled { + return "loading"; + } + "idle" +} + /// Parse Columba register-known inputs and require dest == LXMF delivery hash of the key. #[cfg(feature = "rns-stack")] fn validated_known_identity_key( @@ -1245,12 +1278,18 @@ 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 propagation_mode = inner.propagation_mode; let pn_hosting_policy = inner.pn_hosting_policy.clone(); #[cfg(feature = "rns-stack")] let local_stats = if let Some(live) = self.live.get() { let (count, bytes) = live.propagation_local_stats(); - let serving = live.propagation_is_local_serving(); - Some((count, bytes, serving, live.propagation_local_hash())) + Some(LocalPropagationStats { + count, + bytes, + serving: live.propagation_is_local_serving(), + load_pending: live.propagation_messagestore_load_pending(), + hash: live.propagation_local_hash(), + }) } else { None }; @@ -1270,28 +1309,31 @@ impl StackHandle { }); #[cfg(feature = "rns-stack")] if p.id == "local-prop" { - if let Some((count, bytes, serving, hash)) = &local_stats { + if let Some(stats) = &local_stats { if let Some(obj) = row.as_object_mut() { obj.insert( "message_count".into(), - serde_json::Value::Number((*count).into()), + serde_json::Value::Number(stats.count.into()), ); obj.insert( "storage_bytes".into(), - serde_json::Value::Number((*bytes).into()), + serde_json::Value::Number(stats.bytes.into()), ); - obj.insert("enabled".into(), serde_json::Value::Bool(*serving)); + obj.insert("enabled".into(), serde_json::Value::Bool(stats.serving)); obj.insert( "status".into(), - if *serving { - serde_json::Value::String("active".into()) - } else { - serde_json::Value::String("idle".into()) - }, + serde_json::Value::String( + local_propagation_status( + stats.serving, + stats.load_pending, + p.enabled, + ) + .into(), + ), ); obj.insert( "destination_hash".into(), - serde_json::Value::String(hash.clone()), + serde_json::Value::String(stats.hash.clone()), ); } } @@ -1303,6 +1345,7 @@ impl StackHandle { "propagation": propagation, "preferred_id": preferred_id, "auto_sync_interval_sec": auto_sync_interval_sec, + "propagation_mode": propagation_mode.as_str(), "pn_hosting_policy": pn_hosting_policy, }) } @@ -1316,7 +1359,7 @@ impl StackHandle { } pub async fn set_preferred_propagation(&self, id: &str) -> Result<(), String> { - let prop_hash = { + let (prop_hash, mode) = { let mut inner = self.inner.write().await; inner.set_preferred_propagation(id)?; let hash = inner @@ -1324,13 +1367,19 @@ impl StackHandle { .iter() .find(|p| p.id == id) .and_then(|p| p.destination_hash.clone()); + let mode = inner.propagation_mode; inner.save(&self.config_dir, &self.storage_dir)?; - hash + (hash, mode) }; #[cfg(feature = "rns-stack")] if let Some(live) = self.live.get() { - live.set_outbound_propagation_node(prop_hash.as_deref()) - .await; + // Mode Off keeps Preferred on disk but never arms it for outbound. + let armed = if mode.is_off() { + None + } else { + prop_hash.as_deref() + }; + live.set_outbound_propagation_node(armed).await; live.refresh_pn_cascade_candidates().await; if prop_hash.is_none() { tracing::warn!( @@ -1340,6 +1389,45 @@ impl StackHandle { ); } } + #[cfg(not(feature = "rns-stack"))] + let _ = mode; + Ok(()) + } + + /// Apply the renderer propagation mode. `Off` disarms the outbound PN and empties the + /// Direct→PN cascade so nothing is deposited on any propagation node. + pub async fn set_propagation_mode(&self, mode: &str) -> Result<(), String> { + let mode = parse_propagation_mode(mode)?; + let prop_hash = { + let mut inner = self.inner.write().await; + // Snapshot for rollback if durable save fails after in-memory mutate. + let snapshot = inner.propagation_mode; + inner.set_propagation_mode(mode); + let hash = inner.preferred_propagation_id.as_ref().and_then(|id| { + inner + .propagation + .iter() + .find(|p| p.id == *id) + .and_then(|p| p.destination_hash.clone()) + }); + if let Err(e) = inner.save(&self.config_dir, &self.storage_dir) { + inner.set_propagation_mode(snapshot); + return Err(e); + } + hash + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = self.live.get() { + let armed = if mode.is_off() { + None + } else { + prop_hash.as_deref() + }; + live.set_outbound_propagation_node(armed).await; + live.refresh_pn_cascade_candidates().await; + } + #[cfg(not(feature = "rns-stack"))] + let _ = prop_hash; Ok(()) } @@ -1429,6 +1517,45 @@ impl StackHandle { Ok(()) } + /// One-time remote sync by destination hash. Does not add a configured row or change Preferred. + pub async fn start_propagation_sync_by_hash( + &self, + destination_hash: &str, + ) -> Result<(), String> { + let prop_hash = destination_hash.trim().to_lowercase(); + if prop_hash.len() != 32 || !prop_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("destination_hash must be 32 hex characters".into()); + } + let lxmf = { + let inner = self.inner.read().await; + inner.identity.lxmf_hash.clone() + }; + let local_prop_hash = { + #[cfg(feature = "rns-stack")] + { + self.live + .get() + .map(|live| live.propagation_local_hash()) + .unwrap_or_default() + } + #[cfg(not(feature = "rns-stack"))] + { + String::new() + } + }; + if prop_hash.eq_ignore_ascii_case(&lxmf) + || (!local_prop_hash.is_empty() && prop_hash.eq_ignore_ascii_case(&local_prop_hash)) + { + return Err("LOCAL_PROPAGATION_SYNC_UNSUPPORTED".into()); + } + #[cfg(feature = "rns-stack")] + if let Some(live) = self.live.get() { + live.start_propagation_sync(&prop_hash).await?; + return Ok(()); + } + Err("RNS stack not live".into()) + } + pub async fn cancel_propagation_sync(&self) -> Result<(), String> { #[cfg(feature = "rns-stack")] if let Some(live) = self.live.get() { @@ -1529,12 +1656,13 @@ impl StackHandle { #[cfg(feature = "rns-stack")] if let Some(live) = self.live.get() { live.cancel_propagation_sync().await; + // Quiet supersede — renderer must not map this to "node unreachable". self.emit_event( "propagation_sync", serde_json::json!({ "active": false, "progress": 0.0, - "message": "propagation sync cancelled", + "message": "PROPAGATION_SYNC_SUPERSEDED", }), ); } @@ -3295,6 +3423,17 @@ mod tests { (config, storage) } + #[test] + fn local_propagation_status_reports_loading_only_while_enabled_and_unloaded() { + assert_eq!(local_propagation_status(true, false, true), "active"); + // Serving wins even if a later load is still pending. + assert_eq!(local_propagation_status(true, true, true), "active"); + assert_eq!(local_propagation_status(false, true, true), "loading"); + // Disabled by the user — not loading, just off. + assert_eq!(local_propagation_status(false, true, false), "idle"); + assert_eq!(local_propagation_status(false, false, true), "idle"); + } + #[test] fn with_rncp_listener_ok_stamps_ok_true_on_status() { let status = serde_json::json!({ @@ -3457,6 +3596,48 @@ mod tests { let _ = std::fs::remove_dir_all(storage_dir); } + #[tokio::test] + async fn start_propagation_sync_by_hash_rejects_invalid_and_leaves_list_unchanged() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + let before = handle.list_propagation().await; + let preferred_before = before + .get("preferred_id") + .cloned() + .unwrap_or(serde_json::Value::Null); + let err = handle + .start_propagation_sync_by_hash("dead") + .await + .expect_err("short hash"); + assert!(err.contains("32 hex"), "unexpected error: {err}"); + let after = handle.list_propagation().await; + assert_eq!( + after + .get("preferred_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + preferred_before + ); + assert_eq!( + after + .get("propagation") + .and_then(|n| n.as_array()) + .map(Vec::len), + before + .get("propagation") + .and_then(|n| n.as_array()) + .map(Vec::len) + ); + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + #[tokio::test] async fn bootstrap_clears_persisted_rns_and_lxmf_ready_until_attach_live() { let (config_dir, storage_dir) = temp_stack_dirs(); @@ -3768,6 +3949,50 @@ mod tests { let _ = std::fs::remove_dir_all(storage_dir); } + #[tokio::test] + #[cfg(unix)] + async fn set_propagation_mode_rolls_back_when_save_fails() { + use std::os::unix::fs::PermissionsExt; + + let (config_dir, storage_dir) = temp_stack_dirs(); + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + handle.set_propagation_mode("auto").await.expect("set auto"); + assert_eq!(handle.list_propagation().await["propagation_mode"], "auto"); + + // Directory 555 still allows rewriting an existing writable file; lock the state file. + let state_path = storage_dir.join("mesh_client_stack.json"); + let mut perms = std::fs::metadata(&state_path) + .expect("state meta") + .permissions(); + perms.set_mode(0o444); + std::fs::set_permissions(&state_path, perms).expect("lock state file"); + + let err = handle + .set_propagation_mode("manual") + .await + .expect_err("save must fail"); + assert!(!err.is_empty()); + assert_eq!( + handle.list_propagation().await["propagation_mode"], + "auto", + "in-memory mode must roll back when save fails" + ); + + let mut restore = std::fs::metadata(&state_path) + .expect("state meta") + .permissions(); + restore.set_mode(0o644); + std::fs::set_permissions(&state_path, restore).expect("unlock state file"); + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + #[tokio::test] async fn clear_contacts_empties_persisted_lxmf_contacts() { let (config_dir, storage_dir) = temp_stack_dirs(); diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 65260ce44..cde98c7b0 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use super::path_medium::{PathMediumPreferenceSetting, PathMediumSetting, PeerMediumPins}; use super::pn_hosting_policy::PnHostingPolicy; +use super::propagation_mode::PropagationMode; use super::types::{ AddInterfaceRequest, ContactRow, InterfaceRow, LxmfReactionRequest, LxmfSendRequest, NomadNodeRow, PeerRow, PropagationRow, RrcHubRow, StackIdentity, @@ -30,6 +31,8 @@ pub struct PersistedState { pub primary_local_serial_interface_id: Option, pub propagation_sync: serde_json::Value, pub auto_sync_interval_sec: u32, + /// Renderer propagation mode; `Off` disables the outbound Direct→PN cascade. + pub propagation_mode: PropagationMode, /// LXMF local PN hosting / peering policy (defaults match rsLXMF / lxmd). pub pn_hosting_policy: PnHostingPolicy, pub nomad_nodes: Vec, @@ -85,6 +88,7 @@ impl PersistedState { primary_local_serial_interface_id: None, propagation_sync: serde_json::Value::Null, auto_sync_interval_sec: 3600, + propagation_mode: PropagationMode::default(), pn_hosting_policy: PnHostingPolicy::default(), nomad_nodes: Vec::new(), rrc_hubs: Vec::new(), @@ -402,6 +406,10 @@ impl PersistedState { self.auto_sync_interval_sec = sec; } + pub fn set_propagation_mode(&mut self, mode: PropagationMode) { + self.propagation_mode = mode; + } + pub fn set_pn_hosting_policy(&mut self, policy: PnHostingPolicy) -> Result<(), String> { let policy = policy.sanitized()?; self.pn_hosting_policy = policy; @@ -804,7 +812,7 @@ impl serde::Serialize for PersistedState { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("PersistedState", 27)?; + let mut s = serializer.serialize_struct("PersistedState", 28)?; s.serialize_field("identity", &self.identity)?; s.serialize_field("interfaces", &self.interfaces)?; s.serialize_field("contacts", &self.contacts)?; @@ -820,6 +828,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("propagation_mode", &self.propagation_mode)?; 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)?; @@ -870,6 +879,8 @@ impl<'de> serde::Deserialize<'de> for PersistedState { #[serde(default)] auto_sync_interval_sec: u32, #[serde(default)] + propagation_mode: PropagationMode, + #[serde(default)] pn_hosting_policy: PnHostingPolicy, #[serde(default)] nomad_nodes: Vec, @@ -918,6 +929,7 @@ impl<'de> serde::Deserialize<'de> for PersistedState { raw.propagation_sync }, auto_sync_interval_sec: raw.auto_sync_interval_sec, + propagation_mode: raw.propagation_mode, pn_hosting_policy: raw.pn_hosting_policy, nomad_nodes: raw.nomad_nodes, rrc_hubs: raw.rrc_hubs, diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs index b98405714..af367394a 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -1,15 +1,25 @@ //! Multi-PN outbound cascade after Direct path failover exhausts. //! -//! Order: preferred remote → other enabled remotes (hops asc) → local-prop last. +//! Order: preferred remote → other enabled remotes (hops asc) → Auto's discovered +//! remotes (hops asc) → local-prop last. use std::collections::HashSet; -/// One configured PN eligible for Direct→Propagated cascade. +use crate::stack::DiscoveredPropagationRow; +use crate::stack::propagation_mode::PropagationMode; + +/// Cap on Auto's ephemeral discovered candidates. Mirrors the renderer's +/// `MAX_DISCOVERED_SYNC_ATTEMPTS` so both sides work the same shortlist. +pub const MAX_AUTO_DISCOVERED_PN_CANDIDATES: usize = 3; + +/// One PN eligible for Direct→Propagated cascade. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PnCascadeCandidate { pub hash: [u8; 16], /// True for local-prop / self LXMF hash (offline inbox — last resort only). pub is_local: bool, + /// True for an ephemeral Auto candidate heard from an announce (never persisted). + pub is_discovered: bool, pub hops: Option, pub id: String, } @@ -47,8 +57,8 @@ impl PnCascadePick { /// Build an ordered cascade list from persisted propagation rows. /// -/// `preferred_hash` (when Some) is tried first among remotes; local is always last -/// when present and enabled. +/// `preferred_hash` (when Some) is tried first among remotes; nodes the user added +/// come before Auto's discovered ones; local is always last when present and enabled. pub fn build_pn_cascade_order( candidates: &[PnCascadeCandidate], preferred_hash: Option<[u8; 16]>, @@ -58,7 +68,10 @@ pub fn build_pn_cascade_order( remotes.sort_by(|a, b| { let ah = a.hops.unwrap_or(u8::MAX); let bh = b.hops.unwrap_or(u8::MAX); - ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) + a.is_discovered + .cmp(&b.is_discovered) + .then_with(|| ah.cmp(&bh)) + .then_with(|| a.id.cmp(&b.id)) }); // Only reorder among enabled candidates — never synthesize a disabled/stale preferred. if let Some(pref) = preferred_hash { @@ -126,6 +139,7 @@ pub fn candidates_from_propagation_rows( out.push(PnCascadeCandidate { hash, is_local: true, + is_discovered: false, hops: *hops, id: id.clone(), }); @@ -143,6 +157,7 @@ pub fn candidates_from_propagation_rows( out.push(PnCascadeCandidate { hash, is_local: false, + is_discovered: false, hops: *hops, id: id.clone(), }); @@ -150,6 +165,68 @@ pub fn candidates_from_propagation_rows( out } +/// Ephemeral cascade candidates from heard `lxmf.propagation` announces. +/// +/// Auto may deposit offline LXMF on a PN the user never added, so the outbound cascade +/// matches what Auto sync already does — no Add, no Preferred write, nothing persisted. +/// Manual only uses nodes the user added, and Off has no cascade at all, so both return +/// an empty list. +pub fn auto_discovered_candidates( + discovered: &[DiscoveredPropagationRow], + configured: &[PnCascadeCandidate], + self_lxmf_hash_hex: &str, + mode: PropagationMode, + max_peering_cost: u8, +) -> Vec { + if mode != PropagationMode::Auto { + return Vec::new(); + } + let self_norm = self_lxmf_hash_hex.trim().to_lowercase(); + let mut seen: HashSet<[u8; 16]> = configured.iter().map(|c| c.hash).collect(); + let mut out = Vec::new(); + for row in discovered { + // Only nodes announcing that they are actively serving can accept a deposit. + if !row.node_state || row.peering_cost > max_peering_cost { + continue; + } + let Some(hash) = parse_hash16(&row.destination_hash) else { + continue; + }; + if is_self_lxmf_hash(&hash, &self_norm) || !seen.insert(hash) { + continue; + } + out.push(PnCascadeCandidate { + hash, + is_local: false, + is_discovered: true, + hops: row.hops, + id: format!("discovered-{}", &hex::encode(hash)[..8]), + }); + } + out.sort_by(|a, b| { + let ah = a.hops.unwrap_or(u8::MAX); + let bh = b.hops.unwrap_or(u8::MAX); + ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) + }); + out.truncate(MAX_AUTO_DISCOVERED_PN_CANDIDATES); + out +} + +/// Cascade candidates for the active propagation mode. +/// +/// Mode `Off` means no propagation support: no remote deposit and no local inbox fallback, +/// so Direct exhaustion is terminal. +pub fn candidates_for_propagation_mode( + rows: &[(String, bool, Option, Option)], + self_lxmf_hash_hex: &str, + mode: PropagationMode, +) -> Vec { + if mode.is_off() { + return Vec::new(); + } + candidates_from_propagation_rows(rows, self_lxmf_hash_hex) +} + fn parse_hash16(hex_str: &str) -> Option<[u8; 16]> { let clean: String = hex_str.chars().filter(char::is_ascii_hexdigit).collect(); if clean.len() != 32 { @@ -168,6 +245,7 @@ mod tests { PnCascadeCandidate { hash: [hash_byte; 16], is_local: false, + is_discovered: false, hops, id: id.into(), } @@ -177,11 +255,54 @@ mod tests { PnCascadeCandidate { hash: [hash_byte; 16], is_local: true, + is_discovered: false, hops: Some(0), id: "local-prop".into(), } } + fn discovered_row(hash_hex: &str, hops: Option) -> DiscoveredPropagationRow { + DiscoveredPropagationRow { + destination_hash: hash_hex.into(), + identity_hash: None, + public_key: None, + display_name: None, + hops, + last_seen: Some(1), + node_state: true, + peering_cost: 0, + } + } + + fn rows() -> Vec<(String, bool, Option, Option)> { + vec![ + ("local-prop".into(), true, Some("99".repeat(16)), Some(0u8)), + ("pn-near".into(), true, Some("11".repeat(16)), Some(1u8)), + ] + } + + #[test] + fn propagation_mode_off_yields_no_cascade_candidates() { + let candidates = candidates_for_propagation_mode(&rows(), "", PropagationMode::Off); + assert!(candidates.is_empty()); + assert!(!cascade_has_capacity( + &build_pn_cascade_order(&candidates, None), + &HashSet::new() + )); + } + + #[test] + fn propagation_mode_auto_and_manual_keep_remote_and_local_candidates() { + for mode in [PropagationMode::Auto, PropagationMode::Manual] { + let candidates = candidates_for_propagation_mode(&rows(), "", mode); + assert_eq!(candidates.len(), 2); + assert!(cascade_has_capacity( + &build_pn_cascade_order(&candidates, None), + &HashSet::new() + )); + } + } + #[test] fn order_preferred_first_then_hops_then_local() { let candidates = vec![ @@ -289,6 +410,104 @@ mod tests { assert!(!ordered.iter().any(|c| c.hash == stale_preferred)); } + #[test] + fn auto_appends_discovered_after_configured_and_before_local() { + let configured = candidates_for_propagation_mode(&rows(), "", PropagationMode::Auto); + let discovered = vec![discovered_row(&"ab".repeat(16), Some(0))]; + let extra = auto_discovered_candidates( + &discovered, + &configured, + "", + PropagationMode::Auto, + u8::MAX, + ); + assert_eq!(extra.len(), 1); + let mut all = configured; + all.extend(extra); + let ordered = build_pn_cascade_order(&all, None); + // Configured "pn-near" (1 hop) still beats the 0-hop discovered node. + assert_eq!(ordered[0].id, "pn-near"); + assert!(ordered[1].is_discovered); + assert!(ordered[2].is_local); + } + + #[test] + fn manual_and_off_add_no_discovered_candidates() { + let discovered = vec![discovered_row(&"ab".repeat(16), Some(1))]; + for mode in [PropagationMode::Manual, PropagationMode::Off] { + assert!( + auto_discovered_candidates(&discovered, &[], "", mode, u8::MAX).is_empty(), + "{mode:?} must not deposit on a node the user never added" + ); + } + } + + #[test] + fn auto_discovered_skips_inactive_self_configured_and_costly() { + let self_hex = "aa".repeat(16); + let configured = vec![remote(0xbb, Some(1), "pn-added")]; + let mut inactive = discovered_row(&"cc".repeat(16), Some(1)); + inactive.node_state = false; + let mut costly = discovered_row(&"dd".repeat(16), Some(1)); + costly.peering_cost = 30; + let discovered = vec![ + inactive, + costly, + discovered_row(&self_hex, Some(1)), + // Already added by the user (uppercase on the wire). + discovered_row(&"BB".repeat(16), Some(1)), + discovered_row("not-a-hash", Some(1)), + // Duplicate announce for the same destination. + discovered_row(&"ee".repeat(16), Some(2)), + discovered_row(&"ee".repeat(16), Some(2)), + ]; + let extra = auto_discovered_candidates( + &discovered, + &configured, + &self_hex, + PropagationMode::Auto, + 26, + ); + assert_eq!(extra.len(), 1); + assert_eq!(hex::encode(extra[0].hash), "ee".repeat(16)); + } + + #[test] + fn auto_discovered_sorts_by_hops_and_caps_at_three() { + let discovered = vec![ + discovered_row(&"55".repeat(16), None), + discovered_row(&"44".repeat(16), Some(4)), + discovered_row(&"11".repeat(16), Some(1)), + discovered_row(&"33".repeat(16), Some(3)), + discovered_row(&"22".repeat(16), Some(2)), + ]; + let extra = + auto_discovered_candidates(&discovered, &[], "", PropagationMode::Auto, u8::MAX); + assert_eq!(extra.len(), MAX_AUTO_DISCOVERED_PN_CANDIDATES); + assert_eq!( + extra.iter().map(|c| c.hops).collect::>(), + vec![Some(1), Some(2), Some(3)], + "unknown-hop announces must never displace a known-close node" + ); + } + + #[test] + fn auto_discovered_only_still_reports_cascade_capacity() { + let extra = auto_discovered_candidates( + &[discovered_row(&"ab".repeat(16), Some(1))], + &[], + "", + PropagationMode::Auto, + u8::MAX, + ); + let ordered = build_pn_cascade_order(&extra, None); + assert!(cascade_has_capacity(&ordered, &HashSet::new())); + assert_eq!( + pick_next_pn_cascade(&ordered, &HashSet::new()), + PnCascadePick::Remote(extra[0].hash) + ); + } + #[test] fn is_self_lxmf_hash_case_insensitive() { let hash = [0xaa; 16]; diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 3ff138aa6..c39754981 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -129,6 +129,15 @@ impl PropagationBridge { }); } + /// True while the background messagestore load has not produced a terminal result. + /// Non-blocking counterpart of [`Self::wait_messagestore_loaded`] for status reads. + pub fn messagestore_load_pending(&self) -> bool { + self.messagestore_result + .lock() + .map(|guard| guard.is_none()) + .unwrap_or(true) + } + /// Wait until background messagestore load has finished; returns the stored terminal result. pub async fn wait_messagestore_loaded(&self) -> Result<(), String> { loop { @@ -786,4 +795,26 @@ mod tests { "sync Completes must log retrieve telemetry" ); } + + /// Auto deposits on discovered PNs, so a newly heard announce must refresh the cascade + /// shortlist immediately instead of waiting for a settings write or stack restart. + #[test] + fn source_announce_handler_rebuilds_pn_cascade_candidates() { + let live = include_str!("live.rs"); + let handler_start = live + .find("pub fn register_propagation_announce_handler") + .expect("propagation announce handler"); + let rest = &live[handler_start..]; + let handler_end = rest[1..] + .find("\n pub fn ") + .map_or(rest.len(), |idx| idx + 1); + assert!( + rest[..handler_end].contains("rebuild_pn_cascade_candidates("), + "announce handler must rebuild cascade candidates when a PN is heard" + ); + assert!( + live.contains("async fn rebuild_pn_cascade_candidates("), + "cascade rebuild must be shared with refresh_pn_cascade_candidates" + ); + } } diff --git a/reticulum-sidecar/src/stack/propagation_mode.rs b/reticulum-sidecar/src/stack/propagation_mode.rs new file mode 100644 index 000000000..ede7b28a9 --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_mode.rs @@ -0,0 +1,75 @@ +//! Persisted propagation mode (mirrors the renderer Network → Propagation nodes selector). +//! +//! `Off` means no propagation support at all: no outbound Direct→PN cascade and no +//! propagation deposit route. A saved Preferred node stays on disk but is never armed. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PropagationMode { + /// No propagation node support (renderer default). + #[default] + Off, + Auto, + Manual, +} + +impl PropagationMode { + pub fn as_str(self) -> &'static str { + match self { + PropagationMode::Off => "off", + PropagationMode::Auto => "auto", + PropagationMode::Manual => "manual", + } + } + + pub fn is_off(self) -> bool { + matches!(self, PropagationMode::Off) + } +} + +/// Parse a renderer mode string; unknown values are rejected so a typo cannot +/// silently disable propagation. +pub fn parse_propagation_mode(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => Ok(PropagationMode::Off), + "auto" => Ok(PropagationMode::Auto), + "manual" => Ok(PropagationMode::Manual), + other => Err(format!("unknown propagation mode: {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_modes_case_insensitively() { + assert_eq!(parse_propagation_mode(" Off "), Ok(PropagationMode::Off)); + assert_eq!(parse_propagation_mode("AUTO"), Ok(PropagationMode::Auto)); + assert_eq!( + parse_propagation_mode("manual"), + Ok(PropagationMode::Manual) + ); + } + + #[test] + fn rejects_unknown_mode() { + assert!(parse_propagation_mode("sometimes").is_err()); + } + + #[test] + fn defaults_to_off() { + assert!(PropagationMode::default().is_off()); + assert_eq!(PropagationMode::default().as_str(), "off"); + } + + #[test] + fn round_trips_through_json() { + let json = serde_json::to_string(&PropagationMode::Manual).unwrap(); + assert_eq!(json, "\"manual\""); + let parsed: PropagationMode = serde_json::from_str("\"auto\"").unwrap(); + assert_eq!(parsed, PropagationMode::Auto); + } +} diff --git a/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh index 18bc1d4ab..45e84780c 100755 --- a/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh +++ b/scripts/apply-rsLXMF-link-delivery-has-pending-to.sh @@ -8,7 +8,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-link-delivery-has-pending-to.patch" -LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" LINK_RS="${LXMF_DIR}/crates/lxmf-core/src/link_delivery.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then diff --git a/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh b/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh index 0cb631b24..be93275f2 100755 --- a/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh +++ b/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch" -LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" NODE_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_node.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then diff --git a/scripts/apply-rsLXMF-propagation-node-policy-setters.sh b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh index 6af13b808..6e7d4ef72 100755 --- a/scripts/apply-rsLXMF-propagation-node-policy-setters.sh +++ b/scripts/apply-rsLXMF-propagation-node-policy-setters.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch" -LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" NODE_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_node.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then diff --git a/scripts/apply-rsLXMF-propagation-sync-peering.sh b/scripts/apply-rsLXMF-propagation-sync-peering.sh index b31e86689..6974eeac7 100755 --- a/scripts/apply-rsLXMF-propagation-sync-peering.sh +++ b/scripts/apply-rsLXMF-propagation-sync-peering.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-sync-peering.patch" -LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" SYNC_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_sync.rs" if [[ ! -d "${LXMF_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-auto-beacon-utun.sh b/scripts/apply-rsReticulum-auto-beacon-utun.sh index 64567da42..9960d354c 100755 --- a/scripts/apply-rsReticulum-auto-beacon-utun.sh +++ b/scripts/apply-rsReticulum-auto-beacon-utun.sh @@ -7,7 +7,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-auto-beacon-utun.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" AUTO_RS="${RNS_DIR}/crates/rns-interface/src/auto.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh b/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh index e1aad1c89..6c6585e56 100755 --- a/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh +++ b/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-ble-rnode-bond-desync.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" BLE_RNODE_RS="${RNS_DIR}/crates/rns-interface/src/ble_rnode.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh index 7bb8df624..9d2c1743f 100755 --- a/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh +++ b/scripts/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" BLE_RNODE_RS="${RNS_DIR}/crates/rns-interface/src/ble_rnode.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-discovery-announce-egress.sh b/scripts/apply-rsReticulum-discovery-announce-egress.sh index c76baa0c7..dcf9b6a3c 100755 --- a/scripts/apply-rsReticulum-discovery-announce-egress.sh +++ b/scripts/apply-rsReticulum-discovery-announce-egress.sh @@ -10,7 +10,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-discovery-announce-egress.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" RETICULUM_RS="${RNS_DIR}/crates/rns-runtime/src/reticulum.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-inbound-raw-saturation-log.sh b/scripts/apply-rsReticulum-inbound-raw-saturation-log.sh index 98844f02f..9f2c147bd 100755 --- a/scripts/apply-rsReticulum-inbound-raw-saturation-log.sh +++ b/scripts/apply-rsReticulum-inbound-raw-saturation-log.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-inbound-raw-saturation-log.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" LINK_MANAGER_RS="${RNS_DIR}/crates/rns-runtime/src/link_manager.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-link-client-nomad.sh b/scripts/apply-rsReticulum-link-client-nomad.sh index 5c1355fbb..e1f2a1c8c 100755 --- a/scripts/apply-rsReticulum-link-client-nomad.sh +++ b/scripts/apply-rsReticulum-link-client-nomad.sh @@ -7,7 +7,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" MESSAGES_RS="${RNS_DIR}/crates/rns-transport/src/messages.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-link-client-proof-budget.sh b/scripts/apply-rsReticulum-link-client-proof-budget.sh index a0a1b72d1..43cda4b70 100755 --- a/scripts/apply-rsReticulum-link-client-proof-budget.sh +++ b/scripts/apply-rsReticulum-link-client-proof-budget.sh @@ -7,7 +7,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" LINK_CLIENT_RS="${RNS_DIR}/crates/rns-runtime/src/link_client.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-packet-tap.sh b/scripts/apply-rsReticulum-packet-tap.sh index bfed9bce3..d9d7abe38 100755 --- a/scripts/apply-rsReticulum-packet-tap.sh +++ b/scripts/apply-rsReticulum-packet-tap.sh @@ -7,7 +7,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-packet-tap.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" RETICULUM_RS="${RNS_DIR}/crates/rns-runtime/src/reticulum.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/apply-rsReticulum-path-medium-slots.sh b/scripts/apply-rsReticulum-path-medium-slots.sh index 8d153a2da..98d62da64 100755 --- a/scripts/apply-rsReticulum-path-medium-slots.sh +++ b/scripts/apply-rsReticulum-path-medium-slots.sh @@ -8,7 +8,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/apply-ratspeak-overlay.sh source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch" -RNS_DIR="${RS_RETICULUM_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum}" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" MARKER="${RNS_DIR}/crates/rns-transport/src/constants.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then diff --git a/scripts/build-reticulum-sidecar-release.mjs b/scripts/build-reticulum-sidecar-release.mjs index 8441f2139..b68f8e8e6 100644 --- a/scripts/build-reticulum-sidecar-release.mjs +++ b/scripts/build-reticulum-sidecar-release.mjs @@ -68,7 +68,7 @@ function cloneRatspeakStack() { stdio: 'inherit', env: { ...process.env, - WORKSPACE_ROOT: process.env.WORKSPACE_ROOT ?? path.join(projectRoot, '..'), + WORKSPACE_ROOT: process.env.WORKSPACE_ROOT ?? path.join(projectRoot, '.rsstack'), }, }); if (result.error) { diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index a66704c9f..f1aaef7f3 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -1348,6 +1348,93 @@ function checkReticulumConnectionPanelIssues(ctx) { * @param {LocaleQualityCtx} ctx * @returns {string[]} */ +/** + * Top-level `reticulumPropagation.*` keys (Network section) — not under connectionPanel. + * Catches English rewrite drift that key-parity / --audit cannot see. + */ +function checkReticulumPropagationModeHelpIssues(ctx) { + const { locale, flatKey, val, enVal } = ctx; + const issues = []; + if (!flatKey.startsWith('reticulumPropagation.') || locale === 'en') return issues; + + if ( + flatKey === 'reticulumPropagation.modeHelpAuto' && + /one-time syncs the best Discovered/i.test(enVal) + ) { + const legacyAutoMarkers = [ + /Preferred (is )?managed/i, + /managed for you/i, + /manual Preferred controls are disabled/i, + /Set preferred and Add/i, + /wird für Sie verwaltet/i, + /se gestiona por usted/i, + /est géré pour vous/i, + /gestito per te/i, + /voor u beheerd/i, + /zarządzany za Ciebie/i, + /gerenciado para você/i, + /управляется за вас/i, + /керується за вас/i, + /sizin için yönetilir/i, + /dikelola untuk Anda/i, + /が管理されます/i, + /관리됩니다/i, + /为您管理/i, + /Manuelle Bevorzugte? Steuerelemente sind deaktiviert/i, + /controles preferidos manuales están desactivados/i, + /commandes préférées manuelles sont désactivées/i, + /手動優先コントロールは無効/i, + /手动首选控件已禁用/i, + ]; + for (const re of legacyAutoMarkers) { + if (re.test(val)) { + issues.push( + 'reticulumPropagation.modeHelpAuto is stale: still describes Preferred-managed Auto (must match one-time Discovered sync, no Preferred write)', + ); + break; + } + } + } + + if (flatKey === 'reticulumPropagation.syncLocalLoading' && /still loading/i.test(enVal)) { + // Split on sentence punctuation only so Japanese/Chinese clauses without whitespace still separate. + const clauses = val + .split(/(?<=[.!?。!?])/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const second = clauses.length >= 2 ? clauses[1] : ''; + if (second.length > 0) { + const mentionsLoading = + /load|charg|carga|caric|lad|načít|ładow|carreg|загруз|завантаж|yükl|muat|読み込|로드|加载|載入/i.test( + second, + ); + // Unicode-safe: CJK sync terms have no ASCII word boundaries. + const mentionsSyncOnly = + /(?:^|[^\p{L}\p{N}_])(?:sync|synchron|sincron|синхрон|동기|同期|同步)\p{L}*/iu.test( + second, + ) && !mentionsLoading; + if (mentionsSyncOnly) { + issues.push( + 'reticulumPropagation.syncLocalLoading pronoun: second clause must refer to loading finishing, not sync finishing', + ); + } + } + } + + if ( + flatKey === 'reticulumPropagation.modeHelpManual' && + /^Manual:/i.test(enVal) && + locale === 'cs' && + /^Příručka:/i.test(val) + ) { + issues.push( + 'reticulumPropagation.modeHelpManual cs false friend: use Ručně:/Manuálně: (mode), not Příručka: (handbook)', + ); + } + + return issues; +} + function checkReticulumRemoteIssues(ctx) { const { locale, flatKey, val, enVal } = ctx; const issues = []; @@ -4149,6 +4236,7 @@ const LOCALE_STRING_QUALITY_CHECKS = [ checkAppPanelReduceMotionAndBrandIssues, checkMeshcoreOpenWireIssues, checkReticulumConnectionPanelIssues, + checkReticulumPropagationModeHelpIssues, checkReticulumRemoteIssues, checkReticulumPeerAndPingIssues, checkUkrainianApostropheIssues, diff --git a/scripts/check-i18n-quality.test.mjs b/scripts/check-i18n-quality.test.mjs index 59075f175..3bf5e6636 100644 --- a/scripts/check-i18n-quality.test.mjs +++ b/scripts/check-i18n-quality.test.mjs @@ -850,6 +850,98 @@ describe('interpolationPlaceholderIssues', () => { }); }); +describe('reticulumPropagation mode-help rewrite drift', () => { + const enModeHelpAuto = + 'Auto: one-time syncs the best Discovered propagation node (does not add it or change Preferred), then configured remotes, then the local inbox. With no network interfaces, settles local only.'; + const enSyncLocalLoading = + 'The local propagation node is still loading its stored messages. Sync runs on its own once it finishes.'; + const enModeHelpManual = + 'Manual: syncs your Preferred node, or picks the closest added node for that sync when none is preferred. If it fails, the other added nodes are tried, then the local inbox.'; + + it('flags stale Preferred-managed Auto help', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumPropagation.modeHelpAuto', + val: 'Auto: Preferred wird für Sie verwaltet. Manuelle Bevorzugte Steuerelemente sind deaktiviert.', + enVal: enModeHelpAuto, + }); + expectIssue(issues, 'modeHelpAuto is stale'); + }); + + it('passes rewritten Auto help', () => { + expect( + localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumPropagation.modeHelpAuto', + val: 'Auto: synchronisiert einmalig den besten erkannten Ausbreitungsknoten (fügt ihn nicht hinzu und ändert Preferred nicht), dann konfigurierte Remotes, dann den lokalen Posteingang.', + enVal: enModeHelpAuto, + }), + ).toEqual([]); + }); + + it('flags syncLocalLoading when second clause blames sync completion', () => { + const issues = localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumPropagation.syncLocalLoading', + val: 'Der lokale Ausbreitungsknoten lädt noch seine gespeicherten Nachrichten. Die Synchronisierung läuft von selbst, sobald sie abgeschlossen ist.', + enVal: enSyncLocalLoading, + }); + expectIssue(issues, 'syncLocalLoading pronoun'); + }); + + it('passes syncLocalLoading when second clause refers to loading', () => { + expect( + localeStringQualityIssues({ + locale: 'de', + flatKey: 'reticulumPropagation.syncLocalLoading', + val: 'Der lokale Ausbreitungsknoten lädt noch seine gespeicherten Nachrichten. Die Synchronisierung startet von selbst, sobald das Laden abgeschlossen ist.', + enVal: enSyncLocalLoading, + }), + ).toEqual([]); + }); + + it('flags Japanese syncLocalLoading when second clause blames sync (no whitespace)', () => { + const issues = localeStringQualityIssues({ + locale: 'ja', + flatKey: 'reticulumPropagation.syncLocalLoading', + val: 'ローカル伝播ノードは保存済みメッセージをまだ読み込んでいます。同期が完了すると自動で実行されます。', + enVal: enSyncLocalLoading, + }); + expectIssue(issues, 'syncLocalLoading pronoun'); + }); + + it('flags Chinese syncLocalLoading when second clause blames sync (no whitespace)', () => { + const issues = localeStringQualityIssues({ + locale: 'zh', + flatKey: 'reticulumPropagation.syncLocalLoading', + val: '本地传播节点仍在加载其已存储的消息。同步完成后会自行运行。', + enVal: enSyncLocalLoading, + }); + expectIssue(issues, 'syncLocalLoading pronoun'); + }); + + it('passes Japanese syncLocalLoading when second clause refers to loading', () => { + expect( + localeStringQualityIssues({ + locale: 'ja', + flatKey: 'reticulumPropagation.syncLocalLoading', + val: 'ローカル伝播ノードは保存済みメッセージをまだ読み込んでいます。読み込みが終わると同期は自動で実行されます。', + enVal: enSyncLocalLoading, + }), + ).toEqual([]); + }); + + it('flags Czech Příručka false friend on modeHelpManual', () => { + const issues = localeStringQualityIssues({ + locale: 'cs', + flatKey: 'reticulumPropagation.modeHelpManual', + val: 'Příručka: synchronizuje váš preferovaný uzel…', + enVal: enModeHelpManual, + }); + expectIssue(issues, 'Příručka'); + }); +}); + describe('roomsPanel login-all false friends (recent MeshCore Rooms)', () => { it('flags French chambres plural on loginAllInProgress', () => { const issues = localeStringQualityIssues({ diff --git a/scripts/check-reticulum-sidecar.sh b/scripts/check-reticulum-sidecar.sh index 20ed79407..4c6e4f536 100755 --- a/scripts/check-reticulum-sidecar.sh +++ b/scripts/check-reticulum-sidecar.sh @@ -15,7 +15,7 @@ fi # Optional path deps must exist on disk even for the feature build. bash "${REPO_ROOT}/scripts/clone-ratspeak-stack.sh" -# Lint sibling rsNomad (path dep); Clippy on the sidecar does not analyze path-dep sources. +# Lint .rsstack/rsNomad (path dep); Clippy on the sidecar does not analyze path-dep sources. bash "${REPO_ROOT}/scripts/check-rsnomad.sh" cd "${SIDECAR_DIR}" diff --git a/scripts/check-rsnomad-fmt.sh b/scripts/check-rsnomad-fmt.sh index a913535f5..664c8db12 100755 --- a/scripts/check-rsnomad-fmt.sh +++ b/scripts/check-rsnomad-fmt.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "${REPO_ROOT}/.." && pwd)}" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-${REPO_ROOT}/.rsstack}" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" if ! command -v cargo > /dev/null 2>&1; then echo "check:rsnomad-fmt: cargo not on PATH — skip" >&2 diff --git a/scripts/check-rsnomad.sh b/scripts/check-rsnomad.sh index 00a275663..7e3a38596 100755 --- a/scripts/check-rsnomad.sh +++ b/scripts/check-rsnomad.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "${REPO_ROOT}/.." && pwd)}" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-${REPO_ROOT}/.rsstack}" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" if ! command -v cargo > /dev/null 2>&1; then diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index 001efd92b..8c0597f31 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -5,7 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "${REPO_ROOT}/.." && pwd)}" +# Repo-local workspace (gitignored .rsstack/) so upstream mirror checkouts stay clean. +WORKSPACE_ROOT="${WORKSPACE_ROOT:-${REPO_ROOT}/.rsstack}" # shellcheck source=lib/ratspeak-overlay-apply-list.sh source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" @@ -150,3 +151,11 @@ lxst_sha="$(git -C "${LXST_DIR}" rev-parse HEAD)" lrgp_sha="$(git -C "${LRGP_DIR}" rev-parse HEAD)" echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode}), rsLXST @ ${lxst_sha:0:12} (${lxst_mode}), lrgp-rs @ ${lrgp_sha:0:12} (${lrgp_mode})" echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha} rsLXST=${lxst_sha} lrgp-rs=${lrgp_sha}" +# Record resolved SHAs for release reproducibility (dev still floats origin/main unless RS_*_REF is set). +{ + echo "rsReticulum=${rns_sha}" + echo "rsLXMF=${lxmf_sha}" + echo "rsNomad=${nomad_sha}" + echo "rsLXST=${lxst_sha}" + echo "lrgp-rs=${lrgp_sha}" +} > "${WORKSPACE_ROOT}/RESOLVED_SHAS.txt" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 4cfc9da3c..108308c0a 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -85,6 +85,7 @@ function runEnsureRepo({ remoteUrl, destDir, pinRef = '' }) { describe('clone-ratspeak-stack.sh float policy', () => { it('floats rsReticulum and rsLXMF to origin/main by default', () => { + expect(cloneScript).toContain('WORKSPACE_ROOT="${WORKSPACE_ROOT:-${REPO_ROOT}/.rsstack}"'); expect(cloneScript).toContain("target_ref='origin/main'"); expect(cloneScript).toContain('checkout --quiet --detach'); expect(cloneScript).toMatch(/RS_RETICULUM_REF="\$\{RS_RETICULUM_REF:-\}"/); diff --git a/scripts/ensure-rsReticulum-patches.sh b/scripts/ensure-rsReticulum-patches.sh index 19b80748e..c195252a0 100755 --- a/scripts/ensure-rsReticulum-patches.sh +++ b/scripts/ensure-rsReticulum-patches.sh @@ -6,8 +6,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=lib/ratspeak-overlay-apply-list.sh source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" -RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" -LXMF_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" if [[ ! -d "${RNS_DIR}/.git" ]]; then echo "rsReticulum not found at ${RNS_DIR}; skipping overlay apply (stub build)" diff --git a/scripts/update.sh b/scripts/update.sh index 06f3c519d..cdd39ef91 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -108,12 +108,12 @@ rebuild_reticulum_sidecar() { fi echo 'Preparing rsReticulum, rsLXMF, rsNomad, rsLXST, and lrgp-rs functionality check...' local sidecar_dir='reticulum-sidecar' - # Paths match reticulum-sidecar/Cargo.toml (../../rs* / ../../lrgp-rs from the sidecar dir). - local rns_runtime='../../rsReticulum/crates/rns-runtime/Cargo.toml' - local lxmf_core='../../rsLXMF/crates/lxmf-core/Cargo.toml' - local nomad_core='../../rsNomad/crates/nomad-core/Cargo.toml' - local lxst_telephony='../../rsLXST/crates/lxst-telephony/Cargo.toml' - local lrgp_crate='../../lrgp-rs/Cargo.toml' + # Paths match reticulum-sidecar/Cargo.toml (../.rsstack/* from the sidecar dir). + local rns_runtime='../.rsstack/rsReticulum/crates/rns-runtime/Cargo.toml' + local lxmf_core='../.rsstack/rsLXMF/crates/lxmf-core/Cargo.toml' + local nomad_core='../.rsstack/rsNomad/crates/nomad-core/Cargo.toml' + local lxst_telephony='../.rsstack/rsLXST/crates/lxst-telephony/Cargo.toml' + local lrgp_crate='../.rsstack/lrgp-rs/Cargo.toml' bash scripts/clone-ratspeak-stack.sh local missing_manifest='' local manifest diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index 5a9cf8d28..25812f2c6 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -45,9 +45,9 @@ describe('update.sh Reticulum stack functionality check', () => { expect(rebuildFunction).toBeDefined(); expect(rebuildFunction).toContain('bash scripts/clone-ratspeak-stack.sh'); - expect(rebuildFunction).toContain('../../rsReticulum/crates/rns-runtime/Cargo.toml'); - expect(rebuildFunction).toContain('../../rsLXMF/crates/lxmf-core/Cargo.toml'); - expect(rebuildFunction).toContain('../../rsNomad/crates/nomad-core/Cargo.toml'); + expect(rebuildFunction).toContain('../.rsstack/rsReticulum/crates/rns-runtime/Cargo.toml'); + expect(rebuildFunction).toContain('../.rsstack/rsLXMF/crates/lxmf-core/Cargo.toml'); + expect(rebuildFunction).toContain('../.rsstack/rsNomad/crates/nomad-core/Cargo.toml'); expect(rebuildFunction).toContain('cargo build --features rns-stack,rns-ble,rns-rnode-tcp'); expect(rebuildFunction).not.toMatch(/['"]\.\.\/rs(?:Reticulum|LXMF|Nomad)\//); expect(rebuildFunction).not.toContain('cargo build)'); @@ -281,7 +281,8 @@ exit 0 }); /** - * Temp layout matching Ratspeak siblings: mesh-client/reticulum-sidecar + ../../rs*. + * Temp layout matching the repo-local .rsstack workspace: mesh-client/reticulum-sidecar + + * .rsstack/{rsReticulum,rsLXMF,rsNomad,rsLXST,lrgp-rs}. * @param {{ buildExit: number }} opts */ function prepareRebuildFixture(opts) { @@ -320,7 +321,7 @@ exit 0 path.join(work, 'reticulum-sidecar', 'Cargo.toml'), '[package]\nname = "mesh-client-reticulum"\n', ); - // Path deps are ../../rs* / ../../lrgp-rs from reticulum-sidecar → siblings of mesh-client. + // Path deps are ../.rsstack/rs* from reticulum-sidecar → repo-local .rsstack workspace. for (const rel of [ 'rsReticulum/crates/rns-runtime/Cargo.toml', 'rsLXMF/crates/lxmf-core/Cargo.toml', @@ -328,7 +329,7 @@ exit 0 'rsLXST/crates/lxst-telephony/Cargo.toml', 'lrgp-rs/Cargo.toml', ]) { - const abs = path.join(root, rel); + const abs = path.join(work, '.rsstack', rel); mkdirSync(path.dirname(abs), { recursive: true }); writeFileSync(abs, '[package]\nname = "stub"\n'); } diff --git a/src/main/index.ts b/src/main/index.ts index d07428e28..f959f8843 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -366,7 +366,7 @@ async function shutdownAppResources(): Promise { ); // log-injection-ok internal cleanup } try { - await reticulumSidecarManager?.stop(); + await reticulumSidecarManager?.stop({ forQuit: true }); } catch (err) { console.debug( '[main] Reticulum sidecar stop during shutdown (ignored):', @@ -6953,7 +6953,7 @@ app.on('will-quit', (event) => { ); // log-injection-ok internal cleanup } try { - await reticulumSidecarManager?.stop(); + await reticulumSidecarManager?.stop({ forQuit: true }); } catch (err) { console.debug( '[main] Reticulum sidecar stop during will-quit (ignored):', diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index bbb736cdf..539dcf274 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -824,4 +824,98 @@ describe('ReticulumSidecarManager', () => { existsSpy.mockRestore(); mkdirSpy.mockRestore(); }); + + it('stop({ forQuit: true }) skips prepare-stop and still SIGTERMs', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const proc = mockSidecarProc(); + proc.kill.mockImplementation(() => { + proc.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + status: 'ok', + version: '0.1.0', + rns_ready: false, + lxmf_ready: false, + }), + text: () => Promise.resolve('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const manager = new ReticulumSidecarManager(); + await manager.start(); + fetchMock.mockClear(); + + await manager.stop({ forQuit: true }); + + expect( + fetchMock.mock.calls.some( + (args) => typeof args[0] === 'string' && args[0].includes('/api/v1/stack/prepare-stop'), + ), + ).toBe(false); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + + it('quit stop aborts an in-flight graceful prepare-stop instead of waiting', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const proc = mockSidecarProc(); + proc.kill.mockImplementation(() => { + proc.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + status: 'ok', + version: '0.1.0', + rns_ready: false, + lxmf_ready: false, + }), + text: () => Promise.resolve('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const manager = new ReticulumSidecarManager(); + await manager.start(); + + // prepare-stop hangs until the caller aborts (sidecar RNS drain is unbounded). + let prepareAborted = false; + let prepareStarted!: () => void; + const prepareReached = new Promise((resolve) => { + prepareStarted = resolve; + }); + fetchMock.mockImplementation((url: unknown, init?: { signal?: AbortSignal }) => { + if (typeof url === 'string' && url.includes('/api/v1/stack/prepare-stop')) { + prepareStarted(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + prepareAborted = true; + reject(new Error('aborted')); + }); + }); + } + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('{}') }); + }); + + const gracefulStop = manager.stop(); + await prepareReached; + + await manager.stop({ forQuit: true }); + await gracefulStop; + + expect(prepareAborted).toBe(true); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); }); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index b4275a326..c515e80a6 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -42,6 +42,8 @@ const HEALTH_POLL_TIMEOUT_MS = 30 * MS_PER_SECOND; /** Wait for BLE RNode detach via POST /api/v1/stack/prepare-stop before SIGTERM. */ const PREPARE_STOP_TIMEOUT_MS = 1 * MS_PER_SECOND; const STOP_GRACE_MS = 5 * MS_PER_SECOND; +/** App is exiting: skip the BLE detach drain and SIGKILL quickly so quit stays responsive. */ +const QUIT_STOP_GRACE_MS = 750; /** After yielding Noble BLE, allow CoreBluetooth/btleplug to settle before sidecar connect. */ const RETICULUM_BLE_RNODE_NOBLE_SETTLE_MS = 500; @@ -187,6 +189,12 @@ export class ReticulumSidecarManager extends EventEmitter { * cannot observe a cleared startAbortRequested from a newer start. */ private startAttemptGeneration = 0; + /** Latched by stop({ forQuit: true }) so an in-flight graceful stop escalates to quit speed. */ + private quitFastRequested = false; + /** Aborts an in-flight prepare-stop fetch when quit escalates a graceful stop. */ + private stopPrepareAbort: AbortController | null = null; + /** Shortens the SIGTERM grace of an in-flight stop when quit escalates it. */ + private escalateStopKill: (() => void) | null = null; private readonly stderrDedupe = new ReticulumSidecarStderrDedupe(); private readonly autoBeaconTracker = new ReticulumSidecarAutoBeaconTracker(); private readonly interfaceIssueTracker = new ReticulumSidecarInterfaceIssueTracker(); @@ -281,6 +289,7 @@ export class ReticulumSidecarManager extends EventEmitter { return this.startPromise; } this.startAbortRequested = false; + this.quitFastRequested = false; this.startAttemptGeneration += 1; this.startPromise = this.startOnce(opts).finally(() => { this.startPromise = null; @@ -498,7 +507,14 @@ export class ReticulumSidecarManager extends EventEmitter { this.watchdogStop = null; } - async stop(): Promise { + /** + * Stop the sidecar. `forQuit` skips the BLE detach drain and shortens the SIGTERM grace — + * the app is exiting, so the OS reclaims the child and no other stack reuses the adapter. + */ + async stop(opts: { forQuit?: boolean } = {}): Promise { + if (opts.forQuit) { + this.quitFastRequested = true; + } // Abort in-flight start at checkpoints (cargo/BLE) so Cancel does not wait on build. this.startAbortRequested = true; this.startAttemptGeneration += 1; @@ -514,6 +530,11 @@ export class ReticulumSidecarManager extends EventEmitter { }); } if (this.stopPromise) { + if (opts.forQuit) { + // A graceful stop is already draining; do not let quit wait on it. + this.stopPrepareAbort?.abort(); + this.escalateStopKill?.(); + } return this.stopPromise; } this.stopPromise = this.stopProc().finally(() => { @@ -525,7 +546,9 @@ export class ReticulumSidecarManager extends EventEmitter { private async stopProc(): Promise { this.stopWatchdog(); this.teardownWs(); - await this.prepareStopBestEffort(); + if (!this.quitFastRequested) { + await this.prepareStopBestEffort(); + } if (bleCoexistenceCoordinator.getState().scanOwner === 'reticulum') { bleCoexistenceCoordinator.releaseScan('reticulum'); } @@ -537,14 +560,22 @@ export class ReticulumSidecarManager extends EventEmitter { } await new Promise((resolve) => { - const killTimer = setTimeout(() => { + const forceKill = (): void => { try { proc.kill('SIGKILL'); } catch { // catch-no-log-ok: process may already be gone during forced shutdown } resolve(); - }, STOP_GRACE_MS); + }; + let killTimer = setTimeout( + forceKill, + this.quitFastRequested ? QUIT_STOP_GRACE_MS : STOP_GRACE_MS, + ); + this.escalateStopKill = () => { + clearTimeout(killTimer); + killTimer = setTimeout(forceKill, QUIT_STOP_GRACE_MS); + }; proc.once('exit', () => { clearTimeout(killTimer); @@ -560,6 +591,7 @@ export class ReticulumSidecarManager extends EventEmitter { } }); + this.escalateStopKill = null; this.finalizeStopped(); } @@ -569,12 +601,17 @@ export class ReticulumSidecarManager extends EventEmitter { if (!status.running || status.port <= 0 || !this.proc) { return; } + const abort = new AbortController(); + this.stopPrepareAbort = abort; + const timeoutTimer = setTimeout(() => { + abort.abort(); + }, PREPARE_STOP_TIMEOUT_MS); try { const res = await fetch(`http://127.0.0.1:${status.port}/api/v1/stack/prepare-stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', - signal: AbortSignal.timeout(PREPARE_STOP_TIMEOUT_MS), + signal: abort.signal, }); if (!res.ok) { console.debug( @@ -586,6 +623,9 @@ export class ReticulumSidecarManager extends EventEmitter { '[ReticulumSidecar] prepare-stop failed — continuing with SIGTERM:', sanitizeLogMessage(e instanceof Error ? e.message : String(e)), ); + } finally { + clearTimeout(timeoutTimer); + this.stopPrepareAbort = null; } } diff --git a/src/main/reticulum-sidecar-path.test.ts b/src/main/reticulum-sidecar-path.test.ts index 954e30ea5..b68a5f7ef 100644 --- a/src/main/reticulum-sidecar-path.test.ts +++ b/src/main/reticulum-sidecar-path.test.ts @@ -25,7 +25,7 @@ vi.mock('child_process', () => ({ import { findReticulumSidecarProjectDir, formatReticulumCargoBuildError, - hasRnsStackSiblings, + hasRsstackWorkspace, newestReticulumSidecarSourceMtimeMs, resolveSidecarBinaryPath, reticulumCargoStderrMissingPacketTap, @@ -101,18 +101,22 @@ describe('reticulum-sidecar-path', () => { ); }); - it('sidecarCargoBuildArgs uses rns-stack when Ratspeak siblings exist', () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-siblings-')); + it('sidecarCargoBuildArgs uses rns-stack when the repo-local .rsstack exists', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-rsstack-')); const meshRoot = path.join(tmpDir, 'mesh-client'); const projectDir = path.join(meshRoot, 'reticulum-sidecar'); - fs.mkdirSync(path.join(tmpDir, 'rsReticulum', 'crates', 'rns-runtime'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, 'rsLXMF', 'crates', 'lxmf-core'), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'rsReticulum/crates/rns-runtime/Cargo.toml'), '[package]\n'); - fs.writeFileSync(path.join(tmpDir, 'rsLXMF/crates/lxmf-core/Cargo.toml'), '[package]\n'); + const stackRoot = path.join(meshRoot, '.rsstack'); + fs.mkdirSync(path.join(stackRoot, 'rsReticulum', 'crates', 'rns-runtime'), { recursive: true }); + fs.mkdirSync(path.join(stackRoot, 'rsLXMF', 'crates', 'lxmf-core'), { recursive: true }); + fs.writeFileSync( + path.join(stackRoot, 'rsReticulum/crates/rns-runtime/Cargo.toml'), + '[package]\n', + ); + fs.writeFileSync(path.join(stackRoot, 'rsLXMF/crates/lxmf-core/Cargo.toml'), '[package]\n'); fs.mkdirSync(projectDir, { recursive: true }); fs.writeFileSync(path.join(projectDir, 'Cargo.toml'), '[package]\nname = "test"\n'); - expect(hasRnsStackSiblings(projectDir)).toBe(true); + expect(hasRsstackWorkspace(projectDir)).toBe(true); expect(sidecarCargoBuildArgs(projectDir)).toEqual([ 'build', '--features', @@ -120,6 +124,14 @@ describe('reticulum-sidecar-path', () => { ]); }); + it('hasRsstackWorkspace is false without repo-local .rsstack', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-nostack-')); + const projectDir = path.join(tmpDir, 'mesh-client', 'reticulum-sidecar'); + fs.mkdirSync(projectDir, { recursive: true }); + expect(hasRsstackWorkspace(projectDir)).toBe(false); + expect(sidecarCargoBuildArgs(projectDir)).toEqual(['build']); + }); + it('sidecarBinaryLacksRnsBle detects sidecars built without rns-ble', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-ble-')); const binary = path.join(tmpDir, sidecarBinaryName()); diff --git a/src/main/reticulum-sidecar-path.ts b/src/main/reticulum-sidecar-path.ts index 78233a95b..f55c5d704 100644 --- a/src/main/reticulum-sidecar-path.ts +++ b/src/main/reticulum-sidecar-path.ts @@ -57,19 +57,23 @@ export function resolveSidecarBinaryPath(extraRoots: string[] = []): string { return path.join(app.getAppPath(), 'reticulum-sidecar', 'target', 'debug', name); } -export function hasRnsStackSiblings(projectDir: string): boolean { +/** True when repo-local `.rsstack` has the minimal rns-stack path deps (rsReticulum + rsLXMF). */ +export function hasRsstackWorkspace(projectDir: string): boolean { const rnsRuntime = path.normalize( - path.join(projectDir, '../../rsReticulum/crates/rns-runtime/Cargo.toml'), + path.join(projectDir, '../.rsstack/rsReticulum/crates/rns-runtime/Cargo.toml'), ); const lxmfCore = path.normalize( - path.join(projectDir, '../../rsLXMF/crates/lxmf-core/Cargo.toml'), + path.join(projectDir, '../.rsstack/rsLXMF/crates/lxmf-core/Cargo.toml'), ); return fs.existsSync(rnsRuntime) && fs.existsSync(lxmfCore); } -/** Cargo build args: full RNS stack (+ BLE) when Ratspeak siblings are present. */ +/** @deprecated Use {@link hasRsstackWorkspace}. */ +export const hasRnsStackSiblings = hasRsstackWorkspace; + +/** Cargo build args: full RNS stack (+ BLE) when the repo-local .rsstack is present. */ export function sidecarCargoBuildArgs(projectDir: string): string[] { - if (hasRnsStackSiblings(projectDir)) { + if (hasRsstackWorkspace(projectDir)) { return ['build', '--features', 'rns-stack,rns-ble,rns-rnode-tcp']; } return ['build']; @@ -133,9 +137,9 @@ export function ensureRsReticulumPatchesScriptPath(projectDir: string): string { ); } -/** Apply rsReticulum overlays when Ratspeak siblings exist (no-op for stub builds). */ +/** Apply rsReticulum overlays when the repo-local .rsstack workspace exists (no-op for stub builds). */ export function ensureRsReticulumPatches(projectDir: string): void { - if (!hasRnsStackSiblings(projectDir)) return; + if (!hasRsstackWorkspace(projectDir)) return; const repoRoot = reticulumSidecarRepoRoot(projectDir); const scriptPath = ensureRsReticulumPatchesScriptPath(projectDir); @@ -276,9 +280,9 @@ export async function ensureDevSidecarBinary(binaryPath: string): Promise const missing = !fs.existsSync(binaryPath); const stale = !missing && sidecarBinaryIsStale(binaryPath, projectDir); const lacksRnsStack = - !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsStack(binaryPath); + !missing && hasRsstackWorkspace(projectDir) && sidecarBinaryLacksRnsStack(binaryPath); const lacksRnsBle = - !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsBle(binaryPath); + !missing && hasRsstackWorkspace(projectDir) && sidecarBinaryLacksRnsBle(binaryPath); const action = resolveDevSidecarEnsureAction({ missing, stale, lacksRnsStack, lacksRnsBle }); if (action === 'noop') { diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index 2d133215f..8c2bdbdd2 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -131,6 +131,25 @@ describe('extractLxmfOutboundLogSlice', () => { expect(slice).toContain('dest=abababab…'); expect(slice).not.toContain(dest); }); + + it('keeps PN island diagnosis lines (deposit/preferred/sync_target/HaveAll)', () => { + const chunk = Buffer.from( + [ + 'info deposit_pn=aabb preferred_pn=ccdd sync_target=eeff', + 'info pn_island mismatch detected', + 'info HaveAll empty_offer completed', + 'info unrelated chat toast', + ].join('\n'), + 'utf8', + ); + const slice = extractLxmfOutboundLogSlice(chunk).toString('utf8'); + expect(slice).toContain('deposit_pn'); + expect(slice).toContain('preferred_pn'); + expect(slice).toContain('sync_target'); + expect(slice).toContain('pn_island'); + expect(slice).toContain('HaveAll'); + expect(slice).not.toContain('unrelated chat toast'); + }); }); describe('redactMnemonicFromStackJson', () => { @@ -300,6 +319,36 @@ describe('buildSupportBundleZip', () => { expect(names).toContain('reticulum/config'); }); + it('developer bundle always includes stack json and lxmf-outbound slice with placeholders', async () => { + const userDataDir = path.join(workDir, 'userdata-empty'); + vi.mocked(app.getPath).mockImplementation((key: string) => { + if (key === 'userData') return userDataDir; + if (key === 'temp') return path.join(workDir, 'temp'); + return userDataDir; + }); + // No reticulum artifacts and no matching log lines on disk. + const dest = path.join(workDir, 'developer-placeholders.zip'); + exportDatabase.mockImplementation((destPath: string) => { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, 'sqlite-bytes'); + }); + + await buildSupportBundleZip(dest, 'developer', '{"ok":true}'); + + const names = await zipEntryNames(dest); + expect(names).toContain('reticulum/mesh_client_stack.json'); + expect(names).toContain('reticulum/lxmf-outbound.log'); + + const buf = await fs.promises.readFile(dest); + const zip = await JSZip.loadAsync(buf); + const stack = JSON.parse( + await zip.file('reticulum/mesh_client_stack.json')!.async('string'), + ) as { note?: string }; + expect(stack.note).toMatch(/not found or unreadable/); + const slice = await zip.file('reticulum/lxmf-outbound.log')!.async('string'); + expect(slice).toMatch(/No LXMF outbound \/ PN cascade lines matched/); + }); + it('includes rotated log backup when present', async () => { await fs.promises.writeFile(path.join(workDir, 'mesh-client.log.1'), 'rotated\n', 'utf8'); const dest = path.join(workDir, 'github-with-backup.zip'); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 4780fc325..d538d7331 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -194,6 +194,12 @@ export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { /Direct path failover/i, /PN cascade/i, /DeliverPropagated/i, + // PN island diagnosis: actual deposit PN hash vs preferred, and sync target counts. + /deposit[_ ]?pn/i, + /preferred[_ ]?pn/i, + /sync[_ ]?target/i, + /pn[_ ]?island/i, + /HaveAll|empty[_ ]?offer/i, ]; const lines: string[] = []; for (const chunk of logChunks) { @@ -313,13 +319,32 @@ export async function buildSupportBundleZip( if (reticulumArtifacts.config) { zip.file('reticulum/config', reticulumArtifacts.config); } - if (reticulumArtifacts.stackJson) { - zip.file('reticulum/mesh_client_stack.json', reticulumArtifacts.stackJson); - } + // Always include stack state so a missing PN preferred/config is unambiguous + // (present-but-placeholder vs silently omitted, as in the w0rmt dump). + zip.file( + 'reticulum/mesh_client_stack.json', + reticulumArtifacts.stackJson ?? + Buffer.from( + JSON.stringify( + { note: 'mesh_client_stack.json not found or unreadable at export time' }, + null, + 2, + ) + '\n', + 'utf8', + ), + ); + // Always include the cascade/outbound slice, even when empty, with a header note so + // the absence of PN deposit lines is explicit rather than a missing file. const lxmfSlice = extractLxmfOutboundLogSlice(backupLog, currentLog); - if (lxmfSlice.length > 0) { - zip.file('reticulum/lxmf-outbound.log', lxmfSlice); - } + zip.file( + 'reticulum/lxmf-outbound.log', + lxmfSlice.length > 0 + ? lxmfSlice + : Buffer.from( + '# No LXMF outbound / PN cascade lines matched in the exported logs at capture time.\n', + 'utf8', + ), + ); } const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 71e798510..a9f296b73 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4167,7 +4167,6 @@ function AppContent() { reticulumSidecarReady={ reticulumRuntime.state.status !== 'disconnected' } - reticulumControlsDisabled={!isConnectedOrOperational} /> diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index cea7d422c..2855d4846 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -59,7 +59,6 @@ import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ConfirmModal } from './ConfirmModal'; import { HelpTooltip } from './HelpTooltip'; -import { ReticulumAppPanelSection } from './ReticulumAppPanelSection'; import { useToast } from './Toast'; /** Sentinel for "clear all channels" so MeshCore DM (`channel_idx === -1`) does not collide with "All". */ @@ -222,7 +221,6 @@ interface Props { /** Reticulum LXMF identity for DM-only message clear in Danger Zone. */ reticulumIdentityId?: string | null; reticulumSidecarReady?: boolean; - reticulumControlsDisabled?: boolean; } interface PendingAction { @@ -261,7 +259,6 @@ export default function AppPanel({ onApplyMeshcorePathHashMode, reticulumIdentityId = null, reticulumSidecarReady = false, - reticulumControlsDisabled = false, }: Props) { const [soundNotifEnabled, setSoundNotifEnabled] = useState( () => localStorage.getItem('mesh-client:notifMuted') !== '1', @@ -819,13 +816,6 @@ export default function AppPanel({ )} - {protocol === 'reticulum' ? ( - - ) : null} - {/* GPS / Location */}

{t('appPanel.gpsSection')}

diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index 936e09960..ff90d969b 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -2318,4 +2318,32 @@ describe('ConnectionPanel Reticulum', () => { localStorage.removeItem(lastConnKey); } }); + + it('connected Disconnect & Quit skips onDisconnect and quits (main owns teardown)', async () => { + const onDisconnect = vi.fn().mockResolvedValue(undefined); + vi.mocked(window.electronAPI.quitApp).mockClear(); + vi.mocked(window.electronAPI.mqtt.disconnect).mockClear(); + + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /Disconnect & Quit/i })); + + await waitFor(() => { + expect(window.electronAPI.quitApp).toHaveBeenCalled(); + }); + // Graceful sidecar stop here would add ~2s before quit; main stops it quit-fast. + expect(onDisconnect).not.toHaveBeenCalled(); + expect(window.electronAPI.mqtt.disconnect).toHaveBeenCalled(); + }); }); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index b6b09320e..7bde33433 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1929,12 +1929,9 @@ export default function ConnectionPanel({ try { if (variant === 'connecting') { handleCancelConnection(); - } else if (isConnected) { - await Promise.race([ - onDisconnect(), - new Promise((resolve) => setTimeout(resolve, 10_000)), - ]); } + // Connected quit skips onDisconnect: main owns teardown (BLE disconnectAll, TCP + // destroy, quit-fast sidecar stop), so a graceful stack stop here only delays exit. if (isConnected || variant === 'connecting' || mqttStatus === 'connected') { markMqttUserDisconnect(); void window.electronAPI.mqtt.disconnect().catch((err: unknown) => { @@ -1960,7 +1957,7 @@ export default function ConnectionPanel({ ); } }, - [handleCancelConnection, isConnected, mqttStatus, onDisconnect], + [handleCancelConnection, isConnected, mqttStatus], ); const renderExitActions = (variant: 'connected' | 'idle' | 'connecting') => { diff --git a/src/renderer/components/ReticulumAppPanelSection.tsx b/src/renderer/components/ReticulumAppPanelSection.tsx deleted file mode 100644 index 932030979..000000000 --- a/src/renderer/components/ReticulumAppPanelSection.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; -import { ReticulumPropagationControls } from './ReticulumPropagationControls'; - -export interface ReticulumAppPanelSectionProps { - sidecarReady?: boolean; - disabled?: boolean; -} - -export function ReticulumAppPanelSection({ - sidecarReady = false, - disabled = false, -}: ReticulumAppPanelSectionProps) { - const { t } = useTranslation(); - - return ( -
-

{t('appPanel.reticulumSection')}

-
-

{t('appPanel.reticulumAnnounceHelp')}

- - -
-
- ); -} diff --git a/src/renderer/components/ReticulumPropagationControls.test.tsx b/src/renderer/components/ReticulumPropagationControls.test.tsx deleted file mode 100644 index 02fe9793a..000000000 --- a/src/renderer/components/ReticulumPropagationControls.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { RETICULUM_PROPAGATION_MODE_KEY } from '@/renderer/lib/reticulum/reticulumPropagationMode'; -import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock('./ReticulumPropagationSyncProgress', () => ({ - ReticulumPropagationSyncProgress: () => null, -})); - -import { ReticulumPropagationControls } from './ReticulumPropagationControls'; - -describe('ReticulumPropagationControls', () => { - beforeEach(() => { - localStorage.clear(); - localStorage.removeItem(RETICULUM_PROPAGATION_MODE_KEY); - useReticulumPropagationStore.setState({ - nodes: [ - { - id: 'local-prop', - name: 'Local', - hops: 0, - enabled: true, - status: 'known', - }, - { - id: 'pn-aaaa1111', - name: 'Near node', - hops: 1, - enabled: true, - status: 'known', - }, - ], - preferredId: null, - sync: { active: false, progress: 0, message: null }, - }); - vi.mocked(window.electronAPI.reticulum.proxyGet).mockResolvedValue({ - propagation: useReticulumPropagationStore.getState().nodes, - preferred_id: null, - }); - vi.mocked(window.electronAPI.reticulum.proxyPost).mockResolvedValue({ ok: true }); - }); - - it('renders mode selector and sync button when sidecar is ready', () => { - render(); - - expect(screen.getByLabelText('reticulumPropagationHeader.modeAria')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'reticulumPropagationHeader.syncAria' }), - ).toBeInTheDocument(); - }); - - it('starts sync with auto-picked node in auto mode', async () => { - const user = userEvent.setup(); - const proxyPost = vi.mocked(window.electronAPI.reticulum.proxyPost); - - render(); - - await user.click(screen.getByRole('button', { name: 'reticulumPropagationHeader.syncAria' })); - - await waitFor(() => { - expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/pn-aaaa1111/preferred', {}); - expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/sync', { - propagation_id: 'pn-aaaa1111', - }); - }); - }); - - it('disables sync in off mode', () => { - localStorage.setItem(RETICULUM_PROPAGATION_MODE_KEY, 'off'); - render(); - - expect( - screen.getByRole('button', { name: 'reticulumPropagationHeader.syncAria' }), - ).toBeDisabled(); - }); - - it('disables controls when sidecar is not ready', () => { - render(); - - expect(screen.getByLabelText('reticulumPropagationHeader.modeAria')).toBeDisabled(); - expect( - screen.getByRole('button', { name: 'reticulumPropagationHeader.syncAria' }), - ).toBeDisabled(); - }); -}); diff --git a/src/renderer/components/ReticulumPropagationControls.tsx b/src/renderer/components/ReticulumPropagationControls.tsx deleted file mode 100644 index 8111de23b..000000000 --- a/src/renderer/components/ReticulumPropagationControls.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; -import { - readReticulumPropagationMode, - resolvePropagationSyncTargetId, - type ReticulumPropagationMode, - writeReticulumPropagationMode, -} from '@/renderer/lib/reticulum/reticulumPropagationMode'; -import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; - -import { ReticulumPropagationSyncProgress } from './ReticulumPropagationSyncProgress'; - -export interface ReticulumPropagationControlsProps { - sidecarReady?: boolean; - disabled?: boolean; -} - -/** Propagation mode selector and sync controls for App panel (and other embedded surfaces). */ -export function ReticulumPropagationControls({ - sidecarReady = false, - disabled = false, -}: ReticulumPropagationControlsProps) { - const { t } = useTranslation(); - const nodes = useReticulumPropagationStore((s) => s.nodes); - const preferredId = useReticulumPropagationStore((s) => s.preferredId); - const sync = useReticulumPropagationStore((s) => s.sync); - const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); - const setPreferredOnSidecar = useReticulumPropagationStore((s) => s.setPreferredOnSidecar); - const startSync = useReticulumPropagationStore((s) => s.startSync); - - const [mode, setMode] = useState(() => readReticulumPropagationMode()); - - useEffect(() => { - if (!sidecarReady) return; - // floating-ok: refreshFromSidecar catches and logs sidecar/IPC failures - void refreshFromSidecar(); - }, [sidecarReady, refreshFromSidecar]); - - const syncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId); - - const applyAutoPreferred = useCallback(async () => { - const autoId = resolvePropagationSyncTargetId('auto', nodes, preferredId); - if (!autoId || autoId === preferredId) return; - await setPreferredOnSidecar(autoId); - }, [nodes, preferredId, setPreferredOnSidecar]); - - useEffect(() => { - if (!sidecarReady || mode !== 'auto' || nodes.length === 0) return; - // floating-ok: setPreferredOnSidecar never rejects (returns false on failure) - void applyAutoPreferred(); - }, [applyAutoPreferred, mode, nodes.length, sidecarReady]); - - const handleModeChange = (next: ReticulumPropagationMode) => { - setMode(next); - writeReticulumPropagationMode(next); - }; - - const handleSync = () => { - if (!syncTargetId) return; - if (mode === 'auto' && syncTargetId !== preferredId) { - void setPreferredOnSidecar(syncTargetId) - .then(() => startSync(syncTargetId)) - .catch((e: unknown) => { - console.warn('[ReticulumPropagationControls] sync ' + errLikeToLogString(e)); - }); - return; - } - void startSync(syncTargetId).catch((e: unknown) => { - console.warn('[ReticulumPropagationControls] sync ' + errLikeToLogString(e)); - }); - }; - - const syncDisabled = disabled || !sidecarReady || mode === 'off' || !syncTargetId || sync.active; - - return ( -
- - -

{t('appPanel.reticulumPropagationHelp')}

- - {!sync.active ? ( - - ) : null} -
- ); -} diff --git a/src/renderer/components/ReticulumPropagationNotice.test.tsx b/src/renderer/components/ReticulumPropagationNotice.test.tsx index 1c8f2329c..eca82aa4e 100644 --- a/src/renderer/components/ReticulumPropagationNotice.test.tsx +++ b/src/renderer/components/ReticulumPropagationNotice.test.tsx @@ -1,19 +1,43 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; -import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { + RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY, + useReticulumPropagationStore, +} from '@/renderer/stores/reticulumPropagationStore'; import { ReticulumPropagationNotice } from './ReticulumPropagationNotice'; +const addToast = vi.fn(); +vi.mock('./Toast', () => ({ + useToast: () => ({ addToast }), + pushAppToast: vi.fn(), +})); + +const activeDiscovered = { + destination_hash: 'ab'.repeat(16), + node_state: true, + peering_cost: 0, + hops: 1, +}; + describe('ReticulumPropagationNotice', () => { const originalRefresh = useReticulumPropagationStore.getState().refreshFromSidecar; beforeEach(() => { + localStorage.clear(); + addToast.mockReset(); + // Off means "no propagation node wanted", so the notice only applies to Auto/Manual. + useReticulumPropagationStore.getState().setPropagationMode('auto'); useReticulumPropagationStore.setState({ nodes: [], discovered: [], preferredId: null, + chatNoticeDismissed: false, + lastAddError: null, refreshFromSidecar: vi.fn().mockResolvedValue(undefined), addFromDiscovered: vi.fn().mockResolvedValue(true), }); @@ -24,6 +48,7 @@ describe('ReticulumPropagationNotice', () => { nodes: [], discovered: [], preferredId: null, + chatNoticeDismissed: false, refreshFromSidecar: originalRefresh, }); }); @@ -69,11 +94,110 @@ describe('ReticulumPropagationNotice', () => { }); }); + it('hides in off mode even with no propagation target', () => { + useReticulumPropagationStore.getState().setPropagationMode('off'); + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: true, status: 'online' }], + preferredId: null, + }); + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('unmounts immediately when mode transitions to Off while visible', () => { + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: true, status: 'online' }], + preferredId: null, + }); + const { container } = render( + , + ); + expect(screen.getByRole('alert')).toBeInTheDocument(); + act(() => { + useReticulumPropagationStore.getState().setPropagationMode('off'); + }); + expect(container).toBeEmptyDOMElement(); + }); + it('calls navigation callback when set up propagation is clicked', async () => { useReticulumPropagationStore.setState({ nodes: [], preferredId: null }); const onOpen = vi.fn(); render(); - await userEvent.click(screen.getByRole('button', { name: /propagation/i })); + await userEvent.click(screen.getByRole('button', { name: /open reticulum network/i })); expect(onOpen).toHaveBeenCalledOnce(); }); + + // Auto deposits on the best heard node, so a discovery is a real propagation target. + it('hides in auto when only discovered nodes exist but still shows in manual', () => { + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: true, status: 'online' }], + discovered: [activeDiscovered], + preferredId: null, + }); + const { container, unmount } = render(); + expect(container).toBeEmptyDOMElement(); + unmount(); + + useReticulumPropagationStore.getState().setPropagationMode('manual'); + render(); + expect(screen.getByRole('alert')).toHaveTextContent(/propagation node/i); + }); + + it('dismiss hides the notice and persists the choice', async () => { + useReticulumPropagationStore.setState({ nodes: [], preferredId: null }); + const { container } = render(); + await userEvent.click(screen.getByRole('button', { name: /stop showing/i })); + expect(container).toBeEmptyDOMElement(); + expect(localStorage.getItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY)).toBe('1'); + expect(useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(true); + }); + + it('stays hidden when dismissal was restored from a previous session', () => { + useReticulumPropagationStore.setState({ + nodes: [], + preferredId: null, + chatNoticeDismissed: true, + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('toasts when Add closest fails', async () => { + useReticulumPropagationStore.getState().setPropagationMode('manual'); + const addFromDiscovered = vi.fn().mockResolvedValue(false); + useReticulumPropagationStore.setState({ + nodes: [], + discovered: [activeDiscovered], + preferredId: null, + lastAddError: 'reticulumPropagation.addFailed', + addFromDiscovered, + }); + render(); + await userEvent.click( + screen.getByRole('button', { + name: 'Add the closest discovered propagation node and set it as preferred', + }), + ); + expect(addFromDiscovered).toHaveBeenCalledWith(activeDiscovered.destination_hash, { + prefer: true, + }); + expect(addToast).toHaveBeenCalledWith('Could not add the propagation node.', 'error'); + }); + + it('has no axe violations', async () => { + useReticulumPropagationStore.getState().setPropagationMode('manual'); + useReticulumPropagationStore.setState({ + nodes: [], + discovered: [activeDiscovered], + preferredId: null, + }); + const { container } = render( + , + ); + expect(screen.getByRole('alert')).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); }); diff --git a/src/renderer/components/ReticulumPropagationNotice.tsx b/src/renderer/components/ReticulumPropagationNotice.tsx index a5b0ef385..a78d0ff7a 100644 --- a/src/renderer/components/ReticulumPropagationNotice.tsx +++ b/src/renderer/components/ReticulumPropagationNotice.tsx @@ -2,9 +2,14 @@ import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { hasEffectiveReticulumPropagationTarget } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; -import { readReticulumPropagationMode } from '@/renderer/lib/reticulum/reticulumPropagationMode'; +import { + listDiscoveredPropagationTargets, + pickAutoPropagationTarget, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { useToast } from './Toast'; + export interface ReticulumPropagationNoticeProps { stackLive: boolean; onOpenPropagationSettings?: () => void; @@ -16,44 +21,42 @@ export function ReticulumPropagationNotice({ onOpenPropagationSettings, }: ReticulumPropagationNoticeProps) { const { t } = useTranslation(); + const { addToast } = useToast(); const nodes = useReticulumPropagationStore((s) => s.nodes); const discovered = useReticulumPropagationStore((s) => s.discovered); const preferredId = useReticulumPropagationStore((s) => s.preferredId); const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); const addFromDiscovered = useReticulumPropagationStore((s) => s.addFromDiscovered); + const dismissed = useReticulumPropagationStore((s) => s.chatNoticeDismissed); + const setChatNoticeDismissed = useReticulumPropagationStore((s) => s.setChatNoticeDismissed); + const mode = useReticulumPropagationStore((s) => s.propagationMode); useEffect(() => { if (!stackLive) return; void refreshFromSidecar(); }, [stackLive, refreshFromSidecar]); - const configuredHashes = useMemo( - () => - new Set( - nodes - .map((n) => n.destination_hash?.toLowerCase()) - .filter((h): h is string => typeof h === 'string' && h.length > 0), - ), - [nodes], - ); - const unconfiguredDiscovered = useMemo( - () => - discovered - .filter((d) => !configuredHashes.has(d.destination_hash.toLowerCase())) - .filter((d) => d.node_state) - .slice() - .sort((a, b) => (a.hops ?? 255) - (b.hops ?? 255)), - [discovered, configuredHashes], + () => listDiscoveredPropagationTargets(nodes, discovered), + [nodes, discovered], ); if (!stackLive) return null; - if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, readReticulumPropagationMode())) { + // Off is a deliberate "no propagation node" choice — do not nag to add one. + if (mode === 'off') return null; + // Re-enable from Network → Propagation nodes. + if (dismissed) return null; + if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode, discovered)) { return null; } const discoveryCount = unconfiguredDiscovered.length; - const closest = unconfiguredDiscovered[0]; + // Rank discovered for “Add closest”; Auto never soft-upserts — user must add explicitly. + const closestTarget = pickAutoPropagationTarget(nodes, discovered); + const closestHash = + closestTarget?.kind === 'discovered' + ? closestTarget.destinationHash + : unconfiguredDiscovered[0]?.destinationHash; return (
- {closest ? ( + {closestHash ? ( ) : null} +
); diff --git a/src/renderer/components/ReticulumPropagationSection.test.tsx b/src/renderer/components/ReticulumPropagationSection.test.tsx index 84b725678..d8cf1b34b 100644 --- a/src/renderer/components/ReticulumPropagationSection.test.tsx +++ b/src/renderer/components/ReticulumPropagationSection.test.tsx @@ -2,7 +2,10 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { + RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY, + useReticulumPropagationStore, +} from '@/renderer/stores/reticulumPropagationStore'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -10,11 +13,15 @@ vi.mock('react-i18next', () => ({ }), })); -vi.mock('./ReticulumPropagationSyncProgress', () => ({ - ReticulumPropagationLastRefreshed: () => null, - ReticulumPropagationRefreshButton: () => null, - ReticulumPropagationSyncProgress: () => null, -})); +vi.mock('./ReticulumPropagationSyncProgress', async () => { + const actual = await vi.importActual('./ReticulumPropagationSyncProgress'); + return { + ...(actual as Record), + ReticulumPropagationLastRefreshed: () => null, + ReticulumPropagationRefreshButton: () => null, + ReticulumPropagationSyncProgress: () => null, + }; +}); vi.mock('./ConfirmModal', () => ({ ConfirmModal: ({ @@ -45,8 +52,17 @@ vi.mock('./ConfirmModal', () => ({ const addToast = vi.fn(); vi.mock('./Toast', () => ({ useToast: () => ({ addToast }), + pushAppToast: vi.fn(), })); +vi.mock('@/renderer/lib/i18n', () => ({ + default: { t: (key: string) => key }, +})); + +import { resetPropagationSyncCascadeState } from '@/renderer/lib/reticulum/reticulumPropagationAutoApply'; +import { RETICULUM_PROPAGATION_MODE_KEY } from '@/renderer/lib/reticulum/reticulumPropagationMode'; +import { resetReticulumPropagationSyncFailures } from '@/renderer/lib/reticulum/reticulumPropagationSyncBackoff'; + import ReticulumPropagationSection from './ReticulumPropagationSection'; describe('ReticulumPropagationSection', () => { @@ -57,12 +73,16 @@ describe('ReticulumPropagationSection', () => { setPreferredOnSidecar: useReticulumPropagationStore.getState().setPreferredOnSidecar, setAutoSyncIntervalOnSidecar: useReticulumPropagationStore.getState().setAutoSyncIntervalOnSidecar, + setModeOnSidecar: useReticulumPropagationStore.getState().setModeOnSidecar, startSync: useReticulumPropagationStore.getState().startSync, addPropagationNode: useReticulumPropagationStore.getState().addPropagationNode, }; beforeEach(() => { addToast.mockReset(); + localStorage.clear(); + resetPropagationSyncCascadeState(); + resetReticulumPropagationSyncFailures(); useReticulumPropagationStore.setState({ nodes: [ { @@ -83,12 +103,17 @@ describe('ReticulumPropagationSection', () => { preferredId: null, discovered: [], sync: { active: false, progress: 0, message: null }, + syncTargetId: null, + lastSyncError: null, + chatNoticeDismissed: false, + propagationMode: 'off', refreshFromSidecar: vi.fn().mockResolvedValue(undefined), removePropagationNode: vi.fn().mockResolvedValue(true), renamePropagationNode: vi.fn().mockResolvedValue(true), setPreferredOnSidecar: vi.fn().mockResolvedValue(true), setAutoSyncIntervalOnSidecar: vi.fn().mockResolvedValue(true), - startSync: vi.fn().mockResolvedValue(true), + setModeOnSidecar: vi.fn().mockResolvedValue(true), + startSync: vi.fn().mockResolvedValue('accepted'), addPropagationNode: vi.fn().mockResolvedValue(true), addFromDiscovered: vi.fn().mockResolvedValue(true), }); @@ -280,4 +305,375 @@ describe('ReticulumPropagationSection', () => { expect(addToast).toHaveBeenCalledWith('reticulumPropagation.offerUnsupported', 'error'); }); }); + + it('Manual with Preferred local enables bottom Sync and settles local', async () => { + const user = userEvent.setup(); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Host propagation node', + enabled: true, + status: 'known', + hops: 0, + }, + ], + preferredId: 'local-prop', + startSync, + }); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + const bottomSync = screen.getByRole('button', { + name: 'reticulumPropagation.syncNowPreferredAria', + }); + expect(bottomSync).not.toBeDisabled(); + await user.click(bottomSync); + await waitFor(() => { + expect(startSync).toHaveBeenCalledWith('local-prop'); + }); + }); + + it('defaults to Off: no auto preferred write and Set preferred enabled', () => { + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + render(); + + const modeSelect = screen.getByLabelText('reticulumPropagation.modeAria'); + expect(modeSelect.value).toBe('off'); + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + for (const btn of screen.getAllByRole('button', { + name: 'reticulumPropagation.setPreferred', + })) { + expect(btn).not.toBeDisabled(); + } + expect( + screen.getByRole('button', { name: 'reticulumPropagation.syncNowPreferredAria' }), + ).toBeDisabled(); + }); + + it('Auto does not write Preferred or gate Set preferred', async () => { + const user = userEvent.setup(); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'auto'); + + await waitFor(() => { + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + }); + for (const btn of screen.getAllByRole('button', { + name: 'reticulumPropagation.setPreferred', + })) { + expect(btn).not.toBeDisabled(); + } + }); + + it('Auto one-time syncs best discovered by hash without Add or Preferred', async () => { + const user = userEvent.setup(); + const hash = 'deadbeef'.repeat(4); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Host propagation node', + enabled: true, + status: 'known', + hops: 0, + }, + ], + preferredId: null, + discovered: [ + { + destination_hash: hash, + display_name: 'Discovered PN', + node_state: true, + peering_cost: 0, + hops: 1, + }, + ], + }); + const addFromDiscovered = vi.mocked(useReticulumPropagationStore.getState().addFromDiscovered); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + const startSync = vi.mocked(useReticulumPropagationStore.getState().startSync); + // Cascade probes interfaces; report one enabled so discovered sync is attempted. + vi.mocked(window.electronAPI.reticulum.proxyGet).mockImplementation((path: string) => { + if (path === '/api/v1/interfaces') { + return Promise.resolve({ interfaces: [{ id: 'tcp1', enabled: true }] }); + } + return Promise.resolve({ status: 'ok' }); + }); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'auto'); + + await waitFor(() => { + expect(startSync).toHaveBeenCalledWith(hash.toLowerCase()); + }); + expect(addFromDiscovered).not.toHaveBeenCalled(); + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + }); + + it('Manual keeps Set preferred usable and does not auto-write', async () => { + const user = userEvent.setup(); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + + const remotePrefer = screen + .getAllByRole('button', { name: 'reticulumPropagation.setPreferred' }) + .at(-1); + if (!remotePrefer) throw new Error('expected a Set preferred control'); + await user.click(remotePrefer); + await waitFor(() => { + expect(setPreferredOnSidecar).toHaveBeenCalledWith('pn-aabb1111'); + }); + }); + + it('Sync Now in Auto syncs configured remote without Preferred write', async () => { + const user = userEvent.setup(); + // Start in Auto so mode change does not auto-kick an extra cascade before the click. + useReticulumPropagationStore.getState().setPropagationMode('auto'); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + const startSync = vi.mocked(useReticulumPropagationStore.getState().startSync); + render(); + + const syncBtn = screen.getByRole('button', { + name: 'reticulumPropagation.syncNowPreferredAria', + }); + expect(syncBtn).not.toBeDisabled(); + await user.click(syncBtn); + + await waitFor(() => { + expect(startSync).toHaveBeenCalledWith('pn-aabb1111'); + }); + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + }); + + it('Auto keeps Add & prefer and shows auto mode help', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + discovered: [ + { + destination_hash: 'dead'.repeat(8), + display_name: 'Seen', + node_state: true, + peering_cost: 0, + hops: 1, + }, + ], + }); + render(); + + expect( + screen.getByRole('button', { name: 'reticulumPropagation.discoveredAddPreferAria:Seen' }), + ).toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'auto'); + + expect(screen.getByText('reticulumPropagation.modeHelpAuto')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'reticulumPropagation.discoveredAddPreferAria:Seen' }), + ).toBeInTheDocument(); + }); + + it('persists mode to localStorage on change', async () => { + const user = userEvent.setup(); + render(); + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + expect(localStorage.getItem(RETICULUM_PROPAGATION_MODE_KEY)).toBe('manual'); + }); + + it('pushes the selected mode to the sidecar', async () => { + const user = userEvent.setup(); + const setModeOnSidecar = vi.mocked(useReticulumPropagationStore.getState().setModeOnSidecar); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + await waitFor(() => { + expect(setModeOnSidecar).toHaveBeenCalledWith('manual'); + }); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'off'); + await waitFor(() => { + expect(setModeOnSidecar).toHaveBeenCalledWith('off'); + }); + }); + + it('Off disables per-node Sync as well as bottom Sync', () => { + render(); + + expect( + screen.getByRole('button', { name: 'reticulumPropagation.syncNowFor:Remote hub' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'reticulumPropagation.syncNowPreferredAria' }), + ).toBeDisabled(); + }); + + it('shows the local inbox as loading and blocks its Sync until the store is read', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Host propagation node', + enabled: false, + status: 'loading', + hops: 0, + }, + ], + }); + render(); + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + + expect(screen.getByText(/reticulumPropagation\.nodeStatus\.loading/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'reticulumPropagation.syncNowFor:Host propagation node', + }), + ).toBeDisabled(); + }); + + it('Manual without Preferred syncs the closest added remote', async () => { + const user = userEvent.setup(); + const startSync = vi.mocked(useReticulumPropagationStore.getState().startSync); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + const bottomSync = screen.getByRole('button', { + name: 'reticulumPropagation.syncNowPreferredAria', + }); + expect(bottomSync).not.toBeDisabled(); + await user.click(bottomSync); + + await waitFor(() => { + expect(startSync).toHaveBeenCalledWith('pn-aabb1111'); + }); + expect(setPreferredOnSidecar).not.toHaveBeenCalled(); + }); + + it('toggles the Chat propagation reminder and persists the choice', async () => { + const user = userEvent.setup(); + render(); + + const checkbox = screen.getByLabelText( + 'reticulumPropagation.showChatNoticeAria', + ); + expect(checkbox.checked).toBe(true); + + await user.click(checkbox); + expect(useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(true); + expect(localStorage.getItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY)).toBe('1'); + + await user.click(checkbox); + expect(useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(false); + expect(localStorage.getItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY)).toBeNull(); + }); + + it('names the node the cascade reached in the sync toast once it settles', async () => { + const user = userEvent.setup(); + // Real startSync is mocked, so mirror the target stamp and the deferred settle it would write. + useReticulumPropagationStore.setState({ + startSync: vi.fn().mockImplementation((id?: string) => { + useReticulumPropagationStore.setState({ + syncTargetId: id ?? null, + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + }); + return Promise.resolve().then(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + }); + return 'accepted' as const; + }); + }), + }); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + await user.click( + screen.getByRole('button', { name: 'reticulumPropagation.syncNowFor:Remote hub' }), + ); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + 'reticulumPropagation.syncLocalSettledFor:Remote hub', + 'success', + ); + }); + }); + + it('names the last node the cascade tried in the failure toast', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'pn-aabb1111', + name: 'Remote hub', + enabled: true, + status: 'known', + destination_hash: 'aabb1111222233334444555566667777', + }, + ], + startSync: vi.fn().mockImplementation((id?: string) => { + useReticulumPropagationStore.setState({ + syncTargetId: id ?? null, + lastSyncError: 'reticulumPropagation.syncFailed', + }); + return Promise.resolve('failed' as const); + }), + }); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + await user.click( + screen.getByRole('button', { name: 'reticulumPropagation.syncNowFor:Remote hub' }), + ); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + 'reticulumPropagation.syncErrorWithTarget:Remote hub', + 'error', + ); + }); + }); + + it('leaves the failure toast unprefixed when no node was contacted', async () => { + const user = userEvent.setup(); + const startSync = vi.mocked(useReticulumPropagationStore.getState().startSync); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Host propagation node', enabled: false, status: 'unknown' }, + ], + }); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'manual'); + await user.click( + screen.getByRole('button', { + name: 'reticulumPropagation.syncNowFor:Host propagation node', + }), + ); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('reticulumPropagation.syncNoTarget', 'error'); + }); + expect(startSync).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/components/ReticulumPropagationSection.tsx b/src/renderer/components/ReticulumPropagationSection.tsx index 7e3a49147..0bee3a2aa 100644 --- a/src/renderer/components/ReticulumPropagationSection.tsx +++ b/src/renderer/components/ReticulumPropagationSection.tsx @@ -3,6 +3,14 @@ import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; +import { startPropagationSyncWithTarget } from '@/renderer/lib/reticulum/reticulumPropagationAutoApply'; +import { + configuredPropagationDestinationHashes, + hasPropagationCascadeCandidate, + isReticulumPropagationMode, + resolvePropagationSyncTargetId, + type ReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { RETICULUM_PROPAGATION_REFRESH_MIN_VISIBLE_MS } from '@/renderer/lib/reticulum/reticulumPropagationSync'; import { type DiscoveredPropagationRow, @@ -15,6 +23,7 @@ import { import { ConfirmModal } from './ConfirmModal'; import { + getReticulumPropagationSyncTargetName, ReticulumPropagationLastRefreshed, ReticulumPropagationRefreshButton, ReticulumPropagationSyncProgress, @@ -25,6 +34,7 @@ const PROPAGATION_NODE_STATUS_KEYS = new Set([ 'active', 'idle', 'known', + 'loading', 'pending', 'unknown', 'online', @@ -145,12 +155,16 @@ export default function ReticulumPropagationSection({ const lastPropagationSyncAt = useReticulumPropagationStore((s) => s.lastPropagationSyncAt); const sync = useReticulumPropagationStore((s) => s.sync); const lastSyncError = useReticulumPropagationStore((s) => s.lastSyncError); + const chatNoticeDismissed = useReticulumPropagationStore((s) => s.chatNoticeDismissed); + const setChatNoticeDismissed = useReticulumPropagationStore((s) => s.setChatNoticeDismissed); const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); const setPreferredOnSidecar = useReticulumPropagationStore((s) => s.setPreferredOnSidecar); const setAutoSyncIntervalOnSidecar = useReticulumPropagationStore( (s) => s.setAutoSyncIntervalOnSidecar, ); - const startSync = useReticulumPropagationStore((s) => s.startSync); + const setModeOnSidecar = useReticulumPropagationStore((s) => s.setModeOnSidecar); + const mode = useReticulumPropagationStore((s) => s.propagationMode); + const setPropagationMode = useReticulumPropagationStore((s) => s.setPropagationMode); const addPropagationNode = useReticulumPropagationStore((s) => s.addPropagationNode); const addFromDiscovered = useReticulumPropagationStore((s) => s.addFromDiscovered); const removePropagationNode = useReticulumPropagationStore((s) => s.removePropagationNode); @@ -162,11 +176,68 @@ export default function ReticulumPropagationSection({ const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null); const [pendingEnableLocal, setPendingEnableLocal] = useState(false); const [adding, setAdding] = useState(false); + const [syncStarting, setSyncStarting] = useState(false); + + const handleSyncNow = (targetId: string) => { + if (syncStarting || sync.active) return; + setSyncStarting(true); + void startPropagationSyncWithTarget(targetId) + .then((ok) => { + setSyncStarting(false); + const name = getReticulumPropagationSyncTargetName(t('reticulumPropagation.localHostName')); + if (!ok) { + const errKey = + useReticulumPropagationStore.getState().lastSyncError ?? + 'reticulumPropagation.syncFailed'; + addToast( + name + ? t('reticulumPropagation.syncErrorWithTarget', { name, message: t(errKey) }) + : t(errKey), + 'error', + ); + return; + } + // The cascade waits for the attempt to settle, so success names whichever node + // actually completed — a discovered/configured remote or the local inbox. + addToast( + name + ? t('reticulumPropagation.syncLocalSettledFor', { name }) + : t('reticulumPropagation.syncLocalSettled'), + 'success', + ); + }) + .catch((err: unknown) => { + setSyncStarting(false); + console.warn('[ReticulumPropagationSection] sync cascade rejected', err); + addToast(t('reticulumPropagation.syncFailed'), 'error'); + }); + }; useEffect(() => { void refreshFromSidecar(); }, [refreshFromSidecar]); + const handleModeChange = (next: ReticulumPropagationMode) => { + if (!isReticulumPropagationMode(next)) return; + setPropagationMode(next); + // Sidecar gates its outbound Direct→PN cascade on the same mode. + void setModeOnSidecar(next) + .then((ok) => { + if (!ok) { + console.warn('[ReticulumPropagationSection] setModeOnSidecar failed', next); + } + }) + .catch((err: unknown) => { + console.warn('[ReticulumPropagationSection] setModeOnSidecar rejected', err); + }); + if (next !== 'auto') return; + // Auto: kick discovered hash sync → configured → local (no Add, no Preferred). + if (!hasPropagationCascadeCandidate('auto', nodes, discovered)) return; + const target = resolvePropagationSyncTargetId('auto', nodes, preferredId, discovered); + if (target == null) return; + handleSyncNow(target); + }; + const handleRefresh = async () => { if (refreshing) return; setRefreshing(true); @@ -206,11 +277,24 @@ export default function ReticulumPropagationSection({ }); }; - const configuredHashes = new Set( - nodes - .map((n) => n.destination_hash?.toLowerCase()) - .filter((h): h is string => typeof h === 'string' && h.length > 0), - ); + const configuredHashes = configuredPropagationDestinationHashes(nodes); + + const modeHelpKey = + mode === 'auto' + ? 'reticulumPropagation.modeHelpAuto' + : mode === 'manual' + ? 'reticulumPropagation.modeHelpManual' + : 'reticulumPropagation.modeHelpOff'; + + const bottomSyncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId, discovered); + // Manual resolves Preferred, else a picked remote, else local settle; Off disables Sync. + // Auto Sync (bottom or per-row) runs the full cascade — ignore firstTargetId. + const bottomSyncDisabled = + sync.active || + syncStarting || + mode === 'off' || + (mode === 'manual' && !bottomSyncTargetId) || + (mode === 'auto' && !hasPropagationCascadeCandidate('auto', nodes, discovered)); const body = ( <> @@ -234,6 +318,21 @@ export default function ReticulumPropagationSection({ }} /> )} +
+ +

{t('reticulumPropagation.showChatNoticeHint')}

+
{nodes.map((node) => { const isLocal = node.id === 'local-prop'; + const isLoading = node.status === 'loading'; const isRenaming = renamingId === node.id; return (
  • {t('reticulumPropagation.localHostHint')}

    diff --git a/src/renderer/components/ReticulumPropagationSyncProgress.test.tsx b/src/renderer/components/ReticulumPropagationSyncProgress.test.tsx new file mode 100644 index 000000000..27bea2594 --- /dev/null +++ b/src/renderer/components/ReticulumPropagationSyncProgress.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.name ? `${key}[${opts.status ?? opts.message ?? ''}|${opts.name}]` : key, + }), +})); + +import { ReticulumPropagationSyncProgress } from './ReticulumPropagationSyncProgress'; + +function renderProgress() { + return render( + , + ); +} + +describe('ReticulumPropagationSyncProgress', () => { + beforeEach(() => { + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local propagation node', enabled: true, status: 'known' }, + { + id: 'pn-aabb1111', + name: 'Remote hub', + enabled: true, + status: 'known', + destination_hash: 'aabb'.repeat(8), + }, + ], + discovered: [], + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + syncTargetId: null, + }); + }); + + afterEach(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + syncTargetId: null, + }); + }); + + it('names the node in the progress line while syncing', () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + syncTargetId: 'pn-aabb1111', + }); + renderProgress(); + expect(screen.getByRole('status')).toHaveTextContent( + 'reticulumPropagation.syncStatusWithTarget[reticulumPropagation.syncStatusEstablishing|Remote hub]', + ); + }); + + it('names a discovered node by its announce name', () => { + useReticulumPropagationStore.setState({ + discovered: [ + { + destination_hash: 'dead'.repeat(8), + display_name: 'Discovered PN', + node_state: true, + peering_cost: 0, + hops: 1, + }, + ], + sync: { active: true, progress: 60, message: null }, + syncTargetId: 'dead'.repeat(8), + }); + renderProgress(); + expect(screen.getByRole('status')).toHaveTextContent(/Discovered PN/); + }); + + it('prefixes the error with the node that was tried', () => { + useReticulumPropagationStore.setState({ + lastSyncError: 'reticulumPropagation.syncFailed', + syncTargetId: 'pn-aabb1111', + }); + renderProgress(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'reticulumPropagation.syncErrorWithTarget[reticulumPropagation.syncFailed|Remote hub]', + ); + }); + + it('leaves the error unprefixed when no node was contacted', () => { + useReticulumPropagationStore.setState({ + lastSyncError: 'reticulumPropagation.syncNoTarget', + syncTargetId: null, + }); + renderProgress(); + expect(screen.getByRole('alert')).toHaveTextContent('reticulumPropagation.syncNoTarget'); + }); +}); diff --git a/src/renderer/components/ReticulumPropagationSyncProgress.tsx b/src/renderer/components/ReticulumPropagationSyncProgress.tsx index 6db302352..c62eae6bb 100644 --- a/src/renderer/components/ReticulumPropagationSyncProgress.tsx +++ b/src/renderer/components/ReticulumPropagationSyncProgress.tsx @@ -2,9 +2,38 @@ import { RefreshCw } from 'lucide-react-motion'; import { useTranslation } from 'react-i18next'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; -import { propagationSyncStatusLabel } from '@/renderer/lib/reticulum/reticulumPropagationSync'; +import { resolveReticulumPropagationTargetLabel } from '@/renderer/lib/reticulum/reticulumPropagationMode'; +import { + isPropagationSyncSupersedeMessage, + propagationSyncStatusLabel, +} from '@/renderer/lib/reticulum/reticulumPropagationSync'; import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +/** + * Name of the node the current (or most recent) sync attempt targeted, or `null` when no + * node was contacted — the cascade clears the target so "nothing to sync with" errors are + * never blamed on a node. + */ +export function getReticulumPropagationSyncTargetName(localLabel: string): string | null { + const { nodes, discovered, syncTargetId } = useReticulumPropagationStore.getState(); + if (syncTargetId == null || syncTargetId.length === 0) return null; + return resolveReticulumPropagationTargetLabel(nodes, discovered, syncTargetId, localLabel); +} + +export function useReticulumPropagationSyncTargetName(): string | null { + const { t } = useTranslation(); + const nodes = useReticulumPropagationStore((s) => s.nodes); + const discovered = useReticulumPropagationStore((s) => s.discovered); + const syncTargetId = useReticulumPropagationStore((s) => s.syncTargetId); + if (syncTargetId == null || syncTargetId.length === 0) return null; + return resolveReticulumPropagationTargetLabel( + nodes, + discovered, + syncTargetId, + t('reticulumPropagation.localHostName'), + ); +} + export function ReticulumPropagationSyncProgress({ cancelLabel, cancelAriaLabel, @@ -17,13 +46,20 @@ export function ReticulumPropagationSyncProgress({ const sync = useReticulumPropagationStore((s) => s.sync); const lastSyncError = useReticulumPropagationStore((s) => s.lastSyncError); const cancelSync = useReticulumPropagationStore((s) => s.cancelSync); + const targetName = useReticulumPropagationSyncTargetName(); const { t } = useTranslation(); + const status = t(propagationSyncStatusLabel(sync.progress)); + return ( <> {sync.active ? (
    -

    {t(propagationSyncStatusLabel(sync.progress))}

    +

    + {targetName + ? t('reticulumPropagation.syncStatusWithTarget', { status, name: targetName }) + : status} +

    ) : null} - {!sync.active && lastSyncError ? ( + {!sync.active && lastSyncError && !isPropagationSyncSupersedeMessage(lastSyncError) ? (

    - {t(lastSyncError)} + {targetName + ? t('reticulumPropagation.syncErrorWithTarget', { + name: targetName, + message: t(lastSyncError), + }) + : t(lastSyncError)}

    ) : null} diff --git a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts index be7ef4ae4..a4202657f 100644 --- a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts +++ b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts @@ -20,6 +20,7 @@ import { } from '@/renderer/lib/reticulum/reticulumLocalInterfaceHealth'; import { isPropagationSyncEstablishingStuck, + PROPAGATION_SYNC_SUPERSEDED, RETICULUM_PROPAGATION_SYNC_FAILING_DIAGNOSTIC_TTL_MS, } from '@/renderer/lib/reticulum/reticulumPropagationSync'; import { type DiagnosticRow, rfRowId } from '@/renderer/lib/types'; @@ -564,6 +565,7 @@ export function buildReticulumDiagnosticRows( !propagation.syncActive && propagation.lastSyncError != null && propagation.lastSyncError !== PROPAGATION_SYNC_USER_CANCEL_KEY && + propagation.lastSyncError !== PROPAGATION_SYNC_SUPERSEDED && attemptAt != null && now - attemptAt <= RETICULUM_PROPAGATION_SYNC_FAILING_DIAGNOSTIC_TTL_MS ) { diff --git a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.test.ts b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.test.ts index 6b4680880..888aa3979 100644 --- a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.test.ts +++ b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.test.ts @@ -1,11 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { useReticulumPropagationStore } from '../../stores/reticulumPropagationStore'; import type { DiagnosticRow } from '../types'; import { buildReticulumDiagnosticSnapshotSync, fetchReticulumDiagnosticSnapshot, } from './reticulumDiagnosticSnapshot'; +import { RETICULUM_PROPAGATION_MODE_KEY } from './reticulumPropagationMode'; function reticulumRow(condition: string): DiagnosticRow { return { @@ -79,6 +81,10 @@ describe('fetchReticulumDiagnosticSnapshot', () => { }); describe('buildReticulumDiagnosticSnapshotSync', () => { + afterEach(() => { + localStorage.removeItem(RETICULUM_PROPAGATION_MODE_KEY); + }); + it('includes diagnostic rows without sidecar IPC', () => { useDiagnosticsStore.setState({ diagnosticRows: [reticulumRow('runtime/rnsNotReady')] }); @@ -89,4 +95,27 @@ describe('buildReticulumDiagnosticSnapshotSync', () => { expect(snap.diagnosticRows).toHaveLength(1); expect(snap.fetchErrors).toEqual({}); }); + + it('captures renderer propagation client state for PN island diagnosis', () => { + localStorage.setItem(RETICULUM_PROPAGATION_MODE_KEY, 'manual'); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known', hops: 0 }, + { id: 'pn-0e972735', name: 'Remote', enabled: true, status: 'known', hops: 2 }, + ], + discovered: [], + preferredId: 'pn-0e972735', + lastSyncError: 'reticulumPropagation.syncFailed', + autoSyncIntervalSec: 3600, + }); + + const snap = buildReticulumDiagnosticSnapshotSync(); + + expect(snap.propagationClient?.mode).toBe('manual'); + expect(snap.propagationClient?.preferredId).toBe('pn-0e972735'); + expect(snap.propagationClient?.resolvedSyncTargetId).toBe('pn-0e972735'); + expect(snap.propagationClient?.autoTarget).toBe('configured:pn-0e972735'); + expect(snap.propagationClient?.lastSyncError).toBe('reticulumPropagation.syncFailed'); + expect(snap.propagationClient?.nodeCount).toBe(2); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts index 477f224fa..29a713dcd 100644 --- a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts +++ b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts @@ -1,11 +1,19 @@ import type { ReticulumSidecarStatus, ReticulumStatusResponse } from '@/shared/reticulum-types'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { useReticulumPropagationStore } from '../../stores/reticulumPropagationStore'; import { isReticulumDiagnosticRow } from '../diagnostics/ReticulumDiagnosticEngine'; import { errLikeToLogString } from '../errLikeToLogString'; import type { DiagnosticRow } from '../types'; import type { ReticulumConfigAuditIssue } from './reticulumConfigAudit'; import { getReticulumInboundLxmfDiagnostics } from './reticulumInboundLxmfDiagnostics'; +import { + formatAutoPropagationTargetLabel, + pickAutoPropagationTarget, + readReticulumPropagationMode, + resolvePropagationSyncTargetId, + type ReticulumPropagationMode, +} from './reticulumPropagationMode'; const RETICULUM_PROXY_ROUTES = [ '/api/v1/status', @@ -60,6 +68,49 @@ export interface ReticulumDiagnosticSidecarSnapshot { inboundCatchUpWatermarkSeq: number | null; lastInboundRingLen: number | null; }; + /** + * Renderer-side propagation client state for PN island diagnosis: which node the app + * would sync (mode-resolved), the mode, last sync error, and preferred/attempt timing. + */ + propagationClient?: ReticulumPropagationClientSnapshot; +} + +export interface ReticulumPropagationClientSnapshot { + mode: ReticulumPropagationMode; + preferredId: string | null; + resolvedSyncTargetId: string | null; + /** What Auto would apply as Preferred right now (kind:id) — helps spot island drift. */ + autoTarget: string | null; + lastSyncError: string | null; + lastPropagationSyncAt: number | null; + lastPropagationSyncAttemptAt: number | null; + autoSyncIntervalSec: number; + nodeCount: number; + discoveredCount: number; +} + +/** Snapshot the renderer propagation store (preferred, sync target, mode, last error). */ +export function getReticulumPropagationClientSnapshot(): ReticulumPropagationClientSnapshot { + const s = useReticulumPropagationStore.getState(); + const mode = readReticulumPropagationMode(); + const auto = pickAutoPropagationTarget(s.nodes, s.discovered); + return { + mode, + preferredId: s.preferredId, + resolvedSyncTargetId: resolvePropagationSyncTargetId( + mode, + s.nodes, + s.preferredId, + s.discovered, + ), + autoTarget: formatAutoPropagationTargetLabel(auto), + lastSyncError: s.lastSyncError, + lastPropagationSyncAt: s.lastPropagationSyncAt, + lastPropagationSyncAttemptAt: s.lastPropagationSyncAttemptAt, + autoSyncIntervalSec: s.autoSyncIntervalSec, + nodeCount: s.nodes.length, + discoveredCount: s.discovered.length, + }; } function selectReticulumDiagnosticRows(): DiagnosticRow[] { @@ -121,6 +172,7 @@ export function buildReticulumDiagnosticSnapshotSync(): ReticulumDiagnosticSidec diagnosticRows: selectReticulumDiagnosticRows(), fetchErrors: {}, inboundLxmf: getReticulumInboundLxmfDiagnostics(), + propagationClient: getReticulumPropagationClientSnapshot(), }; } @@ -165,5 +217,6 @@ export async function fetchReticulumDiagnosticSnapshot(): Promise { it('sidecar outbound driver exposes set_inbound_packet_sender', () => { diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts index 4fc80f0aa..89b23c0db 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts @@ -231,11 +231,17 @@ describe('failReticulumSendingOutboundToDestHash', () => { describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { it('returns false when preferred remote PN is set (sidecar owns Direct→PN fallback)', () => { - expect(shouldApplyLinkDeliveryTimeoutFailureBridge([remoteNode], 'pn-remote', 'off')).toBe( + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([remoteNode], 'pn-remote', 'auto')).toBe( false, ); }); + it('returns true in off mode because there is no PN cascade to wait for', () => { + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([remoteNode], 'pn-remote', 'off')).toBe( + true, + ); + }); + it('returns false for mode manual when preferred remote PN is set', () => { expect(shouldApplyLinkDeliveryTimeoutFailureBridge([remoteNode], 'pn-remote', 'manual')).toBe( false, @@ -258,4 +264,14 @@ describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { it('returns true when no remote PN and local-prop disabled', () => { expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'off')).toBe(true); }); + + // Auto deposits on heard PNs, so the sidecar is still cascading with nothing configured. + it('returns false in auto with only a discovered node', () => { + const discovered = [ + { destination_hash: 'ab'.repeat(16), node_state: true, peering_cost: 0, hops: 1 }, + ]; + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'auto', discovered)).toBe(false); + // Manual never uses a node the user did not add, so the timeout is terminal there. + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'manual', discovered)).toBe(true); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts index 04f02d5bf..6c64d16b7 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts @@ -9,7 +9,10 @@ import { } from '@/renderer/lib/reticulum/reticulumPropagationMode'; import type { IdentityId } from '@/renderer/lib/types'; import { useMessageStore } from '@/renderer/stores/messageStore'; -import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import type { + DiscoveredPropagationRow, + PropagationNodeRow, +} from '@/renderer/stores/reticulumPropagationStore'; import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; function normalizeDestHash(hash: string): string { @@ -25,8 +28,9 @@ export function shouldApplyLinkDeliveryTimeoutFailureBridge( nodes: PropagationNodeRow[], preferredId: string | null, mode: ReticulumPropagationMode = readReticulumPropagationMode(), + discovered: readonly DiscoveredPropagationRow[] = [], ): boolean { - return !hasReticulumPnCascadeCapacity(nodes, preferredId, mode); + return !hasReticulumPnCascadeCapacity(nodes, preferredId, mode, discovered); } function destHashMatchesPeer(storedHash: string, targetNorm: string): boolean { diff --git a/src/renderer/lib/reticulum/reticulumPnHostingPolicySetters.contract.test.ts b/src/renderer/lib/reticulum/reticulumPnHostingPolicySetters.contract.test.ts index 27d6d5c00..fe134e396 100644 --- a/src/renderer/lib/reticulum/reticulumPnHostingPolicySetters.contract.test.ts +++ b/src/renderer/lib/reticulum/reticulumPnHostingPolicySetters.contract.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; const REPO_ROOT = join(__dirname, '../../../..'); const PN_APPLY = join(REPO_ROOT, 'reticulum-sidecar/src/stack/pn_hosting_apply.rs'); -const LXMF_NODE = join(REPO_ROOT, '../rsLXMF/crates/lxmf-core/src/propagation_node.rs'); +const LXMF_NODE = join(REPO_ROOT, '.rsstack/rsLXMF/crates/lxmf-core/src/propagation_node.rs'); const POLICY_SETTERS_PATCH = join( REPO_ROOT, 'reticulum-sidecar/patches/rsLXMF-propagation-node-policy-setters.patch', diff --git a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts new file mode 100644 index 000000000..43a0d476d --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts @@ -0,0 +1,752 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type PropagationStartSyncResult, + useReticulumPropagationStore, +} from '@/renderer/stores/reticulumPropagationStore'; + +import { + PROPAGATION_SYNC_LOCAL_LOADING_KEY, + PROPAGATION_SYNC_NO_TARGET_KEY, + resetPropagationSyncCascadeState, + startPropagationSyncCascade, + startPropagationSyncWithTarget, +} from './reticulumPropagationAutoApply'; +import { + RETICULUM_PROPAGATION_MODE_KEY, + writeReticulumPropagationMode, +} from './reticulumPropagationMode'; +import { + hasRecentReticulumPropagationSyncFailure, + resetReticulumPropagationSyncFailures, +} from './reticulumPropagationSyncBackoff'; + +type SettleOutcome = 'success' | 'failure' | 'cancel'; + +const SETTLE_ERROR_KEYS: Record = { + success: null, + failure: 'reticulumPropagation.syncFailed', + cancel: 'reticulumPropagation.syncCancelled', +}; + +/** + * Mimics the real `startSync`: the sidecar accepts the request now and the outcome only + * arrives later on the websocket stream. + */ +function deferredStartSync(outcomeFor: (id: string) => SettleOutcome) { + return vi.fn((id?: string): Promise => { + const target = id ?? ''; + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + syncTargetId: target, + }); + setTimeout(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: SETTLE_ERROR_KEYS[outcomeFor(target)], + }); + }, 0); + return Promise.resolve('accepted'); + }); +} + +describe('reticulumPropagationAutoApply', () => { + beforeEach(() => { + resetPropagationSyncCascadeState(); + resetReticulumPropagationSyncFailures(); + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + clear: () => { + store.clear(); + }, + }); + vi.stubGlobal('electronAPI', { + reticulum: { + proxyGet: vi.fn().mockResolvedValue({ + interfaces: [{ id: 'tcp1', enabled: true }], + }), + }, + }); + // electronAPI is on window in renderer + Object.defineProperty(globalThis, 'window', { + value: { + electronAPI: { + reticulum: { + proxyGet: vi.fn().mockResolvedValue({ + interfaces: [{ id: 'tcp1', enabled: true }], + }), + }, + }, + }, + writable: true, + configurable: true, + }); + writeReticulumPropagationMode('auto'); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'known', + }, + { + id: 'pn-aabb1111', + name: 'Remote', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'aabb'.repeat(8), + }, + ], + discovered: [], + preferredId: null, + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + syncTargetId: null, + setPreferredOnSidecar: vi.fn().mockResolvedValue(true), + addFromDiscovered: vi.fn().mockResolvedValue(true), + startSync: vi.fn().mockResolvedValue('accepted'), + refreshFromSidecar: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('Auto one-time syncs best discovered by hash without Add or Preferred', async () => { + const hash = 'dead'.repeat(8); + const addFromDiscovered = vi.fn().mockResolvedValue(true); + const setPreferred = vi.fn().mockResolvedValue(true); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + preferredId: null, + nodes: [ + { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'known', + }, + ], + discovered: [ + { + destination_hash: hash, + node_state: true, + peering_cost: 0, + hops: 0, + }, + ], + addFromDiscovered, + setPreferredOnSidecar: setPreferred, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith(hash); + expect(addFromDiscovered).not.toHaveBeenCalled(); + expect(setPreferred).not.toHaveBeenCalled(); + expect(startSync).not.toHaveBeenCalledWith('local-prop'); + }); + + it('Auto with no enabled interfaces settles local only', async () => { + const hash = 'dead'.repeat(8); + const addFromDiscovered = vi.fn().mockResolvedValue(true); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'known', + }, + ], + discovered: [ + { + destination_hash: hash, + node_state: true, + peering_cost: 0, + hops: 0, + }, + ], + addFromDiscovered, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: false })).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith('local-prop'); + expect(startSync).toHaveBeenCalledTimes(1); + expect(addFromDiscovered).not.toHaveBeenCalled(); + }); + + it('Auto syncs configured remote without Preferred write when no discoveries', async () => { + const setPreferred = vi.mocked(useReticulumPropagationStore.getState().setPreferredOnSidecar); + const startSync = vi.mocked(useReticulumPropagationStore.getState().startSync); + const addFromDiscovered = vi.mocked(useReticulumPropagationStore.getState().addFromDiscovered); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith('pn-aabb1111'); + expect(addFromDiscovered).not.toHaveBeenCalled(); + expect(setPreferred).not.toHaveBeenCalled(); + }); + + it('Auto cascade falls back to local-prop when remote sync fails', async () => { + const startSync = vi.fn().mockResolvedValueOnce('failed').mockResolvedValueOnce('accepted'); + useReticulumPropagationStore.setState({ + preferredId: 'pn-aabb1111', + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith('pn-aabb1111'); + expect(startSync).toHaveBeenCalledWith('local-prop'); + }); + + it('Manual Preferred local-prop syncs local settle', async () => { + writeReticulumPropagationMode('manual'); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + preferredId: 'local-prop', + startSync, + }); + await expect(startPropagationSyncWithTarget('local-prop')).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith('local-prop'); + expect(startSync).toHaveBeenCalledTimes(1); + }); + + it('Manual remote failure falls back to local-prop', async () => { + writeReticulumPropagationMode('manual'); + const startSync = vi.fn().mockResolvedValueOnce('failed').mockResolvedValueOnce('accepted'); + useReticulumPropagationStore.setState({ + preferredId: 'pn-aabb1111', + startSync, + }); + await expect(startPropagationSyncWithTarget('pn-aabb1111')).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual(['pn-aabb1111', 'local-prop']); + }); + + it('Manual tries the other added remotes before local-prop', async () => { + writeReticulumPropagationMode('manual'); + const startSync = vi + .fn() + .mockResolvedValueOnce('failed') + .mockResolvedValueOnce('failed') + .mockResolvedValueOnce('accepted'); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { id: 'pn-near', name: 'Near', enabled: true, status: 'known', hops: 1 }, + { id: 'pn-far', name: 'Far', enabled: true, status: 'known', hops: 4 }, + ], + preferredId: 'pn-far', + startSync, + }); + await expect(startPropagationSyncCascade()).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual(['pn-far', 'pn-near', 'local-prop']); + }); + + it('Manual without Preferred picks the closest remote without writing Preferred', async () => { + writeReticulumPropagationMode('manual'); + const setPreferred = vi.fn().mockResolvedValue(true); + const addFromDiscovered = vi.fn().mockResolvedValue(true); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { id: 'pn-near', name: 'Near', enabled: true, status: 'known', hops: 1 }, + { id: 'pn-far', name: 'Far', enabled: true, status: 'known', hops: 4 }, + ], + preferredId: null, + setPreferredOnSidecar: setPreferred, + addFromDiscovered, + startSync, + }); + await expect(startPropagationSyncCascade()).resolves.toBe(true); + expect(startSync).toHaveBeenCalledWith('pn-near'); + expect(startSync).toHaveBeenCalledTimes(1); + expect(setPreferred).not.toHaveBeenCalled(); + expect(addFromDiscovered).not.toHaveBeenCalled(); + }); + + it('Manual with no added remotes settles local-prop only', async () => { + writeReticulumPropagationMode('manual'); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: true, status: 'known' }], + preferredId: null, + startSync, + }); + await expect(startPropagationSyncCascade()).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual(['local-prop']); + }); + + it('Off never syncs, even with an explicit target or Preferred', async () => { + writeReticulumPropagationMode('off'); + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + preferredId: 'pn-aabb1111', + startSync, + }); + await expect(startPropagationSyncCascade()).resolves.toBe(false); + await expect(startPropagationSyncWithTarget('pn-aabb1111')).resolves.toBe(false); + await expect(startPropagationSyncWithTarget('local-prop')).resolves.toBe(false); + expect(startSync).not.toHaveBeenCalled(); + }); + + it('Auto with nothing available reports no target instead of an unreachable node', async () => { + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: false, status: 'idle' }], + discovered: [], + preferredId: null, + lastSyncError: null, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(false); + expect(startSync).not.toHaveBeenCalled(); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + PROPAGATION_SYNC_NO_TARGET_KEY, + ); + }); + + it('Auto reports the local inbox as loading while its messagestore is read', async () => { + const startSync = vi.fn().mockResolvedValue('accepted'); + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }], + discovered: [], + preferredId: null, + lastSyncError: null, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(false); + expect(startSync).not.toHaveBeenCalled(); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + PROPAGATION_SYNC_LOCAL_LOADING_KEY, + ); + }); + + it('keeps the real sync error when a node was actually contacted', async () => { + const startSync = vi.fn().mockImplementation(() => { + useReticulumPropagationStore.setState({ + lastSyncError: 'reticulumPropagation.syncEstablishNoLinkProof', + }); + return Promise.resolve('failed'); + }); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: false, status: 'idle' }, + { id: 'pn-aabb1111', name: 'Remote', enabled: true, status: 'known', hops: 2 }, + ], + discovered: [], + preferredId: null, + lastSyncError: null, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(false); + expect(startSync).toHaveBeenCalledWith('pn-aabb1111'); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + 'reticulumPropagation.syncEstablishNoLinkProof', + ); + }); + + it('leaves the sync target naming the last node tried', async () => { + const startSync = vi.fn().mockImplementation((id: string) => { + useReticulumPropagationStore.setState({ syncTargetId: id }); + return Promise.resolve('failed'); + }); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { id: 'pn-aabb1111', name: 'Remote', enabled: true, status: 'known', hops: 2 }, + ], + discovered: [], + preferredId: null, + syncTargetId: null, + startSync, + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(false); + expect(startSync).toHaveBeenLastCalledWith('local-prop'); + expect(useReticulumPropagationStore.getState().syncTargetId).toBe('local-prop'); + }); + + it('clears the sync target when the cascade contacts nobody', async () => { + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: false, status: 'idle' }], + discovered: [], + preferredId: null, + // Stale target from an earlier sync must not be blamed for "nothing to sync with". + syncTargetId: 'pn-aabb1111', + startSync: vi.fn().mockResolvedValue('accepted'), + }); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(false); + expect(useReticulumPropagationStore.getState().syncTargetId).toBeNull(); + }); + + it('honors persisted Auto mode key', () => { + expect(localStorage.getItem(RETICULUM_PROPAGATION_MODE_KEY)).toBe('auto'); + }); + + describe('attempts that fail after the sidecar accepts them', () => { + const near = 'aa11'.repeat(8); + const far = 'bb22'.repeat(8); + let nowSpy: { mockRestore: () => void } | undefined; + + afterEach(() => { + nowSpy?.mockRestore(); + nowSpy = undefined; + }); + + const setUpTwoDiscovered = (startSync: ReturnType) => { + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { + id: 'pn-aabb1111', + name: 'Remote', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'aabb'.repeat(8), + }, + ], + discovered: [ + { destination_hash: near, node_state: true, peering_cost: 0, hops: 0 }, + { destination_hash: far, node_state: true, peering_cost: 0, hops: 1 }, + ], + preferredId: null, + startSync, + }); + }; + + it('Auto moves on to the next discovered node instead of stopping', async () => { + const startSync = deferredStartSync((id) => (id === near ? 'failure' : 'success')); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([near, far]); + }); + + it('Auto reaches the local inbox after every remote fails', async () => { + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([ + near, + far, + 'pn-aabb1111', + 'local-prop', + ]); + }); + + it('Manual moves on to the next added remote instead of stopping', async () => { + writeReticulumPropagationMode('manual'); + const startSync = deferredStartSync((id) => (id === 'pn-far' ? 'failure' : 'success')); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { id: 'pn-near', name: 'Near', enabled: true, status: 'known', hops: 1 }, + { id: 'pn-far', name: 'Far', enabled: true, status: 'known', hops: 4 }, + ], + preferredId: 'pn-far', + startSync, + }); + + await expect(startPropagationSyncCascade()).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual(['pn-far', 'pn-near']); + }); + + it('stops the cascade when the user cancels the attempt', async () => { + const startSync = deferredStartSync(() => 'cancel'); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe( + false, + ); + expect(startSync).toHaveBeenCalledTimes(1); + expect(startSync).toHaveBeenCalledWith(near); + }); + + it('omits a node that failed recently on the next cascade', async () => { + const startSync = deferredStartSync((id) => (id === near ? 'failure' : 'success')); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([near, far]); + + startSync.mockClear(); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([far]); + }); + + it('refreshes sidecar nodes before local fallback when local looks disabled', async () => { + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + const refreshFromSidecar = vi.fn().mockImplementation(() => { + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { + id: 'pn-aabb1111', + name: 'Remote', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'aabb'.repeat(8), + }, + ], + }); + return Promise.resolve(); + }); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }, + { + id: 'pn-aabb1111', + name: 'Remote', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'aabb'.repeat(8), + }, + ], + discovered: [{ destination_hash: near, node_state: true, peering_cost: 0, hops: 0 }], + preferredId: null, + startSync, + refreshFromSidecar, + }); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(refreshFromSidecar).toHaveBeenCalled(); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([near, 'pn-aabb1111', 'local-prop']); + }); + + it('skips straight to local when every discovered node failed recently', async () => { + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([ + near, + far, + 'pn-aabb1111', + 'local-prop', + ]); + + startSync.mockClear(); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual(['local-prop']); + }); + + it('settles the local inbox once the remote budget is spent', async () => { + let nowMs = 1_000_000; + nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => nowMs); + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + const slowStartSync = vi.fn((id?: string) => { + nowMs += 6 * 60_000; + return startSync(id); + }); + setUpTwoDiscovered(slowStartSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(slowStartSync.mock.calls.map((c) => c[0])).toEqual([near, 'local-prop']); + }); + + it('shares one run when auto-sync ticks overlap', async () => { + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + setUpTwoDiscovered(startSync); + + // The second tick must join the running cascade rather than start a competing chain. + const first = startPropagationSyncCascade({ hasEnabledInterfaces: true }); + const second = startPropagationSyncCascade({ hasEnabledInterfaces: true }); + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([ + near, + far, + 'pn-aabb1111', + 'local-prop', + ]); + }); + + it('OUTBOUND_BUSY advances without 15-minute backoff', async () => { + const settleOk = deferredStartSync(() => 'success'); + const startSync = vi.fn((id?: string) => { + if (id === near) return Promise.resolve('deferred' as const); + return settleOk(id); + }); + setUpTwoDiscovered(startSync); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + expect(startSync.mock.calls.map((c) => c[0])).toEqual([near, far]); + expect(hasRecentReticulumPropagationSyncFailure(near)).toBe(false); + + startSync.mockClear(); + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe(true); + // Near was deferred, not failed — next cascade may try it again. + expect(startSync.mock.calls.map((c) => c[0])[0]).toBe(near); + }); + + it('skips a configured remote whose hash was already tried as the Manual seed', async () => { + writeReticulumPropagationMode('manual'); + const shared = 'ccccdddd'.repeat(4); + const startSync = deferredStartSync((id) => (id === 'local-prop' ? 'success' : 'failure')); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: true, status: 'known' }, + { + id: 'pn-shared', + name: 'Same hash as seed', + enabled: true, + status: 'known', + hops: 1, + destination_hash: shared, + }, + { + id: 'pn-other', + name: 'Other', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'ddddcccc'.repeat(4), + }, + ], + preferredId: null, + startSync, + }); + + await expect( + startPropagationSyncCascade({ firstTargetId: shared, hasEnabledInterfaces: true }), + ).resolves.toBe(true); + // Seed hash failed; pn-shared shares that hash so it is skipped; pn-other then local. + expect(startSync.mock.calls.map((c) => c[0])).toEqual([ + shared.toLowerCase(), + 'pn-other', + 'local-prop', + ]); + }); + + it('explicit Sync supersedes an in-flight auto cascade', async () => { + let releaseNear!: () => void; + const nearBlocked = new Promise((resolve) => { + releaseNear = resolve; + }); + const startSync = vi.fn((id?: string) => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + syncTargetId: id ?? '', + }); + if (id === near) { + return nearBlocked.then(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: 'reticulumPropagation.syncFailed', + }); + return 'accepted' as const; + }); + } + setTimeout(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + }); + }, 0); + return Promise.resolve('accepted' as const); + }); + setUpTwoDiscovered(startSync); + + const autoRun = startPropagationSyncCascade({ hasEnabledInterfaces: true }); + // Wait until the first discovered attempt is in flight. + await vi.waitFor(() => { + expect(startSync).toHaveBeenCalledWith(near); + }); + const callsWhileBlocked = startSync.mock.calls.length; + + // firstTargetId is ignored in Auto for cascade order, but still bumps generation + // so this explicit Sync supersedes the in-flight auto tick. + const explicit = startPropagationSyncCascade({ + firstTargetId: 'pn-aabb1111', + hasEnabledInterfaces: true, + }); + releaseNear(); + await expect(autoRun).resolves.toBe(false); + await expect(explicit).resolves.toBe(true); + expect(startSync.mock.calls.length).toBeGreaterThan(callsWhileBlocked); + }); + + it('stops the cascade when mode flips to Off mid-run', async () => { + let releaseNear!: () => void; + const nearBlocked = new Promise((resolve) => { + releaseNear = resolve; + }); + const startSync = vi.fn((id?: string) => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + syncTargetId: id ?? '', + }); + if (id === near) { + return nearBlocked.then(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: 'reticulumPropagation.syncFailed', + }); + return 'accepted' as const; + }); + } + return Promise.resolve('accepted' as const); + }); + setUpTwoDiscovered(startSync); + + const autoRun = startPropagationSyncCascade({ hasEnabledInterfaces: true }); + await vi.waitFor(() => { + expect(startSync).toHaveBeenCalledWith(near); + }); + writeReticulumPropagationMode('off'); + releaseNear(); + await expect(autoRun).resolves.toBe(false); + expect(startSync).toHaveBeenCalledTimes(1); + }); + + it('keeps a remote error when local is still loading after remotes failed', async () => { + const startSync = vi.fn((id?: string) => { + useReticulumPropagationStore.setState({ + syncTargetId: id ?? null, + lastSyncError: 'reticulumPropagation.syncEstablishNoLinkProof', + }); + return Promise.resolve('failed' as const); + }); + useReticulumPropagationStore.setState({ + nodes: [ + { id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }, + { + id: 'pn-aabb1111', + name: 'Remote', + enabled: true, + status: 'known', + hops: 2, + destination_hash: 'aabb'.repeat(8), + }, + ], + discovered: [], + preferredId: null, + lastSyncError: null, + startSync, + }); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe( + false, + ); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + 'reticulumPropagation.syncEstablishNoLinkProof', + ); + expect(useReticulumPropagationStore.getState().syncTargetId).toBe('pn-aabb1111'); + }); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts new file mode 100644 index 000000000..612c64290 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts @@ -0,0 +1,302 @@ +import { + hasEnabledLocalPropagationNode, + isLocalPropagationLoading, + listConfiguredRemotePropagationIds, + listDiscoveredPropagationTargets, + propagationTargetDestinationHash, + readReticulumPropagationMode, + resolveManualCascadeSeed, + type ReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; +import { + awaitPropagationSyncSettled, + type PropagationAttemptOutcome, + RETICULUM_PROPAGATION_SYNC_STALL_MS, +} from '@/renderer/lib/reticulum/reticulumPropagationSync'; +import { + clearReticulumPropagationSyncFailure, + noteReticulumPropagationSyncFailure, + omitRecentlyFailedPropagationTargets, +} from '@/renderer/lib/reticulum/reticulumPropagationSyncBackoff'; +import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { MS_PER_MINUTE, MS_PER_SECOND } from '@/shared/timeConstants'; + +/** Cap Auto discovered one-time sync attempts so a long failure chain cannot hang Sync. */ +const MAX_DISCOVERED_SYNC_ATTEMPTS = 3; + +/** + * Total time the remote half of a cascade may consume. Each attempt is already bounded by the + * stall (45s) and ceiling (180s) watchdogs; this stops a chain of slow nodes from delaying the + * local-inbox settle for many minutes. + */ +export const PROPAGATION_CASCADE_BUDGET_MS = 5 * MS_PER_MINUTE; + +/** + * Per remote attempt while cascading. Remotes that get past Establishing can otherwise burn the + * full ~120s lxmf-core timeout before failing; cascade advances (and reaches local) sooner. + * Slack past the Establishing stall so a late WS failure still settles before we force-cancel. + */ +export const PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS = + RETICULUM_PROPAGATION_SYNC_STALL_MS + 15 * MS_PER_SECOND; + +/** No discovered PN, no reachable configured remote, and no usable local inbox. */ +export const PROPAGATION_SYNC_NO_TARGET_KEY = 'reticulumPropagation.syncNoTarget'; +/** Local inbox is enabled but its messagestore is still loading, so it cannot settle yet. */ +export const PROPAGATION_SYNC_LOCAL_LOADING_KEY = 'reticulumPropagation.syncLocalLoading'; + +/** Shared run for overlapping auto-sync ticks. */ +let inFlightCascade: Promise | null = null; +/** Bumped per run so a superseded cascade stops at its next attempt boundary. */ +let cascadeGeneration = 0; + +/** Test seam — drops the shared run so suites do not leak a cascade between cases. */ +export function resetPropagationSyncCascadeState(): void { + inFlightCascade = null; + cascadeGeneration = 0; +} + +/** Tracks whether any node was actually contacted, so a real error is never overwritten. */ +interface CascadeAttempts { + any: boolean; +} + +/** + * Start one sync and wait for its real outcome. + * + * `startSync` resolves as soon as the sidecar accepts the request, so a node that accepts and + * then fails to establish would otherwise look like success and end the cascade. + */ +async function attemptSync( + id: string, + attempts: CascadeAttempts, +): Promise { + const startResult = await useReticulumPropagationStore.getState().startSync(id); + if (startResult === 'deferred') { + // Soft defer: do not count as contacted and do not 15-minute-backoff the node. + return 'deferred'; + } + if (startResult !== 'accepted') { + attempts.any = true; + noteReticulumPropagationSyncFailure(id); + return 'failed'; + } + attempts.any = true; + // Local settle is immediate; remotes use a cascade-sized budget so a slow PN cannot + // monopolize the whole lxmf-core 120s window before we advance. + const outcome = await awaitPropagationSyncSettled( + id === 'local-prop' ? undefined : { timeoutMs: PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS }, + ); + if (outcome === 'success') { + clearReticulumPropagationSyncFailure(id); + } else if (outcome === 'failed') { + noteReticulumPropagationSyncFailure(id); + } + return outcome; +} + +/** + * Nothing was reachable. When no node was contacted at all, replace the generic + * "node may be unreachable" error with why there was no target in the first place. + */ +function finishWithoutTarget(attempts: CascadeAttempts): boolean { + // Remotes already failed: keep their error (do not overwrite with local-loading). + if (attempts.any) return false; + const { nodes } = useReticulumPropagationStore.getState(); + const loading = isLocalPropagationLoading(nodes); + useReticulumPropagationStore + .getState() + .setLastSyncError( + loading ? PROPAGATION_SYNC_LOCAL_LOADING_KEY : PROPAGATION_SYNC_NO_TARGET_KEY, + ); + // No node was called, so nothing may be named alongside this error. + useReticulumPropagationStore.getState().setSyncTargetId(null); + return false; +} + +async function tryLocalSettleIfEnabled(attempts: CascadeAttempts): Promise { + let { nodes } = useReticulumPropagationStore.getState(); + // Auto ticks can start with a stale nodes list (local still "disabled" until refresh). + if (!hasEnabledLocalPropagationNode(nodes)) { + try { + await useReticulumPropagationStore.getState().refreshFromSidecar(); + nodes = useReticulumPropagationStore.getState().nodes; + } catch (e) { + console.warn('[reticulumPropagationAutoApply] refreshFromSidecar failed', e); + // Keep the nodes already read from the store and continue the enabled check. + } + } + if (!hasEnabledLocalPropagationNode(nodes)) return finishWithoutTarget(attempts); + return (await attemptSync('local-prop', attempts)) === 'success'; +} + +/** True when the sidecar reports at least one enabled interface. Fail open on read errors. */ +export async function fetchHasEnabledReticulumInterfaces(): Promise { + try { + const body = (await window.electronAPI.reticulum.proxyGet('/api/v1/interfaces')) as { + interfaces?: { enabled?: boolean }[]; + }; + const rows = body.interfaces ?? []; + return rows.some((row) => row.enabled === true); + } catch (e) { + console.warn('[reticulumPropagationAutoApply] interfaces read failed', e); + return true; + } +} + +type RemoteAttemptsResult = 'success' | 'stop' | 'exhausted'; + +/** + * Try configured remotes (skipping recently failed / already-tried ids or hashes). + * `stop` = superseded or user cancel; `exhausted` = fall through to local settle. + */ +async function runConfiguredRemoteAttempts(args: { + mode: ReticulumPropagationMode; + tried: Set; + attempts: CascadeAttempts; + generation: number; + remoteDeadlineMs: number; +}): Promise { + const { mode, tried, attempts, generation, remoteDeadlineMs } = args; + const superseded = (): boolean => + readReticulumPropagationMode() !== mode || cascadeGeneration !== generation; + + for (const id of omitRecentlyFailedPropagationTargets( + listConfiguredRemotePropagationIds(useReticulumPropagationStore.getState().nodes), + (remoteId) => remoteId, + )) { + if (superseded()) return 'stop'; + if (Date.now() >= remoteDeadlineMs) break; + if (tried.has(id)) continue; + const currentNodes = useReticulumPropagationStore.getState().nodes; + const rowHash = propagationTargetDestinationHash(currentNodes, id); + if (rowHash && tried.has(rowHash)) continue; + tried.add(id); + if (rowHash) tried.add(rowHash); + const outcome = await attemptSync(id, attempts); + if (outcome === 'success') return 'success'; + if (outcome === 'cancelled') return 'stop'; + } + return 'exhausted'; +} + +/** + * Auto: best discovered (one-time sync by hash — **no** Add, **no** Preferred) → + * configured remotes → local-prop settle. + * Manual: explicit first target, else Preferred, else best configured remote (picked for this + * sync only — **no** Preferred write) → remaining configured remotes → local-prop settle. + * Off: no propagation support — never syncs, even with an explicit target. + * + * Each step waits for that attempt to actually settle, so a node that accepts the request and + * then fails to establish hands off to the next candidate instead of ending the cascade. + * + * In Auto, `firstTargetId` is ignored — per-row Sync and bottom Sync both run the full + * discovered → configured → local cascade. + */ +export async function startPropagationSyncCascade(opts?: { + /** Seeds Manual (Preferred / per-row Sync). Ignored in Auto. */ + firstTargetId?: string | null; + /** + * When false, skip discovered/remote sync and settle local-prop (no active interfaces). + * When omitted, Auto probes `/api/v1/interfaces`. + */ + hasEnabledInterfaces?: boolean; +}): Promise { + const explicitTarget = opts?.firstTargetId != null && opts.firstTargetId.length > 0; + // A cascade now spans the whole attempt chain, so the 30s auto-sync tick would otherwise + // start a competing run between attempts. An explicit user Sync supersedes instead. + if (inFlightCascade != null && !explicitTarget) return inFlightCascade; + + const generation = ++cascadeGeneration; + const run = runPropagationSyncCascade(generation, opts).finally(() => { + if (cascadeGeneration === generation) inFlightCascade = null; + }); + inFlightCascade = run; + return run; +} + +async function runPropagationSyncCascade( + generation: number, + opts?: { firstTargetId?: string | null; hasEnabledInterfaces?: boolean }, +): Promise { + const mode = readReticulumPropagationMode(); + if (mode === 'off') return false; + + const state = useReticulumPropagationStore.getState(); + const { nodes, preferredId, discovered } = state; + const first = opts?.firstTargetId ?? null; + const attempts: CascadeAttempts = { any: false }; + const remoteDeadlineMs = Date.now() + PROPAGATION_CASCADE_BUDGET_MS; + /** Mode changed under us, or a newer cascade took over — abandon this run entirely. */ + const superseded = (forMode: ReticulumPropagationMode): boolean => + readReticulumPropagationMode() !== forMode || cascadeGeneration !== generation; + /** Remote attempts ran long enough; stop chaining them but still settle the local inbox. */ + const remoteBudgetSpent = (): boolean => Date.now() >= remoteDeadlineMs; + + if (mode === 'auto') { + const hasInterfaces = + opts?.hasEnabledInterfaces ?? (await fetchHasEnabledReticulumInterfaces()); + if (!hasInterfaces) { + return tryLocalSettleIfEnabled(attempts); + } + + const tried = new Set(); + const discoveredTargets = omitRecentlyFailedPropagationTargets( + listDiscoveredPropagationTargets(nodes, discovered), + (target) => target.destinationHash, + ).slice(0, MAX_DISCOVERED_SYNC_ATTEMPTS); + + for (const target of discoveredTargets) { + if (superseded('auto')) return false; + if (remoteBudgetSpent()) break; + const hash = target.destinationHash.toLowerCase(); + tried.add(hash); + const outcome = await attemptSync(hash, attempts); + if (outcome === 'success') return true; + if (outcome === 'cancelled') return false; + } + + const remotes = await runConfiguredRemoteAttempts({ + mode: 'auto', + tried, + attempts, + generation, + remoteDeadlineMs, + }); + if (remotes === 'success') return true; + if (remotes === 'stop') return false; + return tryLocalSettleIfEnabled(attempts); + } + + // Manual: explicit first target → Preferred → picked remote → other remotes → local. + const seed = resolveManualCascadeSeed(first, preferredId, nodes); + + if (seed === 'local-prop' || seed == null) { + return tryLocalSettleIfEnabled(attempts); + } + + const tried = new Set([seed]); + const seedHash = propagationTargetDestinationHash(nodes, seed); + if (seedHash) tried.add(seedHash); + const seedOutcome = await attemptSync(seed, attempts); + if (seedOutcome === 'success') return true; + if (seedOutcome === 'cancelled') return false; + + const remotes = await runConfiguredRemoteAttempts({ + mode: 'manual', + tried, + attempts, + generation, + remoteDeadlineMs, + }); + if (remotes === 'success') return true; + if (remotes === 'stop') return false; + return tryLocalSettleIfEnabled(attempts); +} + +/** + * Run the mode-appropriate sync cascade with an optional Manual seed target. + * Auto ignores `targetId` and always runs discovered → configured → local. + */ +export async function startPropagationSyncWithTarget(targetId: string): Promise { + return startPropagationSyncCascade({ firstTargetId: targetId }); +} diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts index ecb3c70fe..9f43c9c3b 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import type { + DiscoveredPropagationRow, + PropagationNodeRow, +} from '@/renderer/stores/reticulumPropagationStore'; import { hasEffectiveReticulumPropagationTarget, @@ -16,29 +19,77 @@ const remoteNode: PropagationNodeRow = { hops: 2, }; +const localOnlyNode: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'online', +}; + +const activeDiscovered: DiscoveredPropagationRow = { + destination_hash: 'dead'.repeat(8), + node_state: true, + peering_cost: 0, + hops: 1, +}; + describe('hasEffectiveReticulumPropagationTarget', () => { it('returns false when mode is off and nothing is preferred', () => { expect(hasEffectiveReticulumPropagationTarget([remoteNode], null, 'off')).toBe(false); }); - it('returns true when preferred remote is set even if sync mode is off', () => { - expect(hasEffectiveReticulumPropagationTarget([remoteNode], 'remote-1', 'off')).toBe(true); + it('returns false in off mode even when a preferred remote is saved', () => { + expect(hasEffectiveReticulumPropagationTarget([remoteNode], 'remote-1', 'off')).toBe(false); + }); + + it('returns true in manual without Preferred when an added remote can be picked', () => { + expect(hasEffectiveReticulumPropagationTarget([remoteNode], null, 'manual')).toBe(true); }); it('returns false when only local-prop is enabled', () => { - const localOnly: PropagationNodeRow = { - id: 'local-prop', - name: 'Local inbox', - enabled: true, - status: 'online', - }; - expect(hasEffectiveReticulumPropagationTarget([localOnly], null, 'auto')).toBe(false); + expect(hasEffectiveReticulumPropagationTarget([localOnlyNode], null, 'auto')).toBe(false); }); it('returns true when auto mode finds an enabled remote node', () => { expect(hasEffectiveReticulumPropagationTarget([remoteNode], null, 'auto')).toBe(true); }); + // Auto deposits on the best heard PN without adding it (sidecar auto_discovered_candidates). + it('returns true in auto when only discovered remotes exist', () => { + expect( + hasEffectiveReticulumPropagationTarget([localOnlyNode], null, 'auto', [activeDiscovered]), + ).toBe(true); + }); + + it('returns false in auto when the discovered node is not serving', () => { + const inactive = { ...activeDiscovered, node_state: false }; + expect(hasEffectiveReticulumPropagationTarget([localOnlyNode], null, 'auto', [inactive])).toBe( + false, + ); + }); + + it('returns false in auto when the discovered node is already added but disabled', () => { + const disabledConfigured: PropagationNodeRow = { + ...remoteNode, + enabled: false, + destination_hash: activeDiscovered.destination_hash, + }; + expect( + hasEffectiveReticulumPropagationTarget([disabledConfigured], null, 'auto', [ + activeDiscovered, + ]), + ).toBe(false); + }); + + it('ignores discovered nodes in manual and off (only nodes the user added count)', () => { + expect( + hasEffectiveReticulumPropagationTarget([localOnlyNode], null, 'manual', [activeDiscovered]), + ).toBe(false); + expect( + hasEffectiveReticulumPropagationTarget([localOnlyNode], null, 'off', [activeDiscovered]), + ).toBe(false); + }); + it('returns true when preferred id is set before the node list loads', () => { expect(hasEffectiveReticulumPropagationTarget([], 'remote-1', 'auto')).toBe(true); }); @@ -69,21 +120,32 @@ describe('hasEffectiveReticulumPropagationTarget', () => { }); describe('hasReticulumPnCascadeCapacity', () => { + const localEnabled: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'active', + preferred: false, + }; + it('is true for preferred remote or enabled local-prop', () => { - const localEnabled: PropagationNodeRow = { - id: 'local-prop', - name: 'Local', - enabled: true, - status: 'active', - preferred: false, - }; - expect(hasReticulumPnCascadeCapacity([remoteNode], 'remote-1', 'off')).toBe(true); - expect(hasReticulumPnCascadeCapacity([localEnabled], 'local-prop', 'off')).toBe(true); + expect(hasReticulumPnCascadeCapacity([remoteNode], 'remote-1', 'manual')).toBe(true); + expect(hasReticulumPnCascadeCapacity([localEnabled], 'local-prop', 'manual')).toBe(true); expect(hasEnabledLocalPropagation([localEnabled])).toBe(true); }); + it('is false in off mode even with a preferred remote or enabled local-prop', () => { + expect(hasReticulumPnCascadeCapacity([remoteNode], 'remote-1', 'off')).toBe(false); + expect(hasReticulumPnCascadeCapacity([localEnabled], 'local-prop', 'off')).toBe(false); + }); + it('is false when nothing is available', () => { - expect(hasReticulumPnCascadeCapacity([], null, 'off')).toBe(false); + expect(hasReticulumPnCascadeCapacity([], null, 'auto')).toBe(false); + }); + + // Sidecar still has somewhere to deposit, so the link-timeout bridge must not fail rows. + it('is true in auto with only a discovered node', () => { + expect(hasReticulumPnCascadeCapacity([], null, 'auto', [activeDiscovered])).toBe(true); }); it('is false when local-prop is present but disabled', () => { @@ -94,6 +156,6 @@ describe('hasReticulumPnCascadeCapacity', () => { status: 'inactive', preferred: false, }; - expect(hasReticulumPnCascadeCapacity([localDisabled], null, 'off')).toBe(false); + expect(hasReticulumPnCascadeCapacity([localDisabled], null, 'auto')).toBe(false); }); }); diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts index e3eaef440..3b2398255 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts @@ -1,34 +1,38 @@ import { - pickAutoPropagationNodeId, + findPropagationNodeByIdOrHash, + hasEnabledLocalPropagationNode, + pickAutoPropagationTarget, readReticulumPropagationMode, type ReticulumPropagationMode, } from '@/renderer/lib/reticulum/reticulumPropagationMode'; -import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import type { + DiscoveredPropagationRow, + PropagationNodeRow, +} from '@/renderer/stores/reticulumPropagationStore'; function isRemotePropagationId(id: string | null | undefined): id is string { return Boolean(id && id !== 'local-prop'); } -function findPropagationNode( - nodes: PropagationNodeRow[], - id: string, -): PropagationNodeRow | undefined { - return nodes.find((n) => n.id === id || n.destination_hash === id); -} - /** * True when a remote (non-local-prop) propagation node can carry offline LXMF. * - * Preferred sidecar outbound node wins over App sync mode — Mode "Off" only - * disables periodic sync, not the presence of an outbound propagation target. + * Mode "Off" means no propagation support at all: a saved Preferred node stays on the + * sidecar but is never used, so there is no effective target. + * Auto without Preferred also counts **discovered** nodes, because the sidecar cascades + * onto the best heard PN without adding it (`auto_discovered_candidates` in `pn_cascade.rs`). + * Manual only counts nodes the user added. */ export function hasEffectiveReticulumPropagationTarget( nodes: PropagationNodeRow[], preferredId: string | null, mode: ReticulumPropagationMode = readReticulumPropagationMode(), + discovered: readonly DiscoveredPropagationRow[] = [], ): boolean { + if (mode === 'off') return false; + if (isRemotePropagationId(preferredId)) { - const preferred = findPropagationNode(nodes, preferredId); + const preferred = findPropagationNodeByIdOrHash(nodes, preferredId); // Prefer sidecar preferred_id even while the node list is still loading. if (!preferred) return true; return preferred.enabled; @@ -38,28 +42,30 @@ export function hasEffectiveReticulumPropagationTarget( return true; } - // Auto / manual without preferred: any enabled remote counts for offline fallback - // capacity. Mode "off" skips inventing a target when none is preferred. - if (mode === 'off') return false; - if (mode === 'manual') return false; + // Manual without Preferred picks a configured remote for the send/sync it needs. + if (mode === 'manual') { + return pickAutoPropagationTarget(nodes, [])?.kind === 'configured'; + } - return pickAutoPropagationNodeId(nodes) != null; + const target = pickAutoPropagationTarget(nodes, discovered); + return target?.kind === 'configured' || target?.kind === 'discovered'; } /** True when local-prop is enabled (cascade last resort / offline inbox). */ -export function hasEnabledLocalPropagation(nodes: PropagationNodeRow[]): boolean { - return nodes.some((n) => n.id === 'local-prop' && n.enabled); -} +export const hasEnabledLocalPropagation = hasEnabledLocalPropagationNode; /** * True when Direct→PN cascade can still run (remote preferred/auto OR local-prop). - * Link-timeout failure bridge must skip while this is true. + * Link-timeout failure bridge must skip while this is true. Mode "Off" has no cascade, + * so a Direct timeout is terminal. */ export function hasReticulumPnCascadeCapacity( nodes: PropagationNodeRow[], preferredId: string | null, mode: ReticulumPropagationMode = readReticulumPropagationMode(), + discovered: readonly DiscoveredPropagationRow[] = [], ): boolean { - if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode)) return true; + if (mode === 'off') return false; + if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode, discovered)) return true; return hasEnabledLocalPropagation(nodes); } diff --git a/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts b/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts index b4ae12796..5ab3e642f 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import type { + DiscoveredPropagationRow, + PropagationNodeRow, +} from '@/renderer/stores/reticulumPropagationStore'; import { + hasPropagationCascadeCandidate, + isLocalPropagationLoading, pickAutoPropagationNodeId, + pickAutoPropagationTarget, + readReticulumPropagationMode, resolvePropagationSyncTargetId, + resolveReticulumPropagationTargetLabel, + RETICULUM_PROPAGATION_MODE_KEY, } from './reticulumPropagationMode'; function row( @@ -17,7 +26,94 @@ function row( }; } +function discovered( + partial: Partial & Pick, +): DiscoveredPropagationRow { + return { + node_state: true, + peering_cost: 0, + ...partial, + }; +} + describe('reticulumPropagationMode', () => { + // renderer-logic runs in node (no jsdom); provide a minimal localStorage stub. + beforeEach(() => { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + clear: () => { + store.clear(); + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports no cascade candidate for a fresh stack with a loading local inbox', () => { + const loadingLocal = [ + row({ id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }), + ]; + expect(hasPropagationCascadeCandidate('auto', loadingLocal, [])).toBe(false); + expect(hasPropagationCascadeCandidate('manual', loadingLocal, [])).toBe(false); + + // Announce lands → Auto has a discovered target; Manual still has nothing. + const found = [discovered({ destination_hash: 'ab'.repeat(16), hops: 1 })]; + expect(hasPropagationCascadeCandidate('auto', loadingLocal, found)).toBe(true); + expect(hasPropagationCascadeCandidate('manual', loadingLocal, found)).toBe(false); + + // Local inbox finishes loading → both modes can settle locally. + const servingLocal = [row({ id: 'local-prop', name: 'Local', status: 'active' })]; + expect(hasPropagationCascadeCandidate('auto', servingLocal, [])).toBe(true); + expect(hasPropagationCascadeCandidate('manual', servingLocal, [])).toBe(true); + expect(hasPropagationCascadeCandidate('off', servingLocal, found)).toBe(false); + }); + + it('excludes an enabled loading local inbox from cascade candidates and local fallback', () => { + const loadingEnabled = [ + row({ id: 'local-prop', name: 'Local', enabled: true, status: 'loading' }), + ]; + expect(hasPropagationCascadeCandidate('auto', loadingEnabled, [])).toBe(false); + expect(hasPropagationCascadeCandidate('manual', loadingEnabled, [])).toBe(false); + expect(resolvePropagationSyncTargetId('auto', loadingEnabled, null)).toBeNull(); + expect(resolvePropagationSyncTargetId('manual', loadingEnabled, null)).toBeNull(); + expect(pickAutoPropagationTarget(loadingEnabled, [])).toBeNull(); + }); + + it('detects the local inbox still loading its messagestore', () => { + expect( + isLocalPropagationLoading([row({ id: 'local-prop', name: 'Local', enabled: false })]), + ).toBe(false); + expect( + isLocalPropagationLoading([ + row({ id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }), + ]), + ).toBe(true); + expect( + isLocalPropagationLoading([row({ id: 'pn-a', name: 'Remote', status: 'loading' })]), + ).toBe(false); + }); + + it('defaults to off when nothing is persisted', () => { + localStorage.removeItem(RETICULUM_PROPAGATION_MODE_KEY); + expect(readReticulumPropagationMode()).toBe('off'); + }); + + it('honors a persisted mode', () => { + localStorage.setItem(RETICULUM_PROPAGATION_MODE_KEY, 'auto'); + expect(readReticulumPropagationMode()).toBe('auto'); + localStorage.setItem(RETICULUM_PROPAGATION_MODE_KEY, 'manual'); + expect(readReticulumPropagationMode()).toBe('manual'); + }); + it('picks lowest-hop enabled node excluding local-prop', () => { const nodes = [ row({ id: 'local-prop', name: 'Local', hops: 0 }), @@ -37,4 +133,141 @@ describe('reticulumPropagationMode', () => { expect(resolvePropagationSyncTargetId('manual', nodes, 'pn-aaaa')).toBe('pn-aaaa'); expect(resolvePropagationSyncTargetId('auto', nodes, null)).toBe('pn-aaaa'); }); + + it('Manual without Preferred picks the closest added remote', () => { + const nodes = [ + row({ id: 'local-prop', name: 'Local', hops: 0 }), + row({ id: 'pn-far', name: 'Far', hops: 4 }), + row({ id: 'pn-near', name: 'Near', hops: 1 }), + ]; + expect(resolvePropagationSyncTargetId('manual', nodes, null)).toBe('pn-near'); + }); + + it('Manual ignores discovered nodes and falls back to local when no remotes are added', () => { + const nodes = [row({ id: 'local-prop', name: 'Local', hops: 0 })]; + const rows = [discovered({ destination_hash: 'dead'.repeat(8), hops: 1 })]; + expect(resolvePropagationSyncTargetId('manual', nodes, null, rows)).toBe('local-prop'); + }); + + it('Manual has no sync target when nothing is added and local is disabled', () => { + const nodes = [row({ id: 'local-prop', name: 'Local', hops: 0, enabled: false })]; + expect(resolvePropagationSyncTargetId('manual', nodes, null)).toBeNull(); + }); + + it('Auto sync target prefers discovered destination hash over local-only', () => { + const hash = 'dead'.repeat(8); + const nodes = [row({ id: 'local-prop', name: 'Local', hops: 0 })]; + const rows = [discovered({ destination_hash: hash, hops: 1 })]; + expect(pickAutoPropagationTarget(nodes, rows)?.kind).toBe('discovered'); + expect(resolvePropagationSyncTargetId('auto', nodes, null, rows)).toBe(hash); + }); + + it('pickAutoPropagationTarget prefers discovered over configured (Add-closest ranking)', () => { + const hash = 'aabb'.repeat(8); + const nodes = [row({ id: 'pn-aabb', name: 'Configured', hops: 1, destination_hash: hash })]; + const rows = [discovered({ destination_hash: 'dead'.repeat(8), hops: 2 })]; + expect(pickAutoPropagationTarget(nodes, rows)).toEqual({ + kind: 'discovered', + destinationHash: 'dead'.repeat(8), + }); + }); + + describe('pickAutoPropagationTarget', () => { + it('picks the lowest-hop configured remote', () => { + const nodes = [ + row({ id: 'local-prop', name: 'Local', hops: 0 }), + row({ id: 'pn-aaaa', name: 'Far', hops: 4 }), + row({ id: 'pn-bbbb', name: 'Near', hops: 1 }), + ]; + expect(pickAutoPropagationTarget(nodes)).toEqual({ kind: 'configured', id: 'pn-bbbb' }); + }); + + it('prefers a closer discovered node over a worse configured remote', () => { + const nodes = [row({ id: 'pn-aaaa', name: 'Far', hops: 4 })]; + const rows = [discovered({ destination_hash: 'dead'.repeat(8), hops: 1 })]; + expect(pickAutoPropagationTarget(nodes, rows)).toEqual({ + kind: 'discovered', + destinationHash: 'dead'.repeat(8), + }); + }); + + it('ignores discovered rows already configured or inactive', () => { + const hash = 'aabb'.repeat(8); + const nodes = [row({ id: 'pn-aabb', name: 'Configured', hops: 2, destination_hash: hash })]; + const rows = [ + discovered({ destination_hash: hash, hops: 1 }), + discovered({ destination_hash: 'ccdd'.repeat(8), hops: 0, node_state: false }), + ]; + expect(pickAutoPropagationTarget(nodes, rows)).toEqual({ + kind: 'configured', + id: 'pn-aabb', + }); + }); + + it('prefers a remote over enabled local', () => { + const nodes = [ + row({ id: 'local-prop', name: 'Local', hops: 0 }), + row({ id: 'pn-aaaa', name: 'Near', hops: 2 }), + ]; + expect(pickAutoPropagationTarget(nodes)).toEqual({ kind: 'configured', id: 'pn-aaaa' }); + }); + + it('falls back to local when only enabled local is available', () => { + const nodes = [row({ id: 'local-prop', name: 'Local', hops: 0 })]; + expect(pickAutoPropagationTarget(nodes)).toEqual({ kind: 'local' }); + }); + + it('returns null when nothing is enabled', () => { + const nodes = [ + row({ id: 'local-prop', name: 'Local', hops: 0, enabled: false }), + row({ id: 'pn-aaaa', name: 'Near', hops: 1, enabled: false }), + ]; + expect(pickAutoPropagationTarget(nodes)).toBeNull(); + }); + }); + + describe('resolveReticulumPropagationTargetLabel', () => { + const hash = 'aabb'.repeat(8); + const nodes = [ + row({ id: 'local-prop', name: 'Local propagation node' }), + row({ id: 'pn-aabb', name: 'Hub PN', destination_hash: hash }), + ]; + + it('uses the translated local name for the local inbox', () => { + expect(resolveReticulumPropagationTargetLabel(nodes, [], 'local-prop', 'Host node')).toBe( + 'Host node', + ); + }); + + it('names a configured node by row id or destination hash', () => { + expect(resolveReticulumPropagationTargetLabel(nodes, [], 'pn-aabb', 'Host node')).toBe( + 'Hub PN', + ); + expect( + resolveReticulumPropagationTargetLabel(nodes, [], hash.toUpperCase(), 'Host node'), + ).toBe('Hub PN'); + }); + + it('names a discovered node from its announce, else a hash prefix', () => { + const named = discovered({ destination_hash: 'ccdd'.repeat(8), display_name: ' Ratspeak ' }); + const anonymous = discovered({ destination_hash: 'eeff'.repeat(8) }); + expect( + resolveReticulumPropagationTargetLabel([], [named, anonymous], named.destination_hash, 'L'), + ).toBe('Ratspeak'); + expect( + resolveReticulumPropagationTargetLabel( + [], + [named, anonymous], + anonymous.destination_hash, + 'L', + ), + ).toBe('eeffeeffeeff'); + }); + + it('falls back to a hash prefix for an unknown target', () => { + expect(resolveReticulumPropagationTargetLabel(nodes, [], '99'.repeat(16), 'Host node')).toBe( + '999999999999', + ); + }); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumPropagationMode.ts b/src/renderer/lib/reticulum/reticulumPropagationMode.ts index 764a84d34..e0491b6e9 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationMode.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationMode.ts @@ -1,20 +1,35 @@ -import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import type { + DiscoveredPropagationRow, + PropagationNodeRow, +} from '@/renderer/stores/reticulumPropagationStore'; export const RETICULUM_PROPAGATION_MODE_KEY = 'mesh-client:reticulumPropagationMode'; export type ReticulumPropagationMode = 'auto' | 'manual' | 'off'; +const PROPAGATION_MODES = new Set(['auto', 'manual', 'off']); + +export function isReticulumPropagationMode(value: unknown): value is ReticulumPropagationMode { + return typeof value === 'string' && PROPAGATION_MODES.has(value as ReticulumPropagationMode); +} + +/** + * Default mode is **off** (MeshChatX parity): no automatic Preferred changes and no + * periodic sync until the user opts into Auto/Manual. Persisted values are honored + * (including legacy `auto` saved when App-panel default was Auto). + */ export function readReticulumPropagationMode(): ReticulumPropagationMode { try { const raw = localStorage.getItem(RETICULUM_PROPAGATION_MODE_KEY); - if (raw === 'auto' || raw === 'manual' || raw === 'off') return raw; + if (isReticulumPropagationMode(raw)) return raw; } catch { // catch-no-log-ok localStorage unavailable in private mode } - return 'auto'; + return 'off'; } export function writeReticulumPropagationMode(mode: ReticulumPropagationMode): void { + if (!isReticulumPropagationMode(mode)) return; try { localStorage.setItem(RETICULUM_PROPAGATION_MODE_KEY, mode); } catch { @@ -22,25 +37,239 @@ export function writeReticulumPropagationMode(mode: ReticulumPropagationMode): v } } -/** Pick the enabled remote propagation node with the lowest hop count (excludes local-prop). */ +/** Destination hashes already present as configured propagation rows. */ +export function configuredPropagationDestinationHashes( + nodes: PropagationNodeRow[], +): ReadonlySet { + return new Set( + nodes + .map((n) => n.destination_hash?.toLowerCase()) + .filter((h): h is string => typeof h === 'string' && h.length > 0), + ); +} + +/** + * Ranking helper for UI (e.g. “Add closest”), diagnostics, and Auto sync order. + * + * Ordering: **best active discovered** (lowest hops) → else **best enabled configured + * remote** → else enabled `local-prop` → else `null`. + * + * Auto one-time-syncs a discovered hash without adding it or writing Preferred. + */ +export type AutoPropagationTarget = + | { kind: 'configured'; id: string } + | { kind: 'discovered'; destinationHash: string } + | { kind: 'local' }; + +interface RankedRemote { + hops: number; + sortKey: string; +} + +function sortByHopsThenKey(a: T, b: T): number { + if (a.hops !== b.hops) return a.hops - b.hops; + return a.sortKey.localeCompare(b.sortKey); +} + +/** Active discovered remotes not already configured, best (lowest hops) first. */ +export function listDiscoveredPropagationTargets( + nodes: PropagationNodeRow[], + discovered: readonly DiscoveredPropagationRow[], +): { destinationHash: string; hops: number }[] { + const configuredHashes = configuredPropagationDestinationHashes(nodes); + const rows: { destinationHash: string; hops: number; sortKey: string }[] = []; + for (const row of discovered) { + if (!row.node_state) continue; + const hash = row.destination_hash.toLowerCase(); + if (configuredHashes.has(hash)) continue; + rows.push({ + destinationHash: row.destination_hash, + hops: row.hops ?? Number.POSITIVE_INFINITY, + sortKey: row.display_name?.trim() || row.destination_hash, + }); + } + rows.sort(sortByHopsThenKey); + return rows.map(({ destinationHash, hops }) => ({ destinationHash, hops })); +} + +/** Enabled configured remotes (excludes local-prop), best (lowest hops) first. */ +export function listConfiguredRemotePropagationIds(nodes: PropagationNodeRow[]): string[] { + const rows: { id: string; hops: number; sortKey: string }[] = []; + for (const node of nodes) { + if (node.id === 'local-prop' || !node.enabled) continue; + rows.push({ + id: node.id, + hops: node.hops ?? Number.POSITIVE_INFINITY, + sortKey: node.name, + }); + } + rows.sort(sortByHopsThenKey); + return rows.map((r) => r.id); +} + +export function hasEnabledLocalPropagationNode(nodes: PropagationNodeRow[]): boolean { + return nodes.some((n) => n.id === 'local-prop' && n.enabled); +} + +/** 32-hex LXMF destination hash (configured row id or bare Auto one-time target). */ +export const RETICULUM_PROPAGATION_DESTINATION_HASH_RE = /^[0-9a-fA-F]{32}$/; + +/** Find a configured row by id or destination hash (case-insensitive). */ +export function findPropagationNodeByIdOrHash( + nodes: PropagationNodeRow[], + id: string, +): PropagationNodeRow | undefined { + const key = id.toLowerCase(); + return nodes.find((n) => n.id === id || n.destination_hash?.toLowerCase() === key); +} + +/** + * Destination hash for a sync target id (or the id itself when it is already a hash); + * empty string when the row has no known hash. + */ +export function propagationTargetDestinationHash(nodes: PropagationNodeRow[], id: string): string { + if (RETICULUM_PROPAGATION_DESTINATION_HASH_RE.test(id)) return id.toLowerCase(); + return nodes.find((n) => n.id === id)?.destination_hash?.toLowerCase() ?? ''; +} + +/** + * Manual cascade seed: explicit per-row target, else Preferred, else best configured remote. + * Does not fall back to local-prop (callers settle local separately). + */ +export function resolveManualCascadeSeed( + firstTargetId: string | null | undefined, + preferredId: string | null, + nodes: PropagationNodeRow[], +): string | null { + if (firstTargetId != null && firstTargetId.length > 0) return firstTargetId; + if (preferredId != null && preferredId.length > 0) return preferredId; + return listConfiguredRemotePropagationIds(nodes).at(0) ?? null; +} + +/** + * True when the local inbox is enabled but the sidecar is still reading its messagestore + * (`status: 'loading'`). Serving — and therefore sync — is deferred until that finishes. + */ +export function isLocalPropagationLoading(nodes: PropagationNodeRow[]): boolean { + return nodes.some((n) => n.id === 'local-prop' && n.status === 'loading'); +} + +/** Enabled local inbox that is ready to sync (messagestore finished loading). */ +function hasReadyEnabledLocalPropagationNode(nodes: PropagationNodeRow[]): boolean { + return hasEnabledLocalPropagationNode(nodes) && !isLocalPropagationLoading(nodes); +} + +/** + * True when this mode has anything to sync with: Auto may use a discovered node, both + * Auto and Manual may use an added remote or the ready local inbox. Off never syncs. + * A loading local node alone is not a cascade candidate. + */ +export function hasPropagationCascadeCandidate( + mode: ReticulumPropagationMode, + nodes: PropagationNodeRow[], + discovered: readonly DiscoveredPropagationRow[] = [], +): boolean { + if (mode === 'off') return false; + return ( + (mode === 'auto' && listDiscoveredPropagationTargets(nodes, discovered).length > 0) || + listConfiguredRemotePropagationIds(nodes).length > 0 || + hasReadyEnabledLocalPropagationNode(nodes) + ); +} + +export function pickAutoPropagationTarget( + nodes: PropagationNodeRow[], + discovered: readonly DiscoveredPropagationRow[] = [], +): AutoPropagationTarget | null { + const discoveredBest = listDiscoveredPropagationTargets(nodes, discovered).at(0); + if (discoveredBest != null) { + return { kind: 'discovered', destinationHash: discoveredBest.destinationHash }; + } + + const configuredBest = listConfiguredRemotePropagationIds(nodes).at(0); + if (configuredBest != null) { + return { kind: 'configured', id: configuredBest }; + } + + if (hasReadyEnabledLocalPropagationNode(nodes)) { + return { kind: 'local' }; + } + return null; +} + +/** + * Lowest-hop enabled configured remote (excludes local-prop and discovered). + * Thin wrapper over {@link pickAutoPropagationTarget} with an empty discovery list. + */ export function pickAutoPropagationNodeId(nodes: PropagationNodeRow[]): string | null { - const candidates = nodes.filter((n) => n.id !== 'local-prop' && n.enabled); - if (candidates.length === 0) return null; - const sorted = [...candidates].sort((a, b) => { - const ha = a.hops ?? Number.POSITIVE_INFINITY; - const hb = b.hops ?? Number.POSITIVE_INFINITY; - if (ha !== hb) return ha - hb; - return a.name.localeCompare(b.name); - }); - return sorted[0]?.id ?? null; + const target = pickAutoPropagationTarget(nodes, []); + return target?.kind === 'configured' ? target.id : null; } +/** + * Sync target hint for UI enablement. + * + * Auto: best discovered destination hash (one-time sync), else best configured remote, + * else local-prop. Manual uses Preferred (including `local-prop`), else picks the best + * configured remote for this sync only (no Preferred write), else local-prop. Off → null. + */ export function resolvePropagationSyncTargetId( mode: ReticulumPropagationMode, nodes: PropagationNodeRow[], preferredId: string | null, + discovered: readonly DiscoveredPropagationRow[] = [], ): string | null { if (mode === 'off') return null; - if (mode === 'auto') return pickAutoPropagationNodeId(nodes); - return preferredId; + if (mode === 'manual') { + if (preferredId != null && preferredId.length > 0) return preferredId; + const configuredBest = listConfiguredRemotePropagationIds(nodes).at(0); + if (configuredBest != null) return configuredBest; + return hasReadyEnabledLocalPropagationNode(nodes) ? 'local-prop' : null; + } + const discoveredBest = listDiscoveredPropagationTargets(nodes, discovered).at(0); + if (discoveredBest != null) { + return discoveredBest.destinationHash.toLowerCase(); + } + const configured = listConfiguredRemotePropagationIds(nodes).at(0); + if (configured != null) return configured; + if (hasReadyEnabledLocalPropagationNode(nodes)) return 'local-prop'; + return null; +} + +/** Hash prefix shown when a sync target has no name yet (matches the discovered list). */ +const PROPAGATION_HASH_LABEL_CHARS = 12; + +/** + * Display name for a sync target id — a configured row id, `local-prop`, or the bare + * destination hash Auto uses for a one-time discovered sync. + * + * Discovered nodes are never in `nodes`, so fall back to the announce name and finally a + * hash prefix. `localLabel` is passed in so this stays pure (callers translate). + */ +export function resolveReticulumPropagationTargetLabel( + nodes: PropagationNodeRow[], + discovered: readonly DiscoveredPropagationRow[], + id: string, + localLabel: string, +): string { + if (id === 'local-prop') return localLabel; + const key = id.toLowerCase(); + const node = nodes.find((n) => n.id === id || n.destination_hash?.toLowerCase() === key); + if (node) return node.id === 'local-prop' ? localLabel : node.name; + const row = discovered.find((d) => d.destination_hash.toLowerCase() === key); + const announced = row?.display_name?.trim(); + if (announced) return announced; + return id.slice(0, PROPAGATION_HASH_LABEL_CHARS); +} + +/** Compact diagnostic label for an Auto target (kind:id). */ +export function formatAutoPropagationTargetLabel( + target: AutoPropagationTarget | null, +): string | null { + if (target == null) return null; + if (target.kind === 'configured') return `configured:${target.id}`; + if (target.kind === 'discovered') { + return `discovered:${target.destinationHash.slice(0, 12)}`; + } + return 'local'; } diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts index fd3962e05..742307cbc 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts @@ -4,9 +4,11 @@ import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropaga import { applyPropagationSyncEvent, + awaitPropagationSyncSettled, clearPropagationSyncStallWatchdog, mapPropagationSyncError, normalizePropagationSyncProgress, + PROPAGATION_SYNC_SUPERSEDED, schedulePropagationSyncStallWatchdog, } from './reticulumPropagationSync'; @@ -120,9 +122,55 @@ describe('reticulumPropagationSync', () => { expect(mapPropagationSyncError('PROPAGATION_SYNC_OUTBOUND_BUSY')).toBe( 'reticulumPropagation.syncOutboundBusy', ); + expect(mapPropagationSyncError('propagation sync cancelled')).toBe( + 'reticulumPropagation.syncCancelled', + ); + expect(mapPropagationSyncError(PROPAGATION_SYNC_SUPERSEDED)).toBeNull(); expect(mapPropagationSyncError('other')).toBe('reticulumPropagation.syncFailed'); }); + it('supersede clears active sync without unreachable error', () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 10, message: null }, + lastSyncError: null, + }); + applyPropagationSyncEvent({ + active: false, + progress: 0, + message: PROPAGATION_SYNC_SUPERSEDED, + }); + expect(useReticulumPropagationStore.getState().sync.active).toBe(false); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe(PROPAGATION_SYNC_SUPERSEDED); + }); + + it('maps cancel message to syncCancelled not syncFailed', () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 10, message: null }, + }); + applyPropagationSyncEvent({ + active: false, + progress: 0, + message: 'propagation sync cancelled', + }); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + 'reticulumPropagation.syncCancelled', + ); + }); + + it('ignores late cancel after local settle already idle', () => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + lastPropagationSyncAt: Date.now(), + }); + applyPropagationSyncEvent({ + active: false, + progress: 0, + message: 'propagation sync cancelled', + }); + expect(useReticulumPropagationStore.getState().lastSyncError).toBeNull(); + }); + it('maps WS failure message when sync ends with zero progress', () => { useReticulumPropagationStore.setState({ sync: { active: true, progress: 10, message: null }, @@ -187,4 +235,100 @@ describe('reticulumPropagationSync', () => { 'reticulumPropagation.syncTimedOut', ); }); + + describe('awaitPropagationSyncSettled', () => { + it('resolves immediately when the attempt already settled', async () => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + }); + await expect(awaitPropagationSyncSettled()).resolves.toBe('success'); + + useReticulumPropagationStore.setState({ + lastSyncError: 'reticulumPropagation.syncFailed', + }); + await expect(awaitPropagationSyncSettled()).resolves.toBe('failed'); + }); + + it('waits for the websocket terminal frame before reporting failure', async () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + }); + + const settled = awaitPropagationSyncSettled(); + let resolvedEarly = false; + void settled.then(() => { + resolvedEarly = true; + }); + await Promise.resolve(); + expect(resolvedEarly).toBe(false); + + applyPropagationSyncEvent({ + active: false, + progress: 0, + message: 'propagation establish failed: NoLinkProof', + }); + + await expect(settled).resolves.toBe('failed'); + }); + + it('reports a user cancel separately so a cascade can stop', async () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + }); + + const settled = awaitPropagationSyncSettled(); + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: 'reticulumPropagation.syncCancelled', + }); + + await expect(settled).resolves.toBe('cancelled'); + }); + + it('does not resolve supersede as success', async () => { + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + }); + + const settled = awaitPropagationSyncSettled(); + applyPropagationSyncEvent({ + active: false, + progress: 0, + message: PROPAGATION_SYNC_SUPERSEDED, + }); + + await expect(settled).resolves.toBe('cancelled'); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + PROPAGATION_SYNC_SUPERSEDED, + ); + }); + + it('cancels and reports failure when no terminal frame ever arrives', async () => { + vi.useFakeTimers(); + const cancelSync = vi.fn(() => { + useReticulumPropagationStore.setState({ + sync: { active: false, progress: 0, message: null }, + lastSyncError: 'reticulumPropagation.syncTimedOut', + }); + return Promise.resolve(true); + }); + useReticulumPropagationStore.setState({ + sync: { active: true, progress: 5, message: null }, + lastSyncError: null, + cancelSync, + }); + + const settled = awaitPropagationSyncSettled({ timeoutMs: 1_000 }); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(settled).resolves.toBe('failed'); + expect(cancelSync).toHaveBeenCalledWith({ + reasonKey: 'reticulumPropagation.syncTimedOut', + }); + }); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.ts index 8c98c867f..1898d1715 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.ts @@ -3,7 +3,7 @@ import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropaga /** Keep refresh affordance visible long enough to perceive (~10ms API otherwise). */ export const RETICULUM_PROPAGATION_REFRESH_MIN_VISIBLE_MS = 500; -/** Cancel sync when stuck establishing connection to an unreachable node. */ +/** Cancel sync when stuck in the Establishing progress band past this window. */ export const RETICULUM_PROPAGATION_SYNC_STALL_MS = 45_000; /** How long a failed sync keeps the Diagnostics failing row visible. */ @@ -41,7 +41,19 @@ export function isPropagationSyncEstablishingStuck( const SYNC_FAILED_KEY = 'reticulumPropagation.syncFailed'; const SYNC_TIMED_OUT_KEY = 'reticulumPropagation.syncTimedOut'; +const SYNC_CANCELLED_KEY = 'reticulumPropagation.syncCancelled'; const SYNC_LOCAL_UNSUPPORTED_KEY = 'reticulumPropagation.syncLocalNotSupported'; + +/** Sidecar cancel when replacing/deleting a PN — not a user-visible failure. */ +export const PROPAGATION_SYNC_SUPERSEDED = 'PROPAGATION_SYNC_SUPERSEDED'; + +export function isPropagationSyncSupersedeMessage(message: string | null | undefined): boolean { + return message === PROPAGATION_SYNC_SUPERSEDED; +} + +export function isPropagationSyncCancelledMessage(message: string | null | undefined): boolean { + return typeof message === 'string' && /propagation sync cancelled/i.test(message); +} const SYNC_IDENTITY_UNKNOWN_KEY = 'reticulumPropagation.syncIdentityUnknown'; const SYNC_TARGET_NOT_PN_KEY = 'reticulumPropagation.syncTargetNotPropagationNode'; const SYNC_PEERAGE_STAMP_FAILED_KEY = 'reticulumPropagation.syncPeeringStampFailed'; @@ -116,9 +128,14 @@ function mapPropagationSyncErrorBySubstring(error: string): string | null { return null; } -/** Map sidecar/API sync error codes or WS failure messages to i18n keys. */ -export function mapPropagationSyncError(error: string | null | undefined): string { +/** + * Map sidecar/API sync error codes or WS failure messages to i18n keys. + * Returns `null` for quiet supersede (delete/replace) — caller must not show unreachable. + */ +export function mapPropagationSyncError(error: string | null | undefined): string | null { + if (isPropagationSyncSupersedeMessage(error)) return null; if (!error) return SYNC_FAILED_KEY; + if (isPropagationSyncCancelledMessage(error)) return SYNC_CANCELLED_KEY; if (error === 'LOCAL_PROPAGATION_SYNC_UNSUPPORTED') return SYNC_LOCAL_UNSUPPORTED_KEY; const byPrefix = mapPropagationSyncErrorByPrefix(error); if (byPrefix) return byPrefix; @@ -170,6 +187,82 @@ export function schedulePropagationSyncStallWatchdog(): void { }, RETICULUM_PROPAGATION_SYNC_CEILING_MS); } +/** + * Outcome of a single propagation sync attempt once it stops being in flight. + * `cancelled` is the user pressing Cancel — a cascade must stop rather than advance. + */ +export type PropagationAttemptOutcome = 'success' | 'failed' | 'cancelled' | 'deferred'; + +/** + * Backstop for {@link awaitPropagationSyncSettled}. The stall/ceiling watchdogs settle a + * real attempt well before this; it only covers a dropped websocket stream. + */ +export const RETICULUM_PROPAGATION_SYNC_SETTLE_TIMEOUT_MS = + RETICULUM_PROPAGATION_SYNC_CEILING_MS + 15_000; + +function classifySettledPropagationSync(lastSyncError: string | null): PropagationAttemptOutcome { + if (lastSyncError == null) return 'success'; + // Supersede keeps a quiet marker (not null) so settle does not look like success. + if (lastSyncError === SYNC_CANCELLED_KEY || isPropagationSyncSupersedeMessage(lastSyncError)) { + return 'cancelled'; + } + return 'failed'; +} + +/** + * Resolve once the in-flight sync attempt goes idle. + * + * `startSync` only reports that the sidecar *accepted* the request; the real outcome arrives + * later on the `propagation_sync` websocket stream or from the stall/ceiling watchdogs. A + * cascade must wait for that before deciding whether to try the next node. + */ +export async function awaitPropagationSyncSettled(opts?: { + timeoutMs?: number; +}): Promise { + const store = useReticulumPropagationStore; + const initial = store.getState(); + // local-prop settles inside startSync, and a fast failure may already have landed. + if (!initial.sync.active) return classifySettledPropagationSync(initial.lastSyncError); + + const timeoutMs = opts?.timeoutMs ?? RETICULUM_PROPAGATION_SYNC_SETTLE_TIMEOUT_MS; + return new Promise((resolve) => { + let settled = false; + let unsubscribe: (() => void) | null = null; + let timer: ReturnType | null = null; + + const finish = (outcome: PropagationAttemptOutcome) => { + if (settled) return; + settled = true; + if (timer != null) clearTimeout(timer); + unsubscribe?.(); + resolve(outcome); + }; + + timer = setTimeout(() => { + timer = null; + // Sidecar never reported a terminal frame — release the sync so the cascade continues. + // Await cancel so lastSyncError is stamped before the cascade reads outcome/UI state. + void store + .getState() + .cancelSync({ reasonKey: SYNC_TIMED_OUT_KEY }) + .finally(() => { + finish('failed'); + }); + }, timeoutMs); + + unsubscribe = store.subscribe((state) => { + if (state.sync.active) return; + finish(classifySettledPropagationSync(state.lastSyncError)); + }); + + // The terminal frame can land between the initial read and the subscription. + const current = store.getState(); + if (!current.sync.active) { + finish(classifySettledPropagationSync(current.lastSyncError)); + } + }); +} + /** Sidecar uses 0–1 for in-progress states and 0–100 for complete. */ export function normalizePropagationSyncProgress(raw: number): number { if (!Number.isFinite(raw) || raw < 0) return 0; @@ -178,7 +271,9 @@ export function normalizePropagationSyncProgress(raw: number): number { } export function propagationSyncStatusLabel(progress: number): string { - if (progress < 15) return 'reticulumPropagation.syncStatusEstablishing'; + if (progress < RETICULUM_PROPAGATION_SYNC_ESTABLISHING_MAX_PROGRESS) { + return 'reticulumPropagation.syncStatusEstablishing'; + } if (progress < 50) return 'reticulumPropagation.syncStatusNegotiating'; return 'reticulumPropagation.syncStatusTransferring'; } @@ -189,13 +284,28 @@ export function applyPropagationSyncEvent(payload: { message?: string | null; }): void { const normalizedProgress = normalizePropagationSyncProgress(payload.progress ?? 0); - const wasActive = useReticulumPropagationStore.getState().sync.active; + const state = useReticulumPropagationStore.getState(); + const wasActive = state.sync.active; + const quietSupersede = isPropagationSyncSupersedeMessage(payload.message); + const cancelMessage = isPropagationSyncCancelledMessage(payload.message); + + // Late cancel/supersede after we already settled (e.g. local-prop) must not re-fail UI. + if ( + payload.active === false && + normalizedProgress === 0 && + !wasActive && + (quietSupersede || cancelMessage) + ) { + return; + } if (payload.active === false && normalizedProgress === 0 && wasActive) { clearPropagationSyncStallWatchdog(); + const mapped = mapPropagationSyncError(payload.message); useReticulumPropagationStore.setState({ sync: { ...RETICULUM_PROPAGATION_SYNC_IDLE }, - lastSyncError: mapPropagationSyncError(payload.message), + // Keep the supersede marker (quiet for UI) so settle classifies non-success. + lastSyncError: quietSupersede ? PROPAGATION_SYNC_SUPERSEDED : mapped, activePropagationSyncAttemptAt: null, }); return; @@ -203,9 +313,9 @@ export function applyPropagationSyncEvent(payload: { if (payload.active === false && normalizedProgress >= 100) { clearPropagationSyncStallWatchdog(); - const state = useReticulumPropagationStore.getState(); - const hadError = state.lastSyncError; - const forAttemptAt = state.activePropagationSyncAttemptAt; + const current = useReticulumPropagationStore.getState(); + const hadError = current.lastSyncError; + const forAttemptAt = current.activePropagationSyncAttemptAt; // Ignore late "complete" frames after user cancel / failure already cleared active. if (!wasActive && hadError) { return; diff --git a/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.test.ts new file mode 100644 index 000000000..774494722 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearReticulumPropagationSyncFailure, + hasRecentReticulumPropagationSyncFailure, + noteReticulumPropagationSyncFailure, + omitRecentlyFailedPropagationTargets, + resetReticulumPropagationSyncFailures, + RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS, + RETICULUM_PROPAGATION_SYNC_FAILURES_LAZY_CLEANUP_THRESHOLD, +} from './reticulumPropagationSyncBackoff'; + +describe('reticulumPropagationSyncBackoff', () => { + beforeEach(() => { + resetReticulumPropagationSyncFailures(); + }); + + it('omits recently failed targets so the cascade can fall through to local', () => { + noteReticulumPropagationSyncFailure('catz', 1_000); + + expect( + omitRecentlyFailedPropagationTargets(['catz', 'near', 'far'], (id) => id, 2_000), + ).toEqual(['near', 'far']); + }); + + it('returns an empty list when every candidate failed recently', () => { + noteReticulumPropagationSyncFailure('a', 1_000); + noteReticulumPropagationSyncFailure('b', 1_000); + + expect(omitRecentlyFailedPropagationTargets(['a', 'b'], (id) => id, 2_000)).toEqual([]); + }); + + it('restores a target once the backoff window elapses', () => { + noteReticulumPropagationSyncFailure('catz', 1_000); + const after = 1_000 + RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS; + + expect(hasRecentReticulumPropagationSyncFailure('catz', after)).toBe(false); + expect(omitRecentlyFailedPropagationTargets(['catz', 'near'], (id) => id, after)).toEqual([ + 'catz', + 'near', + ]); + }); + + it('matches target ids case-insensitively so destination hashes line up', () => { + noteReticulumPropagationSyncFailure('AABB1111', 1_000); + + expect(hasRecentReticulumPropagationSyncFailure('aabb1111', 2_000)).toBe(true); + }); + + it('forgets a target after a success clears it', () => { + noteReticulumPropagationSyncFailure('catz', 1_000); + clearReticulumPropagationSyncFailure('catz'); + + expect(hasRecentReticulumPropagationSyncFailure('catz', 2_000)).toBe(false); + }); + + it('lazily sweeps expired failures when the map reaches the size threshold', () => { + const expiredAt = 1_000; + const now = + expiredAt + + RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS + + RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS; + for (let i = 0; i < RETICULUM_PROPAGATION_SYNC_FAILURES_LAZY_CLEANUP_THRESHOLD - 1; i++) { + noteReticulumPropagationSyncFailure(`expired-${i}`, expiredAt); + } + // Crossing the threshold while recording a fresh failure sweeps the expired set. + noteReticulumPropagationSyncFailure('fresh', now); + + expect(hasRecentReticulumPropagationSyncFailure('expired-0', now)).toBe(false); + expect(hasRecentReticulumPropagationSyncFailure('fresh', now)).toBe(true); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.ts b/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.ts new file mode 100644 index 000000000..336bb6c59 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPropagationSyncBackoff.ts @@ -0,0 +1,66 @@ +import { MS_PER_MINUTE } from '@/shared/timeConstants'; + +/** + * How long a failed sync target stays omitted from the cascade. A dead node that still + * announces the lowest hop count would otherwise be retried on every auto-sync tick, + * burning minutes before the cascade can settle on the local inbox. + */ +export const RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS = 15 * MS_PER_MINUTE; + +/** Sync target id (row id, `local-prop`, or destination hash) to last failure time. */ +const failures = new Map(); + +/** Lazy-sweep expired entries only when the map grows this large (not on every note). */ +export const RETICULUM_PROPAGATION_SYNC_FAILURES_LAZY_CLEANUP_THRESHOLD = 64; + +function backoffKey(id: string): string { + return id.toLowerCase(); +} + +function sweepExpiredPropagationSyncFailures(nowMs: number): void { + for (const [key, at] of failures) { + if (nowMs - at >= RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS) { + failures.delete(key); + } + } +} + +export function noteReticulumPropagationSyncFailure(id: string, atMs = Date.now()): void { + if (id.length === 0) return; + failures.set(backoffKey(id), atMs); + if (failures.size >= RETICULUM_PROPAGATION_SYNC_FAILURES_LAZY_CLEANUP_THRESHOLD) { + sweepExpiredPropagationSyncFailures(atMs); + } +} + +export function clearReticulumPropagationSyncFailure(id: string): void { + failures.delete(backoffKey(id)); +} + +/** Test seam — session memory only, nothing is persisted. */ +export function resetReticulumPropagationSyncFailures(): void { + failures.clear(); +} + +export function hasRecentReticulumPropagationSyncFailure(id: string, nowMs = Date.now()): boolean { + const at = failures.get(backoffKey(id)); + if (at == null) return false; + if (nowMs - at >= RETICULUM_PROPAGATION_SYNC_FAILURE_BACKOFF_MS) { + failures.delete(backoffKey(id)); + return false; + } + return true; +} + +/** + * Drop targets that failed within the backoff window. When every discovered PN just failed, + * the next cascade must skip them and fall through to configured remotes / local-prop instead + * of retrying the same dead set for another full establish timeout each. + */ +export function omitRecentlyFailedPropagationTargets( + items: readonly T[], + keyOf: (item: T) => string, + nowMs = Date.now(), +): T[] { + return items.filter((item) => !hasRecentReticulumPropagationSyncFailure(keyOf(item), nowMs)); +} diff --git a/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.test.ts b/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.test.ts index be489bc48..97e00c50a 100644 --- a/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.test.ts +++ b/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.test.ts @@ -43,15 +43,80 @@ describe('shouldRunPropagationAutoSync', () => { ).toBe(false); }); - it('returns false when preferredId is local-prop', () => { + it('returns false when mode is off even with a remote preferred', () => { expect( shouldRunPropagationAutoSync({ autoSyncIntervalSec: 3600, - preferredId: 'local-prop', + preferredId: 'pn-test', + syncActive: false, + lastPropagationSyncAt: null, + lastPropagationSyncAttemptAt: null, + nowMs: 4_000_000, + mode: 'off', + }), + ).toBe(false); + }); + + it('syncs a remote preferred in auto and manual modes', () => { + for (const mode of ['auto', 'manual'] as const) { + expect( + shouldRunPropagationAutoSync({ + autoSyncIntervalSec: 3600, + preferredId: 'pn-test', + syncActive: false, + lastPropagationSyncAt: null, + lastPropagationSyncAttemptAt: null, + nowMs: 4_000_000, + mode, + }), + ).toBe(true); + } + }); + + it('allows local-prop Preferred in auto and manual (final settle / only-local)', () => { + for (const mode of ['auto', 'manual'] as const) { + expect( + shouldRunPropagationAutoSync({ + autoSyncIntervalSec: 3600, + preferredId: 'local-prop', + syncActive: false, + lastPropagationSyncAt: null, + lastPropagationSyncAttemptAt: null, + nowMs: 4_000_000, + mode, + }), + ).toBe(true); + } + }); + + it('allows Auto and Manual with null Preferred when cascade candidates exist', () => { + for (const mode of ['auto', 'manual'] as const) { + expect( + shouldRunPropagationAutoSync({ + autoSyncIntervalSec: 3600, + preferredId: null, + syncActive: false, + lastPropagationSyncAt: null, + lastPropagationSyncAttemptAt: null, + nowMs: 4_000_000, + mode, + hasCascadeCandidate: true, + }), + ).toBe(true); + } + }); + + it('returns false in Manual with null Preferred and no cascade candidate', () => { + expect( + shouldRunPropagationAutoSync({ + autoSyncIntervalSec: 3600, + preferredId: null, syncActive: false, lastPropagationSyncAt: null, lastPropagationSyncAttemptAt: null, nowMs: 4_000_000, + mode: 'manual', + hasCascadeCandidate: false, }), ).toBe(false); }); diff --git a/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.ts b/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.ts index d761f2e4e..ded70b90d 100644 --- a/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.ts +++ b/src/renderer/lib/reticulum/useReticulumPropagationAutoSync.ts @@ -1,5 +1,11 @@ import { useEffect } from 'react'; +import { startPropagationSyncCascade } from '@/renderer/lib/reticulum/reticulumPropagationAutoApply'; +import { + hasPropagationCascadeCandidate, + readReticulumPropagationMode, + type ReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; import { MS_PER_SECOND } from '@/shared/timeConstants'; @@ -8,11 +14,16 @@ export const PROPAGATION_AUTO_SYNC_FAILURE_COOLDOWN_MS = 120_000; export function shouldRunPropagationAutoSync(args: { autoSyncIntervalSec: number; + /** Preferred or resolved sync target; may be `local-prop` for Manual/Auto final settle. */ preferredId: string | null; syncActive: boolean; lastPropagationSyncAt: number | null; lastPropagationSyncAttemptAt: number | null; nowMs: number; + /** Propagation mode; `off` never runs periodic sync. */ + mode?: ReticulumPropagationMode; + /** Auto/Manual may run with null Preferred when a cascade target exists for that mode. */ + hasCascadeCandidate?: boolean; }): boolean { const { autoSyncIntervalSec, @@ -21,11 +32,14 @@ export function shouldRunPropagationAutoSync(args: { lastPropagationSyncAt, lastPropagationSyncAttemptAt, nowMs, + mode, + hasCascadeCandidate, } = args; - // Local inbox is served in-process; auto-sync must target a remote PN only. - if (!preferredId || preferredId === 'local-prop' || autoSyncIntervalSec <= 0 || syncActive) { - return false; - } + // Mode "off" disables all periodic sync (no automatic PN retrieval). + if (mode === 'off') return false; + if (autoSyncIntervalSec <= 0 || syncActive) return false; + // Manual without Preferred picks a configured remote (or local) for this sync only. + if (!preferredId && !hasCascadeCandidate) return false; // Interval is measured from last *success*. Never-succeeded sessions fall back to last // attempt so the first failure still honors the configured interval once. @@ -50,23 +64,51 @@ export function shouldRunPropagationAutoSync(args: { const AUTO_SYNC_CHECK_MS = 30 * MS_PER_SECOND; -/** Periodically sync the preferred propagation node when auto-sync is enabled. */ +/** + * Periodically sync discovered/configured remotes (Auto) or Preferred/picked remotes + * (Manual) when the interval is enabled. Neither mode adds discovered nodes or rewrites + * Preferred; Off runs no periodic sync. + */ export function useReticulumPropagationAutoSync(sidecarReady: boolean): void { useEffect(() => { if (!sidecarReady) return; // Keep preferred/nodes fresh for Chat notice + auto-sync even if Network tab was never opened. - void useReticulumPropagationStore.getState().refreshFromSidecar(); + void useReticulumPropagationStore + .getState() + .refreshFromSidecar() + .catch((err: unknown) => { + console.warn('[useReticulumPropagationAutoSync] refreshFromSidecar rejected', err); + }); + // Re-push the mode so a restarted sidecar gates its outbound PN cascade the same way. + void useReticulumPropagationStore + .getState() + .setModeOnSidecar(readReticulumPropagationMode()) + .catch((err: unknown) => { + console.warn('[useReticulumPropagationAutoSync] setModeOnSidecar rejected', err); + }); + + const cascadeCandidate = (mode: ReticulumPropagationMode): boolean => { + const { nodes, discovered } = useReticulumPropagationStore.getState(); + return hasPropagationCascadeCandidate(mode, nodes, discovered); + }; + + const tick = async () => { + const mode = readReticulumPropagationMode(); + // Nothing to sync with yet (fresh stack: no announces, local messagestore still + // loading). Re-read the sidecar so Auto/Manual recover on a later tick. + if (mode !== 'off' && !cascadeCandidate(mode)) { + await useReticulumPropagationStore.getState().refreshFromSidecar(); + } - const tick = () => { const { autoSyncIntervalSec, preferredId, sync, lastPropagationSyncAt, lastPropagationSyncAttemptAt, - startSync, } = useReticulumPropagationStore.getState(); + if ( !shouldRunPropagationAutoSync({ autoSyncIntervalSec, @@ -75,14 +117,23 @@ export function useReticulumPropagationAutoSync(sidecarReady: boolean): void { lastPropagationSyncAt, lastPropagationSyncAttemptAt, nowMs: Date.now(), + mode, + hasCascadeCandidate: cascadeCandidate(mode), }) ) { return; } - void startSync(preferredId!); + await startPropagationSyncCascade(); + }; + + const runTick = () => { + void tick().catch((err: unknown) => { + console.warn('[useReticulumPropagationAutoSync] tick rejected', err); + }); }; + runTick(); - const id = window.setInterval(tick, AUTO_SYNC_CHECK_MS); + const id = window.setInterval(runTick, AUTO_SYNC_CHECK_MS); return () => { window.clearInterval(id); }; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 1fa45156e..3b23518d0 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Vymazat přímé zprávy", "clearReticulumMessagesConfirm": "Tím trvale odstraníte všechny {{count}} lokálně uložené přímé zprávy Reticulum. Tuto akci nelze vrátit zpět.", "clearReticulumMessagesConfirmButton": "Vymazat {{count}} zprávy", - "reticulumSection": "Reticulum — Zásobník retikula", - "reticulumAnnounceHelp": "Jak často je vaše totožnost oznámena v síti a nástroje pro vymazání uložených oznámení.", "logPanelHelp": "Je-li povoleno, na pravé straně se zobrazí živý záznam protokolu. Na kartě Reticulum řádky zařízení zahrnují výstup postranního vozíku a stav místního rozhraní. Řádky ladění vyžadují zaškrtávací políčko na panelu protokolu.", "autoPruneUnheardContactsDaysAria": "Automaticky oříznout neslyšené kontakty při spuštění, starší než {{days}} dnů", "capTotalContactsCountAria": "Omezit celkový počet kontaktů, ponechat naposledy zobrazené {{count}} kontakty", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Zatím nejsou uloženy žádné barvy — pomocí tlačítka Uložit vytvořte kontrolní bod", "saveThemeButton": "Uložit", "restoreThemeButton": "Obnovit", - "reticulumPropagationHelp": "Nakonfigurujte vzdálené uzly šíření LXMF pro offline DM. Místní propagace je pouze schránka tohoto zařízení — nenahrazuje vzdálený uzel.", "use24HourTime": "24hodinový čas", "use24HourTimeDesc": "Vynutit hodiny, jako jsou časová razítka chatu, do 24hodinového formátu. Když je vypnuto, postupuje podle místního nastavení systému.", "themeSaveFailed": "Uložení kontrolního bodu barev se nezdařilo.", @@ -4176,7 +4173,9 @@ "openSettingsAria": "Otevřít nastavení propagace sítě Reticulum", "bodyWithDiscoveries": "Není nakonfigurován žádný propagační uzel. {{count}} objeveno v síti — přidej ho níže nebo otevři Nastavení sítě.", "addClosest": "Přidat nejblíže objevené", - "addClosestAria": "Přidejte nejbližší objevený propagační uzel a nastavte jej jako preferovaný" + "addClosestAria": "Přidejte nejbližší objevený propagační uzel a nastavte jej jako preferovaný", + "dismiss": "Znovu nezobrazovat", + "dismissAria": "Zastavit zobrazování připomenutí propagačního uzlu v chatu" }, "rename": "Přejmenovat", "renameLabel": "Název uzlu šíření", @@ -4198,7 +4197,8 @@ "known": "známý", "pending": "čeká na vyřízení", "unknown": "neznámý", - "online": "online" + "online": "online", + "loading": "načítání..." }, "syncIdentityUnknown": "Identita uzlu propagace je neznámá – počkejte na odpověď na oznámení nebo cestu a zkuste to znovu.", "syncTargetNotPropagationNode": "Tento cíl je dosažitelný na Reticulum, ale není to uzel šíření LXMF (rozbočovače TCP jsou pouze transportní cesty). Chcete-li synchronizovat zprávy offline, přidejte cíl, který se ohlašuje jako lxmf.propagation.", @@ -4245,18 +4245,25 @@ "syncLocalNotSupported": "Místní uzel šíření hostitele nelze synchronizovat přes síť jako vzdálený uzel šíření LXMF.", "enableFailed": "Uzel šíření nelze povolit.", "disableFailed": "Uzel šíření nelze zakázat.", - "syncOutboundBusy": "Synchronizace propagace odložena — do tohoto uzlu se ukládá odchozí zpráva." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Synchronizace propagace odložena — do tohoto uzlu se ukládá odchozí zpráva.", "modeLabel": "Režim propagace", "modeAria": "Režim synchronizace propagace", "modeAuto": "Auto", "modeManual": "Manuální", "modeOff": "Vypnuto", - "sync": "Synchronizace", - "syncAria": "Synchronizovat propagační zprávy", - "cancelSync": "Zrušit", - "cancelSyncAria": "Zrušit synchronizaci propagace" + "modeHelpAuto": "Auto: jednorázově synchronizuje nejlepší objevený šířící uzel (nepřidá ho ani nezmění Preferred), pak nakonfigurované vzdálené a nakonec místní schránku. Bez síťových rozhraní dokončí jen místně.", + "syncStarting": "Spuštění synchronizace", + "syncLocalSettled": "Synchronizováno s místní doručenou poštou.", + "modeHelpOff": "Vypnuto: žádná podpora propagačního uzlu. Nic se nesynchronizuje a offline zprávy nejsou uloženy v žádném propagačním uzlu. Upřednostňovaný uzel zůstane uložený, ale nepoužitý, dokud nevyberete možnost Automaticky nebo Ručně.", + "modeHelpManual": "Ručně: synchronizuje váš preferovaný uzel nebo vybere nejbližší přidaný uzel pro tuto synchronizaci, pokud není preferován žádný. Pokud se to nepodaří, jsou vyzkoušeny další přidané uzly, pak místní schránku.", + "syncNoTarget": "Zatím není k dispozici žádný propagační uzel — žádný nebyl objeven a nemáte žádné přidané uzly. Synchronizace se spustí sama, jakmile je k dispozici.", + "syncLocalLoading": "Místní šířící uzel stále načítá uložené zprávy. Synchronizace se spustí sama, jakmile načítání skončí.", + "syncLocalSettledFor": "Synchronizováno s {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Zobrazit připomenutí propagace v chatu", + "showChatNoticeAria": "Zobrazit banner připomenutí propagačního uzlu v chatu", + "showChatNoticeHint": "Vypnutím tohoto tlačítka skryjete banner chatu, který se zobrazí, když není k dispozici žádný propagační uzel." }, "reticulumRmapDiscovery": { "sectionTitle": "Objevení RMAP v4", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index baed41b89..8243aecf2 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Direktnachrichten löschen", "clearReticulumMessagesConfirm": "Dadurch werden alle {{count}} lokal gespeicherten Reticulum-Direktnachrichten dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "clearReticulumMessagesConfirmButton": "{{count}} Nachrichten löschen", - "reticulumSection": "Reticulum-Stack", - "reticulumAnnounceHelp": "Wie oft Ihre Identität im Netzwerk bekannt gegeben wird, und Tools zum Löschen gespeicherter Ankündigungen.", "logPanelHelp": "Wenn diese Option aktiviert ist, wird rechts ein Live-Protokollstream angezeigt. Auf der Registerkarte „Reticulum“ umfassen die Gerätezeilen die Sidecar-Ausgabe und den Zustand der lokalen Schnittstelle. Für Debugzeilen ist das Kontrollkästchen im Protokollbereich erforderlich.", "autoPruneUnheardContactsDaysAria": "Ungehörte Kontakte, die älter als {{days}} Tage sind, werden beim Start automatisch bereinigt", "capTotalContactsCountAria": "Begrenzen Sie die Gesamtzahl der Kontakte und behalten Sie die zuletzt gesehenen {{count}}-Kontakte bei", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Noch keine gespeicherten Farben — verwenden Sie Speichern, um einen Kontrollpunkt zu erstellen", "saveThemeButton": "Speichern", "restoreThemeButton": "Wiederherstellen", - "reticulumPropagationHelp": "Konfigurieren Sie Remote-LXMF-Verbreitungsknoten für Offline-DMS. Die lokale Ausbreitung ist nur der Posteingang dieses Geräts — sie ersetzt keinen Remote-Knoten.", "use24HourTime": "24-Stunden-Zeitformat", "use24HourTimeDesc": "Erzwinge Uhren wie Chat-Zeitstempel im 24-Stunden-Format. Wenn diese Option deaktiviert ist, folgt sie Ihrem Systemgebietsschema.", "themeSaveFailed": "Farbprüfpunkt konnte nicht gespeichert werden.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Es ist kein Ausbreitungsknoten konfiguriert. {{count}} im Netzwerk entdeckt — füge unten eine hinzu oder öffne die Netzwerkeinstellungen.", "addClosest": "Am nächsten entdeckte hinzufügen", - "addClosestAria": "Fügen Sie den nächstgelegenen entdeckten Ausbreitungsknoten hinzu und legen Sie ihn als bevorzugt" + "addClosestAria": "Fügen Sie den nächstgelegenen entdeckten Ausbreitungsknoten hinzu und legen Sie ihn als bevorzugt", + "dismiss": "Nicht erneut anzeigen", + "dismissAria": "Die Erinnerung an den Ausbreitungsknoten im Chat nicht mehr anzeigen" }, "rename": "Umbenennen", "renameLabel": "Name des Ausbreitungsknotens", @@ -4196,7 +4195,8 @@ "known": "bekannt", "pending": "ausstehend", "unknown": "unbekannt", - "online": "online" + "online": "online", + "loading": "Wird geladen..." }, "syncIdentityUnknown": "Die Identität des Ausbreitungsknotens ist unbekannt – warten Sie auf eine Ankündigung oder eine Pfadantwort und versuchen Sie es dann erneut.", "syncTargetNotPropagationNode": "Dieses Ziel ist auf Reticulum erreichbar, aber kein LXMF-Verbreitungsknoten (TCP-Hubs sind nur Transportpfade). Fügen Sie ein Ziel hinzu, das sich als lxmf.propagation ankündigt, um Offline-Nachrichten zu synchronisieren.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Der lokale Host-Verbreitungsknoten kann nicht wie ein entfernter LXMF-Verbreitungsknoten über das Netzwerk synchronisiert werden.", "enableFailed": "Der Ausbreitungsknoten konnte nicht aktiviert werden.", "disableFailed": "Der Ausbreitungsknoten konnte nicht deaktiviert werden.", - "syncOutboundBusy": "Ausbreitungssynchronisierung verschoben — eine ausgehende Nachricht wird auf diesem Knoten hinterlegt." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Ausbreitungssynchronisierung verschoben — eine ausgehende Nachricht wird auf diesem Knoten hinterlegt.", "modeLabel": "Ausbreitungsmodus", "modeAria": "Ausbreitungssynchronisationsmodus", "modeAuto": "Automatisch", - "modeManual": "Hand", + "modeManual": "Manuell", "modeOff": "Aus", - "sync": "Sync", - "syncAria": "Ausbreitungsnachrichten synchronisieren", - "cancelSync": "Abbrechen", - "cancelSyncAria": "Ausbreitungssynchronisation abbrechen" + "modeHelpAuto": "Auto: synchronisiert einmalig den besten erkannten Ausbreitungsknoten (fügt ihn nicht hinzu und ändert Preferred nicht), dann konfigurierte Remotes, dann den lokalen Posteingang. Ohne Netzwerkschnittstellen wird nur lokal abgeschlossen.", + "syncStarting": "Synchronisierung wird gestartet", + "syncLocalSettled": "Synchronisiert mit dem lokalen Posteingang.", + "modeHelpOff": "Aus: keine Unterstützung für Ausbreitungsknoten. Nichts synchronisiert und Offline-Nachrichten werden auf keinem Ausbreitungsknoten abgelegt. Ein bevorzugter Knoten bleibt gespeichert, aber nicht verwendet, bis Sie Auto oder Manuell wählen.", + "modeHelpManual": "Manuell: Synchronisiert Ihren bevorzugten Knoten oder wählt den nächstgelegenen hinzugefügten Knoten für diese Synchronisierung aus, wenn keiner bevorzugt wird. Wenn dies fehlschlägt, werden die anderen hinzugefügten Knoten ausprobiert, dann der lokale Posteingang.", + "syncNoTarget": "Es ist noch kein Ausbreitungsknoten verfügbar — keiner wurde entdeckt und Sie haben keine zusätzlichen Knoten hinzugefügt. Sync läuft von selbst, sobald eines verfügbar ist.", + "syncLocalLoading": "Der lokale Ausbreitungsknoten lädt noch seine gespeicherten Nachrichten. Die Synchronisierung startet von selbst, sobald das Laden abgeschlossen ist.", + "syncLocalSettledFor": "Synchronisiert mit {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Ausbreitungserinnerung im Chat anzeigen", + "showChatNoticeAria": "Verbreitungsknoten-Erinnerungsbanner im Chat anzeigen", + "showChatNoticeHint": "Deaktivieren Sie diese Option, um das Chat-Banner auszublenden, das angezeigt wird, wenn kein Ausbreitungsknoten verfügbar ist." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 Entdeckung", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 4eeb8f377..810ce8c13 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -107,9 +107,6 @@ "title": "App Settings", "logPanelSection": "Log panel", "floodAdvertSection": "Flood Advert", - "reticulumSection": "Reticulum stack", - "reticulumAnnounceHelp": "How often your identity is announced on the network (default 3600 s / 1 h when unset; 0 = startup-only), and tools to clear stored announces.", - "reticulumPropagationHelp": "Configure remote LXMF propagation nodes for offline DMs. Local propagation is this device’s inbox only — it does not replace a remote node.", "floodAdvertScheduleLabel": "Automatically send a flood advert on a schedule:", "floodAdvertEvery12h": "Every 12 hours", "floodAdvertEvery24h": "Every 24 hours", @@ -4492,13 +4489,20 @@ "addClosest": "Add closest discovered", "addClosestAria": "Add the closest discovered propagation node and set it as preferred", "openSettings": "Set up propagation", - "openSettingsAria": "Open Reticulum Network propagation settings" + "openSettingsAria": "Open Reticulum Network propagation settings", + "dismiss": "Don't show again", + "dismissAria": "Stop showing the propagation node reminder in Chat" }, "preferred": "Preferred", "setPreferred": "Set preferred", "setPreferredFailed": "Could not set preferred propagation node.", "syncNow": "Sync messages", "syncNowFor": "Sync messages from {{name}}", + "syncStarting": "Starting sync…", + "syncLocalSettled": "Synced with local inbox.", + "syncLocalSettledFor": "Synced with {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", "cancelSync": "Cancel sync", "autoSyncIntervalLabel": "Auto sync interval", "autoSyncIntervalAria": "Propagation auto sync interval", @@ -4532,6 +4536,7 @@ "active": "active", "idle": "idle", "known": "known", + "loading": "loading…", "pending": "pending", "unknown": "unknown", "online": "online" @@ -4555,6 +4560,8 @@ "syncStatusNegotiating": "Negotiating sync with propagation node…", "syncStatusTransferring": "Transferring messages from propagation node…", "syncFailed": "Propagation sync failed — the node may be unreachable.", + "syncNoTarget": "No propagation node is available yet — none has been discovered, and you have no added nodes. Sync runs on its own as soon as one is available.", + "syncLocalLoading": "The local propagation node is still loading its stored messages. Sync runs on its own once it finishes.", "syncOutboundBusy": "Propagation sync deferred — an outbound message is depositing to this node.", "syncTimedOut": "Propagation sync timed out — the node may be unreachable.", "syncLocalNotSupported": "The local host propagation node cannot be synced over the network like a remote LXMF propagation node.", @@ -4587,18 +4594,18 @@ "discoveredAddAria": "Add discovered propagation node {{name}}", "discoveredAddPrefer": "Add & prefer", "discoveredAddPreferAria": "Add discovered propagation node {{name}} and set as preferred", - "discoveredHash": "{{hash}}" - }, - "reticulumPropagationHeader": { + "discoveredHash": "{{hash}}", "modeLabel": "Propagation mode", "modeAria": "Propagation sync mode", "modeAuto": "Auto", "modeManual": "Manual", "modeOff": "Off", - "sync": "Sync", - "syncAria": "Sync propagation messages", - "cancelSync": "Cancel", - "cancelSyncAria": "Cancel propagation sync" + "modeHelpOff": "Off: no propagation node support. Nothing syncs and offline messages are not deposited on any propagation node. A Preferred node stays saved but unused until you choose Auto or Manual.", + "modeHelpAuto": "Auto: one-time syncs the best Discovered propagation node (does not add it or change Preferred), then configured remotes, then the local inbox. With no network interfaces, settles local only.", + "modeHelpManual": "Manual: syncs your Preferred node, or picks the closest added node for that sync when none is preferred. If it fails, the other added nodes are tried, then the local inbox.", + "showChatNotice": "Show propagation reminder in Chat", + "showChatNoticeAria": "Show the propagation node reminder banner in Chat", + "showChatNoticeHint": "Turn this off to hide the Chat banner that appears when no propagation node is available." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 discovery", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index d7286c9ca..dfe90d8b5 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Perfeccionar mensajes claros y directos", "clearReticulumMessagesConfirm": "Esto eliminará de forma permanente todos los {{count}} mensajes directos de Reticulum almacenados localmente. Esto no se puede deshacer.", "clearReticulumMessagesConfirmButton": "Borrar {{count}} mensajes", - "reticulumSection": "Reticulum — Pila de Reticulum", - "reticulumAnnounceHelp": "Con qué frecuencia se anuncia su identidad en la red y herramientas para borrar los anuncios almacenados.", "logPanelHelp": "Cuando está habilitado, aparece una transmisión de registro en vivo a la derecha. En la pestaña Reticulum, las líneas de dispositivos incluyen la salida del sidecar y el estado de la interfaz local. Las líneas de depuración requieren la casilla de verificación dentro del panel de registro.", "autoPruneUnheardContactsDaysAria": "Eliminación automática de contactos no escuchados al inicio, con más de {{days}} días", "capTotalContactsCountAria": "Limitar el total de contactos, mantener los contactos {{count}} vistos más recientemente", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Aún no hay colores guardados: use Guardar para crear un punto de control", "saveThemeButton": "Guardar", "restoreThemeButton": "Restaurar", - "reticulumPropagationHelp": "Configurar nodos de propagación LXMF remotos para DM sin conexión. La propagación local es solo la bandeja de entrada de este dispositivo: no reemplaza a un nodo remoto.", "use24HourTime": "Modo 24 horas", "use24HourTimeDesc": "Forza los relojes, como las marcas de tiempo del chat, al formato de 24 horas. Cuando esté apagado, siga la configuración regional de su sistema.", "themeSaveFailed": "Error al guardar el punto de verificación de color.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "No hay ningún nodo de propagación configurado. {{count}} descubierta en la red: añade una a continuación o abre Configuración de red.", "addClosest": "Añade el más cercano descubierto", - "addClosestAria": "Añada el nodo de propagación descubierto más cercano y configúrelo como preferido" + "addClosestAria": "Añada el nodo de propagación descubierto más cercano y configúrelo como preferido", + "dismiss": "No volver a mostrar", + "dismissAria": "Dejar de mostrar el recordatorio del nodo de propagación en el chat" }, "rename": "Renombrar", "renameLabel": "Nombre del nodo de propagación", @@ -4196,7 +4195,8 @@ "known": "conocido", "pending": "pendiente", "unknown": "desconocido", - "online": "en línea" + "online": "en línea", + "loading": "cargando" }, "syncIdentityUnknown": "Se desconoce la identidad del nodo de propagación: espere un anuncio o una respuesta de ruta y vuelva a intentarlo.", "syncTargetNotPropagationNode": "Se puede llegar a ese destino en Reticulum pero no es un nodo de propagación LXMF (los centros TCP son solo rutas de transporte). Agregue un destino que se anuncie como lxmf.propagation para sincronizar mensajes sin conexión.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "El nodo de propagación del host local no se puede sincronizar a través de la red como un nodo de propagación LXMF remoto.", "enableFailed": "No se pudo habilitar el nodo de propagación.", "disableFailed": "No se pudo deshabilitar el nodo de propagación.", - "syncOutboundBusy": "Sincronización de propagación diferida: un mensaje saliente se está depositando en este nodo." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Sincronización de propagación diferida: un mensaje saliente se está depositando en este nodo.", "modeLabel": "modo de propagación", "modeAria": "Modo de sincronización de propagación", "modeAuto": "Automático", "modeManual": "Manual", "modeOff": "Apagado", - "sync": "Sincronizar", - "syncAria": "Sincronizar mensajes de propagación", - "cancelSync": "Cancelar", - "cancelSyncAria": "Cancelar sincronización de propagación" + "modeHelpAuto": "Automático: sincroniza una sola vez el mejor nodo de propagación descubierto (no lo añade ni cambia Preferred), luego los remotos configurados y después la bandeja local. Sin interfaces de red, solo completa en local.", + "syncStarting": "Iniciando sincronización", + "syncLocalSettled": "Sincronizado con la bandeja de entrada local.", + "modeHelpOff": "Apagado: sin soporte de nodo de propagación. Nada se sincroniza y los mensajes sin conexión no se depositan en ningún nodo de propagación. Un nodo preferido permanece guardado pero sin usar hasta que elija Auto o Manual.", + "modeHelpManual": "Manual: sincroniza su nodo preferido o elige el nodo añadido más cercano para esa sincronización cuando no se prefiere ninguno. Si falla, se prueban los otros nodos añadidos, luego la bandeja de entrada local.", + "syncNoTarget": "Todavía no hay ningún nodo de propagación disponible; no se ha descubierto ninguno y no tiene nodos añadidos. Sync se ejecuta por sí solo tan pronto como uno está disponible.", + "syncLocalLoading": "El nodo de propagación local aún está cargando sus mensajes almacenados. La sincronización se ejecuta sola cuando termine la carga.", + "syncLocalSettledFor": "Sincronizado con {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Mostrar recordatorio de propagación en el chat", + "showChatNoticeAria": "Mostrar el banner de recordatorio del nodo de propagación en el chat", + "showChatNoticeHint": "Desactive esta opción para ocultar el banner de chat que aparece cuando no hay ningún nodo de propagación disponible." }, "reticulumRmapDiscovery": { "sectionTitle": "Descubrimiento de RMAP v4", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index d5a25d114..5168ce51f 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Effacer les messages directs", "clearReticulumMessagesConfirm": "Cela supprimera définitivement tous les messages directs Reticulum {{count}} stockés localement. Cela ne peut pas être annulé.", "clearReticulumMessagesConfirmButton": "Effacer les messages {{count}}", - "reticulumSection": "Reticulum — Empilement de réticulum", - "reticulumAnnounceHelp": "La fréquence à laquelle votre identité est annoncée sur le réseau et les outils pour effacer les annonces stockées.", "logPanelHelp": "Lorsqu'il est activé, un flux de journaux en direct apparaît sur la droite. Dans l’onglet Reticulum, les lignes de périphériques incluent la sortie side-car et l’état de l’interface locale. Les lignes de débogage nécessitent la case à cocher dans le panneau de journal.", "autoPruneUnheardContactsDaysAria": "Élague automatiquement les contacts non entendus au démarrage, datant de plus de {{days}} jours", "capTotalContactsCountAria": "Limiter le nombre total de contacts, conserver les contacts {{count}} les plus récemment vus", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Pas encore de couleurs enregistrées — utilisez Enregistrer pour créer un point de contrôle", "saveThemeButton": "Sauvegarder", "restoreThemeButton": "Restaurer", - "reticulumPropagationHelp": "Configurez les nœuds de propagation LXMF distants pour les DM hors ligne. La propagation locale est la boîte de réception de cet appareil uniquement — elle ne remplace pas un nœud distant.", "use24HourTime": "Heure (format 24 heures)", "use24HourTimeDesc": "Forcer les horloges comme les horodatages de chat au format 24 heures. Lorsqu'il est désactivé, suit les paramètres régionaux de votre système.", "themeSaveFailed": "Échec de l'enregistrement du point de contrôle de couleur.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Aucun nœud de propagation n'est configuré. {{count}} découvert sur le réseau — ajoutez-en un ci-dessous ou ouvrez les paramètres réseau.", "addClosest": "Ajouter le plus proche découvert", - "addClosestAria": "Ajoutez le nœud de propagation découvert le plus proche et définissez-le comme préféré" + "addClosestAria": "Ajoutez le nœud de propagation découvert le plus proche et définissez-le comme préféré", + "dismiss": "Ne plus afficher", + "dismissAria": "Arrêter d'afficher le rappel du nœud de propagation dans le chat" }, "rename": "Renommer", "renameLabel": "Nom du nœud de propagation", @@ -4196,7 +4195,8 @@ "known": "connu", "pending": "en attente", "unknown": "inconnu", - "online": "en ligne" + "online": "en ligne", + "loading": "chargement…" }, "syncIdentityUnknown": "L'identité du nœud de propagation est inconnue : attendez une annonce ou une réponse de chemin, puis réessayez.", "syncTargetNotPropagationNode": "Cette destination est accessible sur Reticulum mais n'est pas un nœud de propagation LXMF (les hubs TCP sont uniquement des chemins de transport). Ajoutez une destination qui s'annonce comme lxmf.propagation pour synchroniser les messages hors ligne.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Le nœud de propagation de l'hôte local ne peut pas être synchronisé sur le réseau comme un nœud de propagation LXMF distant.", "enableFailed": "Impossible d'activer le nœud de propagation.", "disableFailed": "Impossible de désactiver le nœud de propagation.", - "syncOutboundBusy": "Synchronisation de la propagation différée — un message sortant se dépose sur ce nœud." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Synchronisation de la propagation différée — un message sortant se dépose sur ce nœud.", "modeLabel": "mode de propagation", "modeAria": "Mode de synchronisation de la propagation", "modeAuto": "Auto", "modeManual": "Manuel", "modeOff": "Arrêt", - "sync": "Synchronisation", - "syncAria": "Synchroniser les messages de propagation", - "cancelSync": "Annuler", - "cancelSyncAria": "Annuler la synchronisation de la propagation" + "modeHelpAuto": "Auto : synchronise une seule fois le meilleur nœud de propagation découvert (ne l’ajoute pas et ne modifie pas Preferred), puis les nœuds distants configurés, puis la boîte de réception locale. Sans interfaces réseau, ne règle que le local.", + "syncStarting": "Lancer la synchronisation", + "syncLocalSettled": "Synchronisé avec la boîte de réception locale.", + "modeHelpOff": "Désactivé : pas de support de nœud de propagation. Aucune synchronisation et aucun message hors ligne ne sont déposés sur aucun nœud de propagation. Un nœud Préféré reste enregistré mais inutilisé jusqu'à ce que vous choisissiez Auto ou Manuel.", + "modeHelpManual": "Manuel : synchronise votre nœud préféré ou sélectionne le nœud ajouté le plus proche pour cette synchronisation lorsqu'aucun n'est préféré. S'il échoue, les autres nœuds ajoutés sont essayés, puis la boîte de réception locale.", + "syncNoTarget": "Aucun nœud de propagation n'est encore disponible — aucun n'a été découvert et vous n'avez aucun nœud ajouté. La synchronisation s'exécute d'elle-même dès qu'elle est disponible.", + "syncLocalLoading": "Le nœud de propagation local charge encore ses messages stockés. La synchronisation se lance toute seule une fois le chargement terminé.", + "syncLocalSettledFor": "Synchronisé avec {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}} : {{message}}", + "showChatNotice": "Afficher le rappel de propagation dans le chat", + "showChatNoticeAria": "Afficher la bannière de rappel du nœud de propagation dans le chat", + "showChatNoticeHint": "Désactivez cette option pour masquer la bannière de chat qui apparaît lorsqu'aucun nœud de propagation n'est disponible." }, "reticulumRmapDiscovery": { "sectionTitle": "Découverte RMAP v4", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 25c9cc224..ae5cbb462 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Hapus pesan langsung", "clearReticulumMessagesConfirm": "Ini akan menghapus semua {{count}} pesan langsung Reticulum yang disimpan secara lokal secara permanen. Ini tidak dapat diurungkan.", "clearReticulumMessagesConfirmButton": "Hapus pesan {{count}}", - "reticulumSection": "Reticulum — Tumpukan Reticulum", - "reticulumAnnounceHelp": "Seberapa sering identitas Anda diumumkan di jaringan, dan alat untuk menghapus pengumuman yang tersimpan.", "logPanelHelp": "Saat diaktifkan, streaming log langsung muncul di sebelah kanan. Pada tab Reticulum, jalur perangkat menyertakan keluaran sespan dan kesehatan antarmuka lokal. Baris debug memerlukan kotak centang di dalam panel log.", "autoPruneUnheardContactsDaysAria": "Pangkas otomatis kontak yang belum pernah terdengar saat startup, lebih lama dari {{days}} hari", "capTotalContactsCountAria": "Batasi total kontak, pertahankan kontak {{count}} yang terakhir dilihat", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Belum ada warna yang disimpan — gunakan Simpan untuk membuat pos pemeriksaan", "saveThemeButton": "Simpan", "restoreThemeButton": "Pulihkan", - "reticulumPropagationHelp": "Konfigurasikan node propagasi LXMF jarak jauh untuk DM offline. Propagasi lokal adalah kotak masuk perangkat ini saja — tidak menggantikan node jarak jauh.", "use24HourTime": "Gunakan waktu 24 jam", "use24HourTimeDesc": "Paksa jam seperti stempel waktu obrolan ke format 24 jam. Saat mati, ikuti lokal sistem Anda.", "themeSaveFailed": "Gagal menyimpan pos pemeriksaan warna.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Tidak ada simpul propagasi yang dikonfigurasi. {{count}} ditemukan di jaringan — tambahkan satu di bawah ini atau buka pengaturan Jaringan.", "addClosest": "Tambahkan yang terdekat ditemukan", - "addClosestAria": "Tambahkan simpul propagasi terdekat yang ditemukan dan atur sesuai keinginan" + "addClosestAria": "Tambahkan simpul propagasi terdekat yang ditemukan dan atur sesuai keinginan", + "dismiss": "Jangan tampilkan lagi", + "dismissAria": "Berhenti menampilkan pengingat simpul propagasi di Obrolan" }, "rename": "Ganti nama", "renameLabel": "Nama node propagasi", @@ -4196,7 +4195,8 @@ "known": "diketahui", "pending": "tertunda", "unknown": "tidak dikenal", - "online": "online" + "online": "online", + "loading": "sedang memuat" }, "syncIdentityUnknown": "Identitas node propagasi tidak diketahui — tunggu pengumuman atau respons jalur, lalu coba lagi.", "syncTargetNotPropagationNode": "Tujuan tersebut dapat dijangkau di Reticulum tetapi bukan merupakan node propagasi LXMF (hub TCP hanya merupakan jalur transportasi). Tambahkan tujuan yang diumumkan sebagai lxmf.propagation untuk menyinkronkan pesan offline.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Node propagasi host lokal tidak dapat disinkronkan melalui jaringan seperti node propagasi LXMF jarak jauh.", "enableFailed": "Tidak dapat mengaktifkan node propagasi.", "disableFailed": "Tidak dapat menonaktifkan node propagasi.", - "syncOutboundBusy": "Sinkronisasi propagasi ditangguhkan — pesan keluar disetorkan ke simpul ini." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Sinkronisasi propagasi ditangguhkan — pesan keluar disetorkan ke simpul ini.", "modeLabel": "Mode propagasi", "modeAria": "Mode sinkronisasi propagasi", "modeAuto": "Otomatis", - "modeManual": "Panduan", + "modeManual": "Manual", "modeOff": "Tidak aktif", - "sync": "Sinkronkan", - "syncAria": "Sinkronkan pesan propagasi", - "cancelSync": "Batal", - "cancelSyncAria": "Batalkan sinkronisasi propagasi" + "modeHelpAuto": "Otomatis: menyinkronkan sekali node propagasi yang ditemukan terbaik (tidak menambahkannya atau mengubah Preferred), lalu remote yang dikonfigurasi, lalu kotak masuk lokal. Tanpa antarmuka jaringan, hanya menyelesaikan lokal.", + "syncStarting": "Memulai sinkronisasi", + "syncLocalSettled": "Disinkronkan dengan kotak masuk lokal.", + "modeHelpOff": "Mati: tidak ada dukungan simpul propagasi. Tidak ada sinkronisasi dan pesan offline yang tidak disimpan pada simpul propagasi apa pun. Simpul Pilihan tetap disimpan tetapi tidak digunakan sampai Anda memilih Otomatis atau Manual.", + "modeHelpManual": "Manual: menyinkronkan simpul Pilihan Anda, atau memilih simpul tambahan terdekat untuk sinkronisasi itu ketika tidak ada yang disukai. Jika gagal, node tambahan lainnya dicoba, lalu kotak masuk lokal.", + "syncNoTarget": "Belum ada simpul propagasi yang tersedia — tidak ada yang ditemukan, dan Anda tidak memiliki simpul tambahan. Sinkronisasi berjalan sendiri segera setelah tersedia.", + "syncLocalLoading": "Node propagasi lokal masih memuat pesan tersimpannya. Sinkronisasi berjalan sendiri setelah pemuatan selesai.", + "syncLocalSettledFor": "Disinkronkan dengan {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Tampilkan pengingat propagasi di Obrolan", + "showChatNoticeAria": "Tampilkan spanduk pengingat simpul propagasi di Obrolan", + "showChatNoticeHint": "Nonaktifkan ini untuk menyembunyikan spanduk Obrolan yang muncul ketika tidak ada node propagasi yang tersedia." }, "reticulumRmapDiscovery": { "sectionTitle": "Penemuan RMAP v4", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index a0fd332d4..d9eb9d150 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Cancella messaggi diretti", "clearReticulumMessagesConfirm": "In questo modo verranno eliminati definitivamente tutti i messaggi diretti {{count}} memorizzati localmente su Reticulum. Questa operazione non può essere annullata.", "clearReticulumMessagesConfirmButton": "Cancella messaggi {{count}}", - "reticulumSection": "Reticulum — Stack Reticulum", - "reticulumAnnounceHelp": "Con quale frequenza la tua identità viene annunciata sulla rete e gli strumenti per cancellare gli annunci memorizzati.", "logPanelHelp": "Se abilitato, sulla destra viene visualizzato un flusso di registro in tempo reale. Nella scheda Reticulum, le righe del dispositivo includono l'output collaterale e l'integrità dell'interfaccia locale. Le righe di debug richiedono la casella di controllo all'interno del pannello di registro.", "autoPruneUnheardContactsDaysAria": "Elimina automaticamente i contatti non ascoltati all'avvio, più vecchi di {{days}} giorni", "capTotalContactsCountAria": "Limita i contatti totali, conserva i contatti {{count}} visti più di recente", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Ancora nessun colore salvato — usa Salva per creare un punto di controllo", "saveThemeButton": "Salva", "restoreThemeButton": "Ripristina", - "reticulumPropagationHelp": "Configurare nodi di propagazione LXMF remoti per DM offline. La propagazione locale è solo la casella di posta in arrivo di questo dispositivo — non sostituisce un nodo remoto.", "use24HourTime": "Formato 24 ore", "use24HourTimeDesc": "Forza gli orologi come i timestamp della chat al formato di 24 ore. Quando è spento, seguire le impostazioni locali del sistema.", "themeSaveFailed": "Impossibile salvare il checkpoint del colore.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Non è configurato alcun nodo di propagazione. {{count}} rilevato sulla rete: aggiungine uno qui sotto o apri Impostazioni di rete.", "addClosest": "Aggiungi il più vicino scoperto", - "addClosestAria": "Aggiungi il nodo di propagazione scoperto più vicino e impostalo come preferito" + "addClosestAria": "Aggiungi il nodo di propagazione scoperto più vicino e impostalo come preferito", + "dismiss": "Non mostrare più", + "dismissAria": "Interrompi la visualizzazione del promemoria del nodo di propagazione in Chat" }, "rename": "Rinomina", "renameLabel": "Nome del nodo di propagazione", @@ -4196,7 +4195,8 @@ "known": "conosciuto", "pending": "in attesa", "unknown": "sconosciuto", - "online": "in linea" + "online": "in linea", + "loading": "caricamento ..." }, "syncIdentityUnknown": "L'identità del nodo di propagazione è sconosciuta: attendi un annuncio o una risposta sul percorso, quindi riprova.", "syncTargetNotPropagationNode": "Quella destinazione è raggiungibile su Reticulum ma non è un nodo di propagazione LXMF (gli hub TCP sono solo percorsi di trasporto). Aggiungi una destinazione annunciata come lxmf.propagation per sincronizzare i messaggi offline.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Il nodo di propagazione dell'host locale non può essere sincronizzato sulla rete come un nodo di propagazione LXMF remoto.", "enableFailed": "Impossibile abilitare il nodo di propagazione.", "disableFailed": "Impossibile disabilitare il nodo di propagazione.", - "syncOutboundBusy": "Sincronizzazione di propagazione differita — un messaggio in uscita sta venendo depositato su questo nodo." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Sincronizzazione di propagazione differita — un messaggio in uscita sta venendo depositato su questo nodo.", "modeLabel": "Modalità di propagazione", "modeAria": "Modalità di sincronizzazione della propagazione", "modeAuto": "Auto", "modeManual": "Manuale", - "modeOff": "Spento", - "sync": "Sincronizza", - "syncAria": "Sincronizza messaggi di propagazione", - "cancelSync": "Annulla", - "cancelSyncAria": "Annulla sincronizzazione propagazione" + "modeOff": "Disattivato", + "modeHelpAuto": "Auto: sincronizza una sola volta il miglior nodo di propagazione scoperto (non lo aggiunge e non cambia Preferred), poi i remoti configurati, poi la casella locale. Senza interfacce di rete, completa solo in locale.", + "syncStarting": "Avvio sincronizzazione", + "syncLocalSettled": "Sincronizzato con la casella di posta locale.", + "modeHelpOff": "OFF: nessun supporto del nodo di propagazione. Nulla si sincronizza e i messaggi offline non vengono depositati su alcun nodo di propagazione. Un nodo preferito rimane salvato ma inutilizzato fino a quando non si sceglie Auto o Manuale.", + "modeHelpManual": "Manuale: sincronizza il tuo nodo preferito o sceglie il nodo aggiunto più vicino per quella sincronizzazione quando nessuno è preferito. Se fallisce, vengono provati gli altri nodi aggiunti, quindi la casella di posta locale.", + "syncNoTarget": "Nessun nodo di propagazione è ancora disponibile: nessuno è stato rilevato e non sono stati aggiunti nodi. La sincronizzazione viene eseguita da sola non appena ne è disponibile una.", + "syncLocalLoading": "Il nodo di propagazione locale sta ancora caricando i messaggi archiviati. La sincronizzazione parte da sola quando il caricamento termina.", + "syncLocalSettledFor": "Sincronizzato con {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Mostra promemoria di propagazione in chat", + "showChatNoticeAria": "Mostra il banner di promemoria del nodo di propagazione in Chat", + "showChatNoticeHint": "Disattiva questa opzione per nascondere il banner della chat che appare quando non è disponibile alcun nodo di propagazione." }, "reticulumRmapDiscovery": { "sectionTitle": "Scoperta RMAP v4", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 11fac451f..0f24a9f3f 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "ダイレクトメッセージを消去", "clearReticulumMessagesConfirm": "これにより、ローカルに保存されているすべての{{count}} Reticulumダイレクトメッセージが完全に削除されます。これは元に戻せません。", "clearReticulumMessagesConfirmButton": "{{count}}メッセージを消去", - "reticulumSection": "Reticulum — レティキュラム・スタック", - "reticulumAnnounceHelp": "あなたの身元がネットワーク上で発表される頻度と、保存されているアナウンスをクリアするためのツール。", "logPanelHelp": "有効にすると、ライブ ログ ストリームが右側に表示されます。 Reticulum タブのデバイス行には、サイドカー出力とローカル インターフェイスの状態が含まれます。デバッグ行では、ログ パネル内のチェックボックスが必要です。", "autoPruneUnheardContactsDaysAria": "起動時に、{{days}} 日より古い、聞いていない連絡先を自動的に削除します", "capTotalContactsCountAria": "連絡先の総数を制限し、最近確認された {{count}} 連絡先を保持します", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "保存された色はまだありません—保存を使用してチェックポイントを作成します", "saveThemeButton": "保存", "restoreThemeButton": "リストア", - "reticulumPropagationHelp": "オフラインDMのリモートLXMF伝播ノードを設定します。ローカル伝播は、このデバイスの受信トレイのみであり、リモートノードを置き換えるものではありません。", "use24HourTime": "24時間制を使用する", "use24HourTimeDesc": "チャットタイムスタンプなどのクロックを24時間形式に強制します。オフの場合、システムのロケールに従います。", "themeSaveFailed": "カラーチェックポイントの保存に失敗しました。", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Reticulumネットワーク伝播設定を開く", "bodyWithDiscoveries": "伝播ノードが設定されていません。{{count}}がネットワーク上で検出されました—以下に1つ追加するか、ネットワーク設定を開きます。", "addClosest": "見つかった最も近いものを追加", - "addClosestAria": "最も近い検出された伝播ノードを追加し、優先として設定します" + "addClosestAria": "最も近い検出された伝播ノードを追加し、優先として設定します", + "dismiss": "今後表示しない", + "dismissAria": "チャットでの伝播ノードのリマインダーの表示を停止する" }, "rename": "名前の変更", "renameLabel": "伝播ノード名", @@ -4196,7 +4195,8 @@ "known": "知られている", "pending": "保留中", "unknown": "未知", - "online": "オンライン" + "online": "オンライン", + "loading": "読み込み中..." }, "syncIdentityUnknown": "伝播ノードの ID が不明です。アナウンスまたはパス応答を待ってから、再試行してください。", "syncTargetNotPropagationNode": "その宛先は Reticulum 上で到達可能ですが、LXMF 伝播ノードではありません (TCP ハブはトランスポート パスのみです)。オフライン メッセージを同期するには、lxmf.propagation として通知する宛先を追加します。", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "ローカル ホスト伝播ノードは、リモート LXMF 伝播ノードのようにネットワーク経由で同期できません。", "enableFailed": "伝播ノードを有効にできませんでした。", "disableFailed": "伝播ノードを無効にできませんでした。", - "syncOutboundBusy": "伝播同期を延期しました — 送信メッセージをこのノードに預けています。" - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "伝播同期を延期しました — 送信メッセージをこのノードに預けています。", "modeLabel": "伝播モード", "modeAria": "伝播同期モード", "modeAuto": "自動", "modeManual": "手動", - "modeOff": "OFF", - "sync": "同期", - "syncAria": "伝播メッセージを同期", - "cancelSync": "取り消す", - "cancelSyncAria": "伝播の同期をキャンセル" + "modeOff": "オフ", + "modeHelpAuto": "自動:最適な検出済み伝播ノードを一度だけ同期します(追加せず Preferred も変更しません)。次に構成済みリモート、最後にローカル受信箱です。ネットワークインターフェイスがない場合はローカルのみ完了します。", + "syncStarting": "同期を開始しています", + "syncLocalSettled": "ローカルの受信トレイと同期しました。", + "modeHelpOff": "オフ:伝播ノードはサポートされていません。同期およびオフラインメッセージは、どの伝播ノードにもデポジットされません。Preferredノードは保存されたままですが、自動または手動を選択するまで使用されません。", + "modeHelpManual": "手動:優先ノードを同期するか、優先されない場合はその同期に最も近い追加ノードを選択します。失敗した場合、追加された他のノードが試行され、ローカル受信トレイが試行されます。", + "syncNoTarget": "まだ利用可能な伝播ノードはありません—何も検出されておらず、追加されたノードはありません。同期は、利用可能になるとすぐに単独で実行されます。", + "syncLocalLoading": "ローカル伝播ノードは保存済みメッセージをまだ読み込んでいます。読み込みが終わると同期は自動で実行されます。", + "syncLocalSettledFor": "{{name}}と同期しました。", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}} : {{message}}", + "showChatNotice": "チャットに伝播リマインダーを表示", + "showChatNoticeAria": "チャットで伝播ノードのリマインダーバナーを表示する", + "showChatNoticeHint": "これをオフにすると、伝播ノードが利用できないときに表示されるチャットバナーが非表示になります。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v 4検出", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index a4fa7e575..fdbd8dbb8 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "다이렉트 메시지 지우기", "clearReticulumMessagesConfirm": "이렇게 하면 로컬로 저장된 모든 {{count}} 개의 Reticulum 다이렉트 메시지가 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", "clearReticulumMessagesConfirmButton": "메시지 {{count}} 개 지우기", - "reticulumSection": "Reticulum — 레티큘럼 스택", - "reticulumAnnounceHelp": "귀하의 신원이 네트워크에 공지되는 빈도 및 저장된 공지 사항을 지우는 도구.", "logPanelHelp": "활성화되면 실시간 로그 스트림이 오른쪽에 나타납니다. Reticulum 탭의 장치 라인에는 사이드카 출력 및 로컬 인터페이스 상태가 포함됩니다. 디버그 라인에는 로그 패널 내부의 확인란이 필요합니다.", "autoPruneUnheardContactsDaysAria": "시작 시 {{days}}일 이상 경과한 확인되지 않은 연락처 자동 정리", "capTotalContactsCountAria": "총 연락처 수를 제한하고 가장 최근에 본 연락처를 {{count}}개로 유지합니다.", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "아직 저장된 색상이 없습니다. 저장 을 사용하여 체크포인트를 만드세요.", "saveThemeButton": "저장", "restoreThemeButton": "복원", - "reticulumPropagationHelp": "오프라인 DM에 대한 원격 LXMF 전파 노드를 구성합니다. 로컬 전파는 이 장치의 받은 편지함일 뿐이며 원격 노드를 대체하지 않습니다.", "use24HourTime": "24시간 사용", "use24HourTimeDesc": "채팅 타임스탬프와 같은 시계를 24시간 형식으로 강제 설정합니다. 꺼지면 시스템 로케일을 따릅니다.", "themeSaveFailed": "색상 체크포인트를 저장하지 못했습니다.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Reticulum Network 전파 설정 열기", "bodyWithDiscoveries": "전파 노드가 구성되지 않았습니다. {{count}} 이 (가) 네트워크에서 발견되었습니다. 아래에 추가하거나 네트워크 설정을 엽니다.", "addClosest": "발견된 것 중 가장 가까운 것 추가", - "addClosestAria": "발견된 가장 가까운 전파 노드를 추가하고 선호하는 것으로 설정" + "addClosestAria": "발견된 가장 가까운 전파 노드를 추가하고 선호하는 것으로 설정", + "dismiss": "다시 표시하지 않음", + "dismissAria": "Chat에서 전파 노드 알림 표시 중지" }, "rename": "이름 바꾸기", "renameLabel": "전파 노드 이름", @@ -4196,7 +4195,8 @@ "known": "알려진", "pending": "보류 중", "unknown": "알려지지 않은", - "online": "온라인" + "online": "온라인", + "loading": "로드 중…" }, "syncIdentityUnknown": "전파 노드 ID를 알 수 없습니다. 알림 또는 경로 응답을 기다린 후 다시 시도하십시오.", "syncTargetNotPropagationNode": "해당 대상은 Reticulum에서 도달할 수 있지만 LXMF 전파 노드는 아닙니다(TCP 허브는 전송 경로일 뿐입니다). 오프라인 메시지를 동기화하려면 lxmf.propagation으로 알리는 대상을 추가하세요.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "로컬 호스트 전파 노드는 원격 LXMF 전파 노드처럼 네트워크를 통해 동기화될 수 없습니다.", "enableFailed": "전파 노드를 활성화할 수 없습니다.", "disableFailed": "전파 노드를 비활성화할 수 없습니다.", - "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 저장되는 중입니다." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 저장되는 중입니다.", "modeLabel": "전파 모드", "modeAria": "전파 동기화 모드", "modeAuto": "자동", "modeManual": "수동", "modeOff": "해제", - "sync": "동기화", - "syncAria": "전파 메시지 동기화", - "cancelSync": "취소", - "cancelSyncAria": "전파 동기화 취소" + "modeHelpAuto": "자동: 최상의 검색된 전파 노드를 한 번 동기화합니다(추가하지 않으며 Preferred를 변경하지 않음). 그다음 구성된 원격, 마지막으로 로컬 받은편지함입니다. 네트워크 인터페이스가 없으면 로컬만 완료합니다.", + "syncStarting": "동기화 시작하기", + "syncLocalSettled": "로컬 받은 편지함과 동기화되었습니다.", + "modeHelpOff": "OFF: 전파 노드 지원 없음. 동기화 및 오프라인 메시지는 전파 노드에 저장되지 않습니다. 기본 설정 노드는 자동 또는 수동을 선택할 때까지 저장되지만 사용되지 않습니다.", + "modeHelpManual": "수동: 선호하는 노드를 동기화하거나, 선호하는 노드가 없을 때 해당 동기화에 대해 가장 가까운 추가된 노드를 선택합니다. 실패하면 다른 추가된 노드를 시도한 다음 로컬 받은 편지함을 시도합니다.", + "syncNoTarget": "아직 사용할 수 있는 전파 노드가 없습니다. 발견된 노드가 없으며 추가된 노드가 없습니다. 동기화는 사용 가능한 즉시 자체적으로 실행됩니다.", + "syncLocalLoading": "로컬 전파 노드가 저장된 메시지를 아직 로드 중입니다. 로드가 끝나면 동기화가 자동으로 실행됩니다.", + "syncLocalSettledFor": "{{name}}과(와) 동기화되었습니다.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Chat에 전파 알림 표시", + "showChatNoticeAria": "Chat에 전파 노드 알림 배너 표시", + "showChatNoticeHint": "전파 노드를 사용할 수 없을 때 나타나는 채팅 배너를 숨기려면 이 옵션을 끄십시오." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 검색", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index ee874a413..65f54d2e7 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Directe berichten wissen", "clearReticulumMessagesConfirm": "Hiermee worden alle {{count}} lokaal opgeslagen Reticulum-directe berichten permanent verwijderd. Dit kan niet ongedaan worden gemaakt.", "clearReticulumMessagesConfirmButton": "{{count}} berichten wissen", - "reticulumSection": "Reticulum stack", - "reticulumAnnounceHelp": "Hoe vaak uw identiteit wordt aangekondigd op het netwerk en tools om opgeslagen aankondigingen te wissen.", "logPanelHelp": "Indien ingeschakeld, verschijnt er aan de rechterkant een live logstream. Op het tabblad Reticulum omvatten apparaatregels zijspanuitvoer en lokale interfacestatus. Voor foutopsporingsregels is het selectievakje in het logpaneel vereist.", "autoPruneUnheardContactsDaysAria": "Ongehoorde contacten automatisch opschonen bij het opstarten, ouder dan {{days}} dagen", "capTotalContactsCountAria": "Beperk het totale aantal contacten, bewaar de meest recent geziene {{count}} contacten", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Nog geen opgeslagen kleuren — gebruik Opslaan om een controlepunt te maken", "saveThemeButton": "Opslaan", "restoreThemeButton": "Herstellen", - "reticulumPropagationHelp": "Configureer externe LXMF-voortplantingsknooppunten voor offline DM's. Lokale verspreiding is alleen de inbox van dit apparaat — het vervangt geen extern knooppunt.", "use24HourTime": "24-uursaanduiding", "use24HourTimeDesc": "Forceer klokken zoals chattijdstempels naar 24-uurs formaat. Als deze optie is uitgeschakeld, volgt u de landinstelling van uw systeem.", "themeSaveFailed": "Kan kleurcontrolepunt niet opslaan.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagatie-instellingen", "bodyWithDiscoveries": "Er is geen propagatieknooppunt geconfigureerd. {{count}} ontdekt op het netwerk. Voeg er hieronder een toe of open Netwerkinstellingen.", "addClosest": "Dichtstbijzijnde toevoegen ontdekt", - "addClosestAria": "Voeg het dichtstbijzijnde ontdekte propagatieknooppunt toe en stel dit in als voorkeur" + "addClosestAria": "Voeg het dichtstbijzijnde ontdekte propagatieknooppunt toe en stel dit in als voorkeur", + "dismiss": "Niet meer laten zien", + "dismissAria": "Stop met het weergeven van de herinnering voor het propagatieknooppunt in Chat" }, "rename": "Hernoemen", "renameLabel": "Naam van het voortplantingsknooppunt", @@ -4196,7 +4195,8 @@ "known": "bekend", "pending": "in behandeling", "unknown": "onbekend", - "online": "online" + "online": "online", + "loading": "Aan het laden…" }, "syncIdentityUnknown": "De identiteit van het voortplantingsknooppunt is onbekend: wacht op een aankondiging of padreactie en probeer het vervolgens opnieuw.", "syncTargetNotPropagationNode": "Die bestemming is bereikbaar op Reticulum, maar is geen LXMF-propagatieknooppunt (TCP-hubs zijn alleen transportpaden). Voeg een bestemming toe die wordt aangekondigd als lxmf.propagation om offline berichten te synchroniseren.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Het lokale hostvoortplantingsknooppunt kan niet via het netwerk worden gesynchroniseerd zoals een extern LXMF-voortplantingsknooppunt.", "enableFailed": "Kan het voortplantingsknooppunt niet inschakelen.", "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen.", - "syncOutboundBusy": "Propagatiesynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Propagatiesynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd.", "modeLabel": "voortplantingsmodus", "modeAria": "Voortplantingssynchronisatiemodus", "modeAuto": "Auto", "modeManual": "Handmatig", - "modeOff": "Off", - "sync": "Synchroniseren", - "syncAria": "Voortplantingsberichten synchroniseren", - "cancelSync": "Annuleren", - "cancelSyncAria": "Voortplantingssynchronisatie annuleren" + "modeOff": "Uit", + "modeHelpAuto": "Auto: synchroniseert eenmalig het beste ontdekte propagation-knooppunt (voegt het niet toe en wijzigt Preferred niet), daarna geconfigureerde remotes, daarna de lokale inbox. Zonder netwerkinterfaces alleen lokaal afronden.", + "syncStarting": "Synchronisatie aan het starten", + "syncLocalSettled": "Gesynchroniseerd met lokale inbox.", + "modeHelpOff": "Uit: geen ondersteuning voor propagatieknooppunten. Niets synchroniseert en offline berichten worden niet gedeponeerd op een propagatieknooppunt. Een voorkeursknooppunt blijft opgeslagen maar ongebruikt totdat u Auto of Handmatig kiest.", + "modeHelpManual": "Handmatig: synchroniseert uw voorkeursknooppunt of kiest het dichtstbijzijnde toegevoegde knooppunt voor die synchronisatie wanneer geen de voorkeur heeft. Als het mislukt, worden de andere toegevoegde knooppunten geprobeerd en vervolgens de lokale inbox.", + "syncNoTarget": "Er is nog geen propagatieknooppunt beschikbaar — er is er geen ontdekt en u hebt geen toegevoegde knooppunten. Sync draait op zichzelf zodra er een beschikbaar is.", + "syncLocalLoading": "Het lokale propagation-knooppunt laadt nog zijn opgeslagen berichten. Synchronisatie start vanzelf zodra het laden klaar is.", + "syncLocalSettledFor": "Gesynchroniseerd met {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Toon propagatieherinnering in Chat", + "showChatNoticeAria": "Toon de herinneringsbanner voor het propagatieknooppunt in Chat", + "showChatNoticeHint": "Schakel dit uit om de chatbanner te verbergen die verschijnt als er geen propagatieknooppunt beschikbaar is." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4-detectie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 5e632ec79..184026a5c 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Przejrzyste komunikaty bezpośrednie", "clearReticulumMessagesConfirm": "Spowoduje to trwałe usunięcie wszystkich przechowywanych lokalnie wiadomości bezpośrednich Reticulum {{count}}. Tej czynności nie można cofnąć.", "clearReticulumMessagesConfirmButton": "Wyczyść wiadomości: {{count}}", - "reticulumSection": "Reticulum — Stos siateczkowy", - "reticulumAnnounceHelp": "Jak często Twoja tożsamość jest ogłaszana w sieci i narzędzia do czyszczenia przechowywanych ogłoszeń.", "logPanelHelp": "Po włączeniu po prawej stronie pojawia się strumień dziennika na żywo. Na karcie Reticulum linie urządzeń obejmują wyjście wózka bocznego i stan interfejsu lokalnego. Linie debugowania wymagają pola wyboru w panelu dziennika.", "autoPruneUnheardContactsDaysAria": "Automatycznie usuwaj niesłyszane kontakty przy uruchomieniu, starsze niż {{days}} dni", "capTotalContactsCountAria": "Ogranicz wszystkie kontakty, zachowaj ostatnio widziane kontakty {{count}}", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Brak zapisanych kolorów — użyj przycisku Zapisz, aby utworzyć punkt kontrolny", "saveThemeButton": "Zapisz", "restoreThemeButton": "Przywróć", - "reticulumPropagationHelp": "Skonfiguruj zdalne węzły propagacji LXMF dla wiadomości DM offline. Lokalna propagacja to tylko skrzynka odbiorcza tego urządzenia — nie zastępuje zdalnego węzła.", "use24HourTime": "Format 24-godzinny", "use24HourTimeDesc": "Wymuś wyświetlanie zegarów, takich jak znaczniki czasu czatu, w formacie 24-godzinnym. Gdy jest wyłączony, postępuje zgodnie z ustawieniami regionalnymi systemu.", "themeSaveFailed": "Nie udało się zapisać punktu kontrolnego koloru.", @@ -4178,7 +4175,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Brak skonfigurowanego węzła propagacji. {{count}} wykryte w sieci — dodaj poniżej lub otwórz ustawienia sieci.", "addClosest": "Dodaj najbliższe odkryte", - "addClosestAria": "Dodaj najbliższy odkryty węzeł propagacji i ustaw go jako preferowany" + "addClosestAria": "Dodaj najbliższy odkryty węzeł propagacji i ustaw go jako preferowany", + "dismiss": "Nie pokazuj więcej", + "dismissAria": "Zatrzymaj wyświetlanie przypomnienia o węźle propagacji na czacie" }, "rename": "Zmień nazwę", "renameLabel": "Nazwa węzła propagacji", @@ -4200,7 +4199,8 @@ "known": "znany", "pending": "oczekujący", "unknown": "nieznany", - "online": "online" + "online": "online", + "loading": "ładowanie…" }, "syncIdentityUnknown": "Tożsamość węzła propagacji jest nieznana — poczekaj na ogłoszenie lub odpowiedź dotyczącą ścieżki, a następnie spróbuj ponownie.", "syncTargetNotPropagationNode": "Miejsce docelowe jest osiągalne na Reticulum, ale nie jest węzłem propagacji LXMF (węzły TCP są jedynie ścieżkami transportowymi). Dodaj miejsce docelowe ogłaszające się jako lxmf.propagation, aby synchronizować wiadomości offline.", @@ -4247,18 +4247,25 @@ "syncLocalNotSupported": "Lokalny węzeł propagacji hosta nie może być synchronizowany przez sieć jak zdalny węzeł propagacji LXMF.", "enableFailed": "Nie można włączyć węzła propagacji.", "disableFailed": "Nie można wyłączyć węzła propagacji.", - "syncOutboundBusy": "Synchronizacja propagacji odroczona — wiadomość wychodząca jest deponowana w tym węźle." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Synchronizacja propagacji odroczona — wiadomość wychodząca jest deponowana w tym węźle.", "modeLabel": "Tryb propagacji:", "modeAria": "Tryb synchronizacji propagacji", "modeAuto": "Automatyczne", - "modeManual": "Instrukcja obsługi", + "modeManual": "Ręczny", "modeOff": "Wył.", - "sync": "Synchronizuj", - "syncAria": "Synchronizuj komunikaty propagacji", - "cancelSync": "Anuluj", - "cancelSyncAria": "Anuluj synchronizację propagacji" + "modeHelpAuto": "Auto: jednorazowo synchronizuje najlepszy odkryty węzeł propagacji (nie dodaje go ani nie zmienia Preferred), potem skonfigurowane zdalne, potem lokalną skrzynkę. Bez interfejsów sieciowych kończy tylko lokalnie.", + "syncStarting": "Start synch.", + "syncLocalSettled": "Zsynchronizowane z lokalną skrzynką odbiorczą.", + "modeHelpOff": "OFF: brak obsługi węzła propagacji. Nic nie synchronizuje się i wiadomości offline nie są deponowane w żadnym węźle propagacji. Preferowany węzeł pozostaje zapisany, ale nieużywany, dopóki nie wybierzesz trybu automatycznego lub ręcznego.", + "modeHelpManual": "Ręcznie: synchronizuje preferowany węzeł lub wybiera najbliższy dodany węzeł dla tej synchronizacji, gdy żaden nie jest preferowany. Jeśli się nie powiedzie, wypróbowywane są inne dodane węzły, a następnie lokalna skrzynka odbiorcza.", + "syncNoTarget": "Żaden węzeł propagacji nie jest jeszcze dostępny — żaden nie został odkryty, a Ty nie masz dodanych węzłów. Synchronizacja działa samodzielnie, gdy tylko jest dostępna.", + "syncLocalLoading": "Lokalny węzeł propagacji nadal ładuje zapisane wiadomości. Synchronizacja uruchomi się sama po zakończeniu ładowania.", + "syncLocalSettledFor": "Zsynchronizowano z {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Pokaż przypomnienie o propagacji na czacie", + "showChatNoticeAria": "Pokaż baner przypominający o węźle propagacji na czacie", + "showChatNoticeHint": "Wyłącz tę opcję, aby ukryć baner czatu, który pojawia się, gdy żaden węzeł propagacji nie jest dostępny." }, "reticulumRmapDiscovery": { "sectionTitle": "Wykrywanie RMAP v4", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 516a9bc65..3f16787cb 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Limpar mensagens diretas", "clearReticulumMessagesConfirm": "Isso excluirá permanentemente todas as {{count}} mensagens diretas Reticulum armazenadas localmente. Isso não pode ser desfeito.", "clearReticulumMessagesConfirmButton": "Limpar mensagens {{count}}", - "reticulumSection": "Reticulum — Pilha de Reticulum", - "reticulumAnnounceHelp": "Com que frequência sua identidade é anunciada na rede e ferramentas para limpar anúncios armazenados.", "logPanelHelp": "Quando ativado, um fluxo de log ao vivo aparece à direita. Na guia Reticulum, as linhas do dispositivo incluem saída secundária e integridade da interface local. As linhas de depuração exigem a caixa de seleção dentro do painel de log.", "autoPruneUnheardContactsDaysAria": "Remover automaticamente contatos não ouvidos na inicialização, com mais de {{days}} dias", "capTotalContactsCountAria": "Limite o total de contatos, mantenha os contatos {{count}} vistos mais recentemente", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Ainda não há cores salvas — use Salvar para criar um ponto de verificação", "saveThemeButton": "Salvar", "restoreThemeButton": "Restaurar", - "reticulumPropagationHelp": "Configurar nós de propagação LXMF remotos para DMs offline. A propagação local é apenas a caixa de entrada deste dispositivo — ela não substitui um nó remoto.", "use24HourTime": "Use o tempo de 24 horas", "use24HourTimeDesc": "Forçar relógios como carimbos de data e hora de bate-papo para o formato de 24 horas. Quando desligado, segue a localidade do seu sistema.", "themeSaveFailed": "Falha ao salvar o ponto de verificação de cores.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Nenhum nó de propagação está configurado. {{count}} descoberto na rede — adicione um abaixo ou abra as configurações de rede.", "addClosest": "Adicionar descoberto mais próximo", - "addClosestAria": "Adicione o nó de propagação descoberto mais próximo e defina-o como preferido" + "addClosestAria": "Adicione o nó de propagação descoberto mais próximo e defina-o como preferido", + "dismiss": "Não mostrar novamente", + "dismissAria": "Parar de mostrar o lembrete do nó de propagação no Chat" }, "rename": "Renomear", "renameLabel": "Nome do nó de propagação", @@ -4196,7 +4195,8 @@ "known": "conhecido", "pending": "pendente", "unknown": "desconhecido", - "online": "on-line" + "online": "on-line", + "loading": "Carregando..." }, "syncIdentityUnknown": "A identidade do nó de propagação é desconhecida — aguarde um anúncio ou resposta do caminho e tente novamente.", "syncTargetNotPropagationNode": "Esse destino pode ser alcançado no Reticulum, mas não é um nó de propagação LXMF (os hubs TCP são apenas caminhos de transporte). Adicione um destino anunciado como lxmf.propagation para sincronizar mensagens offline.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "O nó de propagação do host local não pode ser sincronizado pela rede como um nó de propagação LXMF remoto.", "enableFailed": "Não foi possível ativar o nó de propagação.", "disableFailed": "Não foi possível desativar o nó de propagação.", - "syncOutboundBusy": "Sincronização de propagação adiada — uma mensagem de saída está sendo depositada neste nó." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Sincronização de propagação adiada — uma mensagem de saída está sendo depositada neste nó.", "modeLabel": "Modo de propagação", "modeAria": "Modo de sincronização de propagação", "modeAuto": "Automático", "modeManual": "Manual", "modeOff": "Desligado", - "sync": "Sincronizar", - "syncAria": "Sincronizar mensagens de propagação", - "cancelSync": "Cancelar", - "cancelSyncAria": "Cancelar sincronização de propagação" + "modeHelpAuto": "Automático: sincroniza uma vez o melhor nó de propagação descoberto (não o adiciona nem altera Preferred), depois os remotos configurados e, por fim, a caixa de entrada local. Sem interfaces de rede, conclui apenas no local.", + "syncStarting": "Iniciando sincronização", + "syncLocalSettled": "Sincronizado com a caixa de entrada local.", + "modeHelpOff": "Desligado: sem suporte de nó de propagação. Nada é sincronizado e as mensagens offline não são depositadas em nenhum nó de propagação. Um nó Preferred permanece salvo, mas não usado, até que você escolha Auto ou Manual.", + "modeHelpManual": "Manual: sincroniza seu nó Preferred ou escolhe o nó adicionado mais próximo para essa sincronização quando nenhum é preferido. Se falhar, os outros nós adicionados são tentados e, em seguida, a caixa de entrada local.", + "syncNoTarget": "Nenhum nó de propagação está disponível ainda — nenhum foi descoberto e você não tem nós adicionados. A sincronização é executada por conta própria assim que uma estiver disponível.", + "syncLocalLoading": "O nó de propagação local ainda está carregando suas mensagens armazenadas. A sincronização é executada sozinha quando o carregamento terminar.", + "syncLocalSettledFor": "Sincronizado com {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Mostrar lembrete de propagação no chat", + "showChatNoticeAria": "Mostrar o banner de lembrete do nó de propagação no Chat", + "showChatNoticeHint": "Desative isso para ocultar o banner de bate-papo que aparece quando nenhum nó de propagação está disponível." }, "reticulumRmapDiscovery": { "sectionTitle": "Descoberta do RMAP v4", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 9337948d2..51a6c2827 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Очистить прямые сообщения", "clearReticulumMessagesConfirm": "Это приведет к окончательному удалению всех {{count}} локально сохраненных прямых сообщений Reticulum. Это действие нельзя отменить.", "clearReticulumMessagesConfirmButton": "Очистить {{count}} сообщения", - "reticulumSection": "Reticulum — Стек ретикулума", - "reticulumAnnounceHelp": "Как часто ваша личность объявляется в сети, и инструменты для очистки сохраненных объявлений.", "logPanelHelp": "Если этот параметр включен, справа отображается поток журнала в реальном времени. На вкладке Reticulum строки устройства включают вывод дополнительных данных и состояние локального интерфейса. Строки отладки требуют наличия флажка внутри панели журнала.", "autoPruneUnheardContactsDaysAria": "Автоматическое удаление непрослушанных контактов при запуске, старше {{days}} дн.", "capTotalContactsCountAria": "Ограничить общее количество контактов, сохранить последние просмотренные контакты {{count}}", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Пока нет сохраненных цветов — используйте Сохранить для создания контрольной точки", "saveThemeButton": "Сохранить", "restoreThemeButton": "Восстановить", - "reticulumPropagationHelp": "Настройте удаленные узлы распространения LXMF для автономных DM. Локальное распространение - это только почтовый ящик этого устройства — он не заменяет удаленный узел.", "use24HourTime": "24-часовой формат времени", "use24HourTimeDesc": "Принудительно переведите часы, такие как метки времени чата, в 24-часовой формат. При выключении следует за локалью системы.", "themeSaveFailed": "Не удалось сохранить цветовую контрольную точку.", @@ -4176,7 +4173,9 @@ "openSettingsAria": "Настройки распространения сети Open Reticulum", "bodyWithDiscoveries": "Узел распространения не настроен. {{count}} обнаружено в сети — добавьте его ниже или откройте настройки сети.", "addClosest": "Добавить ближайших обнаруженных", - "addClosestAria": "Добавьте ближайший обнаруженный узел распространения и установите его в качестве предпочтительного" + "addClosestAria": "Добавьте ближайший обнаруженный узел распространения и установите его в качестве предпочтительного", + "dismiss": "Больше не показывать", + "dismissAria": "Перестать показывать напоминание об узле распространения в чате" }, "rename": "Переименовать", "renameLabel": "Имя узла распространения", @@ -4198,7 +4197,8 @@ "known": "известный", "pending": "в ожидании", "unknown": "неизвестный", - "online": "онлайн" + "online": "онлайн", + "loading": "Загрузка…" }, "syncIdentityUnknown": "Идентификатор узла распространения неизвестен — дождитесь объявления или ответа о пути, а затем повторите попытку.", "syncTargetNotPropagationNode": "Этот пункт назначения доступен в Reticulum, но не является узлом распространения LXMF (концентраторы TCP являются только транспортными путями). Добавьте пункт назначения, который объявляется как lxmf.propagation, для синхронизации автономных сообщений.", @@ -4245,18 +4245,25 @@ "syncLocalNotSupported": "Локальный узел распространения хоста не может быть синхронизирован по сети, как удаленный узел распространения LXMF.", "enableFailed": "Не удалось включить узел распространения.", "disableFailed": "Не удалось отключить узел распространения.", - "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение сохраняется на этом узле." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение сохраняется на этом узле.", "modeLabel": "Режим распространения", "modeAria": "Режим синхронизации распространения", "modeAuto": "Автоматический режим", "modeManual": "вручную", "modeOff": "Выкл.", - "sync": "Синхронизировать", - "syncAria": "Сообщения о распространении синхронизации", - "cancelSync": "Отмена", - "cancelSyncAria": "Отмена синхронизации распространения" + "modeHelpAuto": "Авто: однократно синхронизирует лучший обнаруженный узел распространения (не добавляет его и не меняет Preferred), затем настроенные удалённые, затем локальный ящик. Без сетевых интерфейсов завершает только локально.", + "syncStarting": "Запуск синхронизации...", + "syncLocalSettled": "Синхронизировано с локальным почтовым ящиком", + "modeHelpOff": "Выкл.: нет поддержки узла распространения. Никакие синхронизации и автономные сообщения не депонируются ни на одном узле распространения. Предпочтительный узел остается сохраненным, но не используется до тех пор, пока вы не выберете Авто или Вручную.", + "modeHelpManual": "Вручную: синхронизирует ваш предпочтительный узел или выбирает ближайший добавленный узел для этой синхронизации, когда ни один не является предпочтительным. Если это не удается, пробуются другие добавленные узлы, а затем локальный почтовый ящик.", + "syncNoTarget": "Узел распространения еще не доступен — ни один не был обнаружен, и у вас нет добавленных узлов. Синхронизация запускается сама по себе, как только она становится доступной.", + "syncLocalLoading": "Локальный узел распространения всё ещё загружает сохранённые сообщения. Синхронизация запустится сама, когда загрузка завершится.", + "syncLocalSettledFor": "Синхронизировано с {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Показывать напоминание о распространении в чате", + "showChatNoticeAria": "Показывать баннер с напоминанием об узле распространения в чате", + "showChatNoticeHint": "Отключите этот параметр, чтобы скрыть баннер чата, который появляется, когда узел распространения недоступен." }, "reticulumRmapDiscovery": { "sectionTitle": "Обнаружение RMAP v4", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 4f12faea0..cdb9b080d 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Doğrudan mesajları temizle", "clearReticulumMessagesConfirm": "Bu, yerel olarak depolanan tüm {{count}} Reticulum doğrudan mesajlarını kalıcı olarak silecektir. Bu işlem geri alınamaz.", "clearReticulumMessagesConfirmButton": "{{count}} mesajı temizle", - "reticulumSection": "Reticulum — Reticulum yığını", - "reticulumAnnounceHelp": "Kimliğinizin ağda ne sıklıkta duyurulduğu ve saklanan duyuruları temizleme araçları.", "logPanelHelp": "Etkinleştirildiğinde sağda canlı bir günlük akışı görünür. Reticulum sekmesinde, cihaz hatları sepet çıkışını ve yerel arayüz durumunu içerir. Hata ayıklama satırları, günlük panelinin içindeki onay kutusunun işaretlenmesini gerektirir.", "autoPruneUnheardContactsDaysAria": "Başlangıçta {{days}} günden eski, duyulmamış kişileri otomatik olarak buda", "capTotalContactsCountAria": "Toplam kişileri sınırlandırın, en son görülen {{count}} kişileri koruyun", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Henüz kaydedilmiş renk yok — bir kontrol noktası oluşturmak için Kaydet'i kullanın", "saveThemeButton": "Kaydet", "restoreThemeButton": "Geri Yükle", - "reticulumPropagationHelp": "Çevrimdışı DM'ler için uzak LXMF yayılım düğümlerini yapılandırın. Yerel yayılım yalnızca bu cihazın gelen kutusudur — uzak bir düğümün yerini almaz.", "use24HourTime": "24-Saat Zaman", "use24HourTimeDesc": "Sohbet zaman damgaları gibi saatleri 24 saat biçimine zorlayın. Kapalıyken, sisteminizin yerel ayarını takip edin.", "themeSaveFailed": "Renk kontrol noktası kaydedilemedi.", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Reticulum Ağı yayılım ayarlarını aç", "bodyWithDiscoveries": "Hiçbir yayılma düğümü yapılandırılmamış. {{count}} ağda keşfedildi — aşağıya bir tane ekleyin veya Ağ ayarlarını açın.", "addClosest": "En yakın bulunanları ekle", - "addClosestAria": "En yakın keşfedilen yayılma düğümünü ekleyin ve tercih edilen olarak ayarlayın" + "addClosestAria": "En yakın keşfedilen yayılma düğümünü ekleyin ve tercih edilen olarak ayarlayın", + "dismiss": "Bir daha gösterme", + "dismissAria": "Chat'te yayılım düğümü hatırlatıcısının gösterilmesini durdurma" }, "rename": "Yeniden adlandır", "renameLabel": "Yayılma düğümü adı", @@ -4196,7 +4195,8 @@ "known": "bilinen", "pending": "askıda olması", "unknown": "bilinmiyor", - "online": "çevrimiçi" + "online": "çevrimiçi", + "loading": "yükleniyor" }, "syncIdentityUnknown": "Yayılım düğümü kimliği bilinmiyor; bir duyuru veya yol yanıtı bekleyin ve ardından tekrar deneyin.", "syncTargetNotPropagationNode": "Bu hedefe Reticulum'da ulaşılabilir ancak bir LXMF yayılım düğümü değildir (TCP hub'ları yalnızca aktarım yollarıdır). Çevrimdışı mesajları senkronize etmek için lxmf.propagation olarak duyuru yapan bir hedef ekleyin.", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "Yerel ana bilgisayar yayılım düğümü, uzak LXMF yayılım düğümü gibi ağ üzerinden senkronize edilemez.", "enableFailed": "Yayılma düğümü etkinleştirilemedi.", "disableFailed": "Yayılma düğümü devre dışı bırakılamadı.", - "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — giden bir mesaj bu düğüme bırakılıyor." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — giden bir mesaj bu düğüme bırakılıyor.", "modeLabel": "Yayılma modu", "modeAria": "Yayılma senkronizasyon modu", "modeAuto": "Otomatik", "modeManual": "Manuel", "modeOff": "Kapalı", - "sync": "Senkronizasyon", - "syncAria": "Yayılma mesajlarını senkronize et", - "cancelSync": "İptal", - "cancelSyncAria": "Yayılma senkronizasyonunu iptal et" + "modeHelpAuto": "Otomatik: en iyi keşfedilen yayılım düğümünü bir kez senkronize eder (eklemez ve Preferred’ı değiştirmez), sonra yapılandırılmış uzak düğümler, sonra yerel gelen kutusu. Ağ arayüzü yoksa yalnızca yerelde tamamlar.", + "syncStarting": "Eşitleme başlatılıyor", + "syncLocalSettled": "Yerel gelen kutusuyla senkronize edildi.", + "modeHelpOff": "Kapalı: Yayılma düğümü desteği yok. Hiçbir şey senkronize edilmez ve çevrimdışı mesajlar herhangi bir yayılma düğümüne yatırılmaz. Tercih edilen bir düğüm, Otomatik veya Manuel'i seçene kadar kaydedilmiş ancak kullanılmamış olarak kalır.", + "modeHelpManual": "Manuel: Tercih edilen düğümünüzü eşitler veya hiçbiri tercih edilmediğinde bu eşitleme için en yakın eklenen düğümü seçer. Başarısız olursa, diğer eklenen düğümler, ardından yerel gelen kutusu denenir.", + "syncNoTarget": "Henüz yayılma düğümü mevcut değil — hiçbiri keşfedilmedi ve ek düğümünüz yok. Senkronizasyon, hazır olur olmaz kendi kendine çalışır.", + "syncLocalLoading": "Yerel yayılım düğümü hâlâ saklanan iletilerini yüklüyor. Senkronizasyon, yükleme bitince kendiliğinden çalışır.", + "syncLocalSettledFor": "{{name}} ile senkronize edildi.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Chat'te yayılma hatırlatıcısını göster", + "showChatNoticeAria": "Yayılma düğümü hatırlatma banner'ını Sohbet'te göster", + "showChatNoticeHint": "Yayılma düğümü bulunmadığında görünen Sohbet başlığını gizlemek için bunu kapatın." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 keşfi", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index dcd8d68b2..4b2c2ed95 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "Очистити прямі повідомлення", "clearReticulumMessagesConfirm": "Це призведе до остаточного видалення всіх {{count}} локально збережених прямих повідомлень Reticulum. Цю дію неможливо скасувати.", "clearReticulumMessagesConfirmButton": "Очистити повідомлення {{count}}", - "reticulumSection": "Reticulum — Стек ретикулуму", - "reticulumAnnounceHelp": "Як часто ваша особа оголошується в мережі, а також інструменти для очищення збережених оголошень.", "logPanelHelp": "Якщо ввімкнено, праворуч з’являється пряма трансляція журналу. На вкладці Reticulum рядки пристроїв включають вихідні дані та стан локального інтерфейсу. Рядки налагодження потребують прапорця на панелі журналу.", "autoPruneUnheardContactsDaysAria": "Автоматично видаляти непрослухані контакти під час запуску, старші {{days}} днів", "capTotalContactsCountAria": "Обмежте загальну кількість контактів, зберігайте останні {{count}} контактів", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "Ще немає збережених кольорів — скористайтеся функцією «Зберегти», щоб створити контрольну точку", "saveThemeButton": "Зберегти", "restoreThemeButton": "Відновити", - "reticulumPropagationHelp": "Налаштуйте віддалені вузли розповсюдження LXMF для автономних DM. Локальне поширення - це лише папка «Вхідні» цього пристрою — вона не замінює віддалений вузол.", "use24HourTime": "Використовувати 24-годинний час", "use24HourTimeDesc": "Примусово перевести годинник, як-от чат, у 24-годинний формат. Коли вимкнено, слідкуйте за локаллю системи.", "themeSaveFailed": "Не вдалося зберегти контрольну точку кольору.", @@ -4176,7 +4173,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "Вузол поширення не налаштовано. {{count}} виявлено в мережі — додайте його нижче або відкрийте налаштування мережі.", "addClosest": "Додати найближче знайдене", - "addClosestAria": "Додайте найближчий виявлений вузол розповсюдження та встановіть його як бажаний" + "addClosestAria": "Додайте найближчий виявлений вузол розповсюдження та встановіть його як бажаний", + "dismiss": "Більше не показувати", + "dismissAria": "Припинити відображення нагадування про вузол поширення в чаті" }, "rename": "Перейменувати", "renameLabel": "Ім'я вузла поширення", @@ -4198,7 +4197,8 @@ "known": "відомий", "pending": "в очікуванні", "unknown": "невідомий", - "online": "онлайн" + "online": "онлайн", + "loading": "Завантаження…" }, "syncIdentityUnknown": "Ідентифікатор вузла розповсюдження невідомий — зачекайте на оголошення або відповідь на шлях і повторіть спробу.", "syncTargetNotPropagationNode": "Цей пункт призначення доступний на Reticulum, але не є вузлом розповсюдження LXMF (центри TCP є лише транспортними шляхами). Додайте призначення, яке оголошує як lxmf.propagation для синхронізації повідомлень в режимі офлайн.", @@ -4245,18 +4245,25 @@ "syncLocalNotSupported": "Локальний хост-вузол поширення не можна синхронізувати через мережу, як віддалений вузол поширення LXMF.", "enableFailed": "Не вдалося ввімкнути вузол розповсюдження.", "disableFailed": "Не вдалося вимкнути вузол розповсюдження.", - "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення зберігається на цьому вузлі." - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення зберігається на цьому вузлі.", "modeLabel": "Режим поширення", "modeAria": "Режим синхронізації поширення", "modeAuto": "Авто", - "modeManual": "Підручник", + "modeManual": "Вручну", "modeOff": "Вимк.", - "sync": "Синхронізувати", - "syncAria": "Повідомлення про поширення синхронізації", - "cancelSync": "Скасувати", - "cancelSyncAria": "Скасувати синхронізацію поширення" + "modeHelpAuto": "Авто: одноразово синхронізує найкращий виявлений вузол поширення (не додає його й не змінює Preferred), потім налаштовані віддалені, потім локальну скриньку. Без мережевих інтерфейсів завершує лише локально.", + "syncStarting": "Початок синхронізації…", + "syncLocalSettled": "Синхронізовано з локальною папкою «Вхідні».", + "modeHelpOff": "Вимкнено: немає підтримки вузла розповсюдження. Ніякі синхронізовані та автономні повідомлення не зберігаються на жодному вузлі розповсюдження. Бажаний вузол залишається збереженим, але не використовується, доки ви не виберете Авто або Вручну.", + "modeHelpManual": "Вручну: синхронізує ваш бажаний вузол або вибирає найближчий доданий вузол для цієї синхронізації, коли жоден з них не є бажаним. Якщо це не вдається, пробуються інші додані вузли, а потім локальна папка вхідних повідомлень.", + "syncNoTarget": "Вузол поширення ще не доступний — жоден не був виявлений, і у вас немає доданих вузлів. Синхронізація виконується самостійно, як тільки вона стає доступною.", + "syncLocalLoading": "Локальний вузол поширення досі завантажує збережені повідомлення. Синхронізація запуститься сама, коли завантаження завершиться.", + "syncLocalSettledFor": "Синхронізовано з {{name}}.", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}}: {{message}}", + "showChatNotice": "Показати нагадування про поширення в чаті", + "showChatNoticeAria": "Показати банер нагадування про вузол поширення в чаті", + "showChatNoticeHint": "Вимкніть, щоб приховати банер чату, який з'являється, коли вузол поширення недоступний." }, "reticulumRmapDiscovery": { "sectionTitle": "Виявлення RMAP v4", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 701073a13..3b2eeec37 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -216,8 +216,6 @@ "clearReticulumMessagesTitle": "清除直接消息", "clearReticulumMessagesConfirm": "这将永久删除所有{{count}}本地存储的Reticulum直接消息。此操作无法撤消。", "clearReticulumMessagesConfirmButton": "清除{{count}}消息", - "reticulumSection": "Reticulum — 网状堆栈", - "reticulumAnnounceHelp": "您的身份在网络上公布的频率,以及清除存储的公告的工具。", "logPanelHelp": "启用后,实时日志流将显示在右侧。在 Reticulum 选项卡上,设备行包括 sidecar 输出和本地接口运行状况。调试行需要日志面板内的复选框。", "autoPruneUnheardContactsDaysAria": "启动时自动删除未听过的、超过 {{days}} 天的联系人", "capTotalContactsCountAria": "联系人总数上限,保留最近查看的 {{count}} 联系人", @@ -381,7 +379,6 @@ "noSavedThemeTooltip": "尚未保存颜色—使用“保存”创建检查点", "saveThemeButton": "保存", "restoreThemeButton": "恢复", - "reticulumPropagationHelp": "为离线DM配置远程LXMF传播节点。本地传播仅是此设备的收件箱—它不会替换远程节点。", "use24HourTime": "24小时时间", "use24HourTimeDesc": "强制将聊天时间戳等时钟设置为24小时格式。关闭时,请遵循您的系统区域设置。", "themeSaveFailed": "无法保存颜色检查点。", @@ -4174,7 +4171,9 @@ "openSettingsAria": "Open Reticulum Network propagation settings", "bodyWithDiscoveries": "未配置传播节点。在网络上发现{{count}} —在下面添加一个或打开网络设置。", "addClosest": "添加最近发现的", - "addClosestAria": "添加最近发现的传播节点并将其设置为首选" + "addClosestAria": "添加最近发现的传播节点并将其设置为首选", + "dismiss": "不再显示", + "dismissAria": "停止在聊天中显示传播节点提醒" }, "rename": "重命名", "renameLabel": "传播节点名称", @@ -4196,7 +4195,8 @@ "known": "已知的", "pending": "等待中", "unknown": "未知", - "online": "在线" + "online": "在线", + "loading": "加载中…" }, "syncIdentityUnknown": "传播节点身份未知 - 等待公告或路径响应,然后重试。", "syncTargetNotPropagationNode": "该目的地在 Reticulum 上可到达,但不是 LXMF 传播节点(TCP 集线器仅是传输路径)。添加一个声明为 lxmf.propagation 的目标来同步离线消息。", @@ -4243,18 +4243,25 @@ "syncLocalNotSupported": "本地主机传播节点无法像远程 LXMF 传播节点一样通过网络同步。", "enableFailed": "无法启用传播节点。", "disableFailed": "无法禁用传播节点。", - "syncOutboundBusy": "传播同步已延迟—出站消息正在存入此节点。" - }, - "reticulumPropagationHeader": { + "syncOutboundBusy": "传播同步已延迟—出站消息正在存入此节点。", "modeLabel": "传播模式", "modeAria": "传播同步模式", - "modeAuto": "推荐加点", - "modeManual": "说明书", + "modeAuto": "自动", + "modeManual": "手动", "modeOff": "关闭", - "sync": "同步", - "syncAria": "同步传播消息", - "cancelSync": "取消推荐", - "cancelSyncAria": "取消传播同步" + "modeHelpAuto": "自动:一次性同步最佳已发现的传播节点(不添加也不更改 Preferred),然后是已配置的远程节点,最后是本地收件箱。没有网络接口时仅完成本地。", + "syncStarting": "开始同步", + "syncLocalSettled": "已与本地收件箱同步。", + "modeHelpOff": "关闭:不支持传播节点。任何同步和离线消息都不会存放在任何传播节点上。首选节点保持保存但未使用,直到您选择“自动”或“手动”。", + "modeHelpManual": "手动:同步首选节点,或在没有首选节点时为该同步选择最近添加的节点。如果失败,则尝试其他添加的节点,然后尝试本地收件箱。", + "syncNoTarget": "没有可用的传播节点—没有发现任何节点,并且您没有添加任何节点。一旦可用,同步将自行运行。", + "syncLocalLoading": "本地传播节点仍在加载其已存储的消息。加载完成后同步会自行运行。", + "syncLocalSettledFor": "已与{{name}}同步。", + "syncStatusWithTarget": "{{status}} ({{name}})", + "syncErrorWithTarget": "{{name}} : {{message}}", + "showChatNotice": "在聊天中显示传播提醒", + "showChatNoticeAria": "在聊天中显示传播节点提醒横幅", + "showChatNoticeHint": "关闭此选项可隐藏在没有可用传播节点时显示的聊天横幅。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4发现", diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index 56c262ff3..ca91906c5 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -294,7 +294,7 @@ describe('useReticulumRuntime outbound delivery persistence', () => { it('skips link-timeout failure bridge when PN cascade is available', () => { expect(SOURCE).toContain('shouldApplyLinkDeliveryTimeoutFailureBridge'); expect(SOURCE).toMatch( - /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*\)/, + /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*readReticulumPropagationMode\(\),\s*propState\.discovered,\s*\)/, ); expect(SOURCE).toContain('propagationHydratedForBridgeRef'); expect(SOURCE).toContain('identityIdRef'); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index d2399bfec..c818a638b 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -85,6 +85,7 @@ import { shouldApplyLinkDeliveryTimeoutFailureBridge, } from '@/renderer/lib/reticulum/reticulumOutboundFailureBridge'; import { shouldDeletePriorReticulumOutboundHash } from '@/renderer/lib/reticulum/reticulumOutboundRetry'; +import { readReticulumPropagationMode } from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { applyPropagationSyncEvent, normalizePropagationSyncProgress, @@ -1561,6 +1562,8 @@ export function useReticulumRuntime(): ProtocolRuntime { const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( propState.nodes, propState.preferredId, + readReticulumPropagationMode(), + propState.discovered, ); console.debug( `[useReticulumRuntime] link-timeout bridge apply=${applyBridge} preferred=${propState.preferredId ?? 'none'} nodes=${propState.nodes.length}`, diff --git a/src/renderer/stores/reticulumPropagationStore.test.ts b/src/renderer/stores/reticulumPropagationStore.test.ts index 9cd108b3d..bc06858ff 100644 --- a/src/renderer/stores/reticulumPropagationStore.test.ts +++ b/src/renderer/stores/reticulumPropagationStore.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const getStatus = vi.fn(); const proxyGet = vi.fn(); @@ -21,7 +21,10 @@ vi.stubGlobal('window', { }, }); -import { useReticulumPropagationStore } from './reticulumPropagationStore'; +import { + RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY, + useReticulumPropagationStore, +} from './reticulumPropagationStore'; describe('reticulumPropagationStore', () => { beforeEach(() => { @@ -44,6 +47,8 @@ describe('reticulumPropagationStore', () => { lastPropagationSyncAt: null, lastPropagationSyncAttemptAt: null, activePropagationSyncAttemptAt: null, + syncTargetId: null, + chatNoticeDismissed: false, }); }); @@ -116,6 +121,42 @@ describe('reticulumPropagationStore', () => { expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/pn-aabbccdd/preferred', {}); }); + it('addFromDiscovered with prefer returns false when Preferred POST fails', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + useReticulumPropagationStore.setState({ + discovered: [ + { + destination_hash: 'aabbccddeeff00112233445566778899', + display_name: 'Heard PN', + hops: 1, + node_state: true, + peering_cost: 0, + }, + ], + }); + proxyPost + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: false, error: 'not_ready' }); + proxyGet.mockResolvedValue({ + propagation: [ + { + id: 'pn-aabbccdd', + name: 'Heard PN', + enabled: true, + status: 'known', + destination_hash: 'aabbccddeeff00112233445566778899', + }, + ], + preferred_id: null, + }); + + await expect( + useReticulumPropagationStore + .getState() + .addFromDiscovered('aabbccddeeff00112233445566778899', { prefer: true }), + ).resolves.toBe(false); + }); + it('refreshFromSidecar skips when sidecar is down', async () => { getStatus.mockResolvedValue({ running: false, port: 0, pid: null }); await useReticulumPropagationStore.getState().refreshFromSidecar(); @@ -136,11 +177,30 @@ describe('reticulumPropagationStore', () => { expect(useReticulumPropagationStore.getState().autoSyncIntervalSec).toBe(1800); }); + it('setModeOnSidecar posts the propagation mode', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyPost.mockResolvedValueOnce({ ok: true }); + + await expect(useReticulumPropagationStore.getState().setModeOnSidecar('off')).resolves.toBe( + true, + ); + expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/mode', { mode: 'off' }); + }); + + it('setModeOnSidecar reports failure without throwing', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyPost.mockRejectedValueOnce(new Error('sidecar down')); + + await expect(useReticulumPropagationStore.getState().setModeOnSidecar('auto')).resolves.toBe( + false, + ); + }); + it('startSync and cancelSync update sync state', async () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); useReticulumPropagationStore.setState({ preferredId: 'p1' }); proxyPost.mockResolvedValueOnce({ ok: true }); - await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe(true); + await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe('accepted'); expect(useReticulumPropagationStore.getState().sync.active).toBe(true); expect(useReticulumPropagationStore.getState().lastPropagationSyncAttemptAt).toBeTypeOf( 'number', @@ -154,6 +214,22 @@ describe('reticulumPropagationStore', () => { ); }); + it('startSync records the target each attempt so progress and errors can name it', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyPost.mockResolvedValueOnce({ ok: false, error: 'PROPAGATION_IDENTITY_UNKNOWN' }); + await expect(useReticulumPropagationStore.getState().startSync('pn-aabb')).resolves.toBe( + 'failed', + ); + // Kept past the failure so the error can be attributed to the node it came from. + expect(useReticulumPropagationStore.getState().syncTargetId).toBe('pn-aabb'); + + proxyPost.mockResolvedValueOnce({ ok: true }); + await expect(useReticulumPropagationStore.getState().startSync('local-prop')).resolves.toBe( + 'accepted', + ); + expect(useReticulumPropagationStore.getState().syncTargetId).toBe('local-prop'); + }); + it('cancelSync keeps a prior sidecar establish failure over timeout reason', async () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); proxyPost.mockResolvedValueOnce({ ok: true }); @@ -247,7 +323,7 @@ describe('reticulumPropagationStore', () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); proxyPost.mockResolvedValueOnce({ ok: true }); await expect(useReticulumPropagationStore.getState().startSync('local-prop')).resolves.toBe( - true, + 'accepted', ); expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/sync', { propagation_id: 'local-prop', @@ -259,6 +335,17 @@ describe('reticulumPropagationStore', () => { expect(useReticulumPropagationStore.getState().activePropagationSyncAttemptAt).toBeNull(); }); + it('startSync posts destination_hash for a 32-hex one-time sync', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyPost.mockResolvedValueOnce({ ok: true }); + const hash = 'deadbeef'.repeat(4); + await expect(useReticulumPropagationStore.getState().startSync(hash)).resolves.toBe('accepted'); + expect(proxyPost).toHaveBeenCalledWith('/api/v1/propagation/sync', { + destination_hash: hash, + }); + expect(useReticulumPropagationStore.getState().sync.active).toBe(true); + }); + it('older success completion does not clear a newer failed attempt stamp', () => { const olderAttempt = 1_000; const newerAttempt = 2_000; @@ -293,7 +380,7 @@ describe('reticulumPropagationStore', () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); useReticulumPropagationStore.setState({ preferredId: 'pn-vegas' }); proxyPost.mockResolvedValueOnce({ ok: false, error: 'PROPAGATION_IDENTITY_UNKNOWN' }); - await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe(false); + await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe('failed'); expect(useReticulumPropagationStore.getState().lastSyncError).toBe( 'reticulumPropagation.syncIdentityUnknown', ); @@ -303,12 +390,22 @@ describe('reticulumPropagationStore', () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); useReticulumPropagationStore.setState({ preferredId: 'pn-vegas' }); proxyPost.mockResolvedValueOnce({ ok: false, error: 'PROPAGATION_TARGET_NOT_PN' }); - await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe(false); + await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe('failed'); expect(useReticulumPropagationStore.getState().lastSyncError).toBe( 'reticulumPropagation.syncTargetNotPropagationNode', ); }); + it('startSync soft-defers OUTBOUND_BUSY without a lastSyncError', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + useReticulumPropagationStore.setState({ preferredId: 'pn-vegas' }); + proxyPost.mockResolvedValueOnce({ ok: false, error: 'PROPAGATION_SYNC_OUTBOUND_BUSY' }); + await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe('deferred'); + expect(useReticulumPropagationStore.getState().sync.active).toBe(false); + expect(useReticulumPropagationStore.getState().lastSyncError).toBeNull(); + expect(useReticulumPropagationStore.getState().activePropagationSyncAttemptAt).toBeNull(); + }); + it('removePropagationNode deletes then refreshes', async () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); proxyDelete.mockResolvedValueOnce({ ok: true }); @@ -448,3 +545,45 @@ describe('reticulumPropagationStore', () => { expect(useReticulumPropagationStore.getState().activePropagationSyncAttemptAt).toBe(attemptAt); }); }); + +describe('chat notice dismissal', () => { + // renderer-logic runs in node (no jsdom); provide a minimal localStorage stub. + function stubLocalStorage(initial?: Record): Map { + const store = new Map(Object.entries(initial ?? {})); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + }); + return store; + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal('window', { + electronAPI: { reticulum: { getStatus, proxyGet, proxyPost, proxyPut, proxyDelete } }, + }); + }); + + it('setChatNoticeDismissed round-trips through localStorage', () => { + const store = stubLocalStorage(); + useReticulumPropagationStore.getState().setChatNoticeDismissed(true); + expect(store.get(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY)).toBe('1'); + expect(useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(true); + + useReticulumPropagationStore.getState().setChatNoticeDismissed(false); + expect(store.has(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY)).toBe(false); + expect(useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(false); + }); + + it('hydrates the dismissal from a previous session', async () => { + stubLocalStorage({ [RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY]: '1' }); + vi.resetModules(); + const fresh = await import('./reticulumPropagationStore'); + expect(fresh.useReticulumPropagationStore.getState().chatNoticeDismissed).toBe(true); + }); +}); diff --git a/src/renderer/stores/reticulumPropagationStore.ts b/src/renderer/stores/reticulumPropagationStore.ts index c3bf23fe0..d60d03e2e 100644 --- a/src/renderer/stores/reticulumPropagationStore.ts +++ b/src/renderer/stores/reticulumPropagationStore.ts @@ -1,6 +1,12 @@ import { create } from 'zustand'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { + readReticulumPropagationMode, + RETICULUM_PROPAGATION_DESTINATION_HASH_RE, + type ReticulumPropagationMode, + writeReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { clearPropagationSyncStallWatchdog, mapPropagationSyncError, @@ -23,6 +29,39 @@ import { RETICULUM_PROPAGATION_AUTO_SYNC_DEFAULT_SEC } from '@/shared/reticulumP /** i18n key written when the user cancels an in-flight propagation sync. */ export const PROPAGATION_SYNC_USER_CANCEL_KEY = 'reticulumPropagation.syncCancelled'; +/** + * Sidecar acceptance for a sync start. + * - `accepted` — request is in flight (or local-prop already settled). + * - `deferred` — soft defer (outbound deposit owns the PN link); retry without backoff. + * - `failed` — hard reject; cascade may backoff and advance. + */ +export type PropagationStartSyncResult = 'accepted' | 'deferred' | 'failed'; + +/** Persists "stop reminding me in Chat to set up a propagation node". */ +export const RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY = + 'mesh-client:reticulumPropagationNoticeDismissed'; + +function readChatNoticeDismissed(): boolean { + try { + return localStorage.getItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY) === '1'; + } catch { + // catch-no-log-ok localStorage unavailable in private mode + return false; + } +} + +function writeChatNoticeDismissed(dismissed: boolean): void { + try { + if (dismissed) { + localStorage.setItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY, '1'); + } else { + localStorage.removeItem(RETICULUM_PROPAGATION_NOTICE_DISMISSED_KEY); + } + } catch { + // catch-no-log-ok localStorage quota or private mode + } +} + export interface PropagationNodeRow { id: string; name: string; @@ -73,12 +112,25 @@ interface ReticulumPropagationStoreState { lastPropagationSyncAttemptAt: number | null; /** Attempt timestamp for the in-flight sync run (WS complete scopes clear to this). */ activePropagationSyncAttemptAt: number | null; + /** + * Target of the most recent sync attempt (row id, `local-prop`, or destination hash), + * so progress and errors can name the node. Survives the sync going idle — the cascade + * re-stamps it per attempt, and it is cleared only when no node was contacted at all. + */ + syncTargetId: string | null; + /** True while the user has dismissed the Chat "no propagation node" reminder. */ + chatNoticeDismissed: boolean; + /** Network → Propagation mode (localStorage-backed; Off hides the Chat reminder). */ + propagationMode: ReticulumPropagationMode; replaceNodes: (nodes: PropagationNodeRow[]) => void; upsertDiscovered: (row: DiscoveredPropagationRow) => void; replaceDiscovered: (rows: DiscoveredPropagationRow[]) => void; setPreferredId: (id: string | null) => void; setSyncState: (patch: Partial) => void; setLastSyncError: (message: string | null) => void; + setSyncTargetId: (id: string | null) => void; + setChatNoticeDismissed: (dismissed: boolean) => void; + setPropagationMode: (mode: ReticulumPropagationMode) => void; /** * Record last successful sync time. When `forAttemptAt` matches the current attempt stamp, * clear it (and the active run stamp); a mismatched/older completion leaves a newer attempt alone. @@ -89,8 +141,10 @@ interface ReticulumPropagationStoreState { refreshDiscoveredFromSidecar: () => Promise; setPreferredOnSidecar: (id: string) => Promise; setAutoSyncIntervalOnSidecar: (sec: number) => Promise; + /** Push the renderer propagation mode so the sidecar gates its outbound PN cascade. */ + setModeOnSidecar: (mode: ReticulumPropagationMode) => Promise; setHostingPolicyOnSidecar: (policy: PnHostingPolicy) => Promise; - startSync: (id?: string) => Promise; + startSync: (id?: string) => Promise; cancelSync: (opts?: { reasonKey?: string }) => Promise; addPropagationNode: (destinationHash: string, name?: string) => Promise; addFromDiscovered: (destinationHash: string, opts?: { prefer?: boolean }) => Promise; @@ -112,6 +166,9 @@ export const useReticulumPropagationStore = create { set({ nodes }); @@ -141,6 +198,20 @@ export const useReticulumPropagationStore = create { + set({ syncTargetId: id }); + }, + + setChatNoticeDismissed: (dismissed) => { + writeChatNoticeDismissed(dismissed); + set({ chatNoticeDismissed: dismissed }); + }, + + setPropagationMode: (mode) => { + writeReticulumPropagationMode(mode); + set({ propagationMode: mode }); + }, + setLastPropagationSyncAt: (atMs, forAttemptAt) => { set((s) => { if (atMs == null) { @@ -255,6 +326,18 @@ export const useReticulumPropagationStore = create { + try { + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/propagation/mode', { + mode, + })) as { ok?: boolean }; + return res.ok === true; + } catch (e) { + console.warn('[reticulumPropagationStore] set propagation mode ' + errLikeToLogString(e)); + return false; + } + }, + setHostingPolicyOnSidecar: async (policy) => { const sanitized = sanitizePnHostingPolicy(policy); if (!sanitized.ok) { @@ -272,7 +355,9 @@ export const useReticulumPropagationStore = create { const propId = id ?? get().preferredId; - if (!propId) return false; + if (!propId) return 'failed'; + const isDestHash = RETICULUM_PROPAGATION_DESTINATION_HASH_RE.test(propId); // Avoid overlapping renderer starts so a late success cannot clear a newer attempt. if (get().sync.active) { await get().cancelSync(); @@ -296,32 +382,37 @@ export const useReticulumPropagationStore = create