From f1b5a0e19745759ee5ccdb76dd3cbb92fec013a7 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 16:52:50 -0600 Subject: [PATCH 01/14] fix(reticulum): move propagation mode to Network; relocate Ratspeak stack to .rsstack Propagation mode (Network + Auto owns Preferred): - Move Off/Auto/Manual mode selector from the App panel into Network -> Propagation nodes; remove the duplicated App controls (announce-only now). - Default mode is Off (MeshChatX parity). Auto picks the best remote PN at pick time (discovered union enabled-configured, lowest hops), soft-upserts + sets Preferred without a manual Add, and falls back to enabled local-prop. - Gate Set preferred / Add & prefer while Auto manages Preferred; Manual/Off never auto-write. Periodic auto-sync honors the mode (Off never ticks; local-prop still skipped). Mode help text + per-option tooltips added. - PN-island diagnostics: sidecar deposit-Completes log carries cascade_step + delivery_method; diagnostic snapshot gains a propagationClient slice; developer bundles always include mesh_client_stack.json + lxmf-outbound.log with expanded island log patterns. Docs + troubleshooting (PN-island section + repro matrix). Relocate Ratspeak overlay stack to repo-local .rsstack workspace: - Move rns-stack path deps and overlay checkouts from sibling ../../rs* to a gitignored .rsstack/ workspace so a standalone rsReticulum mirror stays clean. - Update Cargo.toml, reticulum-sidecar-path.ts, clone/ensure/apply/check-rsnomad scripts, update.sh, build-reticulum-sidecar-release.mjs, all sidecar CI workflows, contract tests, patches/README, and docs to the new layout. - Exclude .rsstack/ from repo-wide linters (yamllint, markdownlint, prettier) so the cloned upstream repos are not scanned by mesh-client tooling. --- .github/workflows/build.yaml | 2 +- .github/workflows/flatpak.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/reticulum-sidecar.yaml | 16 +-- .github/workflows/tests.yaml | 2 +- .gitignore | 2 + .markdownlint-cli2.jsonc | 1 + .prettierignore | 4 + .yamllint | 1 + README.md | 2 +- docs/ci-cd.md | 4 +- docs/development-environment.md | 6 +- docs/reticulum.md | 10 +- docs/troubleshooting.md | 35 ++++- reticulum-sidecar/Cargo.toml | 31 ++--- reticulum-sidecar/patches/README.md | 66 +++++----- reticulum-sidecar/src/stack/lxmf_outbound.rs | 11 ++ ...ply-rsLXMF-link-delivery-has-pending-to.sh | 2 +- ...agation-node-deferred-messagestore-load.sh | 2 +- ...-rsLXMF-propagation-node-policy-setters.sh | 2 +- .../apply-rsLXMF-propagation-sync-peering.sh | 2 +- scripts/apply-rsReticulum-auto-beacon-utun.sh | 2 +- ...apply-rsReticulum-ble-rnode-bond-desync.sh | 2 +- ...m-ble-rnode-pairing-transition-debounce.sh | 2 +- ...y-rsReticulum-discovery-announce-egress.sh | 2 +- ...-rsReticulum-inbound-raw-saturation-log.sh | 2 +- .../apply-rsReticulum-link-client-nomad.sh | 2 +- ...ly-rsReticulum-link-client-proof-budget.sh | 2 +- scripts/apply-rsReticulum-packet-tap.sh | 2 +- .../apply-rsReticulum-path-medium-slots.sh | 2 +- scripts/build-reticulum-sidecar-release.mjs | 2 +- scripts/check-rsnomad-fmt.sh | 2 +- scripts/check-rsnomad.sh | 2 +- scripts/clone-ratspeak-stack.sh | 3 +- scripts/clone-ratspeak-stack.test.mjs | 1 + scripts/ensure-rsReticulum-patches.sh | 4 +- scripts/update.sh | 12 +- scripts/update.test.mjs | 13 +- src/main/reticulum-sidecar-path.test.ts | 14 +- src/main/reticulum-sidecar-path.ts | 8 +- src/main/support-bundle.test.ts | 30 +++++ src/main/support-bundle.ts | 37 +++++- .../components/ReticulumAppPanelSection.tsx | 3 +- .../ReticulumPropagationControls.test.tsx | 93 -------------- .../ReticulumPropagationControls.tsx | 115 ----------------- .../ReticulumPropagationSection.test.tsx | 85 ++++++++++++ .../ReticulumPropagationSection.tsx | 121 +++++++++++++++--- .../reticulumDiagnosticSnapshot.test.ts | 31 ++++- .../reticulum/reticulumDiagnosticSnapshot.ts | 55 ++++++++ ...umDiscoveryAnnounceEgress.contract.test.ts | 2 +- ...mLinkDeliveryHasPendingTo.contract.test.ts | 2 +- ...umLxmfOutboundBackchannel.contract.test.ts | 2 +- ...lumPnHostingPolicySetters.contract.test.ts | 2 +- .../reticulumPropagationMode.test.ts | 107 +++++++++++++++- .../lib/reticulum/reticulumPropagationMode.ts | 81 +++++++++++- .../useReticulumPropagationAutoSync.test.ts | 44 +++++++ .../useReticulumPropagationAutoSync.ts | 10 ++ src/renderer/locales/cs/translation.json | 16 +-- src/renderer/locales/de/translation.json | 16 +-- src/renderer/locales/en/translation.json | 14 +- src/renderer/locales/es/translation.json | 16 +-- src/renderer/locales/fr/translation.json | 16 +-- src/renderer/locales/id/translation.json | 16 +-- src/renderer/locales/it/translation.json | 18 ++- src/renderer/locales/ja/translation.json | 18 ++- src/renderer/locales/ko/translation.json | 16 +-- src/renderer/locales/nl/translation.json | 16 +-- src/renderer/locales/pl/translation.json | 16 +-- src/renderer/locales/pt-BR/translation.json | 16 +-- src/renderer/locales/ru/translation.json | 18 ++- src/renderer/locales/tr/translation.json | 16 +-- src/renderer/locales/uk/translation.json | 16 +-- src/renderer/locales/zh/translation.json | 16 +-- 73 files changed, 859 insertions(+), 505 deletions(-) delete mode 100644 src/renderer/components/ReticulumPropagationControls.test.tsx delete mode 100644 src/renderer/components/ReticulumPropagationControls.tsx 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/README.md b/README.md index 8fef253fc..f669501e1 100644 --- a/README.md +++ b/README.md @@ -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..4fff350ef 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 clones the `.rsstack/` workspace 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. 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). diff --git a/docs/development-environment.md b/docs/development-environment.md index 94ca27100..ff728aba6 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 | diff --git a/docs/reticulum.md b/docs/reticulum.md index 03a3e89c9..47bcc814d 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -38,7 +38,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | 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 | +| Propagation | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance, Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe; Auto soft-upserts the best node as Preferred), 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.*`) | @@ -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**, moved out of the App panel) selects how Preferred is managed — **Off** makes no automatic Preferred changes and runs no periodic sync (you may still Set preferred manually); **Auto** picks the best propagation node at the time from **Discovered ∪ configured** (else your enabled local node) and **soft-upserts** it as Preferred without a manual Add (Set preferred / Add & prefer are disabled while Auto is on); **Manual** pins Preferred and leaves other enabled remotes as cascade backups. 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 Manual list management — Auto is the only path that adds a node without a click), **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`, 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`). --- @@ -409,7 +409,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 +419,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). +When the `.rsstack/` 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 the `.rsstack/` workspace, Cargo builds 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..f01963140 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**. @@ -1388,7 +1413,7 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. -3. Set **Preferred** (manual mode) or leave **Auto** when multiple nodes are listed. +3. Pick a **Propagation mode** in the same section (moved here from the App panel). The default is **Off** (no automatic Preferred, no periodic sync) — set **Preferred** manually, or switch to **Manual** to pin it, or **Auto** to let mesh-client track the best discovered/configured node (Auto soft-upserts it as Preferred without a manual Add and disables the manual Set preferred / Add & prefer controls). 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). 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/patches/README.md b/reticulum-sidecar/patches/README.md index 58d10ac6f..81755baf1 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 (sibling [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/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 575fd4cdd..3a6841b6e 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("propagated"), "outbound PN deposit Completes" ); } 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-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..bd13af4ce 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" 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/reticulum-sidecar-path.test.ts b/src/main/reticulum-sidecar-path.test.ts index 954e30ea5..282be96e0 100644 --- a/src/main/reticulum-sidecar-path.test.ts +++ b/src/main/reticulum-sidecar-path.test.ts @@ -101,14 +101,18 @@ describe('reticulum-sidecar-path', () => { ); }); - it('sidecarCargoBuildArgs uses rns-stack when Ratspeak siblings exist', () => { + it('sidecarCargoBuildArgs uses rns-stack when the repo-local .rsstack exists', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-reticulum-siblings-')); 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'); diff --git a/src/main/reticulum-sidecar-path.ts b/src/main/reticulum-sidecar-path.ts index 78233a95b..740efae86 100644 --- a/src/main/reticulum-sidecar-path.ts +++ b/src/main/reticulum-sidecar-path.ts @@ -59,15 +59,15 @@ export function resolveSidecarBinaryPath(extraRoots: string[] = []): string { export function hasRnsStackSiblings(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. */ +/** Cargo build args: full RNS stack (+ BLE) when the repo-local .rsstack is present. */ export function sidecarCargoBuildArgs(projectDir: string): string[] { if (hasRnsStackSiblings(projectDir)) { return ['build', '--features', 'rns-stack,rns-ble,rns-rnode-tcp']; @@ -133,7 +133,7 @@ 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; diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index 2d133215f..2827ab348 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -300,6 +300,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/components/ReticulumAppPanelSection.tsx b/src/renderer/components/ReticulumAppPanelSection.tsx index 932030979..e99d036cc 100644 --- a/src/renderer/components/ReticulumAppPanelSection.tsx +++ b/src/renderer/components/ReticulumAppPanelSection.tsx @@ -1,7 +1,6 @@ import { useTranslation } from 'react-i18next'; import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; -import { ReticulumPropagationControls } from './ReticulumPropagationControls'; export interface ReticulumAppPanelSectionProps { sidecarReady?: boolean; @@ -20,7 +19,7 @@ export function ReticulumAppPanelSection({

{t('appPanel.reticulumAnnounceHelp')}

- +

{t('appPanel.reticulumPropagationMoved')}

); 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/ReticulumPropagationSection.test.tsx b/src/renderer/components/ReticulumPropagationSection.test.tsx index 84b725678..3682c6f5c 100644 --- a/src/renderer/components/ReticulumPropagationSection.test.tsx +++ b/src/renderer/components/ReticulumPropagationSection.test.tsx @@ -63,6 +63,7 @@ describe('ReticulumPropagationSection', () => { beforeEach(() => { addToast.mockReset(); + localStorage.clear(); useReticulumPropagationStore.setState({ nodes: [ { @@ -280,4 +281,88 @@ describe('ReticulumPropagationSection', () => { expect(addToast).toHaveBeenCalledWith('reticulumPropagation.offerUnsupported', 'error'); }); }); + + 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 picks the best remote and gates manual preferred controls', 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).toHaveBeenCalledWith('pn-aabb1111'); + }); + for (const btn of screen.getAllByRole('button', { + name: 'reticulumPropagation.setPreferred', + })) { + expect(btn).toBeDisabled(); + } + }); + + it('Auto falls back to local when no remote is enabled', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + nodes: [ + { + id: 'local-prop', + name: 'Host propagation node', + enabled: true, + status: 'known', + hops: 0, + }, + ], + preferredId: null, + }); + const setPreferredOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setPreferredOnSidecar, + ); + render(); + + await user.selectOptions(screen.getByLabelText('reticulumPropagation.modeAria'), 'auto'); + + await waitFor(() => { + expect(setPreferredOnSidecar).toHaveBeenCalledWith('local-prop'); + }); + }); + + 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'); + }); + }); }); diff --git a/src/renderer/components/ReticulumPropagationSection.tsx b/src/renderer/components/ReticulumPropagationSection.tsx index 7e3a49147..06ea9b49f 100644 --- a/src/renderer/components/ReticulumPropagationSection.tsx +++ b/src/renderer/components/ReticulumPropagationSection.tsx @@ -1,8 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; +import { + pickAutoPropagationTarget, + readReticulumPropagationMode, + resolvePropagationSyncTargetId, + type ReticulumPropagationMode, + writeReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { RETICULUM_PROPAGATION_REFRESH_MIN_VISIBLE_MS } from '@/renderer/lib/reticulum/reticulumPropagationSync'; import { type DiscoveredPropagationRow, @@ -42,6 +49,8 @@ interface DiscoveredPropagationListProps { configuredHashes: ReadonlySet; onAdd: (destinationHash: string, prefer?: boolean) => void; adding?: boolean; + /** When Auto manages Preferred, hide the explicit "Add & prefer" action. */ + hidePrefer?: boolean; } function DiscoveredPropagationList({ @@ -49,6 +58,7 @@ function DiscoveredPropagationList({ configuredHashes, onAdd, adding = false, + hidePrefer = false, }: Readonly) { const { t } = useTranslation(); const visibleDiscovered = discovered.filter( @@ -104,19 +114,21 @@ function DiscoveredPropagationList({ > {t('reticulumPropagation.discoveredAdd')} - + {!hidePrefer ? ( + + ) : null} ); @@ -162,11 +174,41 @@ 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 [mode, setMode] = useState(() => readReticulumPropagationMode()); + + const isAuto = mode === 'auto'; useEffect(() => { void refreshFromSidecar(); }, [refreshFromSidecar]); + const applyAutoPreferred = useCallback(async () => { + const target = pickAutoPropagationTarget(nodes, discovered); + if (!target) return; + if (target.kind === 'configured') { + if (target.id === preferredId) return; + await setPreferredOnSidecar(target.id); + } else if (target.kind === 'local') { + if (preferredId === 'local-prop') return; + await setPreferredOnSidecar('local-prop'); + } else { + // Discovered but not yet configured: soft-upsert (add + prefer) so the sidecar + // Preferred/sync/cascade APIs work — no manual Add click required. + await addFromDiscovered(target.destinationHash, { prefer: true }); + } + }, [nodes, discovered, preferredId, setPreferredOnSidecar, addFromDiscovered]); + + useEffect(() => { + if (!isAuto) return; + // floating-ok: setPreferredOnSidecar/addFromDiscovered never reject (return false on failure) + void applyAutoPreferred(); + }, [isAuto, applyAutoPreferred]); + + const handleModeChange = (next: ReticulumPropagationMode) => { + setMode(next); + writeReticulumPropagationMode(next); + }; + const handleRefresh = async () => { if (refreshing) return; setRefreshing(true); @@ -212,6 +254,17 @@ export default function ReticulumPropagationSection({ .filter((h): h is string => typeof h === 'string' && h.length > 0), ); + const modeHelpKey = + mode === 'auto' + ? 'reticulumPropagation.modeHelpAuto' + : mode === 'manual' + ? 'reticulumPropagation.modeHelpManual' + : 'reticulumPropagation.modeHelpOff'; + + const bottomSyncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId); + const bottomSyncDisabled = + sync.active || mode === 'off' || !bottomSyncTargetId || bottomSyncTargetId === 'local-prop'; + const body = ( <> {!embedded ? ( @@ -324,7 +377,9 @@ export default function ReticulumPropagationSection({ - {!hidePrefer ? ( - - ) : null} + ); @@ -183,37 +172,42 @@ 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 [mode, setMode] = useState(() => readReticulumPropagationMode()); - const isAuto = mode === 'auto'; + const handleSyncNow = (targetId: string) => { + if (syncStarting || sync.active) return; + setSyncStarting(true); + void ensurePreferredThenStartSync(targetId) + .then((ok) => { + setSyncStarting(false); + if (!ok) { + const errKey = + useReticulumPropagationStore.getState().lastSyncError ?? + 'reticulumPropagation.syncFailed'; + addToast(t(errKey), 'error'); + return; + } + // Local settle finishes inside startSync with no progress bar; remote leaves sync.active. + if (!useReticulumPropagationStore.getState().sync.active) { + addToast(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]); - // Topology / Preferred changes while Auto (mode entry is owned by handleModeChange). - useEffect(() => { - if (!isAuto) return; - // floating-ok: apply returns Result; failures toast after retry - void applyAutoPropagationPreferredIfNeeded(); - }, [isAuto, nodes, discovered, preferredId]); - const handleModeChange = (next: ReticulumPropagationMode) => { if (!isReticulumPropagationMode(next)) return; - if (mode === 'auto' && next !== 'auto') { - bumpPropagationModeGeneration(); - } setMode(next); writeReticulumPropagationMode(next); - if (next !== 'auto') return; - // floating-ok: refresh then apply; bump after refresh so we do not rejoin a pre-hydrate flight - void (async () => { - await refreshFromSidecar(); - bumpPropagationModeGeneration(); - await applyAutoPropagationPreferredIfNeeded({ - generation: getPropagationModeGeneration(), - }); - })(); }; const handleRefresh = async () => { @@ -264,14 +258,13 @@ export default function ReticulumPropagationSection({ ? 'reticulumPropagation.modeHelpManual' : 'reticulumPropagation.modeHelpOff'; - const bottomSyncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId, discovered); + const bottomSyncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId); const autoHasCascadeCandidate = - listDiscoveredPropagationTargets(nodes, discovered).length > 0 || - listConfiguredRemotePropagationIds(nodes).length > 0 || - hasEnabledLocalPropagationNode(nodes); + listConfiguredRemotePropagationIds(nodes).length > 0 || hasEnabledLocalPropagationNode(nodes); // Manual/Auto may Sync local-prop (settle); Off disables bottom Sync. const bottomSyncDisabled = sync.active || + syncStarting || mode === 'off' || (mode === 'manual' && !bottomSyncTargetId) || (mode === 'auto' && !autoHasCascadeCandidate); @@ -389,8 +382,6 @@ export default function ReticulumPropagationSection({

{t('reticulumPropagation.localHostHint')}

@@ -579,7 +572,6 @@ export default function ReticulumPropagationSection({ configuredHashes={configuredHashes} onAdd={handleAddFromDiscovered} adding={adding} - hidePrefer={isAuto} />
); diff --git a/src/renderer/components/ReticulumPropagationSection.test.tsx b/src/renderer/components/ReticulumPropagationSection.test.tsx index df9d6ffe1..1d3a03b51 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: () => ({ @@ -92,6 +95,9 @@ describe('ReticulumPropagationSection', () => { preferredId: null, discovered: [], sync: { active: false, progress: 0, message: null }, + syncTargetId: null, + lastSyncError: null, + chatNoticeDismissed: false, refreshFromSidecar: vi.fn().mockResolvedValue(undefined), removePropagationNode: vi.fn().mockResolvedValue(true), renamePropagationNode: vi.fn().mockResolvedValue(true), @@ -552,4 +558,107 @@ describe('ReticulumPropagationSection', () => { }); 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', async () => { + const user = userEvent.setup(); + // Real startSync is mocked, so mirror the target stamp it would write. + useReticulumPropagationStore.setState({ + startSync: vi.fn().mockImplementation((id?: string) => { + useReticulumPropagationStore.setState({ + syncTargetId: id ?? null, + sync: { active: true, progress: 5, message: null }, + }); + return Promise.resolve(true); + }), + }); + 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.syncStartedWith:Remote hub', + 'info', + ); + }); + }); + + 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(false); + }), + }); + 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 f99edd0b1..81b87029c 100644 --- a/src/renderer/components/ReticulumPropagationSection.tsx +++ b/src/renderer/components/ReticulumPropagationSection.tsx @@ -12,6 +12,7 @@ import { listDiscoveredPropagationTargets, readReticulumPropagationMode, resolvePropagationSyncTargetId, + resolveReticulumPropagationTargetLabel, type ReticulumPropagationMode, writeReticulumPropagationMode, } from '@/renderer/lib/reticulum/reticulumPropagationMode'; @@ -158,6 +159,8 @@ 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( @@ -178,22 +181,53 @@ export default function ReticulumPropagationSection({ const [syncStarting, setSyncStarting] = useState(false); const [mode, setMode] = useState(() => readReticulumPropagationMode()); + /** Name of the node the cascade actually reached, or null when it contacted nobody. */ + const resolveAttemptedName = (): string | null => { + const { + nodes: current, + discovered: currentDiscovered, + syncTargetId, + } = useReticulumPropagationStore.getState(); + if (syncTargetId == null || syncTargetId.length === 0) return null; + return resolveReticulumPropagationTargetLabel( + current, + currentDiscovered, + syncTargetId, + t('reticulumPropagation.localHostName'), + ); + }; + const handleSyncNow = (targetId: string) => { if (syncStarting || sync.active) return; setSyncStarting(true); void ensurePreferredThenStartSync(targetId) .then((ok) => { setSyncStarting(false); + const name = resolveAttemptedName(); if (!ok) { const errKey = useReticulumPropagationStore.getState().lastSyncError ?? 'reticulumPropagation.syncFailed'; - addToast(t(errKey), 'error'); + addToast( + name + ? t('reticulumPropagation.syncErrorWithTarget', { name, message: t(errKey) }) + : t(errKey), + 'error', + ); return; } // Local settle finishes inside startSync with no progress bar; remote leaves sync.active. if (!useReticulumPropagationStore.getState().sync.active) { - addToast(t('reticulumPropagation.syncLocalSettled'), 'success'); + addToast( + name + ? t('reticulumPropagation.syncLocalSettledFor', { name }) + : t('reticulumPropagation.syncLocalSettled'), + 'success', + ); + return; + } + if (name) { + addToast(t('reticulumPropagation.syncStartedWith', { name }), 'info'); } }) .catch((err: unknown) => { @@ -523,6 +557,19 @@ export default function ReticulumPropagationSection({

{t(modeHelpKey)}

+ +

{t('reticulumPropagation.showChatNoticeHint')}