diff --git a/AGENTS.md b/AGENTS.md index bdb08f679..be7877f6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,7 +219,7 @@ Meshtastic BLE: `connection.ts` / `TransportManager`. MeshCore BLE: `noble-ble-m **Meshtastic transport writes:** `meshtasticTransportLossDetection.ts` wraps `transport.toDevice` with `createSerializedWritableStream` on **serial, BLE, HTTP, and TCP** so concurrent SDK `getWriter()` calls (ping, Store & Forward, queue) do not throw `WritableStream is locked`. Meshtastic **WiFi/TCP (fast)** uses `TransportTcpIpc` in the renderer with main-process `meshtastic:tcp-*` IPC (`net.Socket` on port **4403**). After configure, `getMetadata` retries once after `MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS` when NodeDB traffic starves BLE. **`meshtasticSdkRoutingErrorConsoleHook.ts`** intercepts SDK `console.error`/`warn` routing failures, logs matched lines at **`console.debug`**, and applies **`applyMeshtasticOutboundRoutingErrorFromLog`** / **`FromRejection`** to mark outbound chat rows failed; unmatched queue rejections log as `[meshtasticSdkRoutingErrorLog]` (timeouts may log via `warn` in queue.js). -**Linux Web Bluetooth (Meshtastic):** `webbluetooth-ble-manager.ts` subscribes to **fromNum** GATT notify for unsolicited mesh traffic, runs a **3 s background fromRadio poll** between write cycles, and uses **multi-shot read probes** instead of a single post-write safety read (LoRa latency). MeshCore BLE echo filtering: `meshcoreCompanionTxEchoFilter.ts` (Noble + Web Bluetooth). +**Linux Web Bluetooth (Meshtastic):** `webbluetooth-ble-manager.ts` subscribes to **fromNum** GATT notify for unsolicited mesh traffic, runs a **3 s background fromRadio poll** between write cycles, and uses **multi-shot read probes** instead of a single post-write safety read (LoRa latency). MeshCore BLE echo filtering: `meshcoreCompanionTxEchoFilter.ts` (Noble + Web Bluetooth). Chooser sessions are generation-scoped; Connect/Reconnect **await** `cancelBluetoothSelection` before `requestDevice()` (see [troubleshooting](docs/troubleshooting.md#ble-known-issues)). **Dual-radio Noble BLE startup (macOS/Windows):** When both Meshtastic and MeshCore have **different** saved BLE peripherals, the renderer must serialize auto-connect and manual Noble connects. Coordinator: `src/renderer/lib/meshcoreDualNobleBleInit.ts`; UI wiring: `ConnectionPanel.tsx` (both panels stay mounted from `App.tsx`). diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 52f85c25a..3419bc428 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -65,6 +65,25 @@ The Connection tab UI edits a subset: **name** and **mode** for all types; **hos **Config bootstrap (stack start):** When `announce_interval_sec` is missing from rnsd config, the sidecar writes **3600**; explicit **0** is left unchanged (`ensure_announce_interval_sec_default` in `reticulum-sidecar/src/stack/config.rs`). Missing `share_instance` / `instance_name` are filled as **No** / **mesh-client** (explicit values are preserved). Same bootstrap pass may set `discover_interfaces = Yes` for RMAP ingest. +### Path medium preference and pins + +Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/shared-instance) path slots. Backed by rsReticulum `TransportQuery::SetPathMediumPreference` / `SetPeerMediumPin` / `GetPathSlots`; persisted in `mesh_client_stack.json` as `path_medium_preference` (default `"lowest"`) and `peer_medium_pins` (`{ "<32 hex dest>": "rf" | "network" }`, max 256 entries). + +| Method | Path | Body / notes | Response | +| ------ | ----------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/settings/path-medium-preference` | | `{ ok, preference: "lowest"\|"network"\|"rf", pins: { "": "rf"\|"network" } }` | +| PUT | `/api/v1/settings/path-medium-preference` | `{ preference: "lowest"\|"network"\|"rf" }` | `{ ok, preference }`; **400** `{ ok: false, error: "invalid_path_medium_preference" }` on an unknown token. On success emits `path_medium_preference` WS (`{ preference }`) | +| GET | `/api/v1/peers/{hash}/paths` | | `{ ok, destination_hash, preference, pin, effective_preference, live, paths: PathSlot[] }`; **400** on a non-32-hex hash | +| PUT | `/api/v1/peers/{hash}/medium-pin` | `{ pin: "rf"\|"network"\|null }` (`null` clears) | `{ ok, destination_hash, pin }`; **400** `"pin_required"` (key absent), `"invalid_pin"`, or bad hash. Emits `peers_updated` WS | + +**`PathSlot` fields:** `active` (route currently used for outbound), `hops`, `via_hash` (immediate transport id, may be `null`), `interface` (live interface name), `interface_id`, `medium` (`rf` / `network`), `timestamp`, `expires`, `expired`. Slots are ranked active-first and capped by rsReticulum `MAX_PATH_SLOTS` (**3**). + +**`preference` vs `effective_preference`:** `preference` is the persisted global setting and `pin` the persisted per-destination override; `effective_preference` is what the live transport actually applies for that destination (pin resolved against the global) and is `null` when the stack is not live. + +**Offline / persist behavior:** When the stack is down, PUTs persist and are applied on the next live start (`LiveBridge::spawn` re-applies the preference — skipped when it is the default `lowest` — then every pin). While the stack is live, PUTs persist only if the live apply succeeds; a failed live apply rolls back the persisted value so disk/UI cannot drift ahead of the transport. `GET …/paths` returns `live: false` with an empty `paths` array when there is no live transport. `GET /api/v1/peers` stays active-route-only and does **not** embed path arrays; fetch slots per destination. + +`preference` semantics (rsReticulum): `lowest` applies no medium bias and ranks purely by hops; `network` / `rf` are "prefer if possible" — when the preferred medium has no live slot the other medium becomes active without clearing the preference, so the preferred medium can reclaim the route later. + ### LXMF and contacts | Method | Path | Body / notes | Response | @@ -220,7 +239,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`. +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`. **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. diff --git a/docs/reticulum.md b/docs/reticulum.md index 0a685ca45..b620acb4b 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -169,7 +169,7 @@ Config lives under `userData/reticulum/config/` (rnsd INI). The Connection tab s - **IFAC (all types):** optional `network_name` and `passphrase` for private/authenticated network segments ([common interface options](https://reticulum.network/manual/interfaces.html#common-interface-options)). Shown on add and edit; passphrase uses a masked input with show/hide. - **Advanced (edit only):** free-form `key = value` lines for other common options (e.g. `forward_interval`, `ifac_size`). Keys that duplicate typed form fields are ignored. Unknown INI keys are preserved across enable/edit/repair via sidecar `extra_config` (no longer silently dropped). - **TCP client:** host, port (mesh hub — default port **4242**); IPv6 literals use brackets: `[2001:db8::1]:4242` -- **I2P:** comma-separated peer hostnames (`.b32.i2p` addresses, e.g. `{52-base32-chars}.b32.i2p`); max **512** characters total; validated in UI and sidecar before write +- **I2P:** comma-separated peer hostnames (`.b32.i2p` addresses, e.g. `{52-base32-chars}.b32.i2p`); max **512** characters total; validated in UI and sidecar before write. **Host-local only:** run an I2P router on the same machine and enable the **SAM application bridge** on `127.0.0.1:7656` (not HTTP/HTTPS I2PTunnel proxies on `4444`/`4445`). **Restart I2P after enabling SAM** so the bridge listens, then restart the Reticulum stack if the interface stays down. RMAP publish on I2P sets `connectable=yes` (inbound); hub `peers` are dialed as clients as well (Python RNS parity) - **RNode:** USB serial, **Bluetooth** (`ble://…`), or **Wi‑Fi** (`tcp://host[:7633]`, default **7633**), LoRa preset, callsign - **BLE Peer mesh:** optional seed peer addresses - **Auto:** name only (link-local discovery) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7c2003267..4b08927f2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -422,7 +422,10 @@ flatpak run org.coloradomesh.MeshClient - The app uses Web Bluetooth (Chromium's built-in BLE API). You still need a working Bluetooth stack (`systemctl status bluetooth`). - Linux BLE uses the in-app Bluetooth picker (triggered from a button click); if no picker appears, restart the app and try Connect again. -- **Immediate "User cancelled the requestDevice() chooser"** on Connect (AppImage / `.deb` / `.rpm`) without dismissing a picker: Chromium multi-fires `select-bluetooth-device`; the app must retain the first callback. Upgrade to a build that includes that fix, then retry Connect. If the picker still never opens, check `systemctl status bluetooth` and `rfkill list`. +- **Immediate "User cancelled the requestDevice() chooser"** on Connect (AppImage / `.deb` / `.rpm`) without dismissing a picker: + 1. Chromium multi-fires `select-bluetooth-device`; the app must retain the first callback (#749). + 2. A fire-and-forget cancel-before-connect can also race behind the new chooser and kill it (seen on CachyOS / Arch with 5.25.0). Builds that **await** `cancelBluetoothSelection` before `requestDevice()` fix that race. + Upgrade to a release that includes both fixes, then retry Connect. If the picker still never opens, check `systemctl status bluetooth` and `rfkill list`. - **Flatpak:** Connect that fails with little or no UI often means the sandbox lacked `--allow=bluetooth` (needed with `--system-talk-name=org.bluez`). Reinstall a Flatpak from a release that includes that finish-arg. If pairing then fails with **bluetoothctl not found**, use the official AppImage/`.deb`/`.rpm`, or pair the radio on the host with `bluetoothctl` and retry. - If the Bluetooth adapter isn't detected, check: `systemctl status bluetooth` and `rfkill list`. - **MeshCore:** After you pick a radio, the app checks `bluetoothctl info `. If the device is **not** paired at the OS level, you are prompted for the **PIN shown on the device** and pairing runs via **`bluetooth-pair`** before Web Bluetooth finishes connecting. Meshtastic does not use this gate in the same way (it may use PIN `123456` on the first pairing prompt from Chromium). @@ -1021,6 +1024,9 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca | `link_timeout` | Link could not be established in time (UI may say path OK vs stale) | | `response_timeout` | Link opened but page payload did not arrive in time | | `missing_identity_hash` | No remembered identity for the node yet | +| `network_not_ready` | No usable path/interface yet — wait for hub/path or restart stack | +| `nomad_not_serving` | Remote node is not serving Nomad pages | +| `invalid_url` | Malformed Nomad page/file URL | | `transport_unavailable` | Reticulum transport unavailable — restart stack | | `sidecar_not_running` | Sidecar not running — start stack from Connection | | `response_too_large` | Remote response exceeded the sidecar size cap | @@ -1274,6 +1280,18 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) For bulk fixes, use Network **Config import** (merge) instead of hand-editing individual rows. See [reticulum.md — Interface management](reticulum.md#interface-management-connection-tab). +### Reticulum I2P interface stays down + +**Symptoms**: Connection → Interfaces shows an enabled I2P row (e.g. **RNS I2P Hub A**) as **down**; Diagnostics may list `reticulum/interface-down`. The I2P router appears running and “clients” look ready, but mesh-client never comes up. + +**Checks**: + +1. **Host-local only**: mesh-client expects an I2P router on **this machine**. Remote SAM is not supported. +2. **SAM application bridge**, not I2PTunnel: HTTP/HTTPS proxies on `127.0.0.1:4444` / `4445` (and similar “Client ready” lines) are classic I2PTunnel clients. Reticulum needs the **SAM** bridge on **`127.0.0.1:7656`**. In the I2P Router Console → **Clients**, enable **SAM application bridge** (Run on load). The Connection ⓘ tooltip on I2P rows repeats this. +3. **Restart I2P after enabling SAM**: flipping SAM on while the router is already running often does not open `7656` until you fully restart I2P. Confirm something listens on `7656` (e.g. `nc -z 127.0.0.1 7656`). SAM may also delay ~2 minutes after router boot (`delay=120` in the SAM client config). +4. **Restart the Reticulum stack** after SAM is listening (stack restart alone cannot help while `7656` is refused). +5. **Tunnel build time**: first connect to a hub `.b32.i2p` peer can take a while on a fresh router. Sidecar / Device logs may show `I2P client:` / `I2P server:` messages (`failed to connect to SAM bridge`, `STREAM CONNECT failed`, `stream connected`). + ### Reticulum Peers stale or slow with many hubs or testnets **Symptoms**: Peers looks briefly stale after opening the tab, or—after enabling several public hubs or testnets—shows thousands of path-table rows and scrolling, search, or refresh feels sluggish. UI may remain responsive on **Contacts** or **Favorites** because those tabs show a smaller LXMF contact set. diff --git a/package.json b/package.json index 537949f5a..03d8db034 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.13.0", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.7.26", - "@stoprocent/noble": "^2.6.0", + "@stoprocent/noble": "^2.6.5", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@zip.js/zip.js": "^2.8.34", @@ -181,7 +181,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/js-md5": "^0.8.0", - "@types/leaflet": "^1.9.21", + "@types/leaflet": "^1.9.22", "@types/node": "^25.9.5", "@types/node-forge": "^1.3.14", "@types/qrcode": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a6c1c0ec..92283490b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: npm:@jsr/meshtastic__protobufs@^2.7.26 version: '@jsr/meshtastic__protobufs@2.7.26' '@stoprocent/noble': - specifier: ^2.6.0 - version: 2.6.0(supports-color@8.1.1) + specifier: ^2.6.5 + version: 2.6.5(supports-color@8.1.1) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -160,8 +160,8 @@ importers: specifier: ^0.8.0 version: 0.8.0 '@types/leaflet': - specifier: ^1.9.21 - version: 1.9.21 + specifier: ^1.9.22 + version: 1.9.22 '@types/node': specifier: ^25.9.5 version: 25.9.5 @@ -337,8 +337,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -375,8 +375,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -388,12 +388,12 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -1116,8 +1116,8 @@ packages: resolution: {integrity: sha512-xTigfmWCBqNJyPibOCS8F4dTt0LdE5zHAlpOc919ladh1Q+PjQgSGMQZoXxpK9CzZgZCmPF5f90jC0IkogwQEg==} os: [linux, android, freebsd, win32, darwin] - '@stoprocent/noble@2.6.0': - resolution: {integrity: sha512-7z3b+UT+zZxjtjcrHBL3moY/VkN7kplvVkPFssDTSLT/SJd2yuBLQw6jkC858A617giAD6dJ0v/eWPZVrDs/Hw==} + '@stoprocent/noble@2.6.5': + resolution: {integrity: sha512-4/AJNaYtIDT+tQfvCgIjhJUT7M2ZLQgTwjbFmFeeysmenlBlFzx2BbdRTHa5G1ntMQ6hy8yDChgyWtsIP4/trg==} engines: {node: '>=14'} peerDependencies: dbus-next: ^0.10.0 @@ -1337,8 +1337,8 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/leaflet@1.9.21': - resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/leaflet@1.9.22': + resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1676,8 +1676,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.8: - resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==} + baseline-browser-mapping@2.11.9: + resolution: {integrity: sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg==} engines: {node: '>=6.0.0'} hasBin: true @@ -2902,8 +2902,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@29.1.1: @@ -3476,8 +3476,8 @@ packages: resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} engines: {node: ^18 || ^20 || >= 21} - node-addon-api@8.9.0: - resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} engines: {node: ^18 || ^20 || >= 21} node-api-version@0.2.1: @@ -4846,14 +4846,14 @@ snapshots: '@babel/core@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) @@ -4863,10 +4863,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -4883,8 +4883,8 @@ snapshots: '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4893,7 +4893,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -4906,33 +4906,33 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7(supports-color@8.1.1)': + '@babel/traverse@7.29.8(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -5570,7 +5570,7 @@ snapshots: dependencies: async: 3.2.6 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - node-addon-api: 8.9.0 + node-addon-api: 8.9.1 node-gyp-build: 4.8.4 patch-package: 8.0.1 serialport: 12.0.0(supports-color@8.1.1) @@ -5580,10 +5580,10 @@ snapshots: - supports-color optional: true - '@stoprocent/noble@2.6.0(supports-color@8.1.1)': + '@stoprocent/noble@2.6.5(supports-color@8.1.1)': dependencies: debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - node-addon-api: 8.9.0 + node-addon-api: 8.9.1 node-gyp-build: 4.8.4 patch-package: 8.0.1 optionalDependencies: @@ -5780,7 +5780,7 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@types/leaflet@1.9.21': + '@types/leaflet@1.9.22': dependencies: '@types/geojson': 7946.0.16 @@ -6070,7 +6070,7 @@ snapshots: hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.7.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.6 @@ -6206,7 +6206,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.8: {} + baseline-browser-mapping@2.11.9: {} bidi-js@1.0.3: dependencies: @@ -6241,7 +6241,7 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.8 + baseline-browser-mapping: 2.11.9 caniuse-lite: 1.0.30001806 electron-to-chromium: 1.5.399 node-releases: 2.0.51 @@ -6271,7 +6271,7 @@ snapshots: fs-extra: 10.1.0 http-proxy-agent: 7.0.2(supports-color@8.1.1) https-proxy-agent: 7.0.6(supports-color@8.1.1) - js-yaml: 4.3.0 + js-yaml: 4.3.1 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -6568,7 +6568,7 @@ snapshots: app-builder-lib: 26.15.3(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.3)(supports-color@8.1.1) builder-util: 26.15.3(supports-color@8.1.1) fs-extra: 10.1.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color @@ -6652,7 +6652,7 @@ snapshots: dependencies: builder-util-runtime: 9.7.0(supports-color@8.1.1) fs-extra: 10.1.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -6944,7 +6944,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) hermes-parser: 0.25.1 zod: 4.4.3 @@ -7630,7 +7630,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -7928,8 +7928,8 @@ snapshots: magicast@0.5.4: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: @@ -7952,7 +7952,7 @@ snapshots: markdownlint-cli2@0.22.1(supports-color@8.1.1): dependencies: globby: 16.2.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 jsonc-parser: 3.3.1 jsonpointer: 5.0.1 markdown-it: 14.3.0 @@ -8276,7 +8276,7 @@ snapshots: node-addon-api@8.3.0: {} - node-addon-api@8.9.0: {} + node-addon-api@8.9.1: {} node-api-version@0.2.1: dependencies: @@ -9388,7 +9388,7 @@ snapshots: usb@2.18.0(patch_hash=6b746e2d49b9b006a88aec5bed7a13c629d7f5ba7b40e9f1e039136754c32533): dependencies: '@types/w3c-web-usb': 1.0.14 - node-addon-api: 8.9.0 + node-addon-api: 8.9.1 node-gyp-build: 4.8.4 optional: true diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index d141082a1..15cf48cd4 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -378,3 +378,24 @@ git diff 68ad7c835187c052c763bb28c41b04a655f35c64 -- crates/lxmf-core/src/link_d ### Sunset When upstream ships `has_pending_to` (or an equivalent) on the clone pin, remove this patch and drop the apply step. + +## rsReticulum-path-medium-slots.patch + +Ranked multi-path slots (up to 3 per destination) plus global / per-peer RF-vs-network medium preference in `rns-transport`. Apply **after** the other rsReticulum overlays (packet-tap, discovery egress, …). + +| Field | Value | +| ----- | ----- | +| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` (+ prior mesh-client overlays) | +| **Upstream PR** | none yet (mesh-client-local) | + +**Touches:** `constants.rs`, `path_table.rs`, `messages.rs`, `actor/{inbound,mod,rpc,outbound,persistence}.rs` + +### Apply locally + +```bash +./scripts/apply-rsReticulum-path-medium-slots.sh +``` + +### Sunset + +When ratspeak/rsReticulum lands equivalent multi-slot ranking + medium preference, remove this patch and the apply step from `ensure-rsReticulum-patches.sh` / `clone-ratspeak-stack.sh` / `update.sh`. diff --git a/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch b/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch new file mode 100644 index 000000000..3fa49e753 --- /dev/null +++ b/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch @@ -0,0 +1,2153 @@ +--- a/crates/rns-transport/src/constants.rs ++++ b/crates/rns-transport/src/constants.rs +@@ -47,6 +47,12 @@ + /// Max local rebroadcasts before stopping. + pub const LOCAL_REBROADCASTS_MAX: u32 = 2; + ++/// Ranked path slots retained per destination: one active route plus ++/// `MAX_PATH_SLOTS - 1` backups. Backups let a probe/link failure reroute ++/// without waiting for a fresh announce, and are bounded so a destination ++/// heard on many interfaces cannot grow the table without limit. ++pub const MAX_PATH_SLOTS: usize = 3; ++ + /// Path request timeout (seconds). + pub const PATH_REQUEST_TIMEOUT: f64 = 15.0; + +@@ -272,8 +278,74 @@ + Unknown = 0x00, + Unresponsive = 0x01, + Responsive = 0x02, ++} ++ ++/// Transport medium a path was learned over. ++/// ++/// Coarser than [`InterfaceMode`] on purpose: routing preference is only ever ++/// expressed as "radio" versus "network", so callers do not have to enumerate ++/// every mode. See `path_table::path_medium` for the mapping. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum PathMedium { ++ /// LoRa / RNode radio links. ++ Rf, ++ /// IP-style links — TCP/UDP/I2P hubs, gateways, boundaries, shared instances. ++ Network, ++} ++ ++impl PathMedium { ++ pub fn as_str(self) -> &'static str { ++ match self { ++ Self::Rf => "rf", ++ Self::Network => "network", ++ } ++ } ++ ++ /// Parse the wire/RPC spelling produced by [`PathMedium::as_str`]. ++ pub fn from_str_opt(value: &str) -> Option { ++ match value.trim().to_ascii_lowercase().as_str() { ++ "rf" => Some(Self::Rf), ++ "network" => Some(Self::Network), ++ _ => None, ++ } ++ } + } + ++/// Which medium should own the active path slot when both are reachable. ++/// ++/// `Lowest` applies no medium bias and ranks purely by hop count. The other ++/// two are "prefer if possible": when the preferred medium has no live slot, ++/// the best slot of the other medium becomes active without clearing the ++/// preference, so the preferred medium can reclaim the route later. ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] ++pub enum PathMediumPreference { ++ #[default] ++ Lowest, ++ Network, ++ Rf, ++} ++ ++impl PathMediumPreference { ++ pub fn as_str(self) -> &'static str { ++ match self { ++ Self::Lowest => "lowest", ++ Self::Network => "network", ++ Self::Rf => "rf", ++ } ++ } ++ ++ /// Parse the wire/RPC spelling produced by ++ /// [`PathMediumPreference::as_str`]. ++ pub fn from_str_opt(value: &str) -> Option { ++ match value.trim().to_ascii_lowercase().as_str() { ++ "lowest" => Some(Self::Lowest), ++ "network" => Some(Self::Network), ++ "rf" => Some(Self::Rf), ++ _ => None, ++ } ++ } ++} ++ + /// Configured operating mode for an interface. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum InterfaceMode { +--- a/crates/rns-transport/src/path_table.rs ++++ b/crates/rns-transport/src/path_table.rs +@@ -8,6 +8,8 @@ + use crate::messages::InterfaceId; + use rns_wire::types::DestHash; + ++pub use crate::constants::{MAX_PATH_SLOTS, PathMedium, PathMediumPreference}; ++ + /// One known path to a destination. + #[derive(Debug, Clone)] + pub struct PathEntry { +@@ -22,6 +24,9 @@ + /// anti-replay memory on long-lived paths. + pub random_blobs: VecDeque<[u8; 10]>, + pub interface_id: InterfaceId, ++ /// Medium of the interface this path was learned on, captured at insert so ++ /// ranking never has to reach back into the interface table. ++ pub medium: PathMedium, + /// Hash of the cached announce packet — used to satisfy CacheRequest + /// without holding the full packet bytes. + pub packet_hash: Option<[u8; 32]>, +@@ -44,6 +49,7 @@ + expires, + random_blobs: VecDeque::new(), + interface_id, ++ medium: path_medium(interface_mode), + packet_hash: None, + } + } +@@ -86,20 +92,76 @@ + } + } + +-/// Destination-hash → path mapping plus a parallel liveness state map so we +-/// can probe unresponsive paths without rewriting the entries. ++/// A path candidate carried by an inbound announce, before it becomes a ++/// [`PathEntry`]. Ranking is a pure read over this so callers can order their ++/// own bookkeeping (announce table, rebroadcast dedup) before mutating. ++#[derive(Debug, Clone, Copy)] ++pub struct AnnouncedPath { ++ pub interface_id: InterfaceId, ++ /// Next-hop transport id; `None` for a directly reachable destination. ++ pub next_hop: Option<[u8; 16]>, ++ pub hops: u8, ++ pub medium: PathMedium, ++ /// Announce random blob — the anti-replay key. ++ pub random_blob: [u8; 10], ++ /// Announce timebase decoded from `random_blob` (see `announce_timebase`). ++ pub emitted: u64, ++ /// The learning interface is temporarily barred from installing paths for ++ /// this destination (probe or link-establishment failure). ++ pub suppressed: bool, ++} ++ ++/// Where a ranked candidate belongs. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum PathRank { ++ /// Candidate takes the active slot; the previous active is demoted to a ++ /// backup unless it is the same route. ++ Activate, ++ /// Candidate is a usable alternate but must not displace the active slot. ++ Backup, ++ /// Candidate is dropped — suppressed interface, or a replay of the route ++ /// that already owns the active slot. ++ Reject, ++} ++ ++/// Destination-hash → ranked path slots, plus a parallel liveness state map so ++/// we can probe unresponsive paths without rewriting the entries. ++/// ++/// `entries` holds the active route per destination — every legacy accessor ++/// (`get`, `has_path`, `hops_to`, `iter`) reads it, so routing is unchanged for ++/// callers that do not care about alternates. `backups` holds up to ++/// `MAX_PATH_SLOTS - 1` ranked alternates, best first, and only ever exists ++/// alongside an active entry. + #[derive(Clone)] + pub struct PathTable { + entries: HashMap, ++ backups: HashMap>, + states: HashMap, ++ /// Global medium preference. Ranking calls that know the destination pass ++ /// their own effective preference; cull and interface-drop promotions have ++ /// no destination context and fall back to this. ++ preference: PathMediumPreference, + } + + impl PathTable { + pub fn new() -> Self { + Self { + entries: HashMap::new(), ++ backups: HashMap::new(), + states: HashMap::new(), ++ preference: PathMediumPreference::default(), + } ++ } ++ ++ /// Set the global medium preference used by promotions that have no ++ /// per-destination pin available. Callers that own pins should follow this ++ /// with a `rerank` pass over known destinations. ++ pub fn set_preference(&mut self, preference: PathMediumPreference) { ++ self.preference = preference; ++ } ++ ++ pub fn preference(&self) -> PathMediumPreference { ++ self.preference + } + + /// Insert or replace a path entry. The parallel liveness state is +@@ -113,9 +175,15 @@ + /// sites (announce install, PathResponse install, tunnel path restore, disk + /// load), so the invariant is enforced here: state is never older than the + /// entry it describes. ++ /// ++ /// Backup slots for the same route are dropped so the alternate ring never ++ /// shadows the freshly installed active path; alternates on other ++ /// interfaces are left alone. Use [`PathTable::upsert_ranked`] when the old ++ /// active should be demoted rather than discarded. + pub fn insert(&mut self, dest_hash: impl Into, entry: PathEntry) { + let hash: DestHash = dest_hash.into(); + self.states.remove(&hash); ++ self.drop_backup_slot(&hash, entry.interface_id, entry.next_hop); + self.entries.insert(hash, entry); + } + +@@ -145,29 +213,39 @@ + self.get_live(dest_hash).map(|e| e.hops) + } + ++ /// Remove every slot for a destination, active and backups. + pub fn remove(&mut self, dest_hash: &[u8; 16]) -> Option { + self.states.remove(dest_hash); ++ self.backups.remove(dest_hash); + self.entries.remove(dest_hash) + } + +- /// Drop every path whose interface id matches — used when an interface +- /// goes down so we don't keep routing through a dead transport. ++ /// Drop every slot whose interface id matches — used when an interface goes ++ /// down so we don't keep routing through a dead transport. Destinations ++ /// whose active path is dropped promote their best live alternate, so a ++ /// multi-homed peer stays routable. Returns the number of active paths ++ /// removed (promoted or not). + pub fn drop_all_via(&mut self, interface_id: InterfaceId) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, e| e.interface_id != interface_id); +- before - self.entries.len() ++ self.retain_backups(|entry| entry.interface_id != interface_id); ++ self.drop_active_where(|entry| entry.interface_id == interface_id) + } + + pub fn drop_all_via_next_hop(&mut self, next_hop: &[u8; 16]) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, e| e.next_hop != Some(*next_hop)); +- before - self.entries.len() ++ let next_hop = *next_hop; ++ self.retain_backups(|entry| entry.next_hop != Some(next_hop)); ++ self.drop_active_where(|entry| entry.next_hop == Some(next_hop)) + } + +- /// Force-expire a path and cull immediately. Useful when the caller +- /// already knows the path is bad (e.g. a link proof failed) and ++ /// Force-drop a destination's route and cull immediately. Useful when the ++ /// caller already knows the path is bad (e.g. a link proof failed) and + /// shouldn't wait for the periodic cull cycle. ++ /// ++ /// Backups are dropped too: callers use this to make a destination ++ /// *unrouted* (path rediscovery, `DropPath` RPC), so silently promoting an ++ /// alternate would defeat the request. Use ++ /// [`PathTable::suppress_interface`] to reroute onto a backup instead. + pub fn expire(&mut self, dest_hash: &[u8; 16]) -> bool { ++ self.backups.remove(dest_hash); + if let Some(entry) = self.entries.get_mut(dest_hash) { + entry.expires = 0.0; + self.cull_expired(); +@@ -189,17 +267,17 @@ + } + + /// Full cull pass. Prefer `cull_expired_batch` on the hot path to bound +- /// per-tick work. ++ /// per-tick work. Expired alternates are dropped first so a destination ++ /// whose active path expired can only promote a still-live alternate. ++ /// Returns the number of expired active paths removed. + pub fn cull_expired(&mut self) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, entry| !entry.is_expired()); +- self.states +- .retain(|hash, _| self.entries.contains_key(hash)); +- before - self.entries.len() ++ self.retain_backups(|entry| !entry.is_expired()); ++ self.drop_active_where(|entry| entry.is_expired()) + } + + /// Batched cull — removes at most `limit` expired entries so the actor +- /// cannot stall on a very large path table. ++ /// cannot stall on a very large path table. Alternates are only inspected ++ /// for the destinations in this batch, keeping per-tick work bounded. + pub fn cull_expired_batch(&mut self, limit: usize) -> usize { + let to_remove: Vec = self + .entries +@@ -212,21 +290,321 @@ + for hash in &to_remove { + self.entries.remove(hash.as_bytes()); + self.states.remove(hash.as_bytes()); ++ self.promote_best_backup(hash, self.preference); + } + count + } + +- /// Drop paths whose interface id is no longer active. ++ /// Drop slots whose interface id is no longer active, promoting alternates ++ /// on live interfaces where possible. + pub fn cull_dead_interfaces( + &mut self, + active_interfaces: &std::collections::HashSet, + ) -> usize { +- let before = self.entries.len(); +- self.entries +- .retain(|_, entry| active_interfaces.contains(&entry.interface_id)); ++ self.retain_backups(|entry| active_interfaces.contains(&entry.interface_id)); ++ self.drop_active_where(|entry| !active_interfaces.contains(&entry.interface_id)) ++ } ++ ++ // ----- ranked multi-path slots ------------------------------------------- ++ ++ /// Every known slot for a destination, active first, then alternates in ++ /// rank order. Empty when the destination is unknown. ++ pub fn slots(&self, dest_hash: &[u8; 16]) -> Vec<&PathEntry> { ++ let mut slots = Vec::new(); ++ if let Some(active) = self.entries.get(dest_hash) { ++ slots.push(active); ++ } else { ++ return slots; ++ } ++ if let Some(backups) = self.backups.get(dest_hash) { ++ slots.extend(backups.iter()); ++ } ++ slots ++ } ++ ++ /// Ranked alternates for a destination, best first. ++ pub fn backups(&self, dest_hash: &[u8; 16]) -> &[PathEntry] { ++ self.backups ++ .get(dest_hash) ++ .map(|backups| backups.as_slice()) ++ .unwrap_or(&[]) ++ } ++ ++ /// Decide where a freshly heard announce belongs. Pure read — apply the ++ /// result with [`PathTable::upsert_ranked`]. ++ /// ++ /// `preference` is the *effective* preference for this destination (a ++ /// per-destination pin already resolved against the global setting). ++ /// ++ /// Layering, outermost first: ++ /// 1. A suppressed interface is always rejected. ++ /// 2. A live path on the preferred medium is never displaced by the other ++ /// medium, and a preferred-medium candidate always reclaims the active ++ /// slot from the other medium. ++ /// 3. Within one medium rank, a strictly shorter route on a *different* ++ /// interface or next hop wins — this is what lets a second TCP hub or an ++ /// RF neighbour take over when it hears the same announce closer to the ++ /// source. ++ /// 4. Otherwise the pre-multipath announce rules decide (newer announce ++ /// timebase, unseen random blob, expired or unresponsive path), so equal ++ /// hop counts still cannot flap the route. ++ pub fn rank_announced( ++ &self, ++ dest_hash: &[u8; 16], ++ candidate: &AnnouncedPath, ++ preference: PathMediumPreference, ++ ) -> PathRank { ++ if candidate.suppressed { ++ return PathRank::Reject; ++ } ++ let Some(active) = self.entries.get(dest_hash) else { ++ return PathRank::Activate; ++ }; ++ ++ let random_seen = active.has_random_blob(&candidate.random_blob); ++ let path_timebase = path_timebase_from_random_blobs(active.random_blobs.iter()); ++ let timebase_allows = if candidate.hops <= active.hops { ++ !random_seen && candidate.emitted > path_timebase ++ } else if active.is_expired() || candidate.emitted > path_timebase { ++ !random_seen ++ } else if candidate.emitted == path_timebase { ++ self.get_state(dest_hash) == PathState::Unresponsive ++ } else { ++ false ++ }; ++ ++ let same_route = is_same_route(active, candidate.interface_id, candidate.next_hop); ++ let candidate_rank = medium_rank(candidate.medium, preference); ++ let active_rank = medium_rank(active.medium, preference); ++ ++ let activate = if active.is_expired() { ++ // Nothing live to protect: prefer-if-possible means the other ++ // medium may take over until the preferred one is heard again. ++ timebase_allows ++ } else if candidate_rank < active_rank { ++ true ++ } else if candidate_rank > active_rank { ++ false ++ } else if !same_route && candidate.hops < active.hops { ++ true ++ } else { ++ timebase_allows ++ }; ++ ++ if activate { ++ PathRank::Activate ++ } else if same_route { ++ PathRank::Reject ++ } else { ++ PathRank::Backup ++ } ++ } ++ ++ /// Apply a [`PathRank`] decision. Returns `true` when the destination's ++ /// active route changed — a fresh install, or a different interface / ++ /// next hop than before — so callers know when to re-probe or notify. ++ /// ++ /// `Activate` demotes the previous active into the alternate ring; ++ /// `Backup` refreshes the matching alternate slot or inserts a new one, ++ /// evicting the worst-ranked slot once `MAX_PATH_SLOTS` is reached. ++ pub fn upsert_ranked( ++ &mut self, ++ dest_hash: impl Into, ++ entry: PathEntry, ++ rank: PathRank, ++ preference: PathMediumPreference, ++ ) -> bool { ++ let hash: DestHash = dest_hash.into(); ++ match rank { ++ PathRank::Reject => false, ++ PathRank::Activate => { ++ let interface_id = entry.interface_id; ++ let next_hop = entry.next_hop; ++ let previous = self.entries.insert(hash, entry); ++ self.states.remove(&hash); ++ self.drop_backup_slot(&hash, interface_id, next_hop); ++ let changed = match previous { ++ Some(previous) => { ++ let changed = !is_same_route(&previous, interface_id, next_hop); ++ if changed { ++ self.backups.entry(hash).or_default().push(previous); ++ } ++ changed ++ } ++ None => true, ++ }; ++ self.resort_backups(&hash, preference); ++ changed ++ } ++ PathRank::Backup => { ++ if !self.entries.contains_key(hash.as_bytes()) { ++ // No active route to back up — install directly so the ++ // alternate ring never holds the only known path. ++ self.states.remove(&hash); ++ self.entries.insert(hash, entry); ++ return true; ++ } ++ let backups = self.backups.entry(hash).or_default(); ++ match backups ++ .iter_mut() ++ .find(|slot| is_same_route(slot, entry.interface_id, entry.next_hop)) ++ { ++ Some(slot) => *slot = entry, ++ None => backups.push(entry), ++ } ++ self.resort_backups(&hash, preference); ++ false ++ } ++ } ++ } ++ ++ /// Re-rank a destination's slots after a preference or pin change, without ++ /// waiting for a new announce. Returns `true` when the active route moved. ++ /// Ties keep the incumbent active so a preference write cannot flap a route. ++ pub fn rerank(&mut self, dest_hash: &[u8; 16], preference: PathMediumPreference) -> bool { ++ let hash: DestHash = (*dest_hash).into(); ++ if !self.backups.contains_key(&hash) { ++ return false; ++ } ++ let Some(active) = self.entries.remove(&hash) else { ++ self.backups.remove(&hash); ++ return false; ++ }; ++ let previous = (active.interface_id, active.next_hop); ++ let incumbent_rank = slot_rank_key(&active, preference); ++ let mut slots = vec![active]; ++ slots.extend(self.backups.remove(&hash).unwrap_or_default()); ++ sort_slots(&mut slots, preference); ++ if slot_rank_key(&slots[0], preference) >= incumbent_rank { ++ // Nothing strictly better — keep the incumbent to avoid a flap. ++ let position = slots ++ .iter() ++ .position(|entry| is_same_route(entry, previous.0, previous.1)) ++ .unwrap_or(0); ++ let incumbent = slots.remove(position); ++ slots.insert(0, incumbent); ++ } ++ let best = slots.remove(0); ++ let changed = !is_same_route(&best, previous.0, previous.1); ++ if changed { ++ self.states.remove(&hash); ++ } ++ self.entries.insert(hash, best); ++ if !slots.is_empty() { ++ self.backups.insert(hash, slots); ++ } ++ self.resort_backups(&hash, preference); ++ changed ++ } ++ ++ /// Drop every slot for a destination that goes through `interface_id` and ++ /// promote the best remaining live alternate. Called when a probe or link ++ /// attempt over the active interface fails, so the reroute happens before ++ /// rediscovery rather than after it. ++ /// ++ /// Returns `true` when the active slot was owned by `interface_id` — it is ++ /// now either a promoted alternate or absent. ++ pub fn suppress_interface( ++ &mut self, ++ dest_hash: &[u8; 16], ++ interface_id: InterfaceId, ++ preference: PathMediumPreference, ++ ) -> bool { ++ let hash: DestHash = (*dest_hash).into(); ++ if let Some(backups) = self.backups.get_mut(&hash) { ++ backups.retain(|entry| entry.interface_id != interface_id); ++ if backups.is_empty() { ++ self.backups.remove(&hash); ++ } ++ } ++ let owns_active = self ++ .entries ++ .get(&hash) ++ .is_some_and(|entry| entry.interface_id == interface_id); ++ if !owns_active { ++ return false; ++ } ++ self.entries.remove(&hash); ++ self.states.remove(&hash); ++ self.promote_best_backup(&hash, preference); ++ true ++ } ++ ++ /// Promote the best live alternate into an empty active slot. Returns ++ /// `true` when a route was promoted. ++ fn promote_best_backup(&mut self, hash: &DestHash, preference: PathMediumPreference) -> bool { ++ if self.entries.contains_key(hash.as_bytes()) { ++ return false; ++ } ++ let Some(mut backups) = self.backups.remove(hash) else { ++ return false; ++ }; ++ backups.retain(|entry| !entry.is_expired()); ++ sort_slots(&mut backups, preference); ++ if backups.is_empty() { ++ return false; ++ } ++ let promoted = backups.remove(0); ++ if !backups.is_empty() { ++ self.backups.insert(*hash, backups); ++ } ++ // A promoted route is not the one the old liveness reading described. ++ self.states.remove(hash); ++ self.entries.insert(*hash, promoted); ++ true ++ } ++ ++ fn resort_backups(&mut self, hash: &DestHash, preference: PathMediumPreference) { ++ let Some(backups) = self.backups.get_mut(hash) else { ++ return; ++ }; ++ sort_slots(backups, preference); ++ backups.truncate(MAX_PATH_SLOTS.saturating_sub(1)); ++ if backups.is_empty() { ++ self.backups.remove(hash); ++ } ++ } ++ ++ fn drop_backup_slot( ++ &mut self, ++ hash: &DestHash, ++ interface_id: InterfaceId, ++ next_hop: Option<[u8; 16]>, ++ ) { ++ let Some(backups) = self.backups.get_mut(hash) else { ++ return; ++ }; ++ backups.retain(|entry| !is_same_route(entry, interface_id, next_hop)); ++ if backups.is_empty() { ++ self.backups.remove(hash); ++ } ++ } ++ ++ fn retain_backups(&mut self, keep: impl Fn(&PathEntry) -> bool) { ++ self.backups.retain(|_, backups| { ++ backups.retain(&keep); ++ !backups.is_empty() ++ }); ++ } ++ ++ /// Remove every active entry matching `drop`, promoting the best live ++ /// alternate for each. Returns the number of active entries removed. ++ fn drop_active_where(&mut self, drop: impl Fn(&PathEntry) -> bool) -> usize { ++ let hashes: Vec = self ++ .entries ++ .iter() ++ .filter(|(_, entry)| drop(entry)) ++ .map(|(hash, _)| *hash) ++ .collect(); ++ let preference = self.preference; ++ for hash in &hashes { ++ self.entries.remove(hash.as_bytes()); ++ self.states.remove(hash.as_bytes()); ++ self.promote_best_backup(hash, preference); ++ } + self.states + .retain(|hash, _| self.entries.contains_key(hash)); +- before - self.entries.len() ++ hashes.len() + } + + pub fn len(&self) -> usize { +@@ -263,10 +641,153 @@ + } + } + ++/// Classify an interface mode into its transport medium. ++/// ++/// RNodes and other radios are configured as access points (the RNode default) ++/// or roaming; every other mode is an IP-style network link. ++pub fn path_medium(mode: InterfaceMode) -> PathMedium { ++ match mode { ++ InterfaceMode::AccessPoint | InterfaceMode::Roaming => PathMedium::Rf, ++ _ => PathMedium::Network, ++ } ++} ++ ++/// Resolve a per-destination pin against the global preference. A pin always ++/// wins; without one the global setting applies. ++pub fn effective_path_medium_preference( ++ global: PathMediumPreference, ++ pin: Option, ++) -> PathMediumPreference { ++ match pin { ++ Some(PathMedium::Rf) => PathMediumPreference::Rf, ++ Some(PathMedium::Network) => PathMediumPreference::Network, ++ None => global, ++ } ++} ++ ++/// 0 for the preferred medium, 1 for the other. `Lowest` ranks both at 0 so ++/// hop count alone decides. ++fn medium_rank(medium: PathMedium, preference: PathMediumPreference) -> u8 { ++ let preferred = match preference { ++ PathMediumPreference::Lowest => return 0, ++ PathMediumPreference::Network => PathMedium::Network, ++ PathMediumPreference::Rf => PathMedium::Rf, ++ }; ++ u8::from(medium != preferred) ++} ++ ++/// Two slots describe the same route when they share both the learning ++/// interface and the next hop; a hub reachable over two interfaces, or two ++/// relays on one interface, are distinct slots. ++fn is_same_route(entry: &PathEntry, interface_id: InterfaceId, next_hop: Option<[u8; 16]>) -> bool { ++ entry.interface_id == interface_id && entry.next_hop == next_hop ++} ++ ++/// Routing quality of a slot: live before expired, preferred medium before the ++/// other, fewer hops before more. Deliberately excludes learn time so a ++/// re-rank cannot hand the active slot to an equally good route. ++fn slot_rank_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8) { ++ ( ++ u8::from(entry.is_expired()), ++ medium_rank(entry.medium, preference), ++ entry.hops, ++ ) ++} ++ ++/// Rank plus newest-learn-time, used to order the backup ring and to decide ++/// which slot is evicted once `MAX_PATH_SLOTS` is reached. ++fn slot_sort_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8, f64) { ++ let (expired, medium, hops) = slot_rank_key(entry, preference); ++ (expired, medium, hops, -entry.timestamp) ++} ++ ++/// Stable sort so equal-ranked slots keep their relative order — callers rely ++/// on that to avoid flapping the active route. ++fn sort_slots(slots: &mut [PathEntry], preference: PathMediumPreference) { ++ slots.sort_by(|a, b| { ++ slot_sort_key(a, preference) ++ .partial_cmp(&slot_sort_key(b, preference)) ++ .unwrap_or(std::cmp::Ordering::Equal) ++ }); ++} ++ ++/// Announce emission timebase encoded in the trailing 5 bytes of an announce ++/// random blob. Used to order announces without trusting the hop count. ++pub fn announce_timebase(random_blob: &[u8; 10]) -> u64 { ++ let mut emitted = [0u8; 8]; ++ emitted[3..].copy_from_slice(&random_blob[5..10]); ++ u64::from_be_bytes(emitted) ++} ++ ++/// Newest announce timebase recorded on a path. ++pub fn path_timebase_from_random_blobs<'a>( ++ random_blobs: impl Iterator, ++) -> u64 { ++ random_blobs.map(announce_timebase).max().unwrap_or(0) ++} ++ + #[cfg(test)] + mod tests { + use super::*; + ++ /// Announce blob whose trailing bytes encode `emitted` — mirrors what the ++ /// wire carries so `announce_timebase` agrees with the test's intent. ++ fn blob_for(prefix: u8, emitted: u64) -> [u8; 10] { ++ let mut blob = [prefix; 10]; ++ let emitted = emitted.to_be_bytes(); ++ blob[5..].copy_from_slice(&emitted[3..8]); ++ blob ++ } ++ ++ fn announced( ++ hops: u8, ++ interface_id: InterfaceId, ++ medium: PathMedium, ++ blob: [u8; 10], ++ ) -> AnnouncedPath { ++ AnnouncedPath { ++ interface_id, ++ next_hop: None, ++ hops, ++ medium, ++ random_blob: blob, ++ emitted: announce_timebase(&blob), ++ suppressed: false, ++ } ++ } ++ ++ fn slot(hops: u8, interface_id: InterfaceId, medium: PathMedium, blob: [u8; 10]) -> PathEntry { ++ let mode = match medium { ++ PathMedium::Rf => InterfaceMode::AccessPoint, ++ PathMedium::Network => InterfaceMode::Gateway, ++ }; ++ let mut entry = PathEntry::new(None, hops, interface_id, mode); ++ entry.add_random_blob(blob); ++ entry ++ } ++ ++ /// Rank + apply in one step, the way the announce path does. ++ fn learn( ++ table: &mut PathTable, ++ dest: [u8; 16], ++ candidate: &AnnouncedPath, ++ preference: PathMediumPreference, ++ ) -> PathRank { ++ let rank = table.rank_announced(&dest, candidate, preference); ++ let entry = slot( ++ candidate.hops, ++ candidate.interface_id, ++ candidate.medium, ++ candidate.random_blob, ++ ); ++ table.upsert_ranked(dest, entry, rank, preference); ++ rank ++ } ++ ++ fn interface_ids(slots: &[PathEntry]) -> Vec { ++ slots.iter().map(|entry| entry.interface_id).collect() ++ } ++ + #[test] + fn test_path_table_basic() { + let mut table = PathTable::new(); +@@ -392,6 +913,448 @@ + } + + #[test] ++ fn path_medium_maps_radio_modes_to_rf() { ++ assert_eq!(path_medium(InterfaceMode::AccessPoint), PathMedium::Rf); ++ assert_eq!(path_medium(InterfaceMode::Roaming), PathMedium::Rf); ++ for mode in [ ++ InterfaceMode::Full, ++ InterfaceMode::Gateway, ++ InterfaceMode::Boundary, ++ InterfaceMode::PointToPoint, ++ InterfaceMode::Internal, ++ ] { ++ assert_eq!(path_medium(mode), PathMedium::Network, "{mode:?}"); ++ } ++ } ++ ++ #[test] ++ fn medium_and_preference_round_trip_their_wire_spelling() { ++ for medium in [PathMedium::Rf, PathMedium::Network] { ++ assert_eq!(PathMedium::from_str_opt(medium.as_str()), Some(medium)); ++ } ++ for preference in [ ++ PathMediumPreference::Lowest, ++ PathMediumPreference::Network, ++ PathMediumPreference::Rf, ++ ] { ++ assert_eq!( ++ PathMediumPreference::from_str_opt(preference.as_str()), ++ Some(preference) ++ ); ++ } ++ assert_eq!(PathMedium::from_str_opt(" RF "), Some(PathMedium::Rf)); ++ assert_eq!(PathMedium::from_str_opt("lora"), None); ++ assert_eq!(PathMediumPreference::from_str_opt("fastest"), None); ++ } ++ ++ #[test] ++ fn effective_preference_prefers_the_pin() { ++ assert_eq!( ++ effective_path_medium_preference(PathMediumPreference::Lowest, Some(PathMedium::Rf)), ++ PathMediumPreference::Rf ++ ); ++ assert_eq!( ++ effective_path_medium_preference(PathMediumPreference::Rf, Some(PathMedium::Network)), ++ PathMediumPreference::Network ++ ); ++ assert_eq!( ++ effective_path_medium_preference(PathMediumPreference::Network, None), ++ PathMediumPreference::Network ++ ); ++ } ++ ++ /// The multi-TCP case: the same announce heard closer to the source on a ++ /// second interface takes over, and the longer route is kept as a backup. ++ #[test] ++ fn same_blob_with_fewer_hops_on_another_interface_takes_active_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA0; 16]; ++ let blob = blob_for(0x11, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); ++ ++ let candidate = announced(2, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Activate ++ ); ++ ++ assert_eq!(table.get(&dest).unwrap().interface_id, 2); ++ assert_eq!(table.hops_to(&dest), Some(2)); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ assert_eq!(table.slots(&dest).len(), 2); ++ } ++ ++ #[test] ++ fn same_blob_with_more_hops_on_another_interface_is_backup_only() { ++ let mut table = PathTable::new(); ++ let dest = [0xA1; 16]; ++ let blob = blob_for(0x22, 100); ++ table.insert(dest, slot(2, 1, PathMedium::Network, blob)); ++ ++ let candidate = announced(5, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); ++ ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ } ++ ++ /// Equal hop counts must not flap the route, even across interfaces. ++ #[test] ++ fn equal_hops_on_another_interface_does_not_flap_the_active_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA2; 16]; ++ let blob = blob_for(0x33, 100); ++ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); ++ ++ let same_blob = announced(3, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &same_blob, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ ++ // An *older* announce at the same hop count is equally powerless. ++ let older_blob = announced(3, 3, PathMedium::Network, blob_for(0x44, 99)); ++ assert_eq!( ++ learn(&mut table, dest, &older_blob, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(table.hops_to(&dest), Some(3)); ++ } ++ ++ #[test] ++ fn a_fourth_route_evicts_the_worst_ranked_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA3; 16]; ++ let blob = blob_for(0x55, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ for (hops, interface_id) in [(2u8, 2u64), (5, 3)] { ++ let candidate = announced(hops, interface_id, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); ++ } ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2, 3]); ++ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); ++ ++ let fourth = announced(3, 4, PathMedium::Network, blob); ++ learn(&mut table, dest, &fourth, PathMediumPreference::Lowest); ++ ++ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!( ++ interface_ids(table.backups(&dest)), ++ vec![2, 4], ++ "the 5-hop route should be the one evicted" ++ ); ++ } ++ ++ #[test] ++ fn suppressing_the_active_interface_promotes_a_backup() { ++ let mut table = PathTable::new(); ++ let dest = [0xA4; 16]; ++ let blob = blob_for(0x66, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let candidate = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); ++ table.set_state(dest, PathState::Responsive); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); ++ ++ let active = table.get(&dest).expect("backup should have been promoted"); ++ assert_eq!(active.interface_id, 2); ++ assert_eq!(active.hops, 4); ++ assert!(table.backups(&dest).is_empty()); ++ assert_eq!( ++ table.get_state(&dest), ++ PathState::Unknown, ++ "a promoted route must be re-probed" ++ ); ++ } ++ ++ #[test] ++ fn suppressing_the_only_interface_leaves_the_destination_unrouted() { ++ let mut table = PathTable::new(); ++ let dest = [0xA5; 16]; ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob_for(0x77, 100))); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); ++ assert!(!table.has_path(&dest)); ++ ++ assert!( ++ !table.suppress_interface(&dest, 1, PathMediumPreference::Lowest), ++ "an unknown destination cannot be rerouted" ++ ); ++ } ++ ++ #[test] ++ fn suppressing_a_backup_interface_leaves_the_active_route_alone() { ++ let mut table = PathTable::new(); ++ let dest = [0xA6; 16]; ++ let blob = blob_for(0x88, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let candidate = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); ++ ++ assert!(!table.suppress_interface(&dest, 2, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn network_preference_keeps_a_shorter_rf_route_in_the_backups() { ++ let mut table = PathTable::new(); ++ let dest = [0xA7; 16]; ++ let blob = blob_for(0x99, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); ++ ++ let rf = announced(1, 2, PathMedium::Rf, blob); ++ assert_eq!( ++ learn(&mut table, dest, &rf, PathMediumPreference::Network), ++ PathRank::Backup ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ ++ // Without a medium preference the same announce would have won. ++ let mut lowest = PathTable::new(); ++ lowest.insert(dest, slot(4, 1, PathMedium::Network, blob)); ++ assert_eq!( ++ learn(&mut lowest, dest, &rf, PathMediumPreference::Lowest), ++ PathRank::Activate ++ ); ++ assert_eq!(lowest.get(&dest).unwrap().interface_id, 2); ++ } ++ ++ #[test] ++ fn rf_preference_reclaims_the_active_slot_from_a_shorter_network_route() { ++ let mut table = PathTable::new(); ++ let dest = [0xA8; 16]; ++ let blob = blob_for(0xAA, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ ++ let rf = announced(6, 2, PathMedium::Rf, blob); ++ assert_eq!( ++ learn(&mut table, dest, &rf, PathMediumPreference::Rf), ++ PathRank::Activate ++ ); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ } ++ ++ /// Prefer-if-possible: an RF pin with no live RF slot falls back to the ++ /// network route, and the RF route reclaims the slot when heard again. ++ #[test] ++ fn rf_preference_falls_back_to_network_and_reclaims_later() { ++ let mut table = PathTable::new(); ++ let dest = [0xA9; 16]; ++ let blob = blob_for(0xBB, 100); ++ table.insert(dest, slot(6, 1, PathMedium::Rf, blob)); ++ let network = announced(2, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &network, PathMediumPreference::Rf); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Rf)); ++ assert_eq!( ++ table.get(&dest).unwrap().medium, ++ PathMedium::Network, ++ "with no RF slot left the network route must carry traffic" ++ ); ++ ++ let rf_again = announced(6, 3, PathMedium::Rf, blob_for(0xCC, 101)); ++ assert_eq!( ++ learn(&mut table, dest, &rf_again, PathMediumPreference::Rf), ++ PathRank::Activate ++ ); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ } ++ ++ #[test] ++ fn preference_change_reranks_without_a_new_announce() { ++ let mut table = PathTable::new(); ++ let dest = [0xB0; 16]; ++ let blob = blob_for(0xDD, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Rf, blob)); ++ let network = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &network, PathMediumPreference::Lowest); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ ++ table.set_preference(PathMediumPreference::Network); ++ assert!(table.rerank(&dest, PathMediumPreference::Network)); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ ++ // Re-running the same rerank is a no-op — no flap on repeated writes. ++ assert!(!table.rerank(&dest, PathMediumPreference::Network)); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); ++ } ++ ++ #[test] ++ fn rerank_keeps_the_incumbent_on_ties() { ++ let mut table = PathTable::new(); ++ let dest = [0xB1; 16]; ++ let blob = blob_for(0xEE, 100); ++ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); ++ let peer = announced(3, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ ++ assert!(!table.rerank(&dest, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ } ++ ++ #[test] ++ fn suppressed_interfaces_are_rejected_outright() { ++ let mut table = PathTable::new(); ++ let dest = [0xB2; 16]; ++ let blob = blob_for(0x0F, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); ++ ++ let mut candidate = announced(1, 2, PathMedium::Network, blob); ++ candidate.suppressed = true; ++ assert_eq!( ++ table.rank_announced(&dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Reject ++ ); ++ assert_eq!( ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Reject ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn drop_all_via_clears_backups_and_promotes() { ++ let mut table = PathTable::new(); ++ let dest = [0xB3; 16]; ++ let blob = blob_for(0x1F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ ++ assert_eq!( ++ table.drop_all_via(2), ++ 0, ++ "no active path went through iface 2" ++ ); ++ assert!(table.backups(&dest).is_empty()); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.drop_all_via(1), 1); ++ assert_eq!( ++ table.get(&dest).unwrap().interface_id, ++ 2, ++ "the surviving backup should carry the destination" ++ ); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn cull_expired_promotes_a_live_backup() { ++ let mut table = PathTable::new(); ++ let dest = [0xB4; 16]; ++ let blob = blob_for(0x2F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; ++ ++ assert_eq!(table.cull_expired(), 1); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 2); ++ assert!(table.has_path(&dest)); ++ } ++ ++ #[test] ++ fn cull_expired_drops_expired_backups_before_promoting() { ++ let mut table = PathTable::new(); ++ let dest = [0xB5; 16]; ++ let blob = blob_for(0x3F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; ++ table.backups.get_mut(&DestHash::from(dest)).unwrap()[0].expires = now_f64() - 1.0; ++ ++ assert_eq!(table.cull_expired(), 1); ++ assert!(!table.has_path(&dest)); ++ assert!(table.slots(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn expire_drops_every_slot_for_the_destination() { ++ let mut table = PathTable::new(); ++ let dest = [0xB6; 16]; ++ let blob = blob_for(0x4F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ ++ assert!(table.expire(&dest)); ++ assert!(!table.has_path(&dest)); ++ assert!(table.slots(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn remove_and_insert_keep_the_backup_ring_consistent() { ++ let mut table = PathTable::new(); ++ let dest = [0xB7; 16]; ++ let blob = blob_for(0x5F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ ++ // Re-installing the backup's route as active must not leave a duplicate. ++ table.insert(dest, slot(4, 2, PathMedium::Network, blob)); ++ assert!(table.backups(&dest).is_empty()); ++ assert_eq!(table.slots(&dest).len(), 1); ++ ++ let peer = announced(6, 3, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.slots(&dest).len(), 2); ++ table.remove(&dest); ++ assert!(table.slots(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn backup_upsert_without_an_active_route_installs_directly() { ++ let mut table = PathTable::new(); ++ let dest = [0xB8; 16]; ++ let entry = slot(3, 1, PathMedium::Network, blob_for(0x6F, 100)); ++ ++ assert!(table.upsert_ranked(dest, entry, PathRank::Backup, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn refreshing_a_backup_route_replaces_its_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xB9; 16]; ++ let blob = blob_for(0x7F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(6, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.backups(&dest)[0].hops, 6); ++ ++ let closer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &closer, PathMediumPreference::Lowest); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ assert_eq!(table.backups(&dest)[0].hops, 4); ++ } ++ ++ #[test] ++ fn unknown_destination_has_no_slots() { ++ let table = PathTable::new(); ++ assert!(table.slots(&[0xFF; 16]).is_empty()); ++ assert!(table.backups(&[0xFF; 16]).is_empty()); ++ } ++ ++ #[test] + fn test_cull_dead_interfaces() { + let mut table = PathTable::new(); + let mut active = std::collections::HashSet::new(); +--- a/crates/rns-transport/src/messages.rs ++++ b/crates/rns-transport/src/messages.rs +@@ -664,7 +664,24 @@ + SuppressCurrentPathInterface { + dest: [u8; 16], + duration: f64, ++ }, ++ /// Set the global medium preference for the active path slot and re-rank ++ /// known destinations. Response: `IntResult(destinations_rerouted)`. ++ SetPathMediumPreference { ++ preference: crate::constants::PathMediumPreference, ++ }, ++ /// Pin `dest` to a medium (or clear the pin with `None`), overriding the ++ /// global preference for that destination. ++ /// Response: `BoolResult(active_route_moved)`. ++ SetPeerMediumPin { ++ dest: [u8; 16], ++ pin: Option, + }, ++ /// Ranked path slots for `dest` — the active route plus up to ++ /// `MAX_PATH_SLOTS - 1` alternates. Response: `PathSlots`. ++ GetPathSlots { ++ dest: [u8; 16], ++ }, + DropAnnounceQueues, + GetBlackholedIdentities, + BlackholeIdentity { +@@ -780,6 +797,7 @@ + #[derive(Debug)] + pub enum TransportQueryResponse { + PathTable(Vec), ++ PathSlots(PathSlotsRpcEntry), + InterfaceStats(Vec), + RateTable(Vec), + Announces(Vec), +@@ -813,9 +831,35 @@ + pub interface_id: InterfaceId, + pub interface_mode: InterfaceMode, + pub interface_role: InterfaceRole, ++} ++ ++/// Ranked path slots for one destination, active first. ++#[derive(Debug, Clone)] ++pub struct PathSlotsRpcEntry { ++ pub dest: [u8; 16], ++ /// Preference actually applied to this destination (pin resolved against ++ /// the global setting). ++ pub preference: crate::constants::PathMediumPreference, ++ /// Per-destination pin, when one is set. ++ pub pin: Option, ++ pub slots: Vec, + } + + #[derive(Debug, Clone)] ++pub struct PathSlotRpcEntry { ++ /// True for the route currently used for outbound traffic. ++ pub active: bool, ++ pub hops: u8, ++ pub via: Option<[u8; 16]>, ++ pub interface_id: InterfaceId, ++ pub interface: String, ++ pub medium: crate::constants::PathMedium, ++ pub timestamp: f64, ++ pub expires: f64, ++ pub expired: bool, ++} ++ ++#[derive(Debug, Clone)] + pub struct InterfaceStatRpcEntry { + pub id: InterfaceId, + pub name: String, +--- a/crates/rns-transport/src/actor/inbound.rs ++++ b/crates/rns-transport/src/actor/inbound.rs +@@ -375,25 +375,52 @@ + .unwrap_or_default(); + let suppressed = + self.is_path_interface_suppressed(header.destination_hash, interface_id, now_f64()); +- let should_add = if suppressed { +- false +- } else if let Some(existing) = self.path_table.get(&header.destination_hash) { +- let random_seen = existing.has_random_blob(&announce_random_hash); +- let path_timebase = path_timebase_from_random_blobs(existing.random_blobs.iter()); +- if header.hops <= existing.hops { +- !random_seen && announce_emitted > path_timebase +- } else if existing.is_expired() || announce_emitted > path_timebase { +- !random_seen +- } else if announce_emitted == path_timebase { +- self.path_table.get_state(&header.destination_hash) +- == crate::constants::PathState::Unresponsive +- } else { +- false +- } +- } else { +- true ++ let candidate = crate::path_table::AnnouncedPath { ++ interface_id, ++ next_hop: header.transport_id, ++ hops: header.hops, ++ medium: crate::path_table::path_medium(iface_mode), ++ random_blob: announce_random_hash, ++ emitted: announce_emitted, ++ suppressed, + }; ++ let preference = self.effective_path_medium_preference(&header.destination_hash); ++ let rank = self ++ .path_table ++ .rank_announced(&header.destination_hash, &candidate, preference); ++ if rank == crate::path_table::PathRank::Reject { ++ debug!( ++ dest = hex::encode(header.destination_hash), ++ hops = header.hops, ++ announce_emitted, ++ interface_id, ++ suppressed, ++ "ignoring replayed or stale announce" ++ ); ++ return; ++ } ++ let should_add = rank == crate::path_table::PathRank::Activate; + ++ // For Header2 announces via a transport node, next_hop is the relay's ++ // hash from transport_id; for Header1 announces the destination is ++ // directly reachable and next_hop stays None. ++ let mut entry = crate::path_table::PathEntry::new( ++ header.transport_id, ++ header.hops, ++ interface_id, ++ iface_mode, ++ ); ++ if !random_blobs.contains(&announce_random_hash) { ++ if random_blobs.len() >= MAX_RANDOM_BLOBS { ++ random_blobs.pop_front(); ++ } ++ random_blobs.push_back(announce_random_hash); ++ } ++ entry.random_blobs = random_blobs; ++ // Store the announce packet hash so a later CacheRequest for this ++ // destination can replay the exact announce bytes. ++ entry.packet_hash = Some(rns_wire::hash::packet_hash(raw, header.flags.header_type)); ++ + if should_add { + // Announce-table update MUST precede path-table update — the dedup + // check above compares against our own queued copy. Non-transport +@@ -434,26 +461,6 @@ + .insert(header.destination_hash, announce_entry); + } + +- // For Header2 announces via a transport node, next_hop is the relay's +- // hash from transport_id; for Header1 announces the destination is +- // directly reachable and next_hop stays None. +- let mut entry = crate::path_table::PathEntry::new( +- header.transport_id, +- header.hops, +- interface_id, +- iface_mode, +- ); +- if !random_blobs.contains(&announce_random_hash) { +- if random_blobs.len() >= MAX_RANDOM_BLOBS { +- random_blobs.pop_front(); +- } +- random_blobs.push_back(announce_random_hash); +- } +- entry.random_blobs = random_blobs; +- // Store the announce packet hash so a later CacheRequest for this +- // destination can replay the exact announce bytes. +- let announce_packet_hash = rns_wire::hash::packet_hash(raw, header.flags.header_type); +- entry.packet_hash = Some(announce_packet_hash); + let tunnel_path = crate::tunnel::TunnelPath { + timestamp: entry.timestamp, + next_hop: entry.next_hop, +@@ -462,7 +469,12 @@ + random_blobs: entry.random_blobs.iter().copied().collect(), + packet_hash: entry.packet_hash, + }; +- self.path_table.insert(header.destination_hash, entry); ++ self.path_table.upsert_ranked( ++ header.destination_hash, ++ entry, ++ crate::path_table::PathRank::Activate, ++ preference, ++ ); + if let Some(tunnel) = self.tunnel_table.get_mut_by_interface(interface_id) { + tunnel + .tunnel_paths +@@ -494,13 +506,24 @@ + "path learned from announce" + ); + } else { ++ // A route that loses the active slot can still be worth keeping: ++ // parking it in a backup slot lets a later probe or link failure ++ // reroute without a fresh announce. Everything else about the ++ // announce is still ignored — no rebroadcast, no handler dispatch, ++ // no announce-cache refresh — so replays stay inert. ++ let stored_hops = entry.hops; ++ self.path_table.upsert_ranked( ++ header.destination_hash, ++ entry, ++ crate::path_table::PathRank::Backup, ++ preference, ++ ); ++ self.state_dirty = true; + debug!( + dest = hex::encode(header.destination_hash), +- hops = header.hops, +- announce_emitted, ++ hops = stored_hops, + interface_id, +- suppressed, +- "ignoring replayed or stale announce" ++ "alternate path stored in backup slot" + ); + return; + } +--- a/crates/rns-transport/src/actor/mod.rs ++++ b/crates/rns-transport/src/actor/mod.rs +@@ -19,6 +19,7 @@ + InterfaceEntry, InterfaceId, InterfaceRole, TransportMessage, msg_variant_name, + }; + use crate::path_table::PathTable; ++pub(crate) use crate::path_table::{announce_timebase, path_timebase_from_random_blobs}; + use crate::rate_limit::RateTable; + use crate::reverse_table::ReverseTable; + use crate::traffic::TrafficCounter; +@@ -81,6 +82,11 @@ + /// Used when a Direct LinkRequest timed out on one route, so the next + /// path request can discover alternates instead of instantly reusing it. + pub path_interface_suppressions: HashMap<([u8; 16], InterfaceId), f64>, ++ /// Global medium preference for the active path slot. ++ pub path_medium_preference: PathMediumPreference, ++ /// Per-destination medium pins overriding `path_medium_preference`. Only ++ /// pinned destinations appear here; clearing a pin removes the entry. ++ pub peer_medium_pins: HashMap<[u8; 16], PathMedium>, + last_discovery_pr_tx: f64, + /// External interface waiting for a path response from a local shared client. + /// Python calls this `pending_local_path_requests`. +@@ -315,6 +321,8 @@ + discovery_pr_tags: HashMap::new(), + pending_discovery_prs: VecDeque::new(), + path_interface_suppressions: HashMap::new(), ++ path_medium_preference: PathMediumPreference::default(), ++ peer_medium_pins: HashMap::new(), + last_discovery_pr_tx: 0.0, + pending_local_path_requests: HashMap::new(), + path_states: HashMap::new(), +@@ -1237,13 +1245,90 @@ + let until = now_f64() + duration; + self.path_interface_suppressions + .insert((dest, interface_id), until); ++ // Reroute onto the best remaining slot now: rediscovery would otherwise ++ // run with the failed route still installed. ++ let preference = self.effective_path_medium_preference(&dest); ++ let rerouted = self ++ .path_table ++ .suppress_interface(&dest, interface_id, preference); ++ if rerouted { ++ self.state_dirty = true; ++ } + debug!( + dest = %hex::encode(dest), + interface_id, + duration, ++ rerouted, + "temporarily suppressing path interface" + ); + true ++ } ++ ++ /// Preference in force for a destination: its pin if any, else the global ++ /// setting. ++ pub(super) fn effective_path_medium_preference(&self, dest: &[u8; 16]) -> PathMediumPreference { ++ crate::path_table::effective_path_medium_preference( ++ self.path_medium_preference, ++ self.peer_medium_pins.get(dest).copied(), ++ ) ++ } ++ ++ /// Set the global medium preference and re-rank every known destination so ++ /// the change applies without waiting for fresh announces. Returns the ++ /// number of destinations whose active route moved. ++ pub(super) fn set_path_medium_preference(&mut self, preference: PathMediumPreference) -> usize { ++ self.path_medium_preference = preference; ++ self.path_table.set_preference(preference); ++ let moved = self.rerank_all_paths(); ++ debug!( ++ preference = ?preference, ++ moved, "path medium preference updated" ++ ); ++ moved ++ } ++ ++ /// Pin or unpin a destination to a medium. Returns `true` when the pin ++ /// changed the destination's active route. ++ pub(super) fn set_peer_medium_pin(&mut self, dest: [u8; 16], pin: Option) -> bool { ++ match pin { ++ Some(medium) => { ++ self.peer_medium_pins.insert(dest, medium); ++ } ++ None => { ++ self.peer_medium_pins.remove(&dest); ++ } ++ } ++ let preference = self.effective_path_medium_preference(&dest); ++ let moved = self.path_table.rerank(&dest, preference); ++ if moved { ++ self.state_dirty = true; ++ } ++ debug!( ++ dest = %hex::encode(dest), ++ pin = ?pin, ++ moved, ++ "peer medium pin updated" ++ ); ++ moved ++ } ++ ++ fn rerank_all_paths(&mut self) -> usize { ++ let dests: Vec<[u8; 16]> = self ++ .path_table ++ .iter() ++ .map(|(hash, _)| hash.into_bytes()) ++ .collect(); ++ let mut moved = 0usize; ++ for dest in dests { ++ let preference = self.effective_path_medium_preference(&dest); ++ if self.path_table.rerank(&dest, preference) { ++ moved += 1; ++ } ++ } ++ if moved > 0 { ++ self.state_dirty = true; ++ } ++ moved + } + + fn is_path_interface_suppressed( +@@ -1697,16 +1782,6 @@ + .as_ref() + .map(|online| !online.load(std::sync::atomic::Ordering::SeqCst)) + .unwrap_or(false) +-} +- +-fn announce_timebase(random_blob: &[u8; 10]) -> u64 { +- let mut emitted = [0u8; 8]; +- emitted[3..].copy_from_slice(&random_blob[5..10]); +- u64::from_be_bytes(emitted) +-} +- +-fn path_timebase_from_random_blobs<'a>(random_blobs: impl Iterator) -> u64 { +- random_blobs.map(announce_timebase).max().unwrap_or(0) + } + + /// Random jitter in `[0, PATHFINDER_RW)` for announce rebroadcast timing. +@@ -2011,6 +2086,14 @@ + (entry, rx) + } + ++ /// Radio interfaces run in access-point mode, so paths learned here rank as ++ /// [`PathMedium::Rf`]. ++ fn make_rf_test_interface(name: &str) -> (InterfaceEntry, mpsc::Receiver) { ++ let (mut entry, rx) = make_test_interface(name); ++ entry.mode = InterfaceMode::AccessPoint; ++ (entry, rx) ++ } ++ + #[test] + fn test_actor_creation() { + let (actor, _tx) = TransportActor::new(); +@@ -4242,6 +4325,9 @@ + assert!(actor.state_dirty); + } + ++ /// A replay of the same announce on another interface that is *no closer* ++ /// to the source keeps its hands off the active route — it only earns a ++ /// backup slot, and none of the announce-driven side effects fire. + #[test] + fn test_replayed_announce_random_blob_does_not_replace_path() { + let (mut actor, _tx) = TransportActor::new(); +@@ -4254,9 +4340,9 @@ + let identity = rns_identity::identity::Identity::new(); + let blob = random_blob(0xA1, 100); + let (raw_first, dest_hash) = +- make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); +- let (raw_replay, _) = + make_announce_for_with_random_blob(&identity, "test.replay.same", 1, blob); ++ let (raw_replay, _) = ++ make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); + let (htx, mut hrx) = mpsc::channel(8); + actor.announce_handlers.push(AnnounceHandlerRegistration { + id: crate::messages::AnnounceHandlerId(0), +@@ -4274,7 +4360,7 @@ + q: None, + }); + let first_event = hrx.try_recv().expect("fresh announce should dispatch"); +- assert_eq!(first_event.hops, 4); ++ assert_eq!(first_event.hops, 2); + + actor.on_inbound(InboundPacket { + raw: raw_replay, +@@ -4285,13 +4371,19 @@ + }); + + let path = actor.path_table.get(&dest_hash).unwrap(); +- assert_eq!(path.hops, 4); ++ assert_eq!(path.hops, 2); + assert_eq!(path.interface_id, 1); + assert_eq!(path.random_blobs.len(), 1); + assert!(path.has_random_blob(&blob)); + assert_eq!( ++ actor.path_table.backups(&dest_hash).len(), ++ 1, ++ "the longer route is still worth keeping as an alternate" ++ ); ++ assert_eq!(actor.path_table.backups(&dest_hash)[0].interface_id, 2); ++ assert_eq!( + actor.recent_announces.get(&dest_hash).unwrap().hops, +- 4, ++ 2, + "replayed announces must not refresh recent announce state" + ); + assert!( +@@ -4309,7 +4401,335 @@ + ); + } + ++ /// Same announce, fewer hops, second interface: the shorter route wins even ++ /// though the random blob was already seen. This is the multi-TCP / RF+TCP ++ /// case that the pre-multipath replay guard used to block. + #[test] ++ fn replayed_announce_with_fewer_hops_takes_over_and_demotes_the_old_route() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (entry1, _rx1) = make_test_interface("iface1"); ++ let (entry2, _rx2) = make_test_interface("iface2"); ++ actor.interfaces.insert(1, entry1); ++ actor.interfaces.insert(2, entry2); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA5, 100); ++ let (raw_far, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.replay.closer", 3, blob); ++ let (raw_close, _) = ++ make_announce_for_with_random_blob(&identity, "test.replay.closer", 1, blob); ++ ++ actor.on_inbound(InboundPacket { ++ raw: raw_far, ++ interface_id: 1, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ actor.on_inbound(InboundPacket { ++ raw: raw_close, ++ interface_id: 2, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ ++ let path = actor.path_table.get(&dest_hash).unwrap(); ++ assert_eq!(path.hops, 2); ++ assert_eq!(path.interface_id, 2); ++ let backups = actor.path_table.backups(&dest_hash); ++ assert_eq!(backups.len(), 1); ++ assert_eq!(backups[0].interface_id, 1); ++ assert_eq!(backups[0].hops, 4); ++ } ++ ++ /// A failed probe or link attempt reroutes onto the backup immediately — ++ /// no path request, no fresh announce. ++ #[test] ++ fn suppressing_the_current_path_interface_promotes_the_backup_route() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (entry1, _rx1) = make_test_interface("iface1"); ++ let (entry2, _rx2) = make_test_interface("iface2"); ++ actor.interfaces.insert(1, entry1); ++ actor.interfaces.insert(2, entry2); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA6, 100); ++ let (raw_close, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 1, blob); ++ let (raw_far, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 4, blob); ++ for (raw, interface_id) in [(raw_close, 1u64), (raw_far, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } ++ assert_eq!(actor.path_table.get(&dest_hash).unwrap().interface_id, 1); ++ assert_eq!(actor.path_table.backups(&dest_hash).len(), 1); ++ ++ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { ++ dest: dest_hash, ++ duration: 30.0, ++ }) { ++ TransportQueryResponse::BoolResult(true) => {} ++ other => panic!("expected current path-interface suppression, got {other:?}"), ++ } ++ ++ let path = actor ++ .path_table ++ .get(&dest_hash) ++ .expect("the backup route should now carry the destination"); ++ assert_eq!(path.interface_id, 2); ++ assert_eq!(path.hops, 5); ++ assert!(actor.path_table.backups(&dest_hash).is_empty()); ++ } ++ ++ #[test] ++ fn network_preference_keeps_an_rf_shortcut_out_of_the_active_slot() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ assert_eq!( ++ actor.set_path_medium_preference(PathMediumPreference::Network), ++ 0 ++ ); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA7, 100); ++ let (raw_tcp, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 4, blob); ++ let (raw_rf, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 0, blob); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } ++ ++ let path = actor.path_table.get(&dest_hash).unwrap(); ++ assert_eq!(path.medium, PathMedium::Network); ++ assert_eq!(path.interface_id, 1); ++ let backups = actor.path_table.backups(&dest_hash); ++ assert_eq!(backups.len(), 1); ++ assert_eq!(backups[0].medium, PathMedium::Rf); ++ assert_eq!(backups[0].hops, 1); ++ } ++ ++ /// A per-destination RF pin beats the global `Lowest` default, survives an ++ /// RF failure, and reclaims the route once RF is heard again. ++ #[test] ++ fn rf_pin_overrides_global_preference_and_survives_failover() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let (raw_tcp, dest_hash) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 0, ++ random_blob(0xA8, 100), ++ ); ++ let (raw_rf, _) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 5, ++ random_blob(0xA9, 101), ++ ); ++ assert!(!actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } ++ ++ // The 6-hop RF route wins over a 1-hop network route because of the pin. ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf ++ ); ++ ++ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { ++ dest: dest_hash, ++ duration: 30.0, ++ }) { ++ TransportQueryResponse::BoolResult(true) => {} ++ other => panic!("expected suppression, got {other:?}"), ++ } ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Network, ++ "with no live RF slot the network route must carry traffic" ++ ); ++ assert_eq!( ++ actor.peer_medium_pins.get(&dest_hash).copied(), ++ Some(PathMedium::Rf), ++ "failover must not clear the pin" ++ ); ++ ++ // Once suppression lapses, a fresh RF announce reclaims the route. ++ actor.path_interface_suppressions.clear(); ++ let (raw_rf_again, _) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 5, ++ random_blob(0xAA, 102), ++ ); ++ actor.on_inbound(InboundPacket { ++ raw: raw_rf_again, ++ interface_id: 2, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf ++ ); ++ } ++ ++ #[test] ++ fn changing_the_global_preference_reranks_known_destinations() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xAB, 100); ++ let (raw_rf, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 0, blob); ++ let (raw_tcp, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 4, blob); ++ for (raw, interface_id) in [(raw_rf, 2u64), (raw_tcp, 1)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf, ++ "Lowest preference should pick the 1-hop RF route" ++ ); ++ ++ assert_eq!( ++ actor.set_path_medium_preference(PathMediumPreference::Network), ++ 1 ++ ); ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Network ++ ); ++ assert_eq!( ++ actor.path_table.backups(&dest_hash)[0].medium, ++ PathMedium::Rf ++ ); ++ } ++ ++ #[test] ++ fn get_path_slots_reports_ranked_slots_and_the_pin() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xAC, 100); ++ let (raw_tcp, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 1, blob); ++ let (raw_rf, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 4, blob); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } ++ assert!(actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); ++ ++ match actor.handle_query(TransportQuery::GetPathSlots { dest: dest_hash }) { ++ TransportQueryResponse::PathSlots(entry) => { ++ assert_eq!(entry.dest, dest_hash); ++ assert_eq!(entry.pin, Some(PathMedium::Rf)); ++ assert_eq!(entry.preference, PathMediumPreference::Rf); ++ assert_eq!(entry.slots.len(), 2); ++ assert!(entry.slots[0].active); ++ assert_eq!(entry.slots[0].medium, PathMedium::Rf); ++ assert_eq!(entry.slots[0].interface, "rnode"); ++ assert!(!entry.slots[0].expired); ++ assert!(!entry.slots[1].active); ++ assert_eq!(entry.slots[1].medium, PathMedium::Network); ++ assert_eq!(entry.slots[1].interface, "tcp"); ++ } ++ other => panic!("expected PathSlots, got {other:?}"), ++ } ++ ++ match actor.handle_query(TransportQuery::GetPathSlots { dest: [0xFE; 16] }) { ++ TransportQueryResponse::PathSlots(entry) => { ++ assert!(entry.slots.is_empty()); ++ assert_eq!(entry.pin, None); ++ assert_eq!(entry.preference, PathMediumPreference::Lowest); ++ } ++ other => panic!("expected PathSlots, got {other:?}"), ++ } ++ } ++ ++ #[test] ++ fn clearing_a_peer_medium_pin_restores_the_global_preference() { ++ let (mut actor, _tx) = TransportActor::new(); ++ let dest = [0xCD; 16]; ++ actor.set_peer_medium_pin(dest, Some(PathMedium::Network)); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Network ++ ); ++ ++ actor.set_peer_medium_pin(dest, None); ++ assert!(actor.peer_medium_pins.is_empty()); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Lowest ++ ); ++ ++ actor.set_path_medium_preference(PathMediumPreference::Rf); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Rf ++ ); ++ } ++ ++ #[test] + fn test_newer_equal_or_higher_hop_announce_replaces_path() { + let (mut actor, _tx) = TransportActor::new(); + let (entry1, _rx1) = make_test_interface("iface1"); +@@ -7232,6 +7652,7 @@ + expires: now - 1.0, + random_blobs: Default::default(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: None, + }, + ); +@@ -10903,9 +11324,9 @@ + let dest_hash = [0xD1; 16]; + insert_announce_for(&mut actor, dest_hash, &identity); + +- let hit = actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { +- dest: dest_hash, +- }); ++ let hit = actor.handle_query( ++ crate::messages::TransportQuery::RecallDestinationPublicKey { dest: dest_hash }, ++ ); + match hit { + crate::messages::TransportQueryResponse::PublicKeyResult(Some(pk)) => { + assert_eq!(pk, identity.get_public_key()); +@@ -10913,10 +11334,9 @@ + other => panic!("expected PublicKeyResult(Some(_)), got {other:?}"), + } + +- let miss = +- actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { +- dest: [0xEE; 16], +- }); ++ let miss = actor.handle_query( ++ crate::messages::TransportQuery::RecallDestinationPublicKey { dest: [0xEE; 16] }, ++ ); + assert!(matches!( + miss, + crate::messages::TransportQueryResponse::PublicKeyResult(None) +@@ -10924,33 +11344,6 @@ + } + + #[test] +- fn deregister_announce_handler_none_only_sweeps_closed() { +- let (mut actor, _tx) = TransportActor::new(); +- let (live_tx, _live_rx) = tokio::sync::mpsc::channel(4); +- let (dead_tx, dead_rx) = tokio::sync::mpsc::channel(4); +- drop(dead_rx); +- +- actor.announce_handlers.push(AnnounceHandlerRegistration { +- aspect_filter: Some("nomadnetwork.node".into()), +- receive_path_responses: false, +- tx: live_tx, +- }); +- actor.announce_handlers.push(AnnounceHandlerRegistration { +- aspect_filter: Some("nomadnetwork.node".into()), +- receive_path_responses: true, +- tx: dead_tx, +- }); +- +- actor.handle_message(TransportMessage::DeregisterAnnounceHandler { aspect_filter: None }); +- assert_eq!(actor.announce_handlers.len(), 1); +- assert_eq!( +- actor.announce_handlers[0].aspect_filter.as_deref(), +- Some("nomadnetwork.node") +- ); +- assert!(!actor.announce_handlers[0].tx.is_closed()); +- } +- +- #[test] + fn filter_blackholed_dests_returns_only_blackholed() { + let (mut actor, _tx) = TransportActor::new(); + let blocked = rns_identity::identity::Identity::new(); +--- a/crates/rns-transport/src/actor/rpc.rs ++++ b/crates/rns-transport/src/actor/rpc.rs +@@ -261,7 +261,42 @@ + TransportQueryResponse::BoolResult(interface_id.is_some_and(|interface_id| { + self.suppress_path_interface(dest, interface_id, duration) + })) ++ } ++ TransportQuery::SetPathMediumPreference { preference } => { ++ TransportQueryResponse::IntResult(self.set_path_medium_preference(preference) as i64) ++ } ++ TransportQuery::SetPeerMediumPin { dest, pin } => { ++ TransportQueryResponse::BoolResult(self.set_peer_medium_pin(dest, pin)) + } ++ TransportQuery::GetPathSlots { dest } => { ++ let slots: Vec = self ++ .path_table ++ .slots(&dest) ++ .into_iter() ++ .enumerate() ++ .map(|(index, entry)| PathSlotRpcEntry { ++ active: index == 0, ++ hops: entry.hops, ++ via: entry.next_hop, ++ interface_id: entry.interface_id, ++ interface: self ++ .interfaces ++ .get(&entry.interface_id) ++ .map(|e| e.name.clone()) ++ .unwrap_or_else(|| format!("interface_{}", entry.interface_id)), ++ medium: entry.medium, ++ timestamp: entry.timestamp, ++ expires: entry.expires, ++ expired: entry.is_expired(), ++ }) ++ .collect(); ++ TransportQueryResponse::PathSlots(PathSlotsRpcEntry { ++ dest, ++ preference: self.effective_path_medium_preference(&dest), ++ pin: self.peer_medium_pins.get(&dest).copied(), ++ slots, ++ }) ++ } + TransportQuery::DropAnnounceQueues => { + for entry in self.interfaces.values_mut() { + entry.announce_queue.clear(); +--- a/crates/rns-transport/src/actor/outbound.rs ++++ b/crates/rns-transport/src/actor/outbound.rs +@@ -801,6 +801,12 @@ + expires: tunnel_path.expires, + random_blobs: tunnel_path.random_blobs.iter().copied().collect(), + interface_id, ++ medium: crate::path_table::path_medium( ++ self.interfaces ++ .get(&interface_id) ++ .map(|entry| entry.mode) ++ .unwrap_or(crate::constants::InterfaceMode::Full), ++ ), + packet_hash: tunnel_path.packet_hash, + }; + self.path_table.insert(*dest_hash, entry); +--- a/crates/rns-transport/src/actor/persistence.rs ++++ b/crates/rns-transport/src/actor/persistence.rs +@@ -1061,6 +1061,14 @@ + /// just-registered interface. Bound to `RegisterInterface` so each entry + /// rebinds to whatever `interface_id` the runtime allocated this boot. + pub(super) fn drain_pending_for_interface(&mut self, id: InterfaceId, name: &str) { ++ // Restored entries have no persisted medium — recover it from the ++ // interface that is registering now. ++ let medium = crate::path_table::path_medium( ++ self.interfaces ++ .get(&id) ++ .map(|entry| entry.mode) ++ .unwrap_or(crate::constants::InterfaceMode::Full), ++ ); + if !self.pending_path_entries.is_empty() { + let mut promoted = 0usize; + self.pending_path_entries.retain(|pe| { +@@ -1104,6 +1112,7 @@ + }) + .collect(), + interface_id: id, ++ medium, + packet_hash: pe.packet_hash.as_ref().and_then(|h| { + if h.len() == 32 { + let mut arr = [0u8; 32]; +@@ -1316,6 +1325,7 @@ + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some(path_hash), + }, + ); +@@ -1471,6 +1481,7 @@ + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some([0x33; 32]), + }, + ); +@@ -1587,6 +1598,7 @@ + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some([0x66; 32]), + }, + ); diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 86f9aaec4..c777ffb51 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -5,6 +5,7 @@ mod identity; mod interfaces; mod lxmf; mod nomad; +mod path_medium; mod propagation; mod remote; mod rmap; @@ -100,6 +101,19 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/peers", get(lxmf::list_peers)) .route("/api/v1/peers/{hash}/path", post(lxmf::peer_path)) .route("/api/v1/peers/{hash}/probe", post(lxmf::peer_probe)) + .route( + "/api/v1/peers/{hash}/paths", + get(path_medium::get_peer_paths), + ) + .route( + "/api/v1/peers/{hash}/medium-pin", + put(path_medium::put_peer_medium_pin), + ) + .route( + "/api/v1/settings/path-medium-preference", + get(path_medium::get_path_medium_preference) + .put(path_medium::put_path_medium_preference), + ) .route("/api/v1/ping", post(lxmf::ping)) .route("/api/v1/topology", get(system::topology)) .route("/api/v1/rmap/discovered", get(rmap::list_rmap_discovered)) diff --git a/reticulum-sidecar/src/api/path_medium.rs b/reticulum-sidecar/src/api/path_medium.rs new file mode 100644 index 000000000..b5d228c28 --- /dev/null +++ b/reticulum-sidecar/src/api/path_medium.rs @@ -0,0 +1,162 @@ +//! Path medium preference (global) and per-destination medium pins. + +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use serde::Deserialize; + +use crate::stack::{PathMediumPreferenceSetting, PathMediumSetting, StackHandle}; + +type ApiResult = Result, (StatusCode, Json)>; + +fn bad_request(error: &str) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "ok": false, "error": error })), + ) +} + +#[derive(Debug, Deserialize)] +pub struct PathMediumPreferenceBody { + pub preference: String, +} + +pub async fn get_path_medium_preference( + State(stack): State>, +) -> Json { + let preference = stack.path_medium_preference().await; + Json(serde_json::json!({ + "ok": true, + "preference": preference.as_str(), + "pins": stack.peer_medium_pins_json().await, + })) +} + +pub async fn put_path_medium_preference( + State(stack): State>, + Json(body): Json, +) -> ApiResult { + let Some(preference) = PathMediumPreferenceSetting::from_wire(&body.preference) else { + return Err(bad_request("invalid_path_medium_preference")); + }; + match stack.set_path_medium_preference(preference).await { + Ok(()) => Ok(Json( + serde_json::json!({ "ok": true, "preference": preference.as_str() }), + )), + Err(e) => Ok(Json(serde_json::json!({ "ok": false, "error": e }))), + } +} + +pub async fn get_peer_paths( + State(stack): State>, + Path(hash): Path, +) -> ApiResult { + match stack.peer_path_slots(&hash).await { + Ok(res) => Ok(Json(res)), + Err(e) if is_hash_error(&e) => Err(bad_request(&e)), + Err(e) => Ok(Json(serde_json::json!({ "ok": false, "error": e }))), + } +} + +/// `{ "pin": "rf" | "network" | null }`. The raw body keeps `pin: null` (clear) +/// distinguishable from an absent key, which `Option` would collapse. +pub async fn put_peer_medium_pin( + State(stack): State>, + Path(hash): Path, + Json(body): Json, +) -> ApiResult { + let pin = match parse_pin(body.get("pin")) { + Ok(pin) => pin, + Err(e) => return Err(bad_request(e)), + }; + match stack.set_peer_medium_pin(&hash, pin).await { + Ok(destination_hash) => Ok(Json(serde_json::json!({ + "ok": true, + "destination_hash": destination_hash, + "pin": pin.map(PathMediumSetting::as_str), + }))), + Err(e) if is_hash_error(&e) => Err(bad_request(&e)), + Err(e) => Ok(Json(serde_json::json!({ "ok": false, "error": e }))), + } +} + +/// `null` clears the pin; a string must be a known medium token. +fn parse_pin(raw: Option<&serde_json::Value>) -> Result, &'static str> { + match raw { + None => Err("pin_required"), + Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) => PathMediumSetting::from_wire(s) + .map(Some) + .ok_or("invalid_pin"), + Some(_) => Err("invalid_pin"), + } +} + +/// Hash rejections are client errors (400); everything else is a stack failure. +fn is_hash_error(error: &str) -> bool { + error.contains("32 hex characters") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pin_accepts_tokens_and_null() { + assert_eq!( + parse_pin(Some(&serde_json::json!("rf"))), + Ok(Some(PathMediumSetting::Rf)) + ); + assert_eq!( + parse_pin(Some(&serde_json::json!("NETWORK"))), + Ok(Some(PathMediumSetting::Network)) + ); + assert_eq!(parse_pin(Some(&serde_json::Value::Null)), Ok(None)); + } + + #[test] + fn parse_pin_rejects_missing_and_unknown() { + assert_eq!(parse_pin(None), Err("pin_required")); + assert_eq!( + parse_pin(Some(&serde_json::json!("lowest"))), + Err("invalid_pin") + ); + assert_eq!(parse_pin(Some(&serde_json::json!(3))), Err("invalid_pin")); + } + + #[test] + fn hash_errors_map_to_bad_request() { + assert!(is_hash_error("destination_hash must be 32 hex characters")); + assert!(!is_hash_error("path_slots_query_failed")); + } + + #[test] + fn preference_body_parses_known_tokens() { + let body: PathMediumPreferenceBody = + serde_json::from_str("{\"preference\":\" Rf \"}").expect("parse"); + assert_eq!( + PathMediumPreferenceSetting::from_wire(&body.preference), + Some(PathMediumPreferenceSetting::Rf) + ); + let body: PathMediumPreferenceBody = + serde_json::from_str("{\"preference\":\"satellite\"}").expect("parse"); + assert!(PathMediumPreferenceSetting::from_wire(&body.preference).is_none()); + } + + #[test] + fn pin_body_distinguishes_absent_from_null() { + let absent: serde_json::Value = serde_json::from_str("{}").expect("parse"); + assert_eq!(parse_pin(absent.get("pin")), Err("pin_required")); + let explicit_null: serde_json::Value = + serde_json::from_str("{\"pin\":null}").expect("parse"); + assert_eq!(parse_pin(explicit_null.get("pin")), Ok(None)); + let pinned: serde_json::Value = + serde_json::from_str("{\"pin\":\"network\"}").expect("parse"); + assert_eq!( + parse_pin(pinned.get("pin")), + Ok(Some(PathMediumSetting::Network)) + ); + } +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 933cb688a..f83d193d4 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -48,6 +48,7 @@ use super::nomad_timeouts; use super::packet_log::{ PacketLogBuffer, collect_tx_interface_names_for_egress, wire_packet_from_tap, }; +use super::path_medium::{self, PathMediumPreferenceSetting, PathMediumSetting}; use super::path_speed; use super::persistence::PersistedState; use super::pn_hosting_apply::{apply_pn_hosting_policy_to_node, apply_pn_hosting_policy_to_router}; @@ -616,6 +617,30 @@ impl LiveBridge { } } + // Re-apply the persisted routing preference and per-destination pins to + // the fresh transport. Failure point: control query timeout — log and + // continue on the transport default (`lowest`); the user can re-apply + // from Settings, and the stored values survive for the next start. + let (path_preference, peer_pins) = { + let state = inner.read().await; + (state.path_medium_preference, state.peer_medium_pins.clone()) + }; + if path_preference != PathMediumPreferenceSetting::default() { + if let Err(e) = bridge.apply_path_medium_preference(path_preference).await { + tracing::warn!( + "[path-medium] failed to restore preference {}: {e}", + path_preference.as_str() + ); + } + } + if !peer_pins.is_empty() { + for (hash, pin) in peer_pins.iter() { + if let Err(e) = bridge.apply_peer_medium_pin(hash, Some(pin)).await { + tracing::warn!("[path-medium] failed to restore pin for {hash}: {e}"); + } + } + } + Ok(bridge) } @@ -3027,6 +3052,76 @@ impl LiveBridge { } } + /// Apply the global path-medium preference; returns destinations rerouted. + pub async fn apply_path_medium_preference( + &self, + preference: PathMediumPreferenceSetting, + ) -> Result { + let resp = self + .query_control_timed(TransportQuery::SetPathMediumPreference { + preference: path_medium::to_transport_preference(preference), + }) + .await; + match resp { + Some(TransportQueryResponse::IntResult(rerouted)) => Ok(rerouted), + _ => Err("path_medium_preference_apply_failed".into()), + } + } + + /// Pin (or unpin with `None`) one destination to a medium; returns whether the active route moved. + pub async fn apply_peer_medium_pin( + &self, + hash: &str, + pin: Option, + ) -> Result { + let dest = parse_hash16(hash)?; + let resp = self + .query_control_timed(TransportQuery::SetPeerMediumPin { + dest, + pin: pin.map(path_medium::to_transport_medium), + }) + .await; + match resp { + Some(TransportQueryResponse::BoolResult(moved)) => Ok(moved), + _ => Err("peer_medium_pin_apply_failed".into()), + } + } + + /// Ranked path slots for `hash` (active first) plus the preference the transport applies there. + pub async fn path_slots( + &self, + hash: &str, + ) -> Result<(Vec, PathMediumPreferenceSetting), String> { + let dest = parse_hash16(hash)?; + let resp = self + .query_control_timed(TransportQuery::GetPathSlots { dest }) + .await; + let Some(TransportQueryResponse::PathSlots(entry)) = resp else { + return Err("path_slots_query_failed".into()); + }; + let paths = entry + .slots + .iter() + .map(|slot| { + serde_json::json!({ + "active": slot.active, + "hops": slot.hops, + "via_hash": slot.via.map(hex::encode), + "interface": slot.interface, + "interface_id": slot.interface_id, + "medium": slot.medium.as_str(), + "timestamp": slot.timestamp, + "expires": slot.expires, + "expired": slot.expired, + }) + }) + .collect(); + Ok(( + paths, + path_medium::from_transport_preference(entry.preference), + )) + } + pub async fn send_lxmf(&self, req: &LxmfSendRequest) -> Result { let dest = parse_hash16(&req.destination_hash)?; let (mut has_path, mut identity_known) = self diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 865464cac..e5c67dcbc 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -16,6 +16,7 @@ mod nomad_link_errors; mod nomad_request_payload; mod nomad_timeouts; mod packet_log; +mod path_medium; mod path_speed; mod persistence; #[cfg(feature = "rns-stack")] @@ -59,6 +60,7 @@ use std::sync::Arc; pub use config::{ImportMode, ImportResult, StackSettings, UpdateInterfacePatch}; use lxmf_inbound_log::{LxmfInboundBuffer, MAX_LXMF_INBOUND_LOG}; use packet_log::{MAX_WIRE_PACKET_LOG, PacketLogBuffer, WirePacketRow}; +pub use path_medium::{PathMediumPreferenceSetting, PathMediumSetting}; use persistence::PersistedState; pub use pn_hosting_policy::PnHostingPolicy; use tokio::sync::{Mutex, RwLock, broadcast}; @@ -95,8 +97,13 @@ pub struct StackHandle { contact_name_persist_dirty: std::sync::atomic::AtomicBool, /// Serializes create / switch / delete so on-disk slot state cannot interleave. identity_op_lock: Mutex<()>, + /// Serializes path-medium preference/pin persist → live-apply → rollback sequences. + path_medium_op_lock: Mutex<()>, #[cfg(feature = "rns-stack")] live: Option>, + /// Test-only: next preference/pin apply returns this error after persist (exercises rollback). + #[cfg(test)] + test_path_medium_apply_error: Mutex>, } impl StackHandle { @@ -216,7 +223,10 @@ impl StackHandle { inbound_lxmf, contact_name_persist_dirty: std::sync::atomic::AtomicBool::new(false), identity_op_lock: Mutex::new(()), + path_medium_op_lock: Mutex::new(()), live, + #[cfg(test)] + test_path_medium_apply_error: Mutex::new(None), }; #[cfg(not(feature = "rns-stack"))] let handle = Self { @@ -228,6 +238,9 @@ impl StackHandle { inbound_lxmf, contact_name_persist_dirty: std::sync::atomic::AtomicBool::new(false), identity_op_lock: Mutex::new(()), + path_medium_op_lock: Mutex::new(()), + #[cfg(test)] + test_path_medium_apply_error: Mutex::new(None), }; #[cfg(feature = "rns-stack")] if let Some(live) = &handle.live { @@ -874,6 +887,156 @@ impl StackHandle { res } + /// Persisted global path-medium preference (default `lowest`). + pub async fn path_medium_preference(&self) -> PathMediumPreferenceSetting { + self.inner.read().await.path_medium_preference + } + + /// Persist the global preference, then hot-apply it to a live transport. + /// + /// Failure point: durable save — the in-memory value is rolled back so the + /// stored and applied preference cannot diverge. Failure point: live apply — + /// persisted preference is rolled back to the prior snapshot so disk/UI cannot + /// drift ahead of the transport. When the stack is not live the value is + /// stored only and applied on the next start. + pub async fn set_path_medium_preference( + &self, + preference: PathMediumPreferenceSetting, + ) -> Result<(), String> { + let _op = self.path_medium_op_lock.lock().await; + let snapshot = { + let mut inner = self.inner.write().await; + let snapshot = inner.path_medium_preference; + inner.set_path_medium_preference(preference); + if let Err(e) = inner.save(&self.config_dir, &self.storage_dir) { + inner.set_path_medium_preference(snapshot); + return Err(e); + } + snapshot + }; + #[cfg(test)] + if let Some(err) = self.take_test_path_medium_apply_error().await { + self.rollback_path_medium_preference(snapshot).await; + return Err(err); + } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + if let Err(e) = live.apply_path_medium_preference(preference).await { + self.rollback_path_medium_preference(snapshot).await; + return Err(e); + } + } + self.emit_event( + "path_medium_preference", + serde_json::json!({ "preference": preference.as_str() }), + ); + Ok(()) + } + + /// All persisted medium pins as `{ "<32 hex dest>": "rf" | "network" }`. + pub async fn peer_medium_pins_json(&self) -> serde_json::Value { + self.inner.read().await.peer_medium_pins.to_json() + } + + /// Persist a destination medium pin (`None` clears it), then hot-apply it. + /// + /// Failure point: live apply — pin map is rolled back to the pre-save snapshot + /// so disk cannot drift ahead of the transport. + pub async fn set_peer_medium_pin( + &self, + hash: &str, + pin: Option, + ) -> Result { + let _op = self.path_medium_op_lock.lock().await; + let (canonical, pin_snapshot) = { + let mut inner = self.inner.write().await; + let pin_snapshot = inner.peer_medium_pins.clone(); + let canonical = inner.set_peer_medium_pin(hash, pin)?; + if let Err(e) = inner.save(&self.config_dir, &self.storage_dir) { + inner.peer_medium_pins = pin_snapshot; + return Err(e); + } + (canonical, pin_snapshot) + }; + #[cfg(test)] + if let Some(err) = self.take_test_path_medium_apply_error().await { + self.rollback_peer_medium_pins(pin_snapshot).await; + return Err(err); + } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + if let Err(e) = live.apply_peer_medium_pin(&canonical, pin).await { + self.rollback_peer_medium_pins(pin_snapshot).await; + return Err(e); + } + } + self.emit_event("peers_updated", serde_json::json!({ "hash": canonical })); + Ok(canonical) + } + + async fn rollback_path_medium_preference(&self, snapshot: PathMediumPreferenceSetting) { + let mut inner = self.inner.write().await; + inner.set_path_medium_preference(snapshot); + if let Err(save_err) = inner.save(&self.config_dir, &self.storage_dir) { + tracing::warn!("path medium preference rollback persist failed: {save_err}"); + } + } + + async fn rollback_peer_medium_pins(&self, snapshot: path_medium::PeerMediumPins) { + let mut inner = self.inner.write().await; + inner.peer_medium_pins = snapshot; + if let Err(save_err) = inner.save(&self.config_dir, &self.storage_dir) { + tracing::warn!("peer medium pin rollback persist failed: {save_err}"); + } + } + + #[cfg(test)] + async fn take_test_path_medium_apply_error(&self) -> Option { + self.test_path_medium_apply_error.lock().await.take() + } + + #[cfg(test)] + async fn force_next_path_medium_apply_error(&self, err: impl Into) { + *self.test_path_medium_apply_error.lock().await = Some(err.into()); + } + + /// Ranked transport path slots for one destination plus the stored preference / pin. + pub async fn peer_path_slots(&self, hash: &str) -> Result { + let canonical = canonical_peer_hash(hash)?; + let (preference, pin) = { + let inner = self.inner.read().await; + ( + inner.path_medium_preference, + inner.peer_medium_pins.get(&canonical), + ) + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let (paths, effective_preference) = live.path_slots(&canonical).await?; + return Ok(peer_path_slots_json( + &canonical, + &PeerPathSlotsView { + preference, + pin, + effective_preference: Some(effective_preference), + paths, + live: true, + }, + )); + } + // Stack not live: report the stored preference / pin with no live slots. + Ok(peer_path_slots_json( + &canonical, + &PeerPathSlotsView { + preference, + pin, + effective_preference: None, + paths: Vec::new(), + live: false, + }, + )) + } + pub async fn list_propagation(&self) -> serde_json::Value { let inner = self.inner.read().await; let preferred_id = inner.preferred_propagation_id.clone(); @@ -2615,6 +2778,36 @@ fn sync_live_peer_cache(cache: &mut Vec, fetched: Vec) -> Vec< merged } +/// Canonicalize a peer destination hash for path-medium routes (32 lowercase hex). +fn canonical_peer_hash(hash: &str) -> Result { + topology::canonicalize_destination_hash(hash) + .ok_or_else(|| "destination_hash must be 32 hex characters".to_string()) +} + +/// Path-slot response fields for one destination. +struct PeerPathSlotsView { + /// Persisted global preference. + preference: PathMediumPreferenceSetting, + /// Persisted pin for this destination. + pin: Option, + /// Preference the live transport actually applies here (pin resolved); `None` when offline. + effective_preference: Option, + paths: Vec, + live: bool, +} + +fn peer_path_slots_json(destination_hash: &str, view: &PeerPathSlotsView) -> serde_json::Value { + serde_json::json!({ + "ok": true, + "destination_hash": destination_hash, + "preference": view.preference.as_str(), + "pin": view.pin.map(PathMediumSetting::as_str), + "effective_preference": view.effective_preference.map(PathMediumPreferenceSetting::as_str), + "live": view.live, + "paths": view.paths, + }) +} + /// Apply a live path-table fetch: update cache only when non-empty; otherwise keep last known peers. fn merge_live_peer_fetch( cache: &mut Vec, @@ -2847,6 +3040,214 @@ mod tests { assert!(merged.len() <= 1 + MAX_ORPHAN_PEERS); } + #[test] + fn canonical_peer_hash_requires_32_hex() { + assert_eq!( + canonical_peer_hash("AABBCCDDEEFF00112233445566778899").expect("canonical"), + "aabbccddeeff00112233445566778899" + ); + assert!(canonical_peer_hash("abcd").is_err()); + assert!(canonical_peer_hash("aabbccddeeff0011223344556677889g").is_err()); + } + + #[test] + fn peer_path_slots_json_reports_stored_preference_and_pin() { + let hash = "aabbccddeeff00112233445566778899"; + let value = peer_path_slots_json( + hash, + &PeerPathSlotsView { + preference: PathMediumPreferenceSetting::Rf, + pin: Some(PathMediumSetting::Network), + effective_preference: Some(PathMediumPreferenceSetting::Network), + paths: vec![serde_json::json!({ "active": true, "medium": "network" })], + live: true, + }, + ); + assert_eq!(value["ok"], true); + assert_eq!(value["destination_hash"], hash); + assert_eq!(value["preference"], "rf"); + assert_eq!(value["pin"], "network"); + assert_eq!(value["effective_preference"], "network"); + assert_eq!(value["live"], true); + assert_eq!(value["paths"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn peer_path_slots_json_offline_omits_slots_and_effective_preference() { + let hash = "deadbeefcafebabe0123456789abcdef"; + let value = peer_path_slots_json( + hash, + &PeerPathSlotsView { + preference: PathMediumPreferenceSetting::Lowest, + pin: None, + effective_preference: None, + paths: Vec::new(), + live: false, + }, + ); + assert_eq!(value["preference"], "lowest"); + assert!(value["pin"].is_null()); + assert!(value["effective_preference"].is_null()); + assert_eq!(value["live"], false); + assert!(value["paths"].as_array().expect("array").is_empty()); + } + + #[tokio::test] + async fn path_medium_preference_defaults_to_lowest_and_survives_restart() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let hash = "aabbccddeeff00112233445566778899"; + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + assert_eq!( + handle.path_medium_preference().await, + PathMediumPreferenceSetting::Lowest + ); + assert_eq!(handle.peer_medium_pins_json().await, serde_json::json!({})); + + handle + .set_path_medium_preference(PathMediumPreferenceSetting::Rf) + .await + .expect("set preference"); + let canonical = handle + .set_peer_medium_pin(&hash.to_ascii_uppercase(), Some(PathMediumSetting::Network)) + .await + .expect("set pin"); + assert_eq!(canonical, hash); + + let (tx2, _) = broadcast::channel(8); + let reloaded = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx2, + )) + .await; + assert_eq!( + reloaded.path_medium_preference().await, + PathMediumPreferenceSetting::Rf + ); + assert_eq!( + reloaded.peer_medium_pins_json().await, + serde_json::json!({ hash: "network" }) + ); + + // Offline path slots still report the stored preference and pin. + let slots = reloaded.peer_path_slots(hash).await.expect("slots"); + assert_eq!(slots["preference"], "rf"); + assert_eq!(slots["pin"], "network"); + assert_eq!(slots["live"], false); + + reloaded + .set_peer_medium_pin(hash, None) + .await + .expect("clear pin"); + assert_eq!( + reloaded.peer_medium_pins_json().await, + serde_json::json!({}) + ); + assert!(reloaded.peer_path_slots("nothex").await.is_err()); + + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + + #[tokio::test] + async fn path_medium_preference_emits_event_only_after_success() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + // Subscribe after bootstrap so stats_update / startup noise is not in the queue. + let mut rx = handle.subscribe_events(); + + handle + .force_next_path_medium_apply_error("path_medium_preference_apply_failed") + .await; + let _ = handle + .set_path_medium_preference(PathMediumPreferenceSetting::Rf) + .await + .expect_err("apply must fail"); + assert!( + rx.try_recv().is_err(), + "failed preference apply must not emit path_medium_preference" + ); + + handle + .set_path_medium_preference(PathMediumPreferenceSetting::Network) + .await + .expect("set preference"); + let raw = rx.try_recv().expect("success must emit"); + let msg: serde_json::Value = serde_json::from_str(&raw).expect("json"); + assert_eq!(msg["type"], "path_medium_preference"); + assert_eq!(msg["payload"]["preference"], "network"); + + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + + #[tokio::test] + async fn path_medium_apply_failure_rolls_back_persisted_preference_and_pin() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let hash = "aabbccddeeff00112233445566778899"; + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + assert_eq!( + handle.path_medium_preference().await, + PathMediumPreferenceSetting::Lowest + ); + + handle + .force_next_path_medium_apply_error("path_medium_preference_apply_failed") + .await; + let err = handle + .set_path_medium_preference(PathMediumPreferenceSetting::Rf) + .await + .expect_err("apply must fail"); + assert_eq!(err, "path_medium_preference_apply_failed"); + assert_eq!( + handle.path_medium_preference().await, + PathMediumPreferenceSetting::Lowest + ); + + // Persist a known-good pin first, then fail the next apply so rollback restores it. + handle + .set_peer_medium_pin(hash, Some(PathMediumSetting::Rf)) + .await + .expect("set pin while offline/apply-ok"); + assert_eq!( + handle.peer_medium_pins_json().await, + serde_json::json!({ hash: "rf" }) + ); + handle + .force_next_path_medium_apply_error("peer_medium_pin_apply_failed") + .await; + let pin_err = handle + .set_peer_medium_pin(hash, Some(PathMediumSetting::Network)) + .await + .expect_err("pin apply must fail"); + assert_eq!(pin_err, "peer_medium_pin_apply_failed"); + assert_eq!( + handle.peer_medium_pins_json().await, + serde_json::json!({ hash: "rf" }) + ); + + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + #[tokio::test] async fn clear_contacts_empties_persisted_lxmf_contacts() { let (config_dir, storage_dir) = temp_stack_dirs(); diff --git a/reticulum-sidecar/src/stack/path_medium.rs b/reticulum-sidecar/src/stack/path_medium.rs new file mode 100644 index 000000000..590fab779 --- /dev/null +++ b/reticulum-sidecar/src/stack/path_medium.rs @@ -0,0 +1,344 @@ +//! Persisted transport path-medium preference and per-destination medium pins. +//! +//! Wire spellings match rsReticulum `PathMediumPreference::as_str()` / +//! `PathMedium::as_str()` so the state file, HTTP API, and transport control +//! RPC all speak the same tokens. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::topology::canonicalize_destination_hash; + +/// Cap the pin map so a runaway client cannot grow the state file without bound. +pub const MAX_PEER_MEDIUM_PINS: usize = 256; + +/// Global bias for which medium wins a destination's active path slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PathMediumPreferenceSetting { + /// No medium bias — rank purely by hop count. + #[default] + Lowest, + Network, + Rf, +} + +impl PathMediumPreferenceSetting { + pub fn as_str(self) -> &'static str { + match self { + Self::Lowest => "lowest", + Self::Network => "network", + Self::Rf => "rf", + } + } + + pub fn from_wire(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "lowest" => Some(Self::Lowest), + "network" => Some(Self::Network), + "rf" => Some(Self::Rf), + _ => None, + } + } +} + +impl Serialize for PathMediumPreferenceSetting { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for PathMediumPreferenceSetting { + /// Unknown or non-string tokens fall back to the default instead of failing + /// the whole state-file load (which would reset every persisted setting). + fn deserialize>(deserializer: D) -> Result { + let raw = serde_json::Value::deserialize(deserializer)?; + Ok(raw + .as_str() + .and_then(Self::from_wire) + .unwrap_or_else(Self::default)) + } +} + +/// Transport medium a path was learned over (coarser than interface mode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathMediumSetting { + Rf, + Network, +} + +impl PathMediumSetting { + pub fn as_str(self) -> &'static str { + match self { + Self::Rf => "rf", + Self::Network => "network", + } + } + + pub fn from_wire(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "rf" => Some(Self::Rf), + "network" => Some(Self::Network), + _ => None, + } + } +} + +impl Serialize for PathMediumSetting { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Per-destination medium pins keyed by 32 lowercase hex chars. +/// +/// `BTreeMap` keeps the persisted JSON key order stable across saves. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerMediumPins(BTreeMap); + +impl PeerMediumPins { + pub fn get(&self, hash: &str) -> Option { + let key = canonicalize_destination_hash(hash)?; + self.0.get(&key).copied() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(hash, pin)| (hash.as_str(), *pin)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Set (`Some`) or clear (`None`) the pin for `hash`; returns the canonical hash. + pub fn set(&mut self, hash: &str, pin: Option) -> Result { + let key = canonicalize_destination_hash(hash) + .ok_or_else(|| "destination_hash must be 32 hex characters".to_string())?; + match pin { + Some(pin) => { + if !self.0.contains_key(&key) && self.len() >= MAX_PEER_MEDIUM_PINS { + return Err("peer_medium_pins_too_many".into()); + } + self.0.insert(key.clone(), pin); + } + None => { + self.0.remove(&key); + } + } + Ok(key) + } + + pub fn to_json(&self) -> serde_json::Value { + serde_json::Value::Object( + self.0 + .iter() + .map(|(hash, pin)| { + ( + hash.clone(), + serde_json::Value::String(pin.as_str().to_string()), + ) + }) + .collect(), + ) + } +} + +impl Serialize for PeerMediumPins { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PeerMediumPins { + /// Skip malformed hashes / unknown mediums rather than failing the whole + /// state-file load. + fn deserialize>(deserializer: D) -> Result { + let raw = serde_json::Value::deserialize(deserializer)?; + let Some(map) = raw.as_object() else { + return Ok(Self::default()); + }; + let mut pins = BTreeMap::new(); + for (hash, value) in map { + let Some(key) = canonicalize_destination_hash(hash) else { + continue; + }; + let Some(pin) = value.as_str().and_then(PathMediumSetting::from_wire) else { + continue; + }; + if pins.len() >= MAX_PEER_MEDIUM_PINS { + break; + } + pins.insert(key, pin); + } + Ok(Self(pins)) + } +} + +/// Map the persisted preference onto the transport control enum. +#[cfg(feature = "rns-stack")] +pub fn to_transport_preference( + preference: PathMediumPreferenceSetting, +) -> rns_transport::constants::PathMediumPreference { + use rns_transport::constants::PathMediumPreference; + match preference { + PathMediumPreferenceSetting::Lowest => PathMediumPreference::Lowest, + PathMediumPreferenceSetting::Network => PathMediumPreference::Network, + PathMediumPreferenceSetting::Rf => PathMediumPreference::Rf, + } +} + +/// Map a persisted pin onto the transport control enum. +#[cfg(feature = "rns-stack")] +pub fn to_transport_medium(pin: PathMediumSetting) -> rns_transport::constants::PathMedium { + use rns_transport::constants::PathMedium; + match pin { + PathMediumSetting::Rf => PathMedium::Rf, + PathMediumSetting::Network => PathMedium::Network, + } +} + +/// Map a transport-reported preference back onto the persisted spelling. +#[cfg(feature = "rns-stack")] +pub fn from_transport_preference( + preference: rns_transport::constants::PathMediumPreference, +) -> PathMediumPreferenceSetting { + use rns_transport::constants::PathMediumPreference; + match preference { + PathMediumPreference::Lowest => PathMediumPreferenceSetting::Lowest, + PathMediumPreference::Network => PathMediumPreferenceSetting::Network, + PathMediumPreference::Rf => PathMediumPreferenceSetting::Rf, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH_A: &str = "aabbccddeeff00112233445566778899"; + const HASH_B: &str = "deadbeefcafebabe0123456789abcdef"; + + #[test] + fn preference_defaults_to_lowest() { + assert_eq!( + PathMediumPreferenceSetting::default(), + PathMediumPreferenceSetting::Lowest + ); + assert_eq!(PathMediumPreferenceSetting::default().as_str(), "lowest"); + } + + #[test] + fn preference_wire_round_trip() { + for token in ["lowest", "network", "rf"] { + let parsed = PathMediumPreferenceSetting::from_wire(token).expect("parse"); + assert_eq!(parsed.as_str(), token); + let json = serde_json::to_string(&parsed).expect("serialize"); + assert_eq!(json, format!("\"{token}\"")); + } + assert_eq!( + PathMediumPreferenceSetting::from_wire(" RF "), + Some(PathMediumPreferenceSetting::Rf) + ); + assert!(PathMediumPreferenceSetting::from_wire("wired").is_none()); + } + + #[test] + fn preference_deserialize_falls_back_on_garbage() { + let parsed: PathMediumPreferenceSetting = + serde_json::from_str("\"bogus\"").expect("tolerant"); + assert_eq!(parsed, PathMediumPreferenceSetting::Lowest); + let parsed: PathMediumPreferenceSetting = serde_json::from_str("null").expect("tolerant"); + assert_eq!(parsed, PathMediumPreferenceSetting::Lowest); + let parsed: PathMediumPreferenceSetting = serde_json::from_str("7").expect("tolerant"); + assert_eq!(parsed, PathMediumPreferenceSetting::Lowest); + } + + #[test] + fn medium_wire_round_trip() { + assert_eq!(PathMediumSetting::Rf.as_str(), "rf"); + assert_eq!(PathMediumSetting::Network.as_str(), "network"); + assert_eq!( + PathMediumSetting::from_wire("NETWORK"), + Some(PathMediumSetting::Network) + ); + assert!(PathMediumSetting::from_wire("lowest").is_none()); + } + + #[test] + fn pins_set_and_clear() { + let mut pins = PeerMediumPins::default(); + assert!(pins.is_empty()); + let key = pins + .set(&HASH_A.to_ascii_uppercase(), Some(PathMediumSetting::Rf)) + .expect("set"); + assert_eq!(key, HASH_A); + assert_eq!(pins.get(HASH_A), Some(PathMediumSetting::Rf)); + pins.set(HASH_A, Some(PathMediumSetting::Network)) + .expect("update"); + assert_eq!(pins.get(HASH_A), Some(PathMediumSetting::Network)); + pins.set(HASH_A, None).expect("clear"); + assert!(pins.get(HASH_A).is_none()); + assert!(pins.is_empty()); + // Clearing an absent pin is a no-op, not an error. + pins.set(HASH_B, None).expect("clear absent"); + } + + #[test] + fn pins_reject_bad_hash_and_cap_entries() { + let mut pins = PeerMediumPins::default(); + assert!(pins.set("abcd", Some(PathMediumSetting::Rf)).is_err()); + assert!( + pins.set(&format!("{HASH_A}ff"), Some(PathMediumSetting::Rf)) + .is_err() + ); + for i in 0..MAX_PEER_MEDIUM_PINS { + let hash = format!("{:032x}", i as u128); + pins.set(&hash, Some(PathMediumSetting::Rf)).expect("fill"); + } + assert_eq!(pins.len(), MAX_PEER_MEDIUM_PINS); + assert_eq!( + pins.set(HASH_A, Some(PathMediumSetting::Rf)).unwrap_err(), + "peer_medium_pins_too_many" + ); + // Updating an existing key still works at the cap. + let existing = format!("{:032x}", 0u128); + pins.set(&existing, Some(PathMediumSetting::Network)) + .expect("update at cap"); + } + + #[test] + fn pins_serde_round_trip_skips_invalid_entries() { + let mut pins = PeerMediumPins::default(); + pins.set(HASH_A, Some(PathMediumSetting::Rf)).expect("set"); + pins.set(HASH_B, Some(PathMediumSetting::Network)) + .expect("set"); + let json = serde_json::to_string(&pins).expect("serialize"); + let loaded: PeerMediumPins = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(loaded, pins); + + let tolerant: PeerMediumPins = serde_json::from_str(&format!( + "{{\"{HASH_A}\":\"rf\",\"nothex\":\"rf\",\"{HASH_B}\":\"satellite\"}}" + )) + .expect("tolerant"); + assert_eq!(tolerant.len(), 1); + assert_eq!(tolerant.get(HASH_A), Some(PathMediumSetting::Rf)); + + let tolerant: PeerMediumPins = serde_json::from_str("[]").expect("tolerant"); + assert!(tolerant.is_empty()); + } + + #[test] + fn pins_to_json_uses_wire_tokens() { + let mut pins = PeerMediumPins::default(); + pins.set(HASH_A, Some(PathMediumSetting::Network)) + .expect("set"); + assert_eq!( + pins.to_json(), + serde_json::json!({ HASH_A: "network" }), + "pin map should serialize as hash -> medium token" + ); + } +} diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 8aaddd069..0feda9a01 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use serde::Deserialize; +use super::path_medium::{PathMediumPreferenceSetting, PathMediumSetting, PeerMediumPins}; use super::pn_hosting_policy::PnHostingPolicy; use super::types::{ AddInterfaceRequest, ContactRow, InterfaceRow, LxmfReactionRequest, LxmfSendRequest, @@ -49,6 +50,10 @@ pub struct PersistedState { /// Identity hashes for `allow_all_listed` policy; empty means `ask` mode. pub rncp_listener_allowed: Vec, pub rncp_listener_blocked: Vec, + /// Global transport bias for the active path slot (rsReticulum `PathMediumPreference`). + pub path_medium_preference: PathMediumPreferenceSetting, + /// Per-destination medium pins that override the global preference. + pub peer_medium_pins: PeerMediumPins, } impl PersistedState { @@ -93,6 +98,8 @@ impl PersistedState { rncp_listener_overwrite: false, rncp_listener_allowed: Vec::new(), rncp_listener_blocked: Vec::new(), + path_medium_preference: PathMediumPreferenceSetting::default(), + peer_medium_pins: PeerMediumPins::default(), } } @@ -401,6 +408,19 @@ impl PersistedState { Ok(()) } + pub fn set_path_medium_preference(&mut self, preference: PathMediumPreferenceSetting) { + self.path_medium_preference = preference; + } + + /// Set (`Some`) or clear (`None`) a destination's medium pin; returns the canonical hash. + pub fn set_peer_medium_pin( + &mut self, + hash: &str, + pin: Option, + ) -> Result { + self.peer_medium_pins.set(hash, pin) + } + pub fn upsert_nomad_node( &mut self, hash: &str, @@ -780,7 +800,7 @@ impl serde::Serialize for PersistedState { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("PersistedState", 25)?; + let mut s = serializer.serialize_struct("PersistedState", 27)?; s.serialize_field("identity", &self.identity)?; s.serialize_field("interfaces", &self.interfaces)?; s.serialize_field("contacts", &self.contacts)?; @@ -815,6 +835,8 @@ impl serde::Serialize for PersistedState { s.serialize_field("rncp_listener_overwrite", &self.rncp_listener_overwrite)?; s.serialize_field("rncp_listener_allowed", &self.rncp_listener_allowed)?; s.serialize_field("rncp_listener_blocked", &self.rncp_listener_blocked)?; + s.serialize_field("path_medium_preference", &self.path_medium_preference)?; + s.serialize_field("peer_medium_pins", &self.peer_medium_pins)?; s.end() } } @@ -869,6 +891,10 @@ impl<'de> serde::Deserialize<'de> for PersistedState { rncp_listener_allowed: Vec, #[serde(default)] rncp_listener_blocked: Vec, + #[serde(default)] + path_medium_preference: PathMediumPreferenceSetting, + #[serde(default)] + peer_medium_pins: PeerMediumPins, } let raw = Raw::deserialize(deserializer)?; Ok(Self { @@ -901,6 +927,8 @@ impl<'de> serde::Deserialize<'de> for PersistedState { rncp_listener_overwrite: raw.rncp_listener_overwrite, rncp_listener_allowed: raw.rncp_listener_allowed, rncp_listener_blocked: raw.rncp_listener_blocked, + path_medium_preference: raw.path_medium_preference, + peer_medium_pins: raw.peer_medium_pins, }) } } @@ -1150,6 +1178,92 @@ mod tests { assert_eq!(state.pn_hosting_policy, before); } + #[test] + fn path_medium_defaults_to_lowest_with_no_pins() { + let state = PersistedState::default_empty(); + assert_eq!( + state.path_medium_preference, + PathMediumPreferenceSetting::Lowest + ); + assert!(state.peer_medium_pins.is_empty()); + } + + #[test] + fn path_medium_fields_round_trip_and_default_when_absent() { + let hash = "aabbccddeeff00112233445566778899"; + let mut state = PersistedState::default_empty(); + state.set_path_medium_preference(PathMediumPreferenceSetting::Rf); + state + .set_peer_medium_pin(hash, Some(PathMediumSetting::Network)) + .expect("pin"); + let json = serde_json::to_string(&state).expect("serialize"); + let loaded: PersistedState = serde_json::from_str(&json).expect("deserialize"); + assert_eq!( + loaded.path_medium_preference, + PathMediumPreferenceSetting::Rf + ); + assert_eq!( + loaded.peer_medium_pins.get(hash), + Some(PathMediumSetting::Network) + ); + + // Strip the new keys from a valid serialized document (older clients). + let mut value: serde_json::Value = serde_json::from_str(&json).expect("value"); + let obj = value.as_object_mut().expect("object"); + obj.remove("path_medium_preference"); + obj.remove("peer_medium_pins"); + let legacy_state: PersistedState = + serde_json::from_value(value).expect("legacy without path medium keys"); + assert_eq!( + legacy_state.path_medium_preference, + PathMediumPreferenceSetting::Lowest + ); + assert!(legacy_state.peer_medium_pins.is_empty()); + } + + #[test] + fn set_peer_medium_pin_updates_and_clears() { + let hash = "deadbeefcafebabe0123456789abcdef"; + let mut state = PersistedState::default_empty(); + let canonical = state + .set_peer_medium_pin(&hash.to_ascii_uppercase(), Some(PathMediumSetting::Rf)) + .expect("pin"); + assert_eq!(canonical, hash); + assert_eq!( + state.peer_medium_pins.get(hash), + Some(PathMediumSetting::Rf) + ); + state + .set_peer_medium_pin(hash, None) + .expect("clear existing pin"); + assert!(state.peer_medium_pins.get(hash).is_none()); + assert!( + state + .set_peer_medium_pin("nothex", Some(PathMediumSetting::Rf)) + .is_err() + ); + } + + #[test] + fn corrupt_path_medium_values_do_not_reset_state_file() { + let mut state = PersistedState::default_empty(); + state.set_path_medium_preference(PathMediumPreferenceSetting::Network); + let json = serde_json::to_string(&state).expect("serialize"); + let mut value: serde_json::Value = serde_json::from_str(&json).expect("value"); + let obj = value.as_object_mut().expect("object"); + obj.insert("path_medium_preference".into(), serde_json::json!("bogus")); + obj.insert( + "peer_medium_pins".into(), + serde_json::json!({ "nothex": "rf" }), + ); + let loaded: PersistedState = serde_json::from_value(value).expect("tolerant load"); + assert_eq!( + loaded.path_medium_preference, + PathMediumPreferenceSetting::Lowest + ); + assert!(loaded.peer_medium_pins.is_empty()); + } + #[test] fn rncp_listener_fields_round_trip_and_default_when_absent() { let mut state = PersistedState::default_empty(); diff --git a/scripts/apply-rsReticulum-path-medium-slots.sh b/scripts/apply-rsReticulum-path-medium-slots.sh new file mode 100755 index 000000000..f69f29777 --- /dev/null +++ b/scripts/apply-rsReticulum-path-medium-slots.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Apply mesh-client rsReticulum multi-path / medium-preference overlay. +# Keeps up to 3 ranked path slots per destination and RF/network preference. +set -euo pipefail + +RS_RETICULUM_REF="${RS_RETICULUM_REF:-9928abed269a83ec5a7ef165ff1142d938cad706}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch" +RNS_DIR="$(cd "${REPO_ROOT}/.." && pwd)/rsReticulum" +MARKER="${RNS_DIR}/crates/rns-transport/src/constants.rs" + +if [[ ! -d "${RNS_DIR}/.git" ]]; then + echo "error: rsReticulum not found at ${RNS_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsReticulum.git ${RNS_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +if [[ -f "${MARKER}" ]] && grep -q 'MAX_PATH_SLOTS' "${MARKER}"; then + echo "path-medium-slots overlay already present on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +if ! git -C "${RNS_DIR}" diff --quiet || ! git -C "${RNS_DIR}" diff --cached --quiet; then + echo "warning: ${RNS_DIR} has uncommitted changes; checkout may fail or overwrite work" >&2 +fi + +apply_patch() { + git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" + git -C "${RNS_DIR}" apply "${PATCH_FILE}" +} + +if apply_patch 2> /dev/null; then + echo "applied ${PATCH_FILE} on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +echo "path-medium-slots patch did not apply on current HEAD; checking out pinned ref ${RS_RETICULUM_REF:0:12}" +current_head="$(git -C "${RNS_DIR}" rev-parse HEAD)" +if [[ "${current_head}" != "${RS_RETICULUM_REF}" ]]; then + git -C "${RNS_DIR}" fetch origin --tags + git -C "${RNS_DIR}" checkout "${RS_RETICULUM_REF}" +fi + +# Prerequisites (packet-tap, discovery egress, …) must already be applied by +# ensure-rsReticulum-patches.sh / clone-ratspeak-stack.sh before this script. +apply_patch +echo "applied ${PATCH_FILE} on rsReticulum @ ${RS_RETICULUM_REF:0:12}" diff --git a/scripts/check-ipc-contract.mjs b/scripts/check-ipc-contract.mjs index 16952cf7d..9d85d9a27 100644 --- a/scripts/check-ipc-contract.mjs +++ b/scripts/check-ipc-contract.mjs @@ -28,6 +28,8 @@ const MAIN_FILES = [ path.join(ROOT, 'src', 'main', 'mqtt-manager.ts'), path.join(ROOT, 'src', 'main', 'meshcore-mqtt-adapter.ts'), path.join(ROOT, 'src', 'main', 'log-service.ts'), + // Linux Web Bluetooth cancel handlers live beside the session helper (not under ipc/). + path.join(ROOT, 'src', 'main', 'linuxWebBluetoothCancelIpc.ts'), ...collectIpcHandlerFiles(path.join(ROOT, 'src', 'main', 'ipc')), ]; diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index 0d2e383b8..d07e2c479 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -47,6 +47,7 @@ ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${SCRIPT_DIR}/apply-rsReticulum-link-client-proof-budget.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" "${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" +"${SCRIPT_DIR}/apply-rsReticulum-path-medium-slots.sh" ensure_repo "${LXMF_DIR}" 'https://github.com/ratspeak/rsLXMF.git' \ '68ad7c835187c052c763bb28c41b04a655f35c64' 'rsLXMF' diff --git a/scripts/ensure-rsReticulum-patches.sh b/scripts/ensure-rsReticulum-patches.sh index b3ee633e9..26248d86c 100755 --- a/scripts/ensure-rsReticulum-patches.sh +++ b/scripts/ensure-rsReticulum-patches.sh @@ -18,6 +18,7 @@ fi "${SCRIPT_DIR}/apply-rsReticulum-link-client-proof-budget.sh" "${SCRIPT_DIR}/apply-rsReticulum-ble-rnode-pairing-transition-debounce.sh" "${SCRIPT_DIR}/apply-rsReticulum-discovery-announce-egress.sh" +"${SCRIPT_DIR}/apply-rsReticulum-path-medium-slots.sh" if [[ ! -d "${LXMF_DIR}/.git" ]]; then echo "rsLXMF not found at ${LXMF_DIR}; skipping lxmf overlay apply" diff --git a/scripts/update.sh b/scripts/update.sh index baf29ceab..3b92c92b0 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -208,6 +208,7 @@ check_ratspeak_patches() { # Format: "patch-basename|github-owner/repo|pr-number-or-empty|display-label|review-url" local RATSPEAK_PATCH_ENTRIES=( 'rsReticulum-packet-tap.patch|ratspeak/rsReticulum|10|rsReticulum packet-tap|https://github.com/ratspeak/rsReticulum/pull/10' + 'rsReticulum-path-medium-slots.patch|ratspeak/rsReticulum||rsReticulum path-medium slots|' 'rsReticulum-auto-beacon-utun.patch|ratspeak/rsReticulum|11|rsReticulum auto-beacon utun|https://github.com/ratspeak/rsReticulum/pull/11' 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget cap|' diff --git a/src/main/index.ts b/src/main/index.ts index 6d638ef9c..c22261ba3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -111,6 +111,7 @@ import { registerReticulumIdentityIpcHandlers } from './ipc/reticulum-identity-h import { registerRrcDbIpcHandlers } from './ipc/rrc-db-handlers'; import { registerTakIpcHandlers } from './ipc/tak-handlers'; import { createIpcRateLimiter } from './ipcRateLimit'; +import { registerLinuxWebBluetoothCancelIpcHandlers } from './linuxWebBluetoothCancelIpc'; import { formatBluetoothctlSpawnError, linuxWebBluetoothDeviceSelection, @@ -2092,19 +2093,7 @@ ipcMain.on('bluetooth-device-selected', (_event, deviceId: unknown) => { }); // ─── IPC: Cancel Bluetooth selection ──────────────────────────────── -// Optional generation: when provided, ignore delayed cancels from an earlier chooser. -// When omitted, force-cancel (pre-connect cleanup / legacy callers). -ipcMain.on('bluetooth-device-cancelled', (_event, generation: unknown) => { - if (typeof generation === 'number' && Number.isFinite(generation)) { - if (!linuxWebBluetoothDeviceSelection.cancelIfGeneration(generation)) { - console.debug( - '[IPC] bluetooth-device-cancelled: generation mismatch or no pending — ignored', - ); - } - return; - } - linuxWebBluetoothDeviceSelection.cancelSelection(); -}); +registerLinuxWebBluetoothCancelIpcHandlers(); // ─── IPC: Unpair Bluetooth device (Linux only — bluetoothctl remove) ── // Not used on routine disconnect; only ConnectionPanel manual re-pair flow. diff --git a/src/main/index.window-lifecycle.test.ts b/src/main/index.window-lifecycle.test.ts index 8810774cf..dc9e8cd1f 100644 --- a/src/main/index.window-lifecycle.test.ts +++ b/src/main/index.window-lifecycle.test.ts @@ -76,7 +76,9 @@ describe('Linux Web Bluetooth device selection', () => { expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.resolveSelection'); expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.cancelSelection'); expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.armStaleTimeout'); - expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.cancelIfGeneration'); + // Awaitable cancel before requestDevice() — fire-and-forget send raced the new chooser. + expect(INDEX_SOURCE).toContain('registerLinuxWebBluetoothCancelIpcHandlers'); + expect(INDEX_SOURCE).toContain("from './linuxWebBluetoothCancelIpc'"); // Must not overwrite pending callback on every select-bluetooth-device event const handlerIdx = INDEX_SOURCE.indexOf("on('select-bluetooth-device'"); expect(handlerIdx).toBeGreaterThan(-1); diff --git a/src/main/linuxWebBluetoothCancelIpc.test.ts b/src/main/linuxWebBluetoothCancelIpc.test.ts new file mode 100644 index 000000000..220e9086d --- /dev/null +++ b/src/main/linuxWebBluetoothCancelIpc.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const handle = vi.fn(); +const on = vi.fn(); + +vi.mock('electron', () => ({ + ipcMain: { + handle: (...args: unknown[]) => handle(...args), + on: (...args: unknown[]) => on(...args), + }, +})); + +vi.mock('./validate-ipc-sender', () => ({ + assertIpcSender: vi.fn(), +})); + +import { + applyLinuxWebBluetoothCancelIpc, + registerLinuxWebBluetoothCancelIpcHandlers, +} from './linuxWebBluetoothCancelIpc'; +import { linuxWebBluetoothDeviceSelection } from './linuxWebBluetoothDeviceSelection'; +import { assertIpcSender } from './validate-ipc-sender'; + +function makeEvent(url: string | null): { senderFrame: { url: string } | null } { + return { senderFrame: url == null ? null : { url } }; +} + +describe('linuxWebBluetoothCancelIpc', () => { + beforeEach(() => { + handle.mockReset(); + on.mockReset(); + vi.mocked(assertIpcSender).mockReset(); + vi.mocked(assertIpcSender).mockImplementation(() => {}); + linuxWebBluetoothDeviceSelection.clear(); + registerLinuxWebBluetoothCancelIpcHandlers(); + }); + + function getCancelHandler(): (event: unknown, generation: unknown) => { cancelled: boolean } { + const call = handle.mock.calls.find((c) => c[0] === 'bluetooth-device-cancel'); + expect(call).toBeDefined(); + return call![1] as (event: unknown, generation: unknown) => { cancelled: boolean }; + } + + function getCancelledHandler(): (event: unknown, generation: unknown) => void { + const call = on.mock.calls.find((c) => c[0] === 'bluetooth-device-cancelled'); + expect(call).toBeDefined(); + return call![1] as (event: unknown, generation: unknown) => void; + } + + it('rejects unauthorized senders on both cancel channels', () => { + vi.mocked(assertIpcSender).mockImplementation((_event, channel) => { + throw new Error(`${channel}: unauthorized sender`); + }); + const invokeHandler = getCancelHandler(); + const sendHandler = getCancelledHandler(); + const badEvent = makeEvent(null); + + expect(() => invokeHandler(badEvent, 1)).toThrow( + 'bluetooth-device-cancel: unauthorized sender', + ); + expect(() => { + sendHandler(badEvent, 1); + }).toThrow('bluetooth-device-cancelled: unauthorized sender'); + expect(assertIpcSender).toHaveBeenCalledWith(badEvent, 'bluetooth-device-cancel'); + expect(assertIpcSender).toHaveBeenCalledWith(badEvent, 'bluetooth-device-cancelled'); + }); + + it('invoke cancel returns { cancelled } and respects generation', () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const cb = vi.fn(); + const { generation } = linuxWebBluetoothDeviceSelection.beginOrMergeDiscovery( + [{ deviceId: 'aa:bb' }], + cb, + ); + const invokeHandler = getCancelHandler(); + const event = makeEvent('file:///index.html'); + + expect(invokeHandler(event, generation + 1)).toEqual({ cancelled: false }); + expect(cb).not.toHaveBeenCalled(); + expect(linuxWebBluetoothDeviceSelection.hasPendingSelection()).toBe(true); + + expect(invokeHandler(event, generation)).toEqual({ cancelled: true }); + expect(cb).toHaveBeenCalledWith(''); + expect(linuxWebBluetoothDeviceSelection.hasPendingSelection()).toBe(false); + + expect(assertIpcSender).toHaveBeenCalledWith(event, 'bluetooth-device-cancel'); + debug.mockRestore(); + }); + + it('fire-and-forget cancelled force-clears when generation is omitted', () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const cb = vi.fn(); + linuxWebBluetoothDeviceSelection.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], cb); + const sendHandler = getCancelledHandler(); + const event = makeEvent('file:///index.html'); + + sendHandler(event, undefined); + expect(cb).toHaveBeenCalledWith(''); + expect(linuxWebBluetoothDeviceSelection.hasPendingSelection()).toBe(false); + expect(assertIpcSender).toHaveBeenCalledWith(event, 'bluetooth-device-cancelled'); + debug.mockRestore(); + }); + + it('applyLinuxWebBluetoothCancelIpc returns cancelled boolean for force path', () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + expect(applyLinuxWebBluetoothCancelIpc(undefined)).toEqual({ cancelled: false }); + const cb = vi.fn(); + linuxWebBluetoothDeviceSelection.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], cb); + expect(applyLinuxWebBluetoothCancelIpc(null)).toEqual({ cancelled: true }); + expect(cb).toHaveBeenCalledWith(''); + debug.mockRestore(); + }); +}); diff --git a/src/main/linuxWebBluetoothCancelIpc.ts b/src/main/linuxWebBluetoothCancelIpc.ts new file mode 100644 index 000000000..9cb637bb1 --- /dev/null +++ b/src/main/linuxWebBluetoothCancelIpc.ts @@ -0,0 +1,49 @@ +/** + * Linux Web Bluetooth chooser cancel IPC (awaitable invoke + fire-and-forget send). + * + * Extracted from index.ts so sender validation and generation handling can be + * exercised without loading the full Electron main entrypoint. + */ + +import { ipcMain } from 'electron'; + +import { linuxWebBluetoothDeviceSelection } from './linuxWebBluetoothDeviceSelection'; +import { assertIpcSender } from './validate-ipc-sender'; + +/** Apply a cancel request and return the boolean result used by awaitable Connect cleanup. */ +export function applyLinuxWebBluetoothCancelIpc(generation: unknown): { + cancelled: boolean; +} { + const result = linuxWebBluetoothDeviceSelection.applyCancel(generation); + if (result.cancelled && result.mode === 'generation') { + console.debug(`[IPC] bluetooth-device-cancelled: cancelled generation=${result.generation}`); + } else if (result.cancelled && result.mode === 'force') { + console.debug(`[IPC] bluetooth-device-cancelled: force-clear generation=${result.generation}`); + } else if (result.mode === 'ignored') { + const requested = + typeof result.generation === 'number' ? ` requested=${result.generation}` : ''; + const active = + typeof result.activeGeneration === 'number' ? ` active=${result.activeGeneration}` : ''; + console.debug( + `[IPC] bluetooth-device-cancelled: generation mismatch or no pending — ignored${requested}${active}`, + ); + } else { + console.debug('[IPC] bluetooth-device-cancelled: force-clear (no pending)'); + } + return { cancelled: result.cancelled }; +} + +export function registerLinuxWebBluetoothCancelIpcHandlers(): void { + // Prefer invoke (`bluetooth-device-cancel`) before starting requestDevice() so the + // cancel cannot race behind a new select-bluetooth-device session. + ipcMain.handle('bluetooth-device-cancel', (event, generation: unknown) => { + assertIpcSender(event, 'bluetooth-device-cancel'); + return applyLinuxWebBluetoothCancelIpc(generation); + }); + + // Fire-and-forget path (Cancel button / teardown). Connect must use the invoke handle. + ipcMain.on('bluetooth-device-cancelled', (event, generation: unknown) => { + assertIpcSender(event, 'bluetooth-device-cancelled'); + applyLinuxWebBluetoothCancelIpc(generation); + }); +} diff --git a/src/main/linuxWebBluetoothDeviceSelection.test.ts b/src/main/linuxWebBluetoothDeviceSelection.test.ts index 0972865b6..3402744ec 100644 --- a/src/main/linuxWebBluetoothDeviceSelection.test.ts +++ b/src/main/linuxWebBluetoothDeviceSelection.test.ts @@ -132,6 +132,36 @@ describe('LinuxWebBluetoothDeviceSelection', () => { expect(session.currentGeneration()).toBe(2); }); + it('applyCancel force-clears orphans and generation-scopes delayed cancels', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const first = vi.fn(); + const second = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], first); + + expect(session.applyCancel(undefined)).toEqual({ + cancelled: true, + mode: 'force', + generation: 1, + }); + expect(first).toHaveBeenCalledWith(''); + expect(session.applyCancel(null)).toEqual({ cancelled: false, mode: 'force' }); + + session.beginOrMergeDiscovery([{ deviceId: 'cc:dd' }], second); + expect(session.applyCancel(1)).toEqual({ + cancelled: false, + mode: 'ignored', + generation: 1, + activeGeneration: 2, + }); + expect(second).not.toHaveBeenCalled(); + expect(session.applyCancel(2)).toEqual({ + cancelled: true, + mode: 'generation', + generation: 2, + }); + expect(second).toHaveBeenCalledWith(''); + }); + it('armStaleTimeout auto-cancels and clears; resolve clears the timer without firing', () => { vi.useFakeTimers(); const session = new LinuxWebBluetoothDeviceSelection(); diff --git a/src/main/linuxWebBluetoothDeviceSelection.ts b/src/main/linuxWebBluetoothDeviceSelection.ts index 7540ba74d..0bb7d57ef 100644 --- a/src/main/linuxWebBluetoothDeviceSelection.ts +++ b/src/main/linuxWebBluetoothDeviceSelection.ts @@ -18,6 +18,16 @@ export interface LinuxWebBluetoothDiscoveredDevice { export type LinuxWebBluetoothSelectCallback = (deviceId: string) => void; +/** Result of applyCancel — used by main IPC for logging / awaitable Connect cleanup. */ +export type LinuxWebBluetoothCancelResult = + | { cancelled: true; mode: 'generation' | 'force'; generation: number } + | { + cancelled: false; + mode: 'ignored' | 'force'; + generation?: number; + activeGeneration?: number; + }; + export class LinuxWebBluetoothDeviceSelection { private pendingCallback: LinuxWebBluetoothSelectCallback | null = null; private readonly devices = new Map(); @@ -118,6 +128,31 @@ export class LinuxWebBluetoothDeviceSelection { return this.cancelSelection(); } + /** + * Apply a renderer cancel request. + * - Finite `generation`: cancel only that chooser (ignore stale/delayed cancels). + * - Otherwise: force-cancel any pending session (pre-connect cleanup). + */ + applyCancel(generation: unknown): LinuxWebBluetoothCancelResult { + if (typeof generation === 'number' && Number.isFinite(generation)) { + const activeGeneration = this.generation; + if (this.cancelIfGeneration(generation)) { + return { cancelled: true, mode: 'generation', generation }; + } + return { + cancelled: false, + mode: 'ignored', + generation, + activeGeneration: this.pendingCallback ? activeGeneration : undefined, + }; + } + const activeGeneration = this.generation; + if (this.cancelSelection()) { + return { cancelled: true, mode: 'force', generation: activeGeneration }; + } + return { cancelled: false, mode: 'force' }; + } + /** * Auto-cancel only if `callback` is still the retained first callback (stale-timeout guard). */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 534ff0b16..4cd82afde 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -733,12 +733,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.send('bluetooth-device-selected', deviceId); }, - cancelBluetoothSelection: (generation?: number | null) => { - if (typeof generation === 'number' && Number.isFinite(generation)) { - ipcRenderer.send('bluetooth-device-cancelled', generation); - return; - } - ipcRenderer.send('bluetooth-device-cancelled'); + // Awaitable invoke so Connect can clear a stale chooser before requestDevice() + // (fire-and-forget send raced behind select-bluetooth-device and cancelled the new session). + cancelBluetoothSelection: async (generation?: number | null): Promise<{ cancelled: boolean }> => { + const gen = + typeof generation === 'number' && Number.isFinite(generation) ? generation : undefined; + return (await ipcRenderer.invoke('bluetooth-device-cancel', gen)) as { cancelled: boolean }; }, // ─── Bluetooth pairing (Linux) ────────────────────────────────────── diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index d1b665036..df7159b55 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -554,6 +554,7 @@ describe('ConnectionPanel Linux BLE auto-connect', () => { ); const onConnect = vi.fn().mockResolvedValue(undefined); vi.mocked(window.electronAPI.startNobleBleScanning).mockClear(); + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockClear(); try { render( @@ -573,7 +574,74 @@ describe('ConnectionPanel Linux BLE auto-connect', () => { expect(onConnect).toHaveBeenCalledWith('ble', undefined); }); expect(window.electronAPI.startNobleBleScanning).not.toHaveBeenCalled(); + expect(window.electronAPI.cancelBluetoothSelection).toHaveBeenCalled(); + const cancelOrder = vi.mocked(window.electronAPI.cancelBluetoothSelection).mock + .invocationCallOrder[0]; + const connectOrder = onConnect.mock.invocationCallOrder[0]; + expect(cancelOrder).toBeDefined(); + expect(connectOrder).toBeDefined(); + expect(cancelOrder).toBeLessThan(connectOrder); + } finally { + localStorage.removeItem(lastConnKey); + userAgentSpy.mockRestore(); + } + }); + + it('awaits cancelBluetoothSelection before Reconnect onConnect on Linux', async () => { + const user = userEvent.setup(); + const userAgentSpy = mockLinuxUserAgent(); + const lastConnKey = 'mesh-client:lastConnection:meshtastic'; + localStorage.setItem( + lastConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'linux-ble-device' }), + ); + let releaseCancel: (() => void) | undefined; + const cancelSettled = new Promise((resolve) => { + releaseCancel = resolve; + }); + let onConnectStarted = false; + const onConnect = vi.fn().mockImplementation(() => { + onConnectStarted = true; + return Promise.resolve(); + }); + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockImplementation( + () => + new Promise<{ cancelled: boolean }>((resolve) => { + void cancelSettled.then(() => { + resolve({ cancelled: true }); + }); + }), + ); + + try { + render( + , + ); + + await user.click(screen.getByRole('button', { name: /^Reconnect$/i })); + + await waitFor(() => { + expect(window.electronAPI.cancelBluetoothSelection).toHaveBeenCalled(); + }); + expect(onConnectStarted).toBe(false); + expect(onConnect).not.toHaveBeenCalled(); + + releaseCancel?.(); + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('ble', undefined); + }); + expect(onConnectStarted).toBe(true); } finally { + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockResolvedValue({ + cancelled: false, + }); localStorage.removeItem(lastConnKey); userAgentSpy.mockRestore(); } @@ -645,6 +713,69 @@ describe('ConnectionPanel Linux BLE path', () => { userAgentSpy.mockRestore(); }); + it('awaits cancelBluetoothSelection before onConnect so force-clear cannot race the chooser', async () => { + const user = userEvent.setup(); + const userAgentSpy = vi.spyOn(window.navigator, 'userAgent', 'get'); + userAgentSpy.mockReturnValue( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', + ); + + let releaseCancel: (() => void) | undefined; + const cancelSettled = new Promise((resolve) => { + releaseCancel = resolve; + }); + let onConnectStarted = false; + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockImplementation( + () => + new Promise<{ cancelled: boolean }>((resolve) => { + void cancelSettled.then(() => { + resolve({ cancelled: true }); + }); + }), + ); + const onConnect = vi.fn().mockImplementation(() => { + onConnectStarted = true; + return Promise.resolve(); + }); + + try { + render( + , + ); + + const radioCard = screen.getByText('Radio Connection').closest('.bg-deep-black'); + expect(radioCard).toBeTruthy(); + const connectClick = user.click( + within(radioCard as HTMLElement).getByRole('button', { name: 'Connect' }), + ); + + await waitFor(() => { + expect(window.electronAPI.cancelBluetoothSelection).toHaveBeenCalled(); + }); + expect(onConnectStarted).toBe(false); + expect(onConnect).not.toHaveBeenCalled(); + + releaseCancel?.(); + await connectClick; + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('ble', undefined); + }); + expect(onConnectStarted).toBe(true); + } finally { + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockResolvedValue({ + cancelled: false, + }); + userAgentSpy.mockRestore(); + } + }); + it('passes Linux BLE chooser generation to cancelBluetoothSelection on Cancel', async () => { const user = userEvent.setup(); vi.mocked(window.electronAPI.cancelBluetoothSelection).mockClear(); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 9aeeb5466..68226f16d 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1089,7 +1089,9 @@ export default function ConnectionPanel({ bleLinuxPickerSelectionResolvedRef.current = false; const generation = linuxBleChooserGenerationRef.current; linuxBleChooserGenerationRef.current = null; - window.electronAPI.cancelBluetoothSelection(generation); + void window.electronAPI.cancelBluetoothSelection(generation).catch((e: unknown) => { + console.debug('[ConnectionPanel] cancelBluetoothSelection failed ' + errLikeToLogString(e)); + }); setShowPinPrompt(false); setPinInputValue(''); setConnecting(false); @@ -1153,11 +1155,22 @@ export default function ConnectionPanel({ // discovery uses connectionTypeRef for shouldShowEmbeddedPicker. connectionTypeRef.current = 'ble'; // Clear any stale Chromium chooser session before a new requestDevice(). + // Must await: fire-and-forget cancel raced behind select-bluetooth-device and + // cancelled the new chooser (immediate "User cancelled the requestDevice() chooser"). // Pass the prior generation when known so a delayed cancel cannot hit the next chooser; // omit generation only when we have no tracked session (force-clear orphans). const priorGeneration = linuxBleChooserGenerationRef.current; linuxBleChooserGenerationRef.current = null; - window.electronAPI.cancelBluetoothSelection(priorGeneration); + try { + await window.electronAPI.cancelBluetoothSelection(priorGeneration); + } catch (e: unknown) { + console.debug( + '[ConnectionPanel] cancelBluetoothSelection failed ' + errLikeToLogString(e), + ); + setConnecting(false); + setConnectionStage(''); + return; + } pendingMeshcoreLinuxWbMacRef.current = null; bleLinuxPickerSelectionResolvedRef.current = false; setShowBlePicker(false); @@ -1251,7 +1264,11 @@ export default function ConnectionPanel({ // Cancel in-flight requestDevice() (picker or MeshCore pre-connect PIN gate) const generation = linuxBleChooserGenerationRef.current; linuxBleChooserGenerationRef.current = null; - window.electronAPI.cancelBluetoothSelection(generation); + void window.electronAPI.cancelBluetoothSelection(generation).catch((e: unknown) => { + console.debug( + '[ConnectionPanel] cancelBluetoothSelection failed ' + errLikeToLogString(e), + ); + }); } pendingMeshcoreLinuxWbMacRef.current = null; setShowPinPrompt(false); @@ -1628,6 +1645,23 @@ export default function ConnectionPanel({ setConnectionStage('connectionPanel.stageReconnecting'); // Same-tick IPC: discovery may run before setConnectionType('ble') commits; picker gating uses connectionTypeRef. connectionTypeRef.current = 'ble'; + // Mirror handleConnect: await cancel so a stale chooser cannot merge into the new requestDevice(). + const priorGeneration = linuxBleChooserGenerationRef.current; + linuxBleChooserGenerationRef.current = null; + try { + await window.electronAPI.cancelBluetoothSelection(priorGeneration); + } catch (e: unknown) { + console.debug( + '[ConnectionPanel] cancelBluetoothSelection failed ' + errLikeToLogString(e), + ); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setConnecting(false); + setConnectionStage(''); + return; + } + pendingMeshcoreLinuxWbMacRef.current = null; + bleLinuxPickerSelectionResolvedRef.current = false; try { await onConnect('ble', undefined); isAutoConnectingRef.current = false; diff --git a/src/renderer/components/ReticulumNetworkPanel.tsx b/src/renderer/components/ReticulumNetworkPanel.tsx index 2384957aa..d7a03d3f1 100644 --- a/src/renderer/components/ReticulumNetworkPanel.tsx +++ b/src/renderer/components/ReticulumNetworkPanel.tsx @@ -6,6 +6,11 @@ import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { translateReticulumAuditIssue } from '@/renderer/lib/reticulum/reticulumConfigAudit'; +import { + fetchPathMediumPreference, + type PathMediumPreference, + setPathMediumPreference, +} from '@/renderer/lib/reticulum/reticulumPathMedium'; import { reticulumSidecarEventRefreshActions } from '@/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents'; import { createReticulumIdentitySlot, @@ -143,6 +148,9 @@ export function ReticulumNetworkPanel({ share_instance: false, loglevel: 4, }); + const [pathMediumPreference, setPathMediumPreferenceState] = + useState('lowest'); + const [pathMediumBusy, setPathMediumBusy] = useState(false); const [configValidateBusy, setConfigValidateBusy] = useState(false); const [configValidateResult, setConfigValidateResult] = useState(null); @@ -158,11 +166,31 @@ export function ReticulumNetworkPanel({ share_instance: body.share_instance, loglevel: typeof body.loglevel === 'number' ? body.loglevel : 4, }); + const pref = await fetchPathMediumPreference(); + if (pref.ok) setPathMediumPreferenceState(pref.preference); } catch (e) { console.debug('[ReticulumNetworkPanel] stack settings ' + errLikeToLogString(e)); } }, [sidecarApiReady]); + const savePathMediumPreference = async (preference: PathMediumPreference) => { + setPathMediumBusy(true); + try { + const res = await setPathMediumPreference(preference); + if (!res.ok) { + addToast(t('networkPanel.reticulumStackSettings.pathMediumPreferenceSaveFailed'), 'error'); + return; + } + setPathMediumPreferenceState(preference); + addToast(t('networkPanel.reticulumStackSettings.pathMediumPreferenceSaved'), 'success'); + } catch (e) { + console.warn('[ReticulumNetworkPanel] path medium ' + errLikeToLogString(e)); + addToast(t('networkPanel.reticulumStackSettings.pathMediumPreferenceSaveFailed'), 'error'); + } finally { + setPathMediumBusy(false); + } + }; + const refreshPeers = useCallback(async () => { if (!sidecarApiReady) return; try { @@ -529,6 +557,29 @@ export function ReticulumNetworkPanel({ ))} + + ); @@ -894,6 +911,16 @@ export default function ReticulumPeerListPanel({ + + {pathsDetailHash ? ( + { + setPathsDetailHash(null); + }} + /> + ) : null} ); } diff --git a/src/renderer/components/reticulum/ReticulumPeerPathsDetail.test.tsx b/src/renderer/components/reticulum/ReticulumPeerPathsDetail.test.tsx new file mode 100644 index 000000000..4b2c4a923 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumPeerPathsDetail.test.tsx @@ -0,0 +1,196 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === 'peerListPanel.pathsPreferAria') return 'Preferred path medium for this peer'; + if (key === 'peerListPanel.pathsHeading') return `Paths · ${opts?.hash ?? ''}…`; + if (key === 'peerListPanel.pathsDetailAria') return `Ranked paths for ${opts?.hash ?? ''}`; + if (key === 'peerListPanel.pathsGlobalPreference') return `Global: ${opts?.preference ?? ''}`; + if (key === 'peerListPanel.pathsLoadFailed') return 'peerListPanel.pathsLoadFailed'; + if (key === 'peerListPanel.pathsPinFailed') return 'peerListPanel.pathsPinFailed'; + if (key === 'peerListPanel.pathsPreferAuto') return 'Auto (global)'; + if (key === 'networkPanel.reticulumStackSettings.pathMediumLowest') { + return 'Lowest path (hop count)'; + } + if (key === 'networkPanel.reticulumStackSettings.pathMediumNetwork') { + return 'Network (non-RF)'; + } + if (key === 'networkPanel.reticulumStackSettings.pathMediumRf') { + return 'RF (RNode)'; + } + return key; + }, + }), +})); + +const fetchReticulumPeerPaths = vi.fn(); +const setReticulumPeerMediumPin = vi.fn(); + +vi.mock('@/renderer/lib/reticulum/reticulumPathMedium', async () => { + const actual = await vi.importActual('@/renderer/lib/reticulum/reticulumPathMedium'); + return { + ...(actual as Record), + fetchReticulumPeerPaths: (...args: unknown[]) => fetchReticulumPeerPaths(...args), + setReticulumPeerMediumPin: (...args: unknown[]) => setReticulumPeerMediumPin(...args), + }; +}); + +import { pathMediumPreferenceLabelKey, ReticulumPeerPathsDetail } from './ReticulumPeerPathsDetail'; + +const DEST = 'aabbccddeeff00112233445566778899'; + +function okPathsResult(overrides: Record = {}) { + return { + ok: true, + destination_hash: DEST, + preference: 'lowest', + pin: null, + effective_preference: 'lowest', + live: true, + paths: [ + { + active: true, + hops: 2, + via_hash: null, + interface: 'RNode 41F4', + interface_id: 1, + medium: 'rf', + timestamp: 1, + expires: 2, + expired: false, + }, + { + active: false, + hops: 4, + via_hash: 'dddddddddddddddddddddddddddddddd', + interface: 'Ratspeak', + interface_id: 2, + medium: 'network', + timestamp: 1, + expires: 2, + expired: false, + }, + ], + ...overrides, + }; +} + +describe('pathMediumPreferenceLabelKey', () => { + it('maps wire tokens to Network-tab path-medium keys', () => { + expect(pathMediumPreferenceLabelKey('lowest')).toBe( + 'networkPanel.reticulumStackSettings.pathMediumLowest', + ); + expect(pathMediumPreferenceLabelKey('RF')).toBe( + 'networkPanel.reticulumStackSettings.pathMediumRf', + ); + expect(pathMediumPreferenceLabelKey('network')).toBe( + 'networkPanel.reticulumStackSettings.pathMediumNetwork', + ); + expect(pathMediumPreferenceLabelKey('wired')).toBeNull(); + }); +}); + +describe('ReticulumPeerPathsDetail', () => { + let onEventHandler: ((evt: { type: string; payload: unknown }) => void) | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + onEventHandler = null; + vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { + onEventHandler = cb; + return () => { + onEventHandler = null; + }; + }); + fetchReticulumPeerPaths.mockResolvedValue(okPathsResult()); + setReticulumPeerMediumPin.mockResolvedValue({ ok: true }); + }); + + it('renders path slots and has no axe violations', async () => { + const { container } = render( + {}} />, + ); + await waitFor(() => { + expect(screen.getByText(/RNode 41F4/)).toBeInTheDocument(); + }); + expect(screen.getByText('Global: Lowest path (hop count)')).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('pins RF via the prefer control', async () => { + const user = userEvent.setup(); + render( {}} />); + await waitFor(() => { + expect(screen.getByLabelText(/Preferred path medium/i)).toBeInTheDocument(); + }); + await user.selectOptions(screen.getByLabelText(/Preferred path medium/i), 'rf'); + await waitFor(() => { + expect(setReticulumPeerMediumPin).toHaveBeenCalledWith(DEST, 'rf'); + }); + }); + + it('rolls back the prefer control when pin fails', async () => { + setReticulumPeerMediumPin.mockResolvedValue({ ok: false }); + render( {}} />); + await waitFor(() => { + expect(screen.getByLabelText(/Preferred path medium/i)).not.toBeDisabled(); + }); + const select = screen.getByLabelText(/Preferred path medium/i); + expect(select).toHaveValue('auto'); + fireEvent.change(select, { target: { value: 'rf' } }); + await waitFor(() => { + expect(setReticulumPeerMediumPin).toHaveBeenCalledWith(DEST, 'rf'); + expect(select).toHaveValue('auto'); + expect(screen.getByText('peerListPanel.pathsPinFailed')).toBeInTheDocument(); + }); + }); + + it('renders pathsLoadFailed when fetch returns ok: false', async () => { + fetchReticulumPeerPaths.mockResolvedValue({ ok: false, paths: [] }); + render( {}} />); + await waitFor(() => { + expect(screen.getByText('peerListPanel.pathsLoadFailed')).toBeInTheDocument(); + }); + }); + + it('refreshes when path_medium_preference WS event arrives', async () => { + render( {}} />); + await waitFor(() => { + expect(screen.getByText('Global: Lowest path (hop count)')).toBeInTheDocument(); + }); + fetchReticulumPeerPaths.mockResolvedValue( + okPathsResult({ + preference: 'rf', + effective_preference: 'rf', + paths: [ + { + active: true, + hops: 1, + via_hash: null, + interface: 'RNode 41F4', + interface_id: 1, + medium: 'rf', + timestamp: 1, + expires: 2, + expired: false, + }, + ], + }), + ); + expect(onEventHandler).toBeTruthy(); + act(() => { + onEventHandler?.({ type: 'path_medium_preference', payload: { preference: 'rf' } }); + }); + await waitFor(() => { + expect(screen.getByText('Global: RF (RNode)')).toBeInTheDocument(); + }); + expect(fetchReticulumPeerPaths.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/renderer/components/reticulum/ReticulumPeerPathsDetail.tsx b/src/renderer/components/reticulum/ReticulumPeerPathsDetail.tsx new file mode 100644 index 000000000..7d448465b --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumPeerPathsDetail.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { + fetchReticulumPeerPaths, + peerMediumPinApiFromChoice, + type PeerMediumPinChoice, + peerMediumPinChoiceFromApi, + type ReticulumPeerPathsResult, + setReticulumPeerMediumPin, +} from '@/renderer/lib/reticulum/reticulumPathMedium'; + +/** Map wire preference tokens to Network-tab path-medium labels (avoid raw API enums in UI). */ +export function pathMediumPreferenceLabelKey( + preference: string, +): + | 'networkPanel.reticulumStackSettings.pathMediumLowest' + | 'networkPanel.reticulumStackSettings.pathMediumNetwork' + | 'networkPanel.reticulumStackSettings.pathMediumRf' + | null { + switch (preference.trim().toLowerCase()) { + case 'lowest': + return 'networkPanel.reticulumStackSettings.pathMediumLowest'; + case 'network': + return 'networkPanel.reticulumStackSettings.pathMediumNetwork'; + case 'rf': + return 'networkPanel.reticulumStackSettings.pathMediumRf'; + default: + return null; + } +} + +export interface ReticulumPeerPathsDetailProps { + destinationHash: string; + onClose: () => void; +} + +export function ReticulumPeerPathsDetail({ + destinationHash, + onClose, +}: ReticulumPeerPathsDetailProps) { + const { t } = useTranslation(); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [pinBusy, setPinBusy] = useState(false); + const [error, setError] = useState(null); + /** Bumps on each refresh so stale responses cannot overwrite newer ones. */ + const refreshGenRef = useRef(0); + + const refresh = useCallback(async () => { + const gen = ++refreshGenRef.current; + const requestedHash = destinationHash; + setBusy(true); + setError(null); + try { + const next = await fetchReticulumPeerPaths(requestedHash); + if (gen !== refreshGenRef.current) return; + setResult(next); + if (!next.ok) { + setError(next.error ?? t('peerListPanel.pathsLoadFailed')); + } + } catch (e) { + if (gen !== refreshGenRef.current) return; + console.warn('[ReticulumPeerPathsDetail] load ' + errLikeToLogString(e)); + setError(t('peerListPanel.pathsLoadFailed')); + } finally { + if (gen === refreshGenRef.current) { + setBusy(false); + } + } + }, [destinationHash, t]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- load path slots when destination changes + void refresh(); + }, [refresh]); + + useEffect(() => { + const unsub = window.electronAPI.reticulum.onEvent((evt) => { + if (evt.type === 'path_medium_preference') { + void refresh(); + } + }); + return unsub; + }, [refresh]); + + const pinChoice = peerMediumPinChoiceFromApi(result?.pin); + const preferenceLabelKey = result?.preference + ? pathMediumPreferenceLabelKey(result.preference) + : null; + + const onPinChange = async (choice: PeerMediumPinChoice) => { + const previous = result; + setPinBusy(true); + setError(null); + // Optimistic pin state for snappy UI; refresh restores truth. + setResult((cur) => + cur + ? { + ...cur, + pin: peerMediumPinApiFromChoice(choice), + } + : cur, + ); + try { + const res = await setReticulumPeerMediumPin( + destinationHash, + peerMediumPinApiFromChoice(choice), + ); + if (!res.ok) { + setResult(previous); + setError(res.error ?? t('peerListPanel.pathsPinFailed')); + return; + } + await refresh(); + } catch (e) { + setResult(previous); + console.warn('[ReticulumPeerPathsDetail] pin ' + errLikeToLogString(e)); + setError(t('peerListPanel.pathsPinFailed')); + } finally { + setPinBusy(false); + } + }; + + return ( +
+
+

+ {t('peerListPanel.pathsHeading', { hash: destinationHash.slice(0, 12) })} +

+ +
+ + + + {error ?

{error}

: null} + {busy && !result ?

{t('common.loading')}

: null} + + {result?.ok && result.paths.length === 0 ? ( +

{t('peerListPanel.pathsEmpty')}

+ ) : null} + + {result?.ok && result.paths.length > 0 ? ( +
    + {result.paths.map((slot, index) => ( +
  • +
    + + {slot.active + ? t('peerListPanel.pathsActiveBadge') + : t('peerListPanel.pathsBackupBadge')} + + + {t('connectionPanel.reticulumPeers.hops')}: {slot.hops ?? '—'} + + + {t('peerListPanel.colInterface')}: {slot.interface ?? '—'} + + + {t('peerListPanel.pathsMedium')}:{' '} + {slot.medium === 'rf' + ? t('peerListPanel.pathsPreferRf') + : slot.medium === 'network' + ? t('peerListPanel.pathsPreferNetwork') + : '—'} + + {slot.expired ? ( + {t('peerListPanel.pathsExpired')} + ) : null} +
    +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index 58aba0ebe..aef24d82d 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -169,7 +169,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { cancelSerialSelection: noop, onBluetoothDevicesDiscovered: noopUnsub, selectBluetoothDevice: noop, - cancelBluetoothSelection: noop, + cancelBluetoothSelection: async () => ({ cancelled: false }), bluetoothUnpair: noopAsync, bluetoothStartScan: noopAsync, bluetoothStopScan: noopAsync, diff --git a/src/renderer/lib/reticulum/reticulumInterfaceHelp.test.ts b/src/renderer/lib/reticulum/reticulumInterfaceHelp.test.ts index 3611bd702..e9cae2a16 100644 --- a/src/renderer/lib/reticulum/reticulumInterfaceHelp.test.ts +++ b/src/renderer/lib/reticulum/reticulumInterfaceHelp.test.ts @@ -50,4 +50,15 @@ describe('reticulumInterfaceHelp', () => { }); expect(help.purposeKey).toBe('connectionPanel.reticulumInterfaces.purpose.rnodeBle'); }); + + it('classifies I2P interface purpose (SAM bridge hint)', () => { + const help = getReticulumInterfaceHelp({ + id: 'rns-i2p-hub-a', + name: 'RNS I2P Hub A', + type: 'i2p', + }); + expect(help.purposeKey).toBe('connectionPanel.reticulumInterfaces.purpose.i2p'); + expect(help.isRuntimeOnly).toBe(false); + expect(help.isSystemManaged).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumInterfaceHelp.ts b/src/renderer/lib/reticulum/reticulumInterfaceHelp.ts index 39136c5fc..1efad5f4c 100644 --- a/src/renderer/lib/reticulum/reticulumInterfaceHelp.ts +++ b/src/renderer/lib/reticulum/reticulumInterfaceHelp.ts @@ -54,6 +54,13 @@ export function getReticulumInterfaceHelp( isSystemManaged: false, }; } + if (iface.type === 'i2p') { + return { + purposeKey: 'connectionPanel.reticulumInterfaces.purpose.i2p', + isRuntimeOnly: false, + isSystemManaged: false, + }; + } if (iface.type === 'rnode') { const port = iface.serial_port ?? ''; if (isReticulumBleRnodeSerialPort(port)) { diff --git a/src/renderer/lib/reticulum/reticulumPathMedium.test.ts b/src/renderer/lib/reticulum/reticulumPathMedium.test.ts new file mode 100644 index 000000000..ec3ecb2b0 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPathMedium.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchReticulumPeerPaths, + parsePathMedium, + parsePathMediumPreference, + parsePeerPathsResponse, + pathMediumFromInterfaceNameOrType, + peerMediumPinApiFromChoice, + peerMediumPinChoiceFromApi, + setReticulumPeerMediumPin, +} from './reticulumPathMedium'; + +describe('reticulumPathMedium', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ['lowest', 'lowest'], + [' Network ', 'network'], + ['RF', 'rf'], + ['wired', null], + [3, null], + ] as const)('parsePathMediumPreference(%j) → %j', (raw, expected) => { + expect(parsePathMediumPreference(raw)).toBe(expected); + }); + + it.each([ + ['rf', 'rf'], + ['NETWORK', 'network'], + ['lowest', null], + ] as const)('parsePathMedium(%j) → %j', (raw, expected) => { + expect(parsePathMedium(raw)).toBe(expected); + }); + + it('classifies interface names into path mediums', () => { + expect(pathMediumFromInterfaceNameOrType('rnode')).toBe('rf'); + expect(pathMediumFromInterfaceNameOrType('ble://AA')).toBe('rf'); + expect(pathMediumFromInterfaceNameOrType('tcp')).toBe('network'); + expect(pathMediumFromInterfaceNameOrType('i2p')).toBe('network'); + expect(pathMediumFromInterfaceNameOrType('auto')).toBe('network'); + }); + + it('maps pin choice ↔ API null/medium', () => { + expect(peerMediumPinChoiceFromApi(null)).toBe('auto'); + expect(peerMediumPinChoiceFromApi(undefined)).toBe('auto'); + expect(peerMediumPinChoiceFromApi('rf')).toBe('rf'); + expect(peerMediumPinApiFromChoice('auto')).toBeNull(); + expect(peerMediumPinApiFromChoice('network')).toBe('network'); + }); + + it('parsePeerPathsResponse keeps at most 3 slots and marks pin null', () => { + const parsed = parsePeerPathsResponse({ + ok: true, + destination_hash: 'aabbccddeeff00112233445566778899', + preference: 'lowest', + pin: null, + effective_preference: 'lowest', + live: true, + paths: [ + { active: true, hops: 1, medium: 'rf', interface: 'RNode' }, + { active: false, hops: 3, medium: 'network', interface: 'Ratspeak' }, + { active: false, hops: 4, medium: 'network', interface: 'US-East' }, + { active: false, hops: 9, medium: 'network', interface: 'extra' }, + ], + }); + expect(parsed.ok).toBe(true); + expect(parsed.pin).toBeNull(); + expect(parsed.paths).toHaveLength(3); + expect(parsed.paths[0]?.active).toBe(true); + expect(parsed.paths[0]?.medium).toBe('rf'); + }); + + it('parsePeerPathsResponse surfaces errors', () => { + expect(parsePeerPathsResponse({ ok: false, error: 'path_slots_query_failed' })).toEqual({ + ok: false, + paths: [], + error: 'path_slots_query_failed', + }); + }); + + it('rejects malformed hashes without calling proxyGet', async () => { + const proxyGet = vi.fn(); + const proxyPut = vi.fn(); + const getStatus = vi.fn().mockResolvedValue({ running: true, port: 19437, pid: 1 }); + vi.stubGlobal('window', { + electronAPI: { + reticulum: { getStatus, proxyGet, proxyPut }, + }, + }); + try { + await expect(fetchReticulumPeerPaths('not-a-hash')).resolves.toEqual({ + ok: false, + paths: [], + error: 'invalid_hash', + }); + await expect(setReticulumPeerMediumPin('zzzz', 'rf')).resolves.toEqual({ + ok: false, + error: 'invalid_hash', + }); + expect(proxyGet).not.toHaveBeenCalled(); + expect(proxyPut).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumPathMedium.ts b/src/renderer/lib/reticulum/reticulumPathMedium.ts new file mode 100644 index 000000000..dd55f1744 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPathMedium.ts @@ -0,0 +1,204 @@ +/** + * Path-medium preference / per-peer pins (sidecar HTTP contract). + * + * Global preference and per-dest pins are applied in rsReticulum path ranking + * so Chat, Nomad, rncp, and probes all share the same active egress. + */ + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { classifyReticulumVia } from '@/renderer/lib/reticulum/classifyReticulumVia'; +import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; + +export type PathMediumPreference = 'lowest' | 'network' | 'rf'; +export type PathMedium = 'rf' | 'network'; +/** UI pin control: Auto follows the global preference. */ +export type PeerMediumPinChoice = 'auto' | PathMedium; + +export interface ReticulumPathSlot { + active: boolean; + hops: number | null; + via_hash: string | null; + interface: string | null; + interface_id: number | null; + medium: PathMedium | null; + timestamp: number | null; + expires: number | null; + expired: boolean; +} + +export interface ReticulumPeerPathsResult { + ok: boolean; + destination_hash?: string; + preference?: PathMediumPreference; + pin?: PathMedium | null; + effective_preference?: PathMediumPreference | null; + live?: boolean; + paths: ReticulumPathSlot[]; + error?: string; +} + +export function parsePathMediumPreference(raw: unknown): PathMediumPreference | null { + if (typeof raw !== 'string') return null; + const token = raw.trim().toLowerCase(); + if (token === 'lowest' || token === 'network' || token === 'rf') return token; + return null; +} + +export function parsePathMedium(raw: unknown): PathMedium | null { + if (typeof raw !== 'string') return null; + const token = raw.trim().toLowerCase(); + if (token === 'rf' || token === 'network') return token; + return null; +} + +/** Map UI/interface classification onto path-medium tokens (ble counts as RF). */ +export function pathMediumFromInterfaceNameOrType(nameOrType: string): PathMedium { + const via = classifyReticulumVia(nameOrType); + return via === 'rf' || via === 'ble' ? 'rf' : 'network'; +} + +function parsePathSlot(raw: unknown): ReticulumPathSlot | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const hops = + typeof o.hops === 'number' && Number.isFinite(o.hops) ? Math.max(0, Math.floor(o.hops)) : null; + const interfaceId = + typeof o.interface_id === 'number' && Number.isFinite(o.interface_id) + ? Math.floor(o.interface_id) + : null; + return { + active: Boolean(o.active), + hops, + via_hash: typeof o.via_hash === 'string' ? o.via_hash : null, + interface: typeof o.interface === 'string' ? o.interface : null, + interface_id: interfaceId, + medium: parsePathMedium(o.medium), + timestamp: typeof o.timestamp === 'number' ? o.timestamp : null, + expires: typeof o.expires === 'number' ? o.expires : null, + expired: Boolean(o.expired), + }; +} + +export function parsePeerPathsResponse(body: unknown): ReticulumPeerPathsResult { + if (!body || typeof body !== 'object') { + return { ok: false, paths: [], error: 'invalid_response' }; + } + const o = body as Record; + if (o.ok === false) { + return { + ok: false, + paths: [], + error: typeof o.error === 'string' ? o.error : 'request_failed', + }; + } + const pathsRaw = Array.isArray(o.paths) ? o.paths : []; + const paths = pathsRaw + .map(parsePathSlot) + .filter((slot): slot is ReticulumPathSlot => slot != null) + .slice(0, 3); + return { + ok: true, + destination_hash: typeof o.destination_hash === 'string' ? o.destination_hash : undefined, + preference: parsePathMediumPreference(o.preference) ?? undefined, + pin: o.pin === null ? null : (parsePathMedium(o.pin) ?? null), + effective_preference: + o.effective_preference == null + ? null + : (parsePathMediumPreference(o.effective_preference) ?? null), + live: typeof o.live === 'boolean' ? o.live : undefined, + paths, + }; +} + +export async function fetchPathMediumPreference(): Promise<{ + ok: boolean; + preference: PathMediumPreference; + error?: string; +}> { + if (!(await isReticulumSidecarRunning())) { + return { ok: false, preference: 'lowest', error: 'sidecar_not_running' }; + } + try { + const body = await window.electronAPI.reticulum.proxyGet( + '/api/v1/settings/path-medium-preference', + ); + if (!body || typeof body !== 'object') { + return { ok: false, preference: 'lowest', error: 'invalid_response' }; + } + const o = body as Record; + const preference = parsePathMediumPreference(o.preference) ?? 'lowest'; + return { ok: o.ok !== false, preference }; + } catch (e) { + // catch-no-log-ok error returned to caller + return { ok: false, preference: 'lowest', error: errLikeToLogString(e) }; + } +} + +export async function setPathMediumPreference( + preference: PathMediumPreference, +): Promise<{ ok: boolean; error?: string }> { + if (!(await isReticulumSidecarRunning())) { + return { ok: false, error: 'sidecar_not_running' }; + } + try { + const body = (await window.electronAPI.reticulum.proxyPut( + '/api/v1/settings/path-medium-preference', + { preference }, + )) as { ok?: boolean; error?: string }; + return { ok: Boolean(body.ok), error: body.error }; + } catch (e) { + // catch-no-log-ok error returned to caller + return { ok: false, error: errLikeToLogString(e) }; + } +} + +export async function fetchReticulumPeerPaths(hash: string): Promise { + const clean = hash.trim().toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(clean)) { + return { ok: false, paths: [], error: 'invalid_hash' }; + } + if (!(await isReticulumSidecarRunning())) { + return { ok: false, paths: [], error: 'sidecar_not_running' }; + } + try { + const body = await window.electronAPI.reticulum.proxyGet(`/api/v1/peers/${clean}/paths`); + return parsePeerPathsResponse(body); + } catch (e) { + // catch-no-log-ok error returned to caller + return { ok: false, paths: [], error: errLikeToLogString(e) }; + } +} + +export async function setReticulumPeerMediumPin( + hash: string, + pin: PathMedium | null, +): Promise<{ ok: boolean; error?: string }> { + const clean = hash.trim().toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(clean)) { + return { ok: false, error: 'invalid_hash' }; + } + if (!(await isReticulumSidecarRunning())) { + return { ok: false, error: 'sidecar_not_running' }; + } + try { + const body = (await window.electronAPI.reticulum.proxyPut(`/api/v1/peers/${clean}/medium-pin`, { + pin, + })) as { ok?: boolean; error?: string }; + return { ok: Boolean(body.ok), error: body.error }; + } catch (e) { + // catch-no-log-ok error returned to caller + return { ok: false, error: errLikeToLogString(e) }; + } +} + +export function peerMediumPinChoiceFromApi( + pin: PathMedium | null | undefined, +): PeerMediumPinChoice { + if (pin === 'rf' || pin === 'network') return pin; + return 'auto'; +} + +export function peerMediumPinApiFromChoice(choice: PeerMediumPinChoice): PathMedium | null { + if (choice === 'auto') return null; + return choice; +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index b35725895..83bf3135c 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1041,7 +1041,8 @@ "rnodeWifi": "LoRa mesh přes RNode Wi-Fi (tcp:// hostitel).", "blePeer": "BLE peer mesh pomocí seed peer adres.", "generic": "Síťové rozhraní Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "I2P páteř přes router na tomto stroji. Povolte most aplikace SAM (127.0.0.1:7656) — ne http/HTTPS I2PTunnel proxy (4444/4445). Restartujte I2P po povolení SAM, aby most poslouchal, a poté restartujte zásobník Reticulum, pokud toto rozhraní zůstane mimo provoz." }, "rfProfile": { "coordinated": "Koordinované regionální", @@ -2498,7 +2499,15 @@ "shareInstance": "Sdílet instanci Reticulum", "logLevel": "Úroveň protokolu", "save": "Uložte nastavení zásobníku", - "saveFailed": "Nastavení zásobníku se nepodařilo uložit." + "saveFailed": "Nastavení zásobníku se nepodařilo uložit.", + "pathMediumPreference": "Preferované médium cesty", + "pathMediumPreferenceHint": "Když je peer dosažitelný přes RF a síť (TCP/I2P/atd.), zvolte, které médium ve výchozím nastavení vyhrává. Nejnižší pořadí cest pouze podle počtu hopů. Špendlíky per-peer na kartě Peers to mohou přepsat.", + "pathMediumLowest": "Nejnižší cesta (počet hopů)", + "pathMediumNetwork": "Síť (jiná než RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Globální preferované médium cesty", + "pathMediumPreferenceSaved": "Předvolba média cesty uložena.", + "pathMediumPreferenceSaveFailed": "Uložení předvolby média cesty se nezdařilo." }, "reticulumConfigImport": { "title": "Import konfigurace Reticulum", @@ -3123,7 +3132,26 @@ "lookupSubmit": "Vyhledat", "lookupSubmitAria": "Vyžádat cestu a otestovat cílový hash", "lookupInvalid": "Zadejte platný 32znakový LXMF cílový hash nebo odkaz lxmf://.", - "lookupHint": "Požádejte o cestu pro hash, který ještě není v seznamu kolegů (například kolega v centru, které sdílíte)." + "lookupHint": "Požádejte o cestu pro hash, který ještě není v seznamu kolegů (například kolega v centru, které sdílíte).", + "paths": "Složky", + "pathsAria": "Zobrazit hodnocené cesty pro {{hash}}", + "pathsDetailAria": "Hodnocené cesty pro {{hash}}", + "pathsHeading": "Cesty · {{hash}}…", + "pathsCloseAria": "Podrobnosti o zavření cest", + "pathsPreferLabel": "Preferovat médium", + "pathsPreferAria": "Preferované médium cesty pro tohoto kolegu", + "pathsPreferAuto": "Auto (globální)", + "pathsPreferRf": "regionální facilitátor", + "pathsPreferNetwork": "Síť", + "pathsGlobalPreference": "Globální: {{preference}}", + "pathsListAria": "Hodnocené sloty cesty", + "pathsActiveBadge": "Aktivní", + "pathsBackupBadge": "Záloha", + "pathsMedium": "Střední", + "pathsExpired": "Platnost vypršela", + "pathsEmpty": "Zatím žádné sloty pro cestu. Požádejte o cestu nebo počkejte na oznámení.", + "pathsLoadFailed": "Načtení slotů cesty se nezdařilo.", + "pathsPinFailed": "Aktualizace předvolby média pro tohoto protějšku se nezdařila." }, "radioPanel": { "offloadedContacts": "{{count}} kontaktů bylo přesunuto do databáze.", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index ae35a028a..5750532c0 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1038,7 +1038,8 @@ "rnodeWifi": "LoRa-Mesh über RNode Wi-Fi (tcp:// host).", "blePeer": "BLE-Peer-Mesh unter Verwendung von Seed-Peer-Adressen.", "generic": "Reticulum-Netzwerkschnittstelle.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "I2P-Backbone über einen Router auf dieser Maschine. Aktivieren SIE die SAM-Anwendungsbrücke (127.0.0.1:7656) — nicht HTTP/HTTPS-I2PTunnel-Proxys (4444/4445). Starten Sie I2P neu, nachdem Sie SAM aktiviert haben, damit die Brücke zuhört, und starten Sie dann den Reticulum-Stack neu, wenn diese Schnittstelle ausgeschaltet bleibt." }, "rfProfile": { "coordinated": "Regional koordiniert", @@ -2496,7 +2497,15 @@ "shareInstance": "Teilen Sie die Reticulum-Instanz", "logLevel": "Protokollebene", "save": "Stapeleinstellungen speichern", - "saveFailed": "Stapeleinstellungen konnten nicht gespeichert werden." + "saveFailed": "Stapeleinstellungen konnten nicht gespeichert werden.", + "pathMediumPreference": "Bevorzugtes Pfadmedium", + "pathMediumPreferenceHint": "Wenn ein Peer über RF und Netzwerk (TCP/I2P/etc.) erreichbar ist, wählen Sie standardmäßig aus, welches Medium gewinnt. Niedrigste Pfadreihen nur nach Hop-Anzahl. Per-Peer-Pins auf der Registerkarte Peers können dies außer Kraft setzen.", + "pathMediumLowest": "Niedrigster Pfad (Hop Count)", + "pathMediumNetwork": "Netzwerk (nicht RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Global bevorzugtes Pfadmedium", + "pathMediumPreferenceSaved": "Pfad Medium Präferenz gespeichert.", + "pathMediumPreferenceSaveFailed": "Pfad-Medium-Präferenz konnte nicht gespeichert werden." }, "reticulumConfigImport": { "title": "Reticulum-Konfiguration importieren", @@ -3014,7 +3023,7 @@ "pageLoadingCountdownOverdue": "Seite wird geladen... funktioniert immer noch", "pageReadyToast": "Nomad-Seite bereit: {{name}}", "staleLastSeenHint": "Zuletzt gehört {{time}} – dieser Knoten ist möglicherweise offline, auch wenn er als online aufgeführt ist.", - "pageLoadingRetryCountdown": "Zeitüberschreitung beim ersten Versuch — Pfad wird aktualisiert und es wird erneut versucht… {{time}} left", + "pageLoadingRetryCountdown": "Zeitüberschreitung beim ersten Versuch — Pfad wird aktualisiert und es wird erneut versucht… noch {{time}}", "pageLoadingRetryOverdue": "Erfrischungspfad und erneuter Versuch… funktioniert noch" }, "packetDistribution": { @@ -3121,7 +3130,26 @@ "lookupSubmit": "Suchen", "lookupSubmitAria": "Pfad anfordern und Ziel-Hash prüfen", "lookupInvalid": "Gib einen gültigen 32-stelligen LXMF-Ziel-Hash oder einen lxmf://-Link ein.", - "lookupHint": "Fordern Sie einen Pfad für einen Hash an, der noch nicht in der Peers-Liste enthalten ist (z. B. einen Peer auf einem Hub, den Sie teilen)." + "lookupHint": "Fordern Sie einen Pfad für einen Hash an, der noch nicht in der Peers-Liste enthalten ist (z. B. einen Peer auf einem Hub, den Sie teilen).", + "paths": "Pfade", + "pathsAria": "Ranglistenpfade für {{hash}} anzeigen", + "pathsDetailAria": "Ranglistenpfade für {{hash}}", + "pathsHeading": "Pfade · {{hash}}…", + "pathsCloseAria": "Detail Pfade schließen", + "pathsPreferLabel": "Bevorzugt mittel", + "pathsPreferAria": "Bevorzugtes Pfadmedium für diesen Peer", + "pathsPreferAuto": "Auto (global)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Netzwerk-", + "pathsGlobalPreference": "Global: {{preference}}", + "pathsListAria": "Ranglistenpfad-Slots", + "pathsActiveBadge": "Aktiv", + "pathsBackupBadge": "Backup", + "pathsMedium": "Medium", + "pathsExpired": "Abgelaufen", + "pathsEmpty": "Noch keine Pfad-Slots. Fordern Sie einen Pfad an oder warten Sie auf eine Ankündigung.", + "pathsLoadFailed": "Pfad-Slots konnten nicht geladen werden.", + "pathsPinFailed": "Die Medieneinstellung für diesen Peer konnte nicht aktualisiert werden." }, "radioPanel": { "offloadedContacts": "Entladen von {{count}} Kontakten in die Datenbank.", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 9b256ef33..bfac26f0d 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1087,6 +1087,7 @@ "sharedInstance": "Localhost TCP server when Share instance is on — lets other apps on this machine use this stack.", "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", "tcp": "Outbound TCP tunnel to a remote Reticulum hub or gateway.", + "i2p": "I2P backbone via a router on this machine. Enable the SAM application bridge (127.0.0.1:7656) — not HTTP/HTTPS I2PTunnel proxies (4444/4445). Restart I2P after enabling SAM so the bridge listens, then restart the Reticulum stack if this interface stays down.", "rnodeUsb": "LoRa mesh via USB RNode.", "rnodeBle": "LoRa mesh via Bluetooth RNode.", "rnodeWifi": "LoRa mesh via RNode Wi-Fi (tcp:// host).", @@ -2501,6 +2502,14 @@ "enableTransport": "Enable transport (route for other peers)", "shareInstance": "Share Reticulum instance", "logLevel": "Log level", + "pathMediumPreference": "Preferred path medium", + "pathMediumPreferenceHint": "When a peer is reachable over RF and network (TCP/I2P/etc.), choose which medium wins by default. Lowest path ranks by hop count only. Per-peer pins on the Peers tab can override this.", + "pathMediumLowest": "Lowest path (hop count)", + "pathMediumNetwork": "Network (non-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Global preferred path medium", + "pathMediumPreferenceSaved": "Path medium preference saved.", + "pathMediumPreferenceSaveFailed": "Failed to save path medium preference.", "save": "Save stack settings", "saveFailed": "Failed to save stack settings." }, @@ -3261,6 +3270,25 @@ "colFavorite": "Favourite", "toggleFavorite": "Toggle favourite", "openChat": "Open chat", + "paths": "Paths", + "pathsAria": "Paths · {{hash}}", + "pathsDetailAria": "Ranked paths for {{hash}}", + "pathsHeading": "Paths · {{hash}}…", + "pathsCloseAria": "Close paths detail", + "pathsPreferLabel": "Prefer medium", + "pathsPreferAria": "Preferred path medium for this peer", + "pathsPreferAuto": "Auto (global)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Network", + "pathsGlobalPreference": "Global: {{preference}}", + "pathsListAria": "Ranked path slots", + "pathsActiveBadge": "Active", + "pathsBackupBadge": "Backup", + "pathsMedium": "Medium", + "pathsExpired": "Expired", + "pathsEmpty": "No path slots yet. Request a path or wait for an announce.", + "pathsLoadFailed": "Failed to load path slots.", + "pathsPinFailed": "Failed to update medium preference for this peer.", "emptyPeers": "No peers discovered yet. Send an announce or wait for network traffic.", "emptyContacts": "No contacts yet — message a peer to add them here.", "emptyFavorites": "No favourite peers yet — star a contact or peer to pin them here.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 98869762f..82e69a3ad 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "Malla LoRa a través de RNode Wi-Fi (tcp://host).", "blePeer": "Malla de pares BLE que utiliza direcciones de pares iniciales.", "generic": "Interfaz de red Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Red troncal I2P a través de un router en esta máquina. Habilite el puente de aplicación SAM (127.0.0.1:7656), no los proxies HTTP/HTTPS I2PTunnel (4444/4445). Reinicie I2P después de habilitar SAM para que el puente escuche, luego reinicie la pila Reticulum si esta interfaz permanece inactiva." }, "rfProfile": { "coordinated": "Regional coordinado", @@ -2496,7 +2497,15 @@ "shareInstance": "Compartir instancia Reticulum", "logLevel": "Nivel de registro", "save": "Guardar configuración de pila", - "saveFailed": "No se pudo guardar la configuración de la pila." + "saveFailed": "No se pudo guardar la configuración de la pila.", + "pathMediumPreference": "Medio Path preferido", + "pathMediumPreferenceHint": "Cuando se puede acceder a un par a través de RF y red (TCP/I2P/etc.), elija qué medio gana de forma predeterminada. La ruta más baja se clasifica solo por recuento de saltos. Los pines Per-peer en la pestaña Peers pueden anular esto.", + "pathMediumLowest": "Ruta más baja (conteo de saltos)", + "pathMediumNetwork": "Red (no RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Medio Path preferido global", + "pathMediumPreferenceSaved": "Ruta de preferencia media guardada.", + "pathMediumPreferenceSaveFailed": "Error al guardar la preferencia del medio de ruta." }, "reticulumConfigImport": { "title": "Importar config Reticulum", @@ -3014,7 +3023,7 @@ "pageLoadingCountdownOverdue": "Cargando página… sigue funcionando", "pageReadyToast": "Nomad — Página nómada lista: {{name}}", "staleLastSeenHint": "Escuchado por última vez {{time}}: este nodo puede estar fuera de línea incluso si figura como en línea.", - "pageLoadingRetryCountdown": "Se agotó el tiempo de espera del primer intento: actualizar la ruta y volver a intentarlo… {{time}} left", + "pageLoadingRetryCountdown": "Se agotó el tiempo de espera del primer intento: actualizar la ruta y volver a intentarlo… quedan {{time}}", "pageLoadingRetryOverdue": "Actualizando ruta y reintentando... sigue funcionando" }, "packetDistribution": { @@ -3121,7 +3130,26 @@ "lookupSubmit": "Buscar", "lookupSubmitAria": "Solicitar ruta y sondear el hash de destino", "lookupInvalid": "Introduce un hash de destino LXMF válido de 32 caracteres o un enlace lxmf://.", - "lookupHint": "Solicita una ruta para un hash que aún no está en la lista de compañeros (por ejemplo, un compañero en un centro que compartes)." + "lookupHint": "Solicita una ruta para un hash que aún no está en la lista de compañeros (por ejemplo, un compañero en un centro que compartes).", + "paths": "Paths", + "pathsAria": "Mostrar rutas clasificadas para {{hash}}", + "pathsDetailAria": "Rutas clasificadas para {{hash}}", + "pathsHeading": "Rutas · {{hash}}…", + "pathsCloseAria": "Detalle de cerrar rutas", + "pathsPreferLabel": "Prefiero medio", + "pathsPreferAria": "Medio de ruta preferido para este compañero", + "pathsPreferAuto": "Automático/Global", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Red", + "pathsGlobalPreference": "Global: {{preference}}", + "pathsListAria": "Ranuras de ruta clasificadas", + "pathsActiveBadge": "Activo", + "pathsBackupBadge": "Respaldo", + "pathsMedium": "Medio", + "pathsExpired": "Caducada", + "pathsEmpty": "Aún no hay ranuras de ruta. Solicita una ruta o espera un anuncio.", + "pathsLoadFailed": "No se han podido cargar las ranuras de ruta.", + "pathsPinFailed": "Error al actualizar la preferencia del medio para este compañero." }, "radioPanel": { "offloadedContacts": "Se han descargado {{count}} contactos a la base de datos.", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index f8f681792..16762c5dd 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1039,7 +1039,8 @@ "rnodeWifi": "Maillage LoRa via RNode Wi-Fi (hôte tcp://).", "blePeer": "Maillage d’homologues BLE utilisant des adresses d’homologues de départ.", "generic": "Interface réseau Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Backbone I2P via un routeur sur cette machine. Activez le pont d'application SAM (127.0.0.1:7656) — pas les proxys HTTP/HTTPS I2PTunnel (4444/4445). Redémarrez I2P après avoir activé SAM pour que le pont écoute, puis redémarrez la pile Reticulum si cette interface reste en panne." }, "rfProfile": { "coordinated": "Coordination régionale", @@ -2496,7 +2497,15 @@ "shareInstance": "Partager l'instance de Reticulum", "logLevel": "Niveau de journalisation", "save": "Enregistrer les paramètres de la pile", - "saveFailed": "Échec de l'enregistrement des paramètres de la pile." + "saveFailed": "Échec de l'enregistrement des paramètres de la pile.", + "pathMediumPreference": "Milieu de chemin privilégié", + "pathMediumPreferenceHint": "Lorsqu'un pair est joignable sur RF et réseau (TCP/I2P/etc.), choisissez quel support gagne par défaut. Le chemin le plus bas se classe uniquement en fonction du nombre de sauts. Les épingles par pair de l'onglet Pairs peuvent remplacer cela.", + "pathMediumLowest": "Chemin le plus bas (nombre de sauts)", + "pathMediumNetwork": "Réseau (non-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Milieu de chemin privilégié global", + "pathMediumPreferenceSaved": "Préférence de support de chemin enregistrée.", + "pathMediumPreferenceSaveFailed": "Échec de l'enregistrement de la préférence de support de chemin." }, "reticulumConfigImport": { "title": "Importer la configuration du Reticulum", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Rechercher", "lookupSubmitAria": "Demander un chemin et sonder le hash de destination", "lookupInvalid": "Saisissez un hash de destination LXMF valide de 32 caractères ou un lien lxmf://.", - "lookupHint": "Demandez un chemin pour un hachage qui ne figure pas encore dans la liste des pairs (par exemple, un pair sur un hub que vous partagez)." + "lookupHint": "Demandez un chemin pour un hachage qui ne figure pas encore dans la liste des pairs (par exemple, un pair sur un hub que vous partagez).", + "paths": "Chemins", + "pathsAria": "Afficher les chemins classés pour {{hash}}", + "pathsDetailAria": "Chemins classés pour {{hash}}", + "pathsHeading": "Chemins · {{hash}}…", + "pathsCloseAria": "Fermer les détails des chemins", + "pathsPreferLabel": "Préférez moyen", + "pathsPreferAria": "Support de chemin préféré pour ce pair", + "pathsPreferAuto": "Auto (global)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Réseau", + "pathsGlobalPreference": "Global : {{preference}}", + "pathsListAria": "Emplacements de chemin classés", + "pathsActiveBadge": "Actif", + "pathsBackupBadge": "Sauvegarde", + "pathsMedium": "Moyenne", + "pathsExpired": "Expiré", + "pathsEmpty": "Aucun emplacement de chemin pour le moment. Demandez un chemin ou attendez une annonce.", + "pathsLoadFailed": "Échec du chargement des emplacements de chemin.", + "pathsPinFailed": "Échec de la mise à jour de la préférence moyenne pour cet homologue." }, "radioPanel": { "offloadedContacts": "Déchargement des contacts {{count}} dans la base de données.", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 577de5492..9417e9a20 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "LoRa mesh melalui RNode Wi-Fi (tcp:// host).", "blePeer": "Mesh rekan BLE menggunakan alamat rekan benih.", "generic": "Antarmuka jaringan Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Backbone I2P melalui router pada mesin ini. Aktifkan jembatan aplikasi SAM (127.0.0.1:7656) — bukan proxy HTTP/https I2PTunnel (4444/4445). Mulai ulang I2P setelah mengaktifkan SAM sehingga bridge mendengarkan, lalu mulai ulang tumpukan Reticulum jika antarmuka ini tetap down." }, "rfProfile": { "coordinated": "Daerah yang terkoordinasi", @@ -2496,7 +2497,15 @@ "shareInstance": "Bagikan instance Reticulum", "logLevel": "tingkat log", "save": "Simpan pengaturan tumpukan", - "saveFailed": "Gagal menyimpan pengaturan tumpukan." + "saveFailed": "Gagal menyimpan pengaturan tumpukan.", + "pathMediumPreference": "Media jalur yang disukai", + "pathMediumPreferenceHint": "Jika peer dapat dijangkau melalui RF dan jaringan (TCP/I2P/dll.), pilih media mana yang menang secara default. Jalur terendah hanya diurutkan berdasarkan jumlah hop. Pin per-peer di tab Peers dapat menimpa ini.", + "pathMediumLowest": "Jalur terendah (jumlah hop)", + "pathMediumNetwork": "Jaringan (non-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Media jalur global yang disukai", + "pathMediumPreferenceSaved": "Preferensi media jalur disimpan.", + "pathMediumPreferenceSaveFailed": "Gagal menyimpan preferensi media jalur." }, "reticulumConfigImport": { "title": "Impor config Reticulum", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Cari", "lookupSubmitAria": "Minta jalur dan probe hash tujuan", "lookupInvalid": "Masukkan hash tujuan LXMF 32 karakter yang valid atau tautan lxmf://.", - "lookupHint": "Minta jalur untuk hash yang belum ada dalam daftar rekan (misalnya rekan di hub yang Anda bagikan)." + "lookupHint": "Minta jalur untuk hash yang belum ada dalam daftar rekan (misalnya rekan di hub yang Anda bagikan).", + "paths": "Jalur", + "pathsAria": "Tampilkan jalur berperingkat untuk {{hash}}", + "pathsDetailAria": "Jalur berperingkat untuk {{hash}}", + "pathsHeading": "Jalur · {{hash}}…", + "pathsCloseAria": "Tutup detail jalur", + "pathsPreferLabel": "Utamakan media", + "pathsPreferAria": "Media jalur yang disukai untuk peer ini", + "pathsPreferAuto": "Otomatis (global)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Jaringan", + "pathsGlobalPreference": "Global: {{preference}}", + "pathsListAria": "Slot jalur berperingkat", + "pathsActiveBadge": "Aktif", + "pathsBackupBadge": "Cadangan", + "pathsMedium": "Media", + "pathsExpired": "Kedaluwarsa", + "pathsEmpty": "Belum ada slot jalur. Minta jalur atau tunggu pengumuman.", + "pathsLoadFailed": "Gagal memuat slot jalur.", + "pathsPinFailed": "Gagal memperbarui preferensi media untuk peer ini." }, "radioPanel": { "offloadedContacts": "Membongkar {{count}} kontak ke database.", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 913af1ad5..877d3698e 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1038,7 +1038,8 @@ "rnodeWifi": "Mesh LoRa tramite RNode Wi-Fi (tcp:// host).", "blePeer": "Mesh peer BLE che utilizza indirizzi peer seed.", "generic": "Interfaccia di rete Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Dorsale I2P tramite un router su questa macchina. Abilitare il bridge dell'applicazione SAM (127.0.0.1:7656) — non i proxy HTTP/HTTPS I2PTunnel (4444/4445). Riavviare I2P dopo aver abilitato SAM in modo che il bridge ascolti, quindi riavviare lo stack Reticulum se questa interfaccia rimane inattiva." }, "rfProfile": { "coordinated": "Coordinamento regionale", @@ -2496,7 +2497,15 @@ "shareInstance": "Condividi l'istanza di Reticulum", "logLevel": "Livello di registro", "save": "Salva le impostazioni dello stack", - "saveFailed": "Impossibile salvare le impostazioni dello stack." + "saveFailed": "Impossibile salvare le impostazioni dello stack.", + "pathMediumPreference": "Mezzo del percorso preferito", + "pathMediumPreferenceHint": "Quando un peer è raggiungibile tramite RF e rete (TCP/I2P/ecc.), scegli quale mezzo vince per impostazione predefinita. Il percorso più basso viene classificato solo in base al numero di hop. I pin per peer nella scheda Peers possono sovrascriverlo.", + "pathMediumLowest": "Percorso più basso (numero di hop)", + "pathMediumNetwork": "Rete (non RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Mezzo percorso preferito globale", + "pathMediumPreferenceSaved": "Preferenza media del percorso salvata.", + "pathMediumPreferenceSaveFailed": "Impossibile salvare la preferenza media del percorso." }, "reticulumConfigImport": { "title": "Importa configurazione Reticulum", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Cerca", "lookupSubmitAria": "Richiedi percorso e verifica l’hash di destinazione", "lookupInvalid": "Inserisci un hash di destinazione LXMF valido di 32 caratteri o un collegamento lxmf://.", - "lookupHint": "Richiedi un percorso per un hash che non è ancora nell'elenco dei peer (ad esempio un peer su un hub che condividi)." + "lookupHint": "Richiedi un percorso per un hash che non è ancora nell'elenco dei peer (ad esempio un peer su un hub che condividi).", + "paths": "Percorsi", + "pathsAria": "Mostra percorsi classificati per {{hash}}", + "pathsDetailAria": "Percorsi classificati per {{hash}}", + "pathsHeading": "Percorsi · {{hash}}…", + "pathsCloseAria": "Dettaglio percorsi di chiusura", + "pathsPreferLabel": "Preferisco il medio", + "pathsPreferAria": "Mezzo di percorso preferito per questo peer", + "pathsPreferAuto": "Auto (globale)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Network", + "pathsGlobalPreference": "Globale: {{preference}}", + "pathsListAria": "Slot percorsi classificati", + "pathsActiveBadge": "Attivo", + "pathsBackupBadge": "Backup", + "pathsMedium": "Medio", + "pathsExpired": "scaduta", + "pathsEmpty": "Ancora nessuno slot di percorso. Richiedi un percorso o attendi un annuncio.", + "pathsLoadFailed": "Impossibile caricare gli slot del percorso.", + "pathsPinFailed": "Impossibile aggiornare la preferenza media per questo peer." }, "radioPanel": { "offloadedContacts": "Contatti {{count}} scaricati nel database.", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 0b0d8a2d2..78a9d3925 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "RNode Wi-Fi (tcp:// ホスト) 経由の LoRa メッシュ。", "blePeer": "シード ピア アドレスを使用する BLE ピア メッシュ。", "generic": "Reticulumネットワークインターフェース。", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "このマシンのルーターを介したI 2 Pバックボーン。HTTP/HTTPS I 2 PTunnelプロキシ( 4444/4445 )ではなく、SAMアプリケーションブリッジ( 127.0.0.1 : 7656 )を有効にします。ブリッジがリッスンするようにSAMを有効にした後、I 2 Pを再起動し、このインターフェースがダウンしたままになっている場合は、Reticulumスタックを再起動します。" }, "rfProfile": { "coordinated": "地域連携", @@ -1243,7 +1244,7 @@ "openGlobalMap": "グローバルマップ", "openGlobalMapAria": "Rmap.worldでRMAP v 4グローバルマップを開く", "syncFailed": "RMAP検出の同期に失敗しました。", - "publishingOf": "RMAP v4: {{current}} または {{total}} を公開しています" + "publishingOf": "RMAP v4: {{total}} 中 {{current}} を公開しています" }, "coloradoPresetConfirm": "Colorado Mesh MQTTは、コロラド州エリアのメッシュユーザー向けです。プリセットはmeshcore/DEN(デンバーIATA)で公開されています。コロラド州にいない場合は、代わりにLetsMeshまたはMeshMapperを使用してください。続行しますか?", "coloradoServerNote": "Colorado Mesh はコロラド地域のメッシュ利用者向けです。トピック接頭辞はデンバー IATA(meshcore/DEN)を使用します。", @@ -2496,7 +2497,15 @@ "shareInstance": "Reticulum インスタンスを共有する", "logLevel": "ログレベル", "save": "スタック設定の保存", - "saveFailed": "スタック設定の保存に失敗しました。" + "saveFailed": "スタック設定の保存に失敗しました。", + "pathMediumPreference": "好ましいパス媒体", + "pathMediumPreferenceHint": "ピアがRFおよびネットワーク( TCP/I 2 Pなど)を介して到達可能な場合は、デフォルトでどのメディアが勝つかを選択します。最低パスはホップカウントのみでランク付けされます。[ピア]タブのピアごとのピンは、これを上書きできます。", + "pathMediumLowest": "最低パス(ホップカウント)", + "pathMediumNetwork": "ネットワーク(非RF )", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "グローバル優先パスメディア", + "pathMediumPreferenceSaved": "パスのメディア設定が保存されました。", + "pathMediumPreferenceSaveFailed": "パスのメディア設定を保存できませんでした。" }, "reticulumConfigImport": { "title": "Reticulum 構成をインポートする", @@ -3121,7 +3130,26 @@ "lookupSubmit": "検索", "lookupSubmitAria": "パスを要求し宛先ハッシュをプローブ", "lookupInvalid": "有効な32文字のLXMF宛先ハッシュ、または lxmf:// リンクを入力してください。", - "lookupHint": "まだピアリストにないハッシュのパスを要求します(たとえば、共有するハブ上のピア)。" + "lookupHint": "まだピアリストにないハッシュのパスを要求します(たとえば、共有するハブ上のピア)。", + "paths": "パス", + "pathsAria": "{{hash}}のランク付けされたパスを表示", + "pathsDetailAria": "{{hash}}のランクパス", + "pathsHeading": "パス· {{hash}} …", + "pathsCloseAria": "パス詳細を閉じる", + "pathsPreferLabel": "ミディアムを好む", + "pathsPreferAria": "このピアの優先パスメディア", + "pathsPreferAuto": "自動(グローバル)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "ネットワーク", + "pathsGlobalPreference": "グローバル: {{preference}}", + "pathsListAria": "ランクパススロット", + "pathsActiveBadge": "アクティブ", + "pathsBackupBadge": "バックアップ", + "pathsMedium": "中程度", + "pathsExpired": "有効期限切れ", + "pathsEmpty": "パススロットはまだありません。パスをリクエストするか、アナウンスを待ちます。", + "pathsLoadFailed": "パススロットの読み込みに失敗しました。", + "pathsPinFailed": "このピアのメディア設定を更新できませんでした。" }, "radioPanel": { "offloadedContacts": "{{count}} 連絡先をデータベースにオフロードしました。", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 934f93f8b..2aaf70db0 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "LoRa mesh via RNode Wi-Fi (tcp:// host).", "blePeer": "시드 피어 주소를 사용하는 BLE 피어 메시.", "generic": "Reticulum 네트워크 인터페이스.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "이 기계의 라우터를 통해 I2P 백본. HTTP/HTTPS I2PTunnel 프록시 (4444/4445) 가 아닌 SAM 응용 프로그램 브리지 (127.0.0.1: 7656) 를 활성화합니다. 브리지가 수신 대기하도록 SAM을 활성화한 후 I2P를 다시 시작한 다음 이 인터페이스가 다운된 경우 Reticulum 스택을 다시 시작하십시오." }, "rfProfile": { "coordinated": "지역별 조정", @@ -2496,7 +2497,15 @@ "shareInstance": "Reticulum 인스턴스 공유", "logLevel": "로그 수준", "save": "스택 설정 저장", - "saveFailed": "스택 설정을 저장하지 못했습니다." + "saveFailed": "스택 설정을 저장하지 못했습니다.", + "pathMediumPreference": "선호 경로 매체", + "pathMediumPreferenceHint": "RF 및 네트워크 (TCP/I2P 등) 를 통해 피어에 연결할 수 있는 경우 기본적으로 승리할 매체를 선택합니다. 홉 수만 기준으로 가장 낮은 경로 순위입니다. 피어 탭의 피어별 핀이 이 핀을 재정의할 수 있습니다.", + "pathMediumLowest": "최저 경로 (홉 수)", + "pathMediumNetwork": "네트워크 (비 RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "글로벌 선호 경로 매체", + "pathMediumPreferenceSaved": "경로 중간 환경설정이 저장되었습니다.", + "pathMediumPreferenceSaveFailed": "경로 중간 환경설정을 저장하지 못했습니다." }, "reticulumConfigImport": { "title": "Reticulum config 가져오기", @@ -3121,7 +3130,26 @@ "lookupSubmit": "검색", "lookupSubmitAria": "경로 요청 및 대상 해시 프로브", "lookupInvalid": "유효한 32자 LXMF 대상 해시 또는 lxmf:// 링크를 입력하세요.", - "lookupHint": "아직 피어 목록에 없는 해시의 경로를 요청합니다 (예: 공유하는 허브의 피어)." + "lookupHint": "아직 피어 목록에 없는 해시의 경로를 요청합니다 (예: 공유하는 허브의 피어).", + "paths": "Paths", + "pathsAria": "{{hash}}의 순위가 지정된 경로 표시", + "pathsDetailAria": "{{hash}} 의 순위 경로", + "pathsHeading": "경로 · {{hash}} …", + "pathsCloseAria": "경로 세부 정보 닫기", + "pathsPreferLabel": "매체 선호", + "pathsPreferAria": "이 피어에 대한 선호 경로 매체", + "pathsPreferAuto": "자동 (전역)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "네트워크", + "pathsGlobalPreference": "전 세계: {{preference}}", + "pathsListAria": "순위가 지정된 경로 슬롯", + "pathsActiveBadge": "활성화", + "pathsBackupBadge": "백업", + "pathsMedium": "중간", + "pathsExpired": "만료됨", + "pathsEmpty": "아직 경로 슬롯이 없습니다. 경로를 요청하거나 발표를 기다리세요.", + "pathsLoadFailed": "경로 슬롯을 로드하지 못했습니다.", + "pathsPinFailed": "이 피어의 중간 환경 설정을 업데이트하지 못했습니다." }, "radioPanel": { "offloadedContacts": "{{count}} 연락처를 데이터베이스로 오프로드했습니다.", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 63db3debb..39d9eb583 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1039,7 +1039,8 @@ "rnodeWifi": "LoRa mesh via RNode Wi-Fi (tcp://host).", "blePeer": "BLE-peer mesh met behulp van zaad-peer-adressen.", "generic": "Reticulum-netwerkinterface.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "I2P-backbone via een router op deze machine. Schakel de SAM-toepassingsbrug (127.0.0.1:7656) in — niet HTTP/HTTPS I2PTunnel-proxy's (4444/4445). Start I2P opnieuw op nadat SAM is ingeschakeld, zodat de brug luistert, en start vervolgens de Reticulum-stack opnieuw als deze interface niet werkt." }, "rfProfile": { "coordinated": "Regionaal gecoördineerd", @@ -2496,7 +2497,15 @@ "shareInstance": "Deel Reticulum-instantie", "logLevel": "Logniveau", "save": "Bewaar stapelinstellingen", - "saveFailed": "Kan stapelinstellingen niet opslaan." + "saveFailed": "Kan stapelinstellingen niet opslaan.", + "pathMediumPreference": "Voorkeurspad medium", + "pathMediumPreferenceHint": "Wanneer een peer bereikbaar is via RF en netwerk (TCP/I2P/etc.), kies dan standaard welk medium wint. Het laagste pad wordt alleen gerangschikt op hoptelling. Per-peer pins op het tabblad Peers kunnen dit overschrijven.", + "pathMediumLowest": "Laagste pad (hoptelling)", + "pathMediumNetwork": "Netwerk (niet-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Globaal voorkeurspadmedium", + "pathMediumPreferenceSaved": "Pad medium voorkeur opgeslagen.", + "pathMediumPreferenceSaveFailed": "Kan pad mediumvoorkeur niet opslaan." }, "reticulumConfigImport": { "title": "Importeer Reticulum-configuratie", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Opzoeken", "lookupSubmitAria": "Pad aanvragen en bestemmings-hash sondieren", "lookupInvalid": "Voer een geldige 32-tekens LXMF-bestemmings-hash of lxmf://-link in.", - "lookupHint": "Vraag een pad aan voor een hash die nog niet in de lijst met peers staat (bijvoorbeeld een peer op een hub die je deelt)." + "lookupHint": "Vraag een pad aan voor een hash die nog niet in de lijst met peers staat (bijvoorbeeld een peer op een hub die je deelt).", + "paths": "Paden", + "pathsAria": "Toon gerangschikte paden voor {{hash}}", + "pathsDetailAria": "Gerangschikte paden voor {{hash}}", + "pathsHeading": "Paden · {{hash}}…", + "pathsCloseAria": "Padendetail sluiten", + "pathsPreferLabel": "Voorkeur voor medium", + "pathsPreferAria": "Voorkeurspadmedium voor deze peer", + "pathsPreferAuto": "Auto (globaal)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Netwerk", + "pathsGlobalPreference": "Wereldwijd: {{preference}}", + "pathsListAria": "Gerangschikte pad-sleuven", + "pathsActiveBadge": "Actief", + "pathsBackupBadge": "Back-up", + "pathsMedium": "Gemiddeld", + "pathsExpired": "Verlopen", + "pathsEmpty": "Nog geen padvakken. Vraag een pad aan of wacht op een aankondiging.", + "pathsLoadFailed": "Kan padvakken niet laden.", + "pathsPinFailed": "Kan mediumvoorkeur voor deze peer niet bijwerken." }, "radioPanel": { "offloadedContacts": "{{count}} contacten naar de database overgebracht.", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 212380323..09d0adabf 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1042,7 +1042,8 @@ "rnodeWifi": "Siatka LoRa przez Wi-Fi RNode (tcp://host).", "blePeer": "Siatka równorzędna BLE wykorzystująca adresy równorzędne początkowe.", "generic": "Interfejs sieciowy Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Szkielet I2P za pośrednictwem routera na tym komputerze. Włącz mostek aplikacji SAM (127.0.0.1:7656) — nie proxy HTTP/HTTPS I2PTunnel (4444/4445). Uruchom ponownie I2P po włączeniu SAM, aby mostek nasłuchiwał, a następnie uruchom ponownie stos Reticulum, jeśli ten interfejs pozostanie wyłączony." }, "rfProfile": { "coordinated": "Skoordynowane regionalnie", @@ -2500,7 +2501,15 @@ "shareInstance": "Udostępnij instancję Reticulum", "logLevel": "Poziom dziennika", "save": "Zapisz ustawienia stosu", - "saveFailed": "Nie udało się zapisać ustawień stosu." + "saveFailed": "Nie udało się zapisać ustawień stosu.", + "pathMediumPreference": "Preferowana ścieżka medium", + "pathMediumPreferenceHint": "Gdy partner jest osiągalny przez RF i sieć (TCP/I2P/itp.), wybierz, które medium domyślnie wygrywa. Najniższa ścieżka plasuje się tylko według liczby przeskoków. Pinezki per peer na karcie Peers mogą to zastąpić.", + "pathMediumLowest": "Najniższa ścieżka (liczba przeskoków)", + "pathMediumNetwork": "Sieć (nie-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Globalna preferowana ścieżka medium", + "pathMediumPreferenceSaved": "Zapisano preferencję medium ścieżki.", + "pathMediumPreferenceSaveFailed": "Nie udało się zapisać preferencji medium ścieżki." }, "reticulumConfigImport": { "title": "Importuj config Reticulum", @@ -3125,7 +3134,26 @@ "lookupSubmit": "Wyszukaj", "lookupSubmitAria": "Poproś o ścieżkę i zbadaj hash docelowy", "lookupInvalid": "Wprowadź prawidłowy 32-znakowy hash docelowy LXMF lub łącze lxmf://.", - "lookupHint": "Poproś o ścieżkę do skrótu, który nie znajduje się jeszcze na liście partnerów (na przykład partner w centrum, które udostępniasz)." + "lookupHint": "Poproś o ścieżkę do skrótu, który nie znajduje się jeszcze na liście partnerów (na przykład partner w centrum, które udostępniasz).", + "paths": "Ścieżki", + "pathsAria": "Pokaż ścieżki uszeregowane dla {{hash}}", + "pathsDetailAria": "Ścieżki rankingowe dla {{hash}}", + "pathsHeading": "Ścieżki · {{hash}}…", + "pathsCloseAria": "Zamknij szczegóły ścieżek", + "pathsPreferLabel": "Preferowane medium", + "pathsPreferAria": "Preferowana ścieżka medium dla tego partnera", + "pathsPreferAuto": "Automatyczny (globalny)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Sieć", + "pathsGlobalPreference": "Globalnie: {{preference}}", + "pathsListAria": "Miejsca na ścieżkę rankingową", + "pathsActiveBadge": "Aktywna", + "pathsBackupBadge": "Backup", + "pathsMedium": "Średnia", + "pathsExpired": "Wygasłe", + "pathsEmpty": "Nie ma jeszcze slotów ścieżek. Poproś o ścieżkę lub poczekaj na ogłoszenie.", + "pathsLoadFailed": "Nie udało się załadować slotów ścieżki.", + "pathsPinFailed": "Nie udało się zaktualizować preferencji medium dla tego partnera." }, "radioPanel": { "offloadedContacts": "Przeniesiono kontakty {{count}} do bazy danych.", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 9e120c05b..d20e2f490 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "Malha LoRa via RNode Wi-Fi (tcp: // host).", "blePeer": "Malha de pares BLE usando endereços de pares iniciais.", "generic": "Interface de rede Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Backbone I2P através de um roteador nesta máquina. Habilite a ponte do aplicativo SAM (127.0.0.1:7656) — não proxies HTTP/HTTPS I2PTunnel (4444/4445). Reinicie o I2P depois de habilitar o SAM para que a ponte ouça e reinicie a pilha Reticulum se essa interface permanecer inativa." }, "rfProfile": { "coordinated": "Regional coordenado", @@ -2496,7 +2497,15 @@ "shareInstance": "Compartilhar instância do Reticulum", "logLevel": "Nível de registro", "save": "Salvar configurações de pilha", - "saveFailed": "Falha ao salvar as configurações da pilha." + "saveFailed": "Falha ao salvar as configurações da pilha.", + "pathMediumPreference": "Meio de caminho preferido", + "pathMediumPreferenceHint": "Quando um par é acessível por RF e rede (TCP/I2P/etc.), escolha qual meio ganha por padrão. O caminho mais baixo é classificado apenas por contagem de saltos. Os pinos por peer na guia Peers podem substituir isso.", + "pathMediumLowest": "Caminho mais baixo (contagem de saltos)", + "pathMediumNetwork": "Rede (não RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Meio de caminho preferido global", + "pathMediumPreferenceSaved": "Preferência média do caminho salva.", + "pathMediumPreferenceSaveFailed": "Falha ao salvar a preferência do meio do caminho." }, "reticulumConfigImport": { "title": "Importar config Reticulum", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Pesquisar", "lookupSubmitAria": "Solicitar caminho e sondar o hash de destino", "lookupInvalid": "Digite um hash de destino LXMF válido de 32 caracteres ou um link lxmf://.", - "lookupHint": "Solicite um caminho para um hash que ainda não está na lista de pares (por exemplo, um ponto em um hub que você compartilha)." + "lookupHint": "Solicite um caminho para um hash que ainda não está na lista de pares (por exemplo, um ponto em um hub que você compartilha).", + "paths": "Trilhas", + "pathsAria": "Mostrar caminhos classificados para {{hash}}", + "pathsDetailAria": "Caminhos classificados para {{hash}}", + "pathsHeading": "Caminhos · {{hash}}…", + "pathsCloseAria": "Fechar detalhes dos caminhos", + "pathsPreferLabel": "Preferir médio", + "pathsPreferAria": "Meio de caminho preferido para este par", + "pathsPreferAuto": "Automático (global)", + "pathsPreferRf": "Frequência de rádio (RF)", + "pathsPreferNetwork": "Rede", + "pathsGlobalPreference": "Global: {{preference}}", + "pathsListAria": "Ranhuras de caminho classificado", + "pathsActiveBadge": "Ativo", + "pathsBackupBadge": "Backup", + "pathsMedium": "Médio", + "pathsExpired": "Expirado", + "pathsEmpty": "Ainda não há slots de caminho. Solicite um caminho ou aguarde um anúncio.", + "pathsLoadFailed": "Falha ao carregar slots de caminho.", + "pathsPinFailed": "Falha ao atualizar a preferência do meio para este par." }, "radioPanel": { "offloadedContacts": "Transferiu contatos {{count}} para o banco de dados.", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index c1965df28..b5164f8fd 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1042,7 +1042,8 @@ "rnodeWifi": "Сетка LoRa через RNode Wi-Fi (хост tcp://).", "blePeer": "Одноранговая сеть BLE с использованием начальных одноранговых адресов.", "generic": "Сетевой интерфейс Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Магистраль I2P через маршрутизатор на этом компьютере. Включите мост приложения SAM (127.0.0.1:7656) — не прокси HTTP/HTTPS I2PTunnel (4444/4445). Перезапустите I2P после включения SAM, чтобы мост прослушивал, а затем перезапустите стек Reticulum, если этот интерфейс не работает." }, "rfProfile": { "coordinated": "Скоординированная региональная", @@ -2498,7 +2499,15 @@ "shareInstance": "Поделиться экземпляром Reticulum", "logLevel": "Уровень журнала", "save": "Сохранить настройки стека", - "saveFailed": "Не удалось сохранить настройки стека." + "saveFailed": "Не удалось сохранить настройки стека.", + "pathMediumPreference": "Предпочтительная среда пути", + "pathMediumPreferenceHint": "Когда одноранговый узел доступен по радиочастоте и сети (TCP/I2P/и т. д.), выберите, какой носитель выигрывает по умолчанию. Самый низкий путь ранжируется только по количеству прыжков. Одноранговые контакты на вкладке Одноранговые узлы могут переопределить это.", + "pathMediumLowest": "Наименьший путь (количество прыжков)", + "pathMediumNetwork": "Сеть (не-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Глобальная предпочтительная среда пути", + "pathMediumPreferenceSaved": "Предпочтение среды пути сохранено.", + "pathMediumPreferenceSaveFailed": "Не удалось сохранить настройки среды пути." }, "reticulumConfigImport": { "title": "Импортировать конфигурацию Reticulum", @@ -3123,7 +3132,26 @@ "lookupSubmit": "Найти", "lookupSubmitAria": "Запросить путь и проверить хеш назначения", "lookupInvalid": "Введите действительный 32-символьный хеш назначения LXMF или ссылку lxmf://.", - "lookupHint": "Запросите путь для хеша, которого еще нет в списке одноранговых узлов (например, одноранговый узел на концентраторе, которым вы делитесь)." + "lookupHint": "Запросите путь для хеша, которого еще нет в списке одноранговых узлов (например, одноранговый узел на концентраторе, которым вы делитесь).", + "paths": "Пути", + "pathsAria": "Показать ранжированные пути для {{hash}}", + "pathsDetailAria": "Ранжированные пути для {{hash}}", + "pathsHeading": "Пути · {{hash}}…", + "pathsCloseAria": "Сведения о закрытых путях", + "pathsPreferLabel": "Предпочтительная среда", + "pathsPreferAria": "Предпочтительная среда пути для этого однорангового узла", + "pathsPreferAuto": "Авто (глобально)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Сеть", + "pathsGlobalPreference": "Глобальный: {{preference}}", + "pathsListAria": "Слоты ранжированного пути", + "pathsActiveBadge": "Действующая", + "pathsBackupBadge": "Резервное копирование", + "pathsMedium": "Средний", + "pathsExpired": "Истек срок действия", + "pathsEmpty": "Слотов для траекторий пока нет. Запросите путь или дождитесь объявления.", + "pathsLoadFailed": "Не удалось загрузить слоты пути.", + "pathsPinFailed": "Не удалось обновить настройки среды для этого узла." }, "radioPanel": { "offloadedContacts": "Контакты {{count}} выгружены в базу данных.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 84d41e3b2..fe3d6e25a 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "RNode Wi-Fi (tcp:// ana bilgisayarı) aracılığıyla LoRa ağı.", "blePeer": "Çekirdek eş adreslerini kullanan BLE eş ağı.", "generic": "Reticulum ağ arayüzü.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Bu makinedeki bir yönlendirici aracılığıyla I2P omurgası. HTTP/https I2PTunnel proxy'leri (4444/4445) değil, SAM uygulama köprüsünü (127.0.0.1:7656) etkinleştirin. Köprünün dinlemesi için SAM'İ etkinleştirdikten sonra I2P'yi yeniden başlatın, ardından bu arayüz kapalı kalırsa Reticulum yığınını yeniden başlatın." }, "rfProfile": { "coordinated": "Koordineli bölgesel", @@ -2496,7 +2497,15 @@ "shareInstance": "Reticulum örneğini paylaşın", "logLevel": "Günlük düzeyi", "save": "Yığın ayarlarını kaydet", - "saveFailed": "Yığın ayarları kaydedilemedi." + "saveFailed": "Yığın ayarları kaydedilemedi.", + "pathMediumPreference": "Tercih edilen yol ortamı", + "pathMediumPreferenceHint": "Bir eşe RF ve ağ (TCP/I2P/vb.) üzerinden erişilebilir olduğunda, varsayılan olarak hangi ortamın kazanacağını seçin. En düşük yol, yalnızca atlama sayısına göre sıralanır. Eşler sekmesindeki eş başına pinler bunu geçersiz kılabilir.", + "pathMediumLowest": "En düşük yol (atlama sayısı)", + "pathMediumNetwork": "Ağ (RF olmayan)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Küresel tercih edilen yol ortamı", + "pathMediumPreferenceSaved": "Yol ortamı tercihi kaydedildi.", + "pathMediumPreferenceSaveFailed": "Yol ortamı tercihi kaydedilemedi." }, "reticulumConfigImport": { "title": "Reticulum yapılandırmasını içe aktar", @@ -3121,7 +3130,26 @@ "lookupSubmit": "Ara", "lookupSubmitAria": "Yol isteyin ve hedef özetini yoklayın", "lookupInvalid": "Geçerli 32 karakterlik bir LXMF hedef özeti veya lxmf:// bağlantısı girin.", - "lookupHint": "Henüz eşler listesinde olmayan bir karma için bir yol isteyin (örneğin, paylaştığınız bir merkezdeki bir eş)." + "lookupHint": "Henüz eşler listesinde olmayan bir karma için bir yol isteyin (örneğin, paylaştığınız bir merkezdeki bir eş).", + "paths": "Yollar", + "pathsAria": "{{hash}} için sıralanmış yolları göster", + "pathsDetailAria": "{{hash}} için dereceli yollar", + "pathsHeading": "Yollar · {{hash}}…", + "pathsCloseAria": "Yol detayını kapat", + "pathsPreferLabel": "Ortamı tercih et", + "pathsPreferAria": "Bu akran için tercih edilen yol ortamı", + "pathsPreferAuto": "Otomatik (genel)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Ağ", + "pathsGlobalPreference": "Küresel: {{preference}}", + "pathsListAria": "Dereceli yol yuvaları", + "pathsActiveBadge": "Aktif", + "pathsBackupBadge": "Yedekleme", + "pathsMedium": "Orta", + "pathsExpired": "Süresi doldu", + "pathsEmpty": "Henüz yol aralığı yok. Bir yol isteyin veya bir duyuru bekleyin.", + "pathsLoadFailed": "Yol yuvaları yüklenemedi.", + "pathsPinFailed": "Bu eş için ortam tercihi güncellenemedi." }, "radioPanel": { "offloadedContacts": "{{count}} kişi veritabanına aktarıldı.", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 305c337d0..97bddc9e8 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1042,7 +1042,8 @@ "rnodeWifi": "Сітка LoRa через RNode Wi-Fi (tcp:// host).", "blePeer": "Однорангова сітка BLE з використанням початкових однорангових адрес.", "generic": "Мережевий інтерфейс Reticulum.", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "Магістраль I2P через маршрутизатор на цьому комп'ютері. Увімкніть міст додатків SAM (127.0.0.1:7656), а не проксі-сервери HTTP/HTTPS I2PTunnel (4444/4445). Перезапустіть I2P після ввімкнення SAM, щоб міст прослуховував, а потім перезапустіть стек Reticulum, якщо цей інтерфейс не працює." }, "rfProfile": { "coordinated": "Узгоджена обл", @@ -2498,7 +2499,15 @@ "shareInstance": "Поділитися екземпляром Reticulum", "logLevel": "Рівень журналу", "save": "Зберегти налаштування стека", - "saveFailed": "Не вдалося зберегти налаштування стека." + "saveFailed": "Не вдалося зберегти налаштування стека.", + "pathMediumPreference": "Бажаний носій шляху", + "pathMediumPreferenceHint": "Коли вузол доступний через радіочастоту та мережу (TCP/I2P/тощо), виберіть, який носій виграє за замовчуванням. Найнижчий шлях ранжується лише за кількістю стрибків. PIN-коди однорангових вузлів на вкладці Однорангові вузли можуть замінити це.", + "pathMediumLowest": "Найнижчий шлях (кількість стрибків)", + "pathMediumNetwork": "Мережа (не-RF)", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "Глобальне бажане середовище шляху", + "pathMediumPreferenceSaved": "Налаштування носія шляху збережено.", + "pathMediumPreferenceSaveFailed": "Не вдалося зберегти налаштування середнього шляху." }, "reticulumConfigImport": { "title": "Імпорт конфігурації Reticulum", @@ -3123,7 +3132,26 @@ "lookupSubmit": "Шукати", "lookupSubmitAria": "Запитати шлях і перевірити хеш призначення", "lookupInvalid": "Введіть дійсний 32-символьний хеш призначення LXMF або посилання lxmf://.", - "lookupHint": "Запит шляху для хешу, якого ще немає в списку вузлів (наприклад, вузол на концентраторі, яким ви ділитеся)." + "lookupHint": "Запит шляху для хешу, якого ще немає в списку вузлів (наприклад, вузол на концентраторі, яким ви ділитеся).", + "paths": "Стежки", + "pathsAria": "Показати ранжовані шляхи для {{hash}}", + "pathsDetailAria": "Шляхи ранжування для {{hash}}", + "pathsHeading": "Шляхи · {{hash}}…", + "pathsCloseAria": "Детальна інформація про закриті шляхи", + "pathsPreferLabel": "Віддавати перевагу середньому", + "pathsPreferAria": "Бажане середовище шляху для цього вузла", + "pathsPreferAuto": "Автоматично (глобально)", + "pathsPreferRf": "RF", + "pathsPreferNetwork": "Засоби зв'язку", + "pathsGlobalPreference": "Глобально: {{preference}}", + "pathsListAria": "Ранжування слотів траєкторії", + "pathsActiveBadge": "Активний", + "pathsBackupBadge": "Заднім ходом", + "pathsMedium": "Середній", + "pathsExpired": "Минув термін дії", + "pathsEmpty": "Ще немає слотів для шляхів. Запитуйте шлях або чекайте оголошення.", + "pathsLoadFailed": "Не вдалося завантажити слоти шляху.", + "pathsPinFailed": "Не вдалося оновити налаштування середовища для цього вузла." }, "radioPanel": { "offloadedContacts": "Вивантажено {{count}} контакти до бази даних.", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 7d6125e36..d816187ad 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1040,7 +1040,8 @@ "rnodeWifi": "通过 RNode Wi-Fi (tcp:// 主机) 的 LoRa 网格。", "blePeer": "使用种子对等地址的 BLE 对等网格。", "generic": "Reticulum 网络接口。", - "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here." + "sharedInstanceClient": "Attached as a client of another app’s shared Reticulum instance — local TCP hubs from this config are not started here.", + "i2p": "通过此机器上的路由器的I2P骨干网。启用SAM应用程序桥(127.0.0.1: 7656) —而不是HTTP/HTTPS I2P隧道代理(4444/4445)。在启用SAM后重新启动I2P ,以便桥接器侦听,然后如果此接口保持关闭状态,则重新启动Reticulum堆栈。" }, "rfProfile": { "coordinated": "区域协调", @@ -1243,7 +1244,7 @@ "openGlobalMap": "全球测绘", "openGlobalMapAria": "在rmap.world上打开RMAP v4全球地图", "syncFailed": "RMAP发现同步失败。", - "publishingOf": "RMAP v4:发布 {{total}} 的 {{current}}" + "publishingOf": "RMAP v4:正在发布 {{current}}/{{total}}" }, "coloradoPresetConfirm": "Colorado Mesh MQTT适用于科罗拉多州地区的网格用户。预设在meshcore/DEN(丹佛IATA)下发布。如果您不在科罗拉多州,请改用LetsMesh或MeshMapper。是否继续?", "coloradoServerNote": "Colorado Mesh 面向科罗拉多州地区的网格用户。主题前缀使用丹佛 IATA(meshcore/DEN)。", @@ -2496,7 +2497,15 @@ "shareInstance": "共享 Reticulum 实例", "logLevel": "日志级别", "save": "保存堆栈设置", - "saveFailed": "无法保存堆栈设置。" + "saveFailed": "无法保存堆栈设置。", + "pathMediumPreference": "首选路径介质", + "pathMediumPreferenceHint": "当可以通过射频和网络( TCP/I2P等)访问对等节点时,默认情况下选择哪种介质获胜。最低路径仅按跳数排名。“同行”选项卡上的每点引脚可以覆盖此设置。", + "pathMediumLowest": "最低路径(跳数)", + "pathMediumNetwork": "网络(非RF )", + "pathMediumRf": "RF (RNode)", + "pathMediumPreferenceAria": "全局首选路径介质", + "pathMediumPreferenceSaved": "路径介质首选项已保存。", + "pathMediumPreferenceSaveFailed": "保存路径介质首选项失败。" }, "reticulumConfigImport": { "title": "导入 Reticulum config", @@ -3121,7 +3130,26 @@ "lookupSubmit": "查找", "lookupSubmitAria": "请求路径并探测目标哈希", "lookupInvalid": "请输入有效的 32 字符 LXMF 目标哈希或 lxmf:// 链接。", - "lookupHint": "请求尚未在对等体列表中的哈希路径(例如,您共享的集线器上的对等体)。" + "lookupHint": "请求尚未在对等体列表中的哈希路径(例如,您共享的集线器上的对等体)。", + "paths": "路径", + "pathsAria": "显示 {{hash}} 的排名路径", + "pathsDetailAria": "{{hash}}的排名路径", + "pathsHeading": "路径· {{hash}} …", + "pathsCloseAria": "关闭路径详细信息", + "pathsPreferLabel": "首选中等", + "pathsPreferAria": "此对等方的首选路径介质", + "pathsPreferAuto": "自动/全局", + "pathsPreferRf": "射频", + "pathsPreferNetwork": "网络", + "pathsGlobalPreference": "全球: {{preference}}", + "pathsListAria": "排名路径槽位", + "pathsActiveBadge": "Active", + "pathsBackupBadge": "后援", + "pathsMedium": "中粗线", + "pathsExpired": "已失效", + "pathsEmpty": "还没有路径插槽。请求路径或等待公告。", + "pathsLoadFailed": "加载路径插槽失败。", + "pathsPinFailed": "无法更新此对等方的媒体首选项。" }, "radioPanel": { "offloadedContacts": "已将{{count}}个联系人卸载到数据库。", diff --git a/src/renderer/stores/nomadPageViewerLoad.test.ts b/src/renderer/stores/nomadPageViewerLoad.test.ts index 3e9a9f4cb..c128f8cad 100644 --- a/src/renderer/stores/nomadPageViewerLoad.test.ts +++ b/src/renderer/stores/nomadPageViewerLoad.test.ts @@ -90,6 +90,37 @@ describe('nomadPageViewerStore loadPage cache', () => { } }); + it('does not auto-retry when loadPage already requested forcePathRefresh', async () => { + vi.useFakeTimers(); + const { restore } = mockConsoleWarn(); + try { + const fetchNomadPage = vi.fn().mockResolvedValue({ + ok: false, + error: 'link_timeout', + egress: 'tcp', + link_hops: 5, + proof_budget_secs: 30, + }); + useNomadNetworkStore.setState({ fetchNomadPage }); + + // Caller already forced (announce reload / manual ↻) — skip debounce. + const loadPromise = useNomadPageViewerStore + .getState() + .loadPage('abc1234567890', '/page/index.mu', { forcePathRefresh: true }); + await loadPromise; + + expect(fetchNomadPage).toHaveBeenCalledTimes(1); + expect(fetchNomadPage).toHaveBeenCalledWith('abc1234567890', '/page/index.mu', undefined, { + forcePathRefresh: true, + }); + expect(useNomadPageViewerStore.getState().pageErrorRaw).toBe('link_timeout'); + expect(useNomadPageViewerStore.getState().pageLoadingRetrying).toBe(false); + } finally { + restore(); + vi.useRealTimers(); + } + }); + it('auto-retries TCP link_timeout once with forcePathRefresh', async () => { vi.useFakeTimers(); const { restore } = mockConsoleWarn(); diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 5099b7d02..a4f0bd6b8 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -357,7 +357,9 @@ export const useNomadPageViewerStore = create((set, get) = set({ pageLoadingBudgetSec: budgetSec }); } + // Caller already forced (announce reload / manual ↻) — do not DropPath again. if ( + !options.forcePathRefresh && (!res.ok || !res.content) && shouldForceNomadPathRefreshRetry(res.error, egressFromNomadPageResponse(res)) ) { diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 68f82af3d..0b6fcc55f 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -190,7 +190,7 @@ export function createElectronAPIMock(): ElectronAPI { cancelSerialSelection: vi.fn(), onBluetoothDevicesDiscovered: vi.fn().mockReturnValue(() => {}), selectBluetoothDevice: vi.fn(), - cancelBluetoothSelection: vi.fn(), + cancelBluetoothSelection: vi.fn().mockResolvedValue({ cancelled: false }), bluetoothUnpair: vi.fn().mockResolvedValue(undefined), bluetoothStartScan: vi.fn().mockResolvedValue(undefined), bluetoothStopScan: vi.fn().mockResolvedValue(undefined), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 98f82aa9f..c8c7bf420 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -815,8 +815,12 @@ export interface ElectronAPI { callback: (devices: NobleBleDevice[], generation?: number) => void, ) => () => void; selectBluetoothDevice: (deviceId: string) => void; - /** Pass the chooser generation from onBluetoothDevicesDiscovered to ignore stale cancels. */ - cancelBluetoothSelection: (generation?: number | null) => void; + /** + * Cancel the Linux Web Bluetooth chooser. + * Pass the generation from onBluetoothDevicesDiscovered to ignore stale cancels. + * Await before starting a new requestDevice() so force-clear cannot race the new session. + */ + cancelBluetoothSelection: (generation?: number | null) => Promise<{ cancelled: boolean }>; // ─── Bluetooth pairing (Linux) ────────────────────────────────────────────── bluetoothUnpair: (macAddress: string) => Promise;