diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 01f732645..b081bf9ef 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -246,7 +246,7 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. -- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. +- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f9c62a9c..b5f4a21e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: app-builder-lib: ^26.15.0 brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 + fast-uri: ^3.1.5 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' postcss: ^8.5.18 @@ -2380,8 +2381,8 @@ packages: resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==} engines: {node: '>=18.2.0'} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -6023,7 +6024,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7091,7 +7092,7 @@ snapshots: '@babel/runtime': 7.29.7 tslib: 2.8.1 - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastq@1.20.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 69aaa63cd..2d6f2c6d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,9 +44,12 @@ overrides: # brace-expansion: keep a single 5.0.9 floor. GHSA-rgw5-rvv9-x895 is a # bypass of the CVE-2026-14257 mitigation and marks >=4.0.0 <5.0.9 vulnerable # (only >=5.0.9 counts as patched). CI audit is blocking. + # fast-uri: GHSA-7p8r-x3mc-p8w7 (host confusion via backslash authority); + # electron-builder → app-builder-lib → ajv still pulls 3.1.4. app-builder-lib: ^26.15.0 brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 + fast-uri: ^3.1.5 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' postcss: ^8.5.18 diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs index 6392d63a9..7cc5b8dbb 100644 --- a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -19,6 +19,10 @@ pub struct AnnounceWsRow { pub destination_hash: String, pub display_name: Option, pub hops: u8, + /// Known destination aspect string when `name_hash` maps (e.g. `lxmf.delivery`). + pub aspect: Option, + /// Identity hash recovered from the announce payload (hex), when present. + pub identity_hash: Option, } /// Snapshot published for `GET /api/v1/diagnostics` (`announce_ws`). @@ -39,6 +43,10 @@ static LAST_WINDOW_OVERFLOW: AtomicU64 = AtomicU64::new(0); static LAST_STORM_AT_MS: AtomicU64 = AtomicU64::new(0); static LAST_FLUSH_AT_MS: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "rns-stack")] +static KNOWN_ANNOUNCE_ASPECTS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + fn now_unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -140,29 +148,72 @@ impl AnnounceWsCoalescer { } } +/// Map announce `name_hash` (`SHA-256(app_name)[:10]`) to a known aspect string. +/// +/// Unknown / zero hashes return `None` — callers must not invent `"unknown"`. +#[cfg(feature = "rns-stack")] +pub fn resolve_announce_aspect(name_hash: &[u8; 10]) -> Option<&'static str> { + if name_hash.iter().all(|&b| b == 0) { + return None; + } + let table = KNOWN_ANNOUNCE_ASPECTS.get_or_init(|| { + [ + "lxmf.delivery", + "lxmf.propagation", + "nomadnetwork.node", + "rrc.hub", + ] + .into_iter() + .map(|aspect| (rns_identity::name_hash::name_hash(aspect), aspect)) + .collect() + }); + table + .iter() + .find(|(nh, _)| nh == name_hash) + .map(|(_, aspect)| *aspect) +} + +fn announce_row_payload(r: &AnnounceWsRow) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert( + "destination_hash".to_string(), + serde_json::Value::String(r.destination_hash.clone()), + ); + map.insert( + "display_name".to_string(), + match &r.display_name { + Some(n) => serde_json::Value::String(n.clone()), + None => serde_json::Value::Null, + }, + ); + map.insert( + "hops".to_string(), + serde_json::Value::Number(serde_json::Number::from(r.hops)), + ); + if let Some(ref aspect) = r.aspect { + map.insert( + "aspect".to_string(), + serde_json::Value::String(aspect.clone()), + ); + } + if let Some(ref identity_hash) = r.identity_hash { + map.insert( + "identity_hash".to_string(), + serde_json::Value::String(identity_hash.clone()), + ); + } + serde_json::Value::Object(map) +} + /// Build the WS text frame for one flush. Single-row keeps the legacy payload shape. pub fn build_announce_received_frame(rows: &[AnnounceWsRow]) -> Option { if rows.is_empty() { return None; } let payload = if rows.len() == 1 { - let r = &rows[0]; - serde_json::json!({ - "destination_hash": r.destination_hash, - "display_name": r.display_name, - "hops": r.hops, - }) + announce_row_payload(&rows[0]) } else { - let announces: Vec = rows - .iter() - .map(|r| { - serde_json::json!({ - "destination_hash": r.destination_hash, - "display_name": r.display_name, - "hops": r.hops, - }) - }) - .collect(); + let announces: Vec = rows.iter().map(announce_row_payload).collect(); serde_json::json!({ "announces": announces }) }; Some( @@ -192,6 +243,23 @@ mod tests { destination_hash: hash.to_string(), display_name: name.map(str::to_string), hops: 1, + aspect: None, + identity_hash: None, + } + } + + fn row_full( + hash: &str, + name: Option<&str>, + aspect: Option<&str>, + identity_hash: Option<&str>, + ) -> AnnounceWsRow { + AnnounceWsRow { + destination_hash: hash.to_string(), + display_name: name.map(str::to_string), + hops: 2, + aspect: aspect.map(str::to_string), + identity_hash: identity_hash.map(str::to_string), } } @@ -217,6 +285,31 @@ mod tests { assert!(snap.last_flush_at_ms > 0); } + #[test] + fn last_write_wins_preserves_latest_aspect_and_identity_hash() { + let _guard = pressure_metrics_lock(); + let mut c = AnnounceWsCoalescer::new(); + c.push(row_full( + "aa", + Some("Old"), + Some("lxmf.propagation"), + Some("id_old"), + )); + c.push(row_full( + "aa", + Some("New"), + Some("lxmf.delivery"), + Some("id_new"), + )); + let flushed = c.take_flush_rows(); + let aa = flushed + .iter() + .find(|r| r.destination_hash == "aa") + .expect("aa"); + assert_eq!(aa.aspect.as_deref(), Some("lxmf.delivery")); + assert_eq!(aa.identity_hash.as_deref(), Some("id_new")); + } + #[test] fn flush_prefers_named_when_over_cap_and_records_overflow() { let _guard = pressure_metrics_lock(); @@ -272,6 +365,24 @@ mod tests { assert_eq!(v["type"], "announce.received"); assert_eq!(v["payload"]["destination_hash"], "aa"); assert!(v["payload"].get("announces").is_none()); + assert!(v["payload"].get("aspect").is_none()); + assert!(v["payload"].get("identity_hash").is_none()); + } + + #[test] + fn build_frame_single_includes_aspect_and_identity_hash_when_present() { + let id = "aabbccddeeff00112233445566778899"; + let frame = build_announce_received_frame(&[row_full( + "aa", + Some("Alice"), + Some("lxmf.delivery"), + Some(id), + )]) + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["payload"]["aspect"], "lxmf.delivery"); + assert_eq!(v["payload"]["identity_hash"], id); + assert_eq!(v["payload"]["hops"], 2); } #[test] @@ -282,6 +393,37 @@ mod tests { assert_eq!(v["payload"]["announces"].as_array().unwrap().len(), 2); } + #[test] + fn build_frame_batch_carries_per_row_aspect_and_identity_hash() { + let frame = build_announce_received_frame(&[ + row_full("aa", Some("A"), Some("lxmf.delivery"), Some("id_a")), + row_full("bb", None, Some("nomadnetwork.node"), None), + row("cc", Some("C")), + ]) + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + let announces = v["payload"]["announces"].as_array().unwrap(); + assert_eq!(announces.len(), 3); + let aa = announces + .iter() + .find(|a| a["destination_hash"] == "aa") + .expect("aa"); + assert_eq!(aa["aspect"], "lxmf.delivery"); + assert_eq!(aa["identity_hash"], "id_a"); + let bb = announces + .iter() + .find(|a| a["destination_hash"] == "bb") + .expect("bb"); + assert_eq!(bb["aspect"], "nomadnetwork.node"); + assert!(bb.get("identity_hash").is_none()); + let cc = announces + .iter() + .find(|a| a["destination_hash"] == "cc") + .expect("cc"); + assert!(cc.get("aspect").is_none()); + assert!(cc.get("identity_hash").is_none()); + } + #[test] fn many_distinct_dests_still_one_flush_batch() { let _guard = pressure_metrics_lock(); @@ -295,4 +437,27 @@ mod tests { assert!(c.is_empty()); assert!(build_announce_received_frame(&flushed).is_some()); } + + #[cfg(feature = "rns-stack")] + #[test] + fn resolve_announce_aspect_maps_known_aspects() { + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("lxmf.delivery")), + Some("lxmf.delivery") + ); + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("lxmf.propagation")), + Some("lxmf.propagation") + ); + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("nomadnetwork.node")), + Some("nomadnetwork.node") + ); + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("rrc.hub")), + Some("rrc.hub") + ); + assert_eq!(resolve_announce_aspect(&[0u8; 10]), None); + assert_eq!(resolve_announce_aspect(&[1u8; 10]), None); + } } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index f6497fc2f..70b2d9514 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -33,7 +33,7 @@ use tokio::sync::{RwLock, broadcast}; use super::StackHandle; use super::announce_ws_coalesce::{ - AnnounceWsCoalescer, AnnounceWsRow, build_announce_received_frame, + AnnounceWsCoalescer, AnnounceWsRow, build_announce_received_frame, resolve_announce_aspect, }; use super::config; use super::local_rnode_primary; @@ -2459,6 +2459,8 @@ impl LiveBridge { destination_hash: dest_hex, display_name, hops: evt.hops, + aspect: resolve_announce_aspect(&evt.name_hash).map(str::to_string), + identity_hash: evt.identity_hash.map(hex::encode), }); } () = async { diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index fea424c04..202800a58 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -322,6 +322,53 @@ describe('reticulum destination / activity prune IPC', () => { expect(count).toBe(250); }); + it('named identity-activity upsert clears sibling unknown aspect rows', () => { + const dest = 'aa'.repeat(16); + db! + .prepareOnce( + `INSERT INTO reticulum_identity_activity (destination_hash, aspect, identity_hash, last_seen, hops) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(dest, 'unknown', null, Date.now() - 1000, 2); + const upsert = handlers.get('db:upsertReticulumIdentityActivity'); + upsert?.(event, { + destination_hash: dest, + aspect: 'lxmf.delivery', + identity_hash: 'bb'.repeat(16), + last_seen: Date.now(), + hops: 1, + }); + const rows = db! + .prepareOnce( + 'SELECT aspect, identity_hash FROM reticulum_identity_activity WHERE destination_hash = ? ORDER BY aspect', + ) + .all(dest) as { aspect: string; identity_hash: string | null }[]; + expect(rows).toEqual([{ aspect: 'lxmf.delivery', identity_hash: 'bb'.repeat(16) }]); + }); + + it('named identity-activity batch upsert clears sibling unknown aspect rows', () => { + const dest = 'cc'.repeat(16); + db! + .prepareOnce( + `INSERT INTO reticulum_identity_activity (destination_hash, aspect, identity_hash, last_seen, hops) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(dest, 'unknown', null, Date.now() - 1000, 3); + const result = handlers.get('db:upsertReticulumIdentityActivityBatch')?.(event, [ + { + destination_hash: dest, + aspect: 'nomadnetwork.node', + last_seen: Date.now(), + hops: 1, + }, + ]) as { changes: number }; + expect(result.changes).toBe(1); + const rows = db! + .prepareOnce('SELECT aspect FROM reticulum_identity_activity WHERE destination_hash = ?') + .all(dest) as { aspect: string }[]; + expect(rows.map((r) => r.aspect)).toEqual(['nomadnetwork.node']); + }); + it('upsertReticulumDestination normalizes hash casing into one row', () => { const upsert = handlers.get('db:upsertReticulumDestination'); const mixed = 'AABBCCDDEEFF00112233445566778899'; diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index 46cf087c3..d572e7f8e 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -721,6 +721,18 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): last_seen = excluded.last_seen, hops = COALESCE(excluded.hops, reticulum_identity_activity.hops)`; + const IDENTITY_ACTIVITY_DELETE_UNKNOWN_SQL = `DELETE FROM reticulum_identity_activity + WHERE destination_hash = ? AND aspect = 'unknown'`; + + function clearUnknownIdentityActivity( + db: NonNullable>, + destinationHash: string, + aspect: string, + ): void { + if (aspect === 'unknown') return; + db.prepareOnce(IDENTITY_ACTIVITY_DELETE_UNKNOWN_SQL).run(destinationHash); + } + function parseIdentityActivityRow(row: unknown): { destinationHash: string; aspect: string; @@ -762,6 +774,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): parsed.lastSeen, parsed.hops, ); + clearUnknownIdentityActivity(db, parsed.destinationHash, parsed.aspect); return { changes: 1 }; } catch (err) { finishDbIpcHandler('db:upsertReticulumIdentityActivity', err); @@ -781,9 +794,13 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): } if (parsed.length === 0) return { changes: 0 }; const stmt = db.prepareOnce(IDENTITY_ACTIVITY_UPSERT_SQL); + const clearUnknown = db.prepareOnce(IDENTITY_ACTIVITY_DELETE_UNKNOWN_SQL); const run = db.transaction(() => { for (const p of parsed) { stmt.run(p.destinationHash, p.aspect, p.identityHash, p.lastSeen, p.hops); + if (p.aspect !== 'unknown') { + clearUnknown.run(p.destinationHash); + } } }); run(); diff --git a/src/renderer/components/InstantTooltipBubble.test.tsx b/src/renderer/components/InstantTooltipBubble.test.tsx new file mode 100644 index 000000000..e00e3933b --- /dev/null +++ b/src/renderer/components/InstantTooltipBubble.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Z_INSTANT_TOOLTIP } from '@/renderer/lib/modalZIndex'; + +import { InstantTooltipBubble } from './InstantTooltipBubble'; + +describe('InstantTooltipBubble', () => { + it('renders with Z_INSTANT_TOOLTIP above modals', () => { + render(); + const tip = screen.getByRole('tooltip'); + expect(tip).toHaveStyle({ zIndex: String(Z_INSTANT_TOOLTIP) }); + }); +}); diff --git a/src/renderer/components/InstantTooltipBubble.tsx b/src/renderer/components/InstantTooltipBubble.tsx index c9c327e82..22fc38cbc 100644 --- a/src/renderer/components/InstantTooltipBubble.tsx +++ b/src/renderer/components/InstantTooltipBubble.tsx @@ -1,4 +1,5 @@ import type { InstantTooltipPosition } from '@/renderer/lib/instantTooltipPosition'; +import { Z_INSTANT_TOOLTIP } from '@/renderer/lib/modalZIndex'; export function InstantTooltipBubble({ text, pos }: { text: string; pos: InstantTooltipPosition }) { return ( @@ -9,7 +10,7 @@ export function InstantTooltipBubble({ text, pos }: { text: string; pos: Instant top: pos.top, left: pos.left, transform: pos.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)', - zIndex: 9999, + zIndex: Z_INSTANT_TOOLTIP, }} className="pointer-events-none w-64 rounded border border-gray-600 bg-gray-800 px-2.5 py-1.5 text-xs whitespace-pre-wrap text-gray-200 shadow-lg" > diff --git a/src/renderer/components/ReticulumPeerDetailModal.test.tsx b/src/renderer/components/ReticulumPeerDetailModal.test.tsx index 1721ba846..fedacb0fb 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.test.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.test.tsx @@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const addToast = vi.fn(); const refreshReticulumPeersFromSidecarMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const refreshReticulumPeerRouteFromPathsMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ ok: false, paths: [] }), +); +const requestReticulumPeerPathMock = vi.hoisted(() => vi.fn()); +const probeReticulumPeerMock = vi.hoisted(() => vi.fn()); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -20,15 +25,26 @@ vi.mock('./Toast', () => ({ })); vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ - requestReticulumPeerPath: vi.fn(), - probeReticulumPeer: vi.fn(), + requestReticulumPeerPath: (...args: unknown[]) => requestReticulumPeerPathMock(...args), + probeReticulumPeer: (...args: unknown[]) => probeReticulumPeerMock(...args), formatReticulumPeerPathToast: () => ({ message: 'peerDetailModal.pathOk', variant: 'success' }), - formatReticulumPeerProbeToast: () => ({ message: 'peerDetailModal.probeOk', variant: 'success' }), + formatReticulumPeerProbeToast: (_t: unknown, result: { hops?: number }) => + result.hops != null + ? { message: `peerDetailModal.probeHops:${result.hops}`, variant: 'success' } + : { message: 'peerDetailModal.probeOk', variant: 'success' }, })); +vi.mock('@/renderer/lib/reticulum/reticulumPathMedium', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + refreshReticulumPeerRouteFromPaths: (...args: unknown[]) => + refreshReticulumPeerRouteFromPathsMock(...args), + }; +}); + vi.mock('../stores/reticulumPeerStore', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importOriginal needs typeof import() - const actual = await importOriginal(); + const actual = await importOriginal>(); return { ...actual, refreshReticulumPeersFromSidecar: (...args: unknown[]) => @@ -44,6 +60,10 @@ const PEER_HASH = 'abcdef1234567890abcdef1234567890'; describe('ReticulumPeerDetailModal — copy hash', () => { beforeEach(() => { addToast.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockResolvedValue({ ok: false, paths: [] }); + requestReticulumPeerPathMock.mockReset(); + probeReticulumPeerMock.mockReset(); vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined); @@ -60,6 +80,7 @@ describe('ReticulumPeerDetailModal — copy hash', () => { ], ]), contacts: new Map(), + history: new Map(), peerAppearanceByHash: new Map(), lastRefreshAt: null, }); @@ -104,6 +125,8 @@ describe('ReticulumPeerDetailModal — copy hash', () => { describe('ReticulumPeerDetailModal — avatar icon', () => { beforeEach(() => { addToast.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockResolvedValue({ ok: false, paths: [] }); vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined); @@ -120,6 +143,7 @@ describe('ReticulumPeerDetailModal — avatar icon', () => { ], ]), contacts: new Map(), + history: new Map(), peerAppearanceByHash: new Map(), lastRefreshAt: null, }); @@ -193,3 +217,167 @@ describe('ReticulumPeerDetailModal — avatar icon', () => { }); }); }); + +describe('ReticulumPeerDetailModal — Network route hydrate', () => { + beforeEach(() => { + addToast.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockResolvedValue({ ok: false, paths: [] }); + requestReticulumPeerPathMock.mockReset(); + probeReticulumPeerMock.mockReset(); + vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map([ + [ + PEER_HASH, + { + destination_hash: PEER_HASH, + display_name: 'Contact Peer', + last_heard: 100, + is_contact: true, + hops: null, + interface: null, + }, + ], + ]), + history: new Map(), + peerAppearanceByHash: new Map(), + lastRefreshAt: null, + peersRevision: 0, + }); + }); + + it('hydrates path slots on open', async () => { + render( + , + ); + await waitFor(() => { + expect(refreshReticulumPeerRouteFromPathsMock).toHaveBeenCalledWith(PEER_HASH); + }); + }); + + it('shows medium and backup paths from hydrated slots', async () => { + refreshReticulumPeerRouteFromPathsMock.mockImplementation((hash: string) => { + useReticulumPeerStore.getState().updatePeer(hash, { + hops: 1, + interface: 'RNode', + }); + return Promise.resolve({ + ok: true, + paths: [ + { + active: true, + hops: 1, + via_hash: null, + interface: 'RNode', + interface_id: 1, + medium: 'rf' as const, + timestamp: null, + expires: null, + expired: false, + }, + { + active: false, + hops: 4, + via_hash: null, + interface: 'Ratspeak', + interface_id: 2, + medium: 'network' as const, + timestamp: null, + expires: null, + expired: false, + }, + ], + }); + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('peerListPanel.pathsPreferRf')).toBeInTheDocument(); + }); + expect(screen.getByText('peerDetailModal.backupPaths')).toBeInTheDocument(); + expect(screen.getByText(/Ratspeak/)).toBeInTheDocument(); + expect(screen.getByText(/peerListPanel.pathsPreferNetwork/)).toBeInTheDocument(); + expect(screen.queryByText('peerDetailModal.pathHash')).not.toBeInTheDocument(); + expect(screen.queryByText('peerDetailModal.serviceBadge')).not.toBeInTheDocument(); + }); + + it('probe applies hops and refreshes path slots', async () => { + const user = userEvent.setup(); + const seededLastSeen = 9_001; + useReticulumPeerStore.setState((s) => { + const contacts = new Map(s.contacts); + const prev = contacts.get(PEER_HASH); + if (prev) { + contacts.set(PEER_HASH, { ...prev, last_seen: seededLastSeen }); + } + return { contacts }; + }); + probeReticulumPeerMock.mockResolvedValue({ ok: true, hops: 3 }); + const { applyReticulumPeerActivePathSlot } = await import('../stores/reticulumPeerStore'); + refreshReticulumPeerRouteFromPathsMock.mockImplementation((hash: string) => { + const result = { + ok: true as const, + paths: [ + { + active: true, + hops: 3, + via_hash: '11'.repeat(16), + interface: 'RMAP World', + interface_id: 1, + medium: 'network' as const, + timestamp: null, + expires: null, + expired: false, + }, + ], + }; + applyReticulumPeerActivePathSlot(hash, result); + return Promise.resolve(result); + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'connectionPanel.reticulumPeers.probe' })); + + await waitFor(() => { + expect(probeReticulumPeerMock).toHaveBeenCalledWith(PEER_HASH); + }); + await waitFor(() => { + const peer = useReticulumPeerStore.getState().getPeer(PEER_HASH); + expect(peer?.hops).toBe(3); + expect(peer?.interface).toBe('RMAP World'); + expect(peer?.path_hash).toBe('11'.repeat(16)); + expect(peer?.last_seen).toBe(seededLastSeen); + }); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('RMAP World')).toBeInTheDocument(); + }); + + it('path success refreshes route with settle options', async () => { + const user = userEvent.setup(); + requestReticulumPeerPathMock.mockResolvedValue({ ok: true }); + + render( + , + ); + refreshReticulumPeerRouteFromPathsMock.mockClear(); + + await user.click(screen.getByRole('button', { name: 'connectionPanel.reticulumPeers.path' })); + + await waitFor(() => { + expect(requestReticulumPeerPathMock).toHaveBeenCalledWith(PEER_HASH); + expect(refreshReticulumPeerRouteFromPathsMock).toHaveBeenCalledWith( + PEER_HASH, + expect.objectContaining({ settleMs: expect.any(Number), retryMs: expect.any(Number) }), + ); + }); + }); +}); diff --git a/src/renderer/components/ReticulumPeerDetailModal.tsx b/src/renderer/components/ReticulumPeerDetailModal.tsx index 95dcb21da..2adcfe284 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.tsx @@ -16,6 +16,14 @@ import { isDefaultReticulumProfileIcon, resolveReticulumProfileIconName, } from '@/renderer/lib/reticulum/reticulumIconAppearance'; +import { + activeReticulumPathSlot, + backupReticulumPathSlots, + refreshReticulumPeerRouteFromPaths, + RETICULUM_PATH_RETRY_MS, + RETICULUM_PATH_SETTLE_MS, + type ReticulumPeerPathsResult, +} from '@/renderer/lib/reticulum/reticulumPathMedium'; import { formatReticulumPeerPathToast, formatReticulumPeerProbeToast, @@ -57,6 +65,7 @@ export default function ReticulumPeerDetailModal({ const toggleFavorite = useReticulumPeerStore((s) => s.toggleFavorite); const setCustomDisplayName = useReticulumPeerStore((s) => s.setCustomDisplayName); const removeContact = useReticulumPeerStore((s) => s.removeContact); + const updatePeer = useReticulumPeerStore((s) => s.updatePeer); const identityId = getIdentityIdForProtocol('reticulum') ?? getOfflineIdentityIdForProtocol('reticulum'); @@ -67,10 +76,6 @@ export default function ReticulumPeerDetailModal({ const activityRows = useReticulumIdentityActivityStore((s) => s.byDestination.get(activityKey)); const loadActivity = useReticulumIdentityActivityStore((s) => s.loadForDestination); - useEffect(() => { - void loadActivity(peerHash); - }, [loadActivity, peerHash]); - const [editingName, setEditingName] = useState(false); const [nameDraft, setNameDraft] = useState(''); const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); @@ -78,11 +83,43 @@ export default function ReticulumPeerDetailModal({ const [pathStatus, setPathStatus] = useState(null); const [probeStatus, setProbeStatus] = useState(null); const [busy, setBusy] = useState(false); + const [pathsResult, setPathsResult] = useState(null); const [iconColor, setIconColor] = useState('green'); const [iconName, setIconName] = useState('circle'); const [verified, setVerified] = useState(false); const [verifiedIdentityHash, setVerifiedIdentityHash] = useState(null); + const hydratePaths = useCallback( + async (opts?: { settleMs?: number; retryMs?: number }) => { + const result = await refreshReticulumPeerRouteFromPaths(peerHash, opts); + setPathsResult(result); + return result; + }, + [peerHash], + ); + + useEffect(() => { + void loadActivity(peerHash); + }, [loadActivity, peerHash]); + + // Hydrate Network fields from sidecar path slots (path may already exist for Chat). + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const result = await refreshReticulumPeerRouteFromPaths(peerHash); + if (!cancelled) setPathsResult(result); + } catch (e) { + if (!cancelled) { + console.debug('[ReticulumPeerDetailModal] path hydrate ' + errLikeToLogString(e)); + } + } + })(); + return () => { + cancelled = true; + }; + }, [peerHash]); + const liveIdentityHash = useMemo(() => { const rows = activityRows ?? []; for (const row of rows) { @@ -262,6 +299,12 @@ export default function ReticulumPeerDetailModal({ const result = await requestReticulumPeerPath(peerHash); const toast = formatReticulumPeerPathToast(t, result); setPathStatus(toast.message); + if (result.ok) { + await hydratePaths({ + settleMs: RETICULUM_PATH_SETTLE_MS, + retryMs: RETICULUM_PATH_RETRY_MS, + }); + } } catch (e) { console.warn('[ReticulumPeerDetailModal] path ' + errLikeToLogString(e)); setPathStatus(t('peerDetailModal.pathFailed', { error: errLikeToLogString(e) })); @@ -277,6 +320,12 @@ export default function ReticulumPeerDetailModal({ const result = await probeReticulumPeer(peerHash); const toast = formatReticulumPeerProbeToast(t, result); setProbeStatus(toast.message); + if (result.ok && result.hops != null) { + updatePeer(peerHash, { hops: result.hops }); + } + if (result.ok) { + await hydratePaths(); + } } catch (e) { console.warn('[ReticulumPeerDetailModal] probe ' + errLikeToLogString(e)); setProbeStatus(t('peerDetailModal.probeFailed', { error: errLikeToLogString(e) })); @@ -302,6 +351,21 @@ export default function ReticulumPeerDetailModal({ ) : 0; + const activePathSlot = useMemo( + () => (pathsResult?.ok ? activeReticulumPathSlot(pathsResult.paths) : null), + [pathsResult], + ); + const backupPathSlots = useMemo( + () => (pathsResult?.ok ? backupReticulumPathSlots(pathsResult.paths) : []), + [pathsResult], + ); + const mediumLabel = + activePathSlot?.medium === 'rf' + ? t('peerListPanel.pathsPreferRf') + : activePathSlot?.medium === 'network' + ? t('peerListPanel.pathsPreferNetwork') + : '—'; + const openChat = () => { const nodeId = reticulumHashToNodeId(peerHash); registerReticulumDestinationHash(nodeId, peerHash); @@ -610,37 +674,44 @@ export default function ReticulumPeerDetailModal({

{t('peerDetailModal.networkSection')}

- {(activityRows ?? []).length > 0 ? ( -
- {(activityRows ?? []).map((row) => ( - - {t('peerDetailModal.serviceBadge', { - service: row.aspect.includes('.') - ? (row.aspect.split('.').pop() ?? row.aspect) - : row.aspect, - })} - - ))} -
- ) : null}
{t('peerListPanel.colInterface')}
{peer?.interface ?? '—'}
{t('connectionPanel.reticulumPeers.hops')}
{peer?.hops ?? '—'}
-
{t('peerDetailModal.pathHash')}
-
{peer?.path_hash ?? '—'}
+
{t('peerListPanel.pathsMedium')}
+
{mediumLabel}
{t('peerListPanel.colLastSeen')}
{lastSeenMs ? formatRelativeOrIsoDate(lastSeenMs, t, normalizeLastHeardMs) : '—'}
+
{t('peerDetailModal.backupPaths')}
+
+ {backupPathSlots.length === 0 ? ( + '—' + ) : ( +
    + {backupPathSlots.map((slot, index) => ( +
  • + {t('peerListPanel.pathsBackupBadge')} + {' · '} + {t('connectionPanel.reticulumPeers.hops')}: {slot.hops ?? '—'} + {' · '} + {slot.interface ?? '—'} + {' · '} + {slot.medium === 'rf' + ? t('peerListPanel.pathsPreferRf') + : slot.medium === 'network' + ? t('peerListPanel.pathsPreferNetwork') + : '—'} +
  • + ))} +
+ )} +
diff --git a/src/renderer/components/ReticulumPeerListPanel.test.tsx b/src/renderer/components/ReticulumPeerListPanel.test.tsx index 2ca0f7aa8..bbcbd9ce9 100644 --- a/src/renderer/components/ReticulumPeerListPanel.test.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.test.tsx @@ -32,6 +32,7 @@ const reticulumSidecarMocks = vi.hoisted(() => ({ requestReticulumPeerPath: vi.fn(), probeReticulumPeer: vi.fn(), refreshReticulumPeersFromSidecar: vi.fn(), + refreshReticulumPeerRouteFromPaths: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -70,9 +71,17 @@ vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ }, })); +vi.mock('@/renderer/lib/reticulum/reticulumPathMedium', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + refreshReticulumPeerRouteFromPaths: (...args: unknown[]) => + reticulumSidecarMocks.refreshReticulumPeerRouteFromPaths(...args), + }; +}); + vi.mock('../stores/reticulumPeerStore', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importOriginal needs typeof import() - const actual = await importOriginal(); + const actual = await importOriginal>(); return { ...actual, refreshReticulumPeersFromSidecar: reticulumSidecarMocks.refreshReticulumPeersFromSidecar, @@ -113,6 +122,8 @@ describe('ReticulumPeerListPanel', () => { reticulumSidecarMocks.probeReticulumPeer.mockReset(); reticulumSidecarMocks.refreshReticulumPeersFromSidecar.mockReset(); reticulumSidecarMocks.refreshReticulumPeersFromSidecar.mockResolvedValue([]); + reticulumSidecarMocks.refreshReticulumPeerRouteFromPaths.mockReset(); + reticulumSidecarMocks.refreshReticulumPeerRouteFromPaths.mockResolvedValue(false); useNomadNetworkStore.setState({ nodes: new Map() }); useReticulumPeerStore.setState({ peers: new Map([ diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index b91761c1d..1eabd9355 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -20,6 +20,11 @@ import { reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; import { parseReticulumDestinationInput } from '@/renderer/lib/reticulum/reticulumDestinationInput'; +import { + refreshReticulumPeerRouteFromPaths, + RETICULUM_PATH_RETRY_MS, + RETICULUM_PATH_SETTLE_MS, +} from '@/renderer/lib/reticulum/reticulumPathMedium'; import { cheapReticulumPeerLabel, filterPreparedReticulumPeerRows, @@ -468,7 +473,12 @@ export default function ReticulumPeerListPanel({ const result = await requestReticulumPeerPath(hash); const toast = formatReticulumPeerPathToast(t, result); addToast(toast.message, toast.variant); - // Path results arrive via WS peers_updated patches — avoid a full dump. + if (result.ok) { + await refreshReticulumPeerRouteFromPaths(hash, { + settleMs: RETICULUM_PATH_SETTLE_MS, + retryMs: RETICULUM_PATH_RETRY_MS, + }); + } } catch (e) { console.warn('[ReticulumPeerListPanel] path ' + errLikeToLogString(e)); } finally { @@ -489,7 +499,9 @@ export default function ReticulumPeerListPanel({ if (result.ok && result.hops != null) { useReticulumPeerStore.getState().updatePeer(hash, { hops: result.hops }); } - // Probe hops applied locally; skip full path-table refresh. + if (result.ok) { + await refreshReticulumPeerRouteFromPaths(hash); + } } catch (e) { console.warn('[ReticulumPeerListPanel] probe ' + errLikeToLogString(e)); } finally { diff --git a/src/renderer/lib/modalZIndex.contract.test.ts b/src/renderer/lib/modalZIndex.contract.test.ts index 04fa4ef6b..40b4201ea 100644 --- a/src/renderer/lib/modalZIndex.contract.test.ts +++ b/src/renderer/lib/modalZIndex.contract.test.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { Z_NESTED_AUTH_OVERLAY, Z_NODE_DETAIL_MODAL } from './modalZIndex'; +import { Z_INSTANT_TOOLTIP, Z_NESTED_AUTH_OVERLAY, Z_NODE_DETAIL_MODAL } from './modalZIndex'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -38,7 +38,8 @@ describe('modal z-index layering contract', () => { } }); - it('constants remain ordered so auth can cover node detail', () => { + it('constants remain ordered so auth can cover node detail and tooltips cover both', () => { expect(Z_NESTED_AUTH_OVERLAY).toBeGreaterThan(Z_NODE_DETAIL_MODAL); + expect(Z_INSTANT_TOOLTIP).toBeGreaterThan(Z_NESTED_AUTH_OVERLAY); }); }); diff --git a/src/renderer/lib/modalZIndex.test.ts b/src/renderer/lib/modalZIndex.test.ts index a33db8d7c..09dfe19a4 100644 --- a/src/renderer/lib/modalZIndex.test.ts +++ b/src/renderer/lib/modalZIndex.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { Z_NESTED_AUTH_OVERLAY, Z_NODE_DETAIL_MODAL } from './modalZIndex'; +import { Z_INSTANT_TOOLTIP, Z_NESTED_AUTH_OVERLAY, Z_NODE_DETAIL_MODAL } from './modalZIndex'; describe('modalZIndex', () => { it('keeps nested auth overlays above the node detail modal', () => { expect(Z_NESTED_AUTH_OVERLAY).toBeGreaterThan(Z_NODE_DETAIL_MODAL); }); + + it('keeps instant tooltips above nested auth and node detail modals', () => { + expect(Z_INSTANT_TOOLTIP).toBeGreaterThan(Z_NESTED_AUTH_OVERLAY); + expect(Z_INSTANT_TOOLTIP).toBeGreaterThan(Z_NODE_DETAIL_MODAL); + }); }); diff --git a/src/renderer/lib/modalZIndex.ts b/src/renderer/lib/modalZIndex.ts index 19b059065..419da3388 100644 --- a/src/renderer/lib/modalZIndex.ts +++ b/src/renderer/lib/modalZIndex.ts @@ -3,3 +3,6 @@ export const Z_NODE_DETAIL_MODAL = 10_000; /** Nested auth overlays opened from NodeDetailModal — must sit above the node modal. */ export const Z_NESTED_AUTH_OVERLAY = 10_001; + +/** Instant tooltips (portal) — must sit above node/peer detail modals and nested auth. */ +export const Z_INSTANT_TOOLTIP = 10_100; diff --git a/src/renderer/lib/reticulum/reticulumPathMedium.test.ts b/src/renderer/lib/reticulum/reticulumPathMedium.test.ts index ec3ecb2b0..973b8849a 100644 --- a/src/renderer/lib/reticulum/reticulumPathMedium.test.ts +++ b/src/renderer/lib/reticulum/reticulumPathMedium.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + activeReticulumPathSlot, + backupReticulumPathSlots, fetchReticulumPeerPaths, parsePathMedium, parsePathMediumPreference, @@ -8,9 +10,26 @@ import { pathMediumFromInterfaceNameOrType, peerMediumPinApiFromChoice, peerMediumPinChoiceFromApi, + type ReticulumPathSlot, setReticulumPeerMediumPin, } from './reticulumPathMedium'; +function slot( + partial: Partial & Pick, +): ReticulumPathSlot { + return { + hops: null, + via_hash: null, + interface: null, + interface_id: null, + medium: null, + timestamp: null, + expires: null, + expired: false, + ...partial, + }; +} + describe('reticulumPathMedium', () => { beforeEach(() => { vi.clearAllMocks(); @@ -50,6 +69,32 @@ describe('reticulumPathMedium', () => { expect(peerMediumPinApiFromChoice('network')).toBe('network'); }); + it('activeReticulumPathSlot prefers active live, then first live, then first', () => { + const active = slot({ active: true, medium: 'rf', interface: 'RNode' }); + const backup = slot({ active: false, medium: 'network', interface: 'TCP' }); + expect(activeReticulumPathSlot([active, backup])).toBe(active); + + const expiredActive = slot({ active: true, expired: true, medium: 'rf' }); + const liveBackup = slot({ active: false, medium: 'network', interface: 'Hub' }); + expect(activeReticulumPathSlot([expiredActive, liveBackup])).toBe(liveBackup); + + expect(activeReticulumPathSlot([])).toBeNull(); + expect(activeReticulumPathSlot([expiredActive])).toBe(expiredActive); + }); + + it('backupReticulumPathSlots excludes active and expired slots', () => { + const active = slot({ active: true, medium: 'rf', interface: 'RNode' }); + const backup = slot({ + active: false, + medium: 'network', + interface: 'Ratspeak', + hops: 3, + }); + const expired = slot({ active: false, expired: true, medium: 'network', interface: 'Old' }); + expect(backupReticulumPathSlots([active, backup, expired])).toEqual([backup]); + expect(backupReticulumPathSlots([active])).toEqual([]); + }); + it('parsePeerPathsResponse keeps at most 3 slots and marks pin null', () => { const parsed = parsePeerPathsResponse({ ok: true, diff --git a/src/renderer/lib/reticulum/reticulumPathMedium.ts b/src/renderer/lib/reticulum/reticulumPathMedium.ts index dd55f1744..8e30e1270 100644 --- a/src/renderer/lib/reticulum/reticulumPathMedium.ts +++ b/src/renderer/lib/reticulum/reticulumPathMedium.ts @@ -169,6 +169,53 @@ export async function fetchReticulumPeerPaths(hash: string): Promise s.active && !s.expired) ?? paths.find((s) => !s.expired) ?? paths[0]; +} + +/** Non-expired slots excluding the chosen active slot (transport caps at 3 total). */ +export function backupReticulumPathSlots(paths: readonly ReticulumPathSlot[]): ReticulumPathSlot[] { + const active = activeReticulumPathSlot(paths); + return paths.filter((s) => !s.expired && s !== active); +} + +function sleepMs(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Fetch ranked path slots and apply the active route onto the peer store + * (peers + contacts + history). Optional settle/retry for post-RequestPath timing. + * Returns the last `/paths` payload (for Medium / backup UI). + */ +export async function refreshReticulumPeerRouteFromPaths( + hash: string, + opts?: { settleMs?: number; retryMs?: number }, +): Promise { + const { applyReticulumPeerActivePathSlot } = await import('@/renderer/stores/reticulumPeerStore'); + const settleMs = opts?.settleMs ?? 0; + if (settleMs > 0) await sleepMs(settleMs); + const first = await fetchReticulumPeerPaths(hash); + if (applyReticulumPeerActivePathSlot(hash, first)) return first; + const retryMs = opts?.retryMs ?? 0; + if (retryMs <= 0) return first; + await sleepMs(retryMs); + const second = await fetchReticulumPeerPaths(hash); + applyReticulumPeerActivePathSlot(hash, second); + return second; +} + export async function setReticulumPeerMediumPin( hash: string, pin: PathMedium | null, diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index fef1b5eba..67561a724 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -3119,7 +3119,6 @@ }, "peerDetailModal": { "networkSection": "Síť", - "pathHash": "Hash cesty", "copyHash": "Kopírovat cílový hash", "editNameAria": "Vlastní zobrazované jméno", "sendMessage": "Odeslat zprávu", @@ -3136,8 +3135,6 @@ "probeOk": "Sonda OK — cesta dosažitelná.", "blockContact": "Zablokovat kontakt", "unblockContact": "Odblokovat kontakt", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — naposledy viděn/a {{seen}}", "verifyMark": "Označit jako ověřené", "verifyRevoke": "Odvolat žádost o ověření", "verifyFingerprint": "Otisk prstu totožnosti", @@ -3147,7 +3144,8 @@ "shareContactQrAria": "Zobrazit QR kód pro tento kontakt", "verifiedRowAria": "Kontakt ověřen", "renameFailed": "Zobrazovaný název nelze uložit.", - "removeContactConfirmBody": "Tím odeberete kontakt ze svého uloženého seznamu. Zprávy chatu zůstávají v Historii, pokud jsi jim poslal/a zprávu." + "removeContactConfirmBody": "Tím odeberete kontakt ze svého uloženého seznamu. Zprávy chatu zůstávají v Historii, pokud jsi jim poslal/a zprávu.", + "backupPaths": "Cesty zálohování" }, "peerGraph": { "title": "Graf vzájemné komunikace", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index d921db6cd..631c3a40e 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Netzwerk-", - "pathHash": "Pfad-Hash", "copyHash": "Ziel-Hash kopieren", "editNameAria": "Ben.def. Display", "sendMessage": "Nachricht senden", @@ -3132,10 +3131,8 @@ "removeContact": "Kontakt entfernen", "removeContactConfirmTitle": "Kontakt entfernen?", "probeOk": "Sonde OK — Weg erreichbar.", - "serviceBadge": "{{service}}", "blockContact": "Kontakt blockieren", "unblockContact": "Kontakt entsperren", - "serviceBadgeTitle": "{{aspect}} — zuletzt gesehen {{seen}}", "verifyMark": "Verifizierte markieren", "verifyRevoke": "Verifizierung widerrufen", "verifyFingerprint": "Identitäts-Fingerabdruck", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "QR-Code für diesen Kontakt anzeigen", "verifiedRowAria": "Kontakt verifiziert", "renameFailed": "Der Anzeigename konnte nicht gespeichert werden.", - "removeContactConfirmBody": "Dadurch wird der Kontakt aus Ihrer gespeicherten Liste entfernt. Chat-Nachrichten bleiben bestehen; sie bleiben im Verlauf, wenn Sie ihnen eine Nachricht gesendet haben." + "removeContactConfirmBody": "Dadurch wird der Kontakt aus Ihrer gespeicherten Liste entfernt. Chat-Nachrichten bleiben bestehen; sie bleiben im Verlauf, wenn Sie ihnen eine Nachricht gesendet haben.", + "backupPaths": "Backup-Pfade" }, "peerGraph": { "title": "Peer-Kommunikationsgrafik", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 583860419..ad8f908eb 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -3262,7 +3262,7 @@ }, "peerDetailModal": { "networkSection": "Network", - "pathHash": "Path hash", + "backupPaths": "Backup paths", "copyHash": "Copy destination hash", "editNameAria": "Custom display name", "sendMessage": "Send message", @@ -3280,8 +3280,6 @@ "probeFailed": "Probe failed: {{error}}", "blockContact": "Block contact", "unblockContact": "Unblock contact", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — last seen {{seen}}", "verifyMark": "Mark verified", "verifyRevoke": "Revoke verification", "verifyFingerprint": "Identity fingerprint", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 105330ed1..30789507c 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Red", - "pathHash": "Hash de ruta", "copyHash": "Copiar hash de destino", "editNameAria": "Establecer nombre para mostrar personalizado", "sendMessage": "Enviar mensaje", @@ -3134,8 +3133,6 @@ "probeOk": "Sonda OK — ruta accesible.", "blockContact": "Bloquear contacto", "unblockContact": "Desbloquear contacto", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — visto por última vez {{seen}}", "verifyMark": "Marcar como verificado", "verifyRevoke": "Revocar verificación", "verifyFingerprint": "Huella dactilar de identidad", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Mostrar código QR para este contacto", "verifiedRowAria": "Contacto no verificado", "renameFailed": "No se pudo guardar el nombre para mostrar.", - "removeContactConfirmBody": "Esto elimina el contacto de su lista guardada. Los mensajes de chat permanecen; permanecen en el historial si los has enviado." + "removeContactConfirmBody": "Esto elimina el contacto de su lista guardada. Los mensajes de chat permanecen; permanecen en el historial si los has enviado.", + "backupPaths": "Rutas Respaldo" }, "peerGraph": { "title": "Gráfico de comunicación con los compañeros", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index d14762241..92c61a015 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Réseau", - "pathHash": "Hachage du chemin", "copyHash": "Hash de Destination", "editNameAria": "Affichage Personnalisé", "sendMessage": "Envoyer un message", @@ -3134,8 +3133,6 @@ "probeOk": "Sonde OK — chemin accessible.", "blockContact": "Bloquer contact", "unblockContact": "Débloquer contact", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — dernière vue {{seen}}", "verifyMark": "Marquer comme vérifié", "verifyRevoke": "Révoquer la vérification", "verifyFingerprint": "Empreinte digitale d'identité", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Afficher le code QR pour ce contact", "verifiedRowAria": "Contact vérifié", "renameFailed": "Impossible d'enregistrer le nom d'affichage.", - "removeContactConfirmBody": "Cela supprime le contact de votre liste enregistrée. Les messages de chat restent ; ils restent dans l'historique si vous leur avez envoyé un message." + "removeContactConfirmBody": "Cela supprime le contact de votre liste enregistrée. Les messages de chat restent ; ils restent dans l'historique si vous leur avez envoyé un message.", + "backupPaths": "Chemins de sauvegarde" }, "peerGraph": { "title": "Graphique de communication par les pairs", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index d5faaf92a..239f4d848 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Jaringan", - "pathHash": "Hash jalur", "copyHash": "Salin hash tujuan", "editNameAria": "Nama tampilan khusus", "sendMessage": "Kirim pesan", @@ -3134,8 +3133,6 @@ "probeOk": "Probe OK — jalur dapat dijangkau.", "blockContact": "Blokir kontak", "unblockContact": "Buka blokir kontak", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — terakhir terlihat {{seen}}", "verifyMark": "Tandai terverifikasi", "verifyRevoke": "Cabut verifikasi", "verifyFingerprint": "Sidik jari identitas", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Tampilkan kode QR untuk kontak ini", "verifiedRowAria": "Kontak terverifikasi", "renameFailed": "Tidak dapat menyimpan nama tampilan.", - "removeContactConfirmBody": "Ini akan menghapus kontak dari daftar tersimpan Anda. Pesan obrolan tetap ada; pesan tersebut akan tetap ada di Riwayat jika Anda mengirimkannya melalui pesan." + "removeContactConfirmBody": "Ini akan menghapus kontak dari daftar tersimpan Anda. Pesan obrolan tetap ada; pesan tersebut akan tetap ada di Riwayat jika Anda mengirimkannya melalui pesan.", + "backupPaths": "Jalur cadangan" }, "peerGraph": { "title": "Grafik Komunikasi Rekan", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 935d74093..ae23d3283 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Network", - "pathHash": "Hash percorso", "copyHash": "Copia hash di destinazione", "editNameAria": "Nome visualizzato personalizzato", "sendMessage": "Invia messaggio", @@ -3134,8 +3133,6 @@ "probeOk": "Sonda OK — percorso raggiungibile.", "blockContact": "Blocca contatto", "unblockContact": "Sblocca contatto", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — ultimo accesso {{seen}}", "verifyMark": "Imposta come Verificato", "verifyRevoke": "Revoca la verifica", "verifyFingerprint": "Impronta digitale dell'identità", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Mostra il codice QR per questo contatto", "verifiedRowAria": "Contatto verificato", "renameFailed": "Impossibile salvare il nome visualizzato.", - "removeContactConfirmBody": "Questo rimuove il contatto dall'elenco salvato. I messaggi di chat rimangono; rimangono nella Cronologia se li hai inviati." + "removeContactConfirmBody": "Questo rimuove il contatto dall'elenco salvato. I messaggi di chat rimangono; rimangono nella Cronologia se li hai inviati.", + "backupPaths": "Percorsi di backup" }, "peerGraph": { "title": "Grafico di comunicazione peer", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index eaefc4ae6..7bfa7271e 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "ネットワーク", - "pathHash": "パスハッシュ", "copyHash": "コピー先ハッシュ", "editNameAria": "手動インストール", "sendMessage": "メッセージを送信", @@ -3134,8 +3133,6 @@ "probeOk": "プローブOK —到達可能なパス。", "blockContact": "連絡先をブロックする", "unblockContact": "連絡先のブロックを解除する", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} —最終閲覧{{seen}}", "verifyMark": "承認としてマーク", "verifyRevoke": "認証の取り消し", "verifyFingerprint": "IDフィンガープリント", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "この連絡先のQRコードを表示", "verifiedRowAria": "連絡先が認証されました", "renameFailed": "表示名を保存できませんでした。", - "removeContactConfirmBody": "これにより、保存したリストから連絡先が削除されます。チャットメッセージは残ります。メッセージを送信した場合は履歴に残ります。" + "removeContactConfirmBody": "これにより、保存したリストから連絡先が削除されます。チャットメッセージは残ります。メッセージを送信した場合は履歴に残ります。", + "backupPaths": "バックアップパス" }, "peerGraph": { "title": "ピアコミュニケーショングラフ", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index b33f2d1e0..07f290104 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "네트워크", - "pathHash": "경로 해시", "copyHash": "대상 해시 복사", "editNameAria": "사용자 지정 표시 이름을 사용?.", "sendMessage": "메시지 전송", @@ -3134,8 +3133,6 @@ "probeOk": "프로브 확인 — 경로에 도달할 수 있습니다.", "blockContact": "대화 상대 차단( B)", "unblockContact": "대화 상대 차단 해제( B)", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — 마지막 확인 {{seen}}", "verifyMark": "확인됨으로 표시", "verifyRevoke": "인증 취소", "verifyFingerprint": "신원 지문", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "이 연락처의 QR 코드 표시", "verifiedRowAria": "연락처가 확인되었습니다.", "renameFailed": "표시 이름을 저장할 수 없습니다.", - "removeContactConfirmBody": "이렇게 하면 저장된 목록에서 연락처가 제거됩니다. 채팅 메시지는 계속 유지됩니다. 메시지를 보낸 경우 채팅 메시지는 기록에 남아 있습니다." + "removeContactConfirmBody": "이렇게 하면 저장된 목록에서 연락처가 제거됩니다. 채팅 메시지는 계속 유지됩니다. 메시지를 보낸 경우 채팅 메시지는 기록에 남아 있습니다.", + "backupPaths": "백업 경로" }, "peerGraph": { "title": "동료 커뮤니케이션 그래프", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 11aec5e0f..c552fa15e 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Netwerk", - "pathHash": "Pad hash", "copyHash": "Bestemmingshash kopiëren", "editNameAria": "Aangepaste weergavenaam", "sendMessage": "Verstuur bericht", @@ -3134,8 +3133,6 @@ "probeOk": "Sonde OK — pad bereikbaar.", "blockContact": "Contactpersoon blokkeren", "unblockContact": "Contactpersoon deblokkeren", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — laatst gezien {{seen}}", "verifyMark": "Markeer geverifieerd", "verifyRevoke": "Verificatie intrekken", "verifyFingerprint": "Identiteit vingerafdruk", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Toon QR-code voor dit contact", "verifiedRowAria": "Contact geverifieerd", "renameFailed": "Kan de weergavenaam niet opslaan.", - "removeContactConfirmBody": "Hiermee wordt de contactpersoon uit uw opgeslagen lijst verwijderd. Chatberichten blijven; ze blijven in de geschiedenis staan als je ze een bericht hebt gestuurd." + "removeContactConfirmBody": "Hiermee wordt de contactpersoon uit uw opgeslagen lijst verwijderd. Chatberichten blijven; ze blijven in de geschiedenis staan als je ze een bericht hebt gestuurd.", + "backupPaths": "Back-uppaden" }, "peerGraph": { "title": "Peer-communicatiegrafiek", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 45c5c82b3..5edf85191 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -3121,7 +3121,6 @@ }, "peerDetailModal": { "networkSection": "Sieć", - "pathHash": "Skrót ścieżki", "copyHash": "Skopiuj skrót miejsca docelowego", "editNameAria": "Niestandardowa nazwa wyświetlana", "sendMessage": "Wyślij wiadomość", @@ -3138,8 +3137,6 @@ "probeOk": "Sonda OK — ścieżka osiągalna.", "blockContact": "Blokuj kontakt", "unblockContact": "Odblokuj kontakt", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — ostatnio widziany {{seen}}", "verifyMark": "Oznacz jako zweryfikowane", "verifyRevoke": "Cofnij weryfikację", "verifyFingerprint": "Tożsamość odcisku palca", @@ -3149,7 +3146,8 @@ "shareContactQrAria": "Pokaż kod QR dla tego kontaktu", "verifiedRowAria": "Kontakt zweryfikowany", "renameFailed": "Nie można zapisać nazwy wyświetlanej.", - "removeContactConfirmBody": "Spowoduje to usunięcie kontaktu z zapisanej listy. Wiadomości na czacie pozostają w Historii, jeśli zostały przez Ciebie wysłane." + "removeContactConfirmBody": "Spowoduje to usunięcie kontaktu z zapisanej listy. Wiadomości na czacie pozostają w Historii, jeśli zostały przez Ciebie wysłane.", + "backupPaths": "Ścieżki tworzenia kopii zapasowych" }, "peerGraph": { "title": "Wykres komunikacji z partnerami", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 99643eaa4..d132f3f98 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Rede", - "pathHash": "Hash do caminho", "copyHash": "Hash de destino", "editNameAria": "Exibição de nome personalizado", "sendMessage": "Enviar Mensagem", @@ -3134,8 +3133,6 @@ "probeOk": "Sonda OK — caminho alcançável.", "blockContact": "Bloquear contacto", "unblockContact": "Desbloquear contacto", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — visto pela última vez {{seen}}", "verifyMark": "Marcar Verificado", "verifyRevoke": "Anular verificação", "verifyFingerprint": "Impressão digital de identidade", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Mostrar código QR para este contato", "verifiedRowAria": "Contato verificado", "renameFailed": "Não foi possível salvar o nome de exibição.", - "removeContactConfirmBody": "Isso remove o contato da sua lista salva. As mensagens de bate-papo permanecem; elas permanecem no Histórico se você as enviou." + "removeContactConfirmBody": "Isso remove o contato da sua lista salva. As mensagens de bate-papo permanecem; elas permanecem no Histórico se você as enviou.", + "backupPaths": "Caminhos de backup" }, "peerGraph": { "title": "Gráfico de Comunicação entre Pares", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index ff30fc024..29f8ade6b 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -3119,7 +3119,6 @@ }, "peerDetailModal": { "networkSection": "Сеть", - "pathHash": "Хеш пути", "copyHash": "Копировать хэш назначения", "editNameAria": "Пользовательское отображение", "sendMessage": "Сообщение \"Отправить\"", @@ -3136,8 +3135,6 @@ "probeOk": "Зонд в порядке — путь достижим.", "blockContact": "Заблокировать контакт", "unblockContact": "Разблокировать контакт", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — последний раз видели {{seen}}", "verifyMark": "Отметить как проверенный", "verifyRevoke": "Отменить подтверждение", "verifyFingerprint": "Идентификационный отпечаток пальца", @@ -3147,7 +3144,8 @@ "shareContactQrAria": "Показать QR-код для этого контакта", "verifiedRowAria": "Контакт подтвержден", "renameFailed": "Не удалось сохранить отображаемое имя.", - "removeContactConfirmBody": "Контакт будет удален из сохраненного списка. Сообщения чата остаются; они остаются в истории, если вы отправили им сообщение." + "removeContactConfirmBody": "Контакт будет удален из сохраненного списка. Сообщения чата остаются; они остаются в истории, если вы отправили им сообщение.", + "backupPaths": "Пути резервного копирования" }, "peerGraph": { "title": "График одноранговой связи", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 2b0a739ed..b706956fc 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "Ağ", - "pathHash": "Yol karması", "copyHash": "Hedef karmasını kopyala", "editNameAria": "Özel görüntü adı", "sendMessage": "Mesaj gönder", @@ -3134,8 +3133,6 @@ "probeOk": "Prob tamam — yola ulaşılabilir.", "blockContact": "Kişiyi & Engelle", "unblockContact": "& Kişinin Engellemesini Kaldır", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — son görülme {{seen}}", "verifyMark": "Doğrulandı olarak işaretle", "verifyRevoke": "Doğrulamayı iptal et", "verifyFingerprint": "Kimlik parmak izi", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "Bu kişiye ait QR kodunu göster", "verifiedRowAria": "Kişi doğrulandı", "renameFailed": "Görünen ad kaydedilemedi.", - "removeContactConfirmBody": "Bu, kişiyi kayıtlı listenizden kaldırır. Sohbet mesajları kalır; mesaj gönderdiyseniz Geçmiş'te kalır." + "removeContactConfirmBody": "Bu, kişiyi kayıtlı listenizden kaldırır. Sohbet mesajları kalır; mesaj gönderdiyseniz Geçmiş'te kalır.", + "backupPaths": "Yedekleme yolları" }, "peerGraph": { "title": "Akran İletişim Grafiği", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 21d9bbdb9..b6d54d76b 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -3119,7 +3119,6 @@ }, "peerDetailModal": { "networkSection": "Засоби зв'язку", - "pathHash": "Хеш шляху", "copyHash": "Скопіювати хеш призначення", "editNameAria": "Custom Display (Відображення користувача)", "sendMessage": "Надіслати повідомлення", @@ -3136,8 +3135,6 @@ "probeOk": "Зонд OK — шлях досяжний.", "blockContact": "& Заблокувати контакт", "unblockContact": "& Розблокувати контакт", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} — останній раз бачили {{seen}}", "verifyMark": "Позначити як перевірений", "verifyRevoke": "Відкликати підтвердження", "verifyFingerprint": "Відбиток особи", @@ -3147,7 +3144,8 @@ "shareContactQrAria": "Показати QR-код для цього контакту", "verifiedRowAria": "Контакт підтверджено", "renameFailed": "Не вдалося зберегти відображуване ім'я.", - "removeContactConfirmBody": "Це видалить контакт із збереженого списку. Повідомлення чату залишаються; вони залишаються в історії, якщо ви надіслали їм повідомлення." + "removeContactConfirmBody": "Це видалить контакт із збереженого списку. Повідомлення чату залишаються; вони залишаються в історії, якщо ви надіслали їм повідомлення.", + "backupPaths": "Шляхи резервного копіювання" }, "peerGraph": { "title": "Графік однорангової комунікації", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 2a8c1d9a6..d019df96c 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -3117,7 +3117,6 @@ }, "peerDetailModal": { "networkSection": "网络", - "pathHash": "哈希路径", "copyHash": "复制目标哈希", "editNameAria": "自定义显示名称", "sendMessage": "发讯", @@ -3134,8 +3133,6 @@ "probeOk": "探测正常—路径可达。", "blockContact": "阻止联系人", "unblockContact": "取消阻止联系人", - "serviceBadge": "{{service}}", - "serviceBadgeTitle": "{{aspect}} —最后一次出现{{seen}}", "verifyMark": "标记已验证", "verifyRevoke": "撤销验证", "verifyFingerprint": "身份指纹", @@ -3145,7 +3142,8 @@ "shareContactQrAria": "显示此联系人的二维码", "verifiedRowAria": "已验证联系人48", "renameFailed": "无法保存显示名称。", - "removeContactConfirmBody": "此操作会将联系人从保存的列表中删除。聊天消息会保留;如果您向其发送消息,它们将保留在历史记录中。" + "removeContactConfirmBody": "此操作会将联系人从保存的列表中删除。聊天消息会保留;如果您向其发送消息,它们将保留在历史记录中。", + "backupPaths": "设置备份路径" }, "peerGraph": { "title": "同行沟通图表", diff --git a/src/renderer/stores/reticulumIdentityActivityStore.test.ts b/src/renderer/stores/reticulumIdentityActivityStore.test.ts index 01120273d..f2206b492 100644 --- a/src/renderer/stores/reticulumIdentityActivityStore.test.ts +++ b/src/renderer/stores/reticulumIdentityActivityStore.test.ts @@ -34,21 +34,54 @@ describe('parseAnnounceActivityRows', () => { expect(rows.map((r) => r.aspect)).toEqual(['nomadnetwork.node', 'lxmf.delivery']); }); - it('parses batched announces array payload', () => { + it('returns no rows when aspect is missing (does not invent unknown)', () => { + const rows = parseAnnounceActivityRows({ + destination_hash: 'peer1', + hops: 3, + identity_hash: 'id_a', + }); + expect(rows).toEqual([]); + }); + + it('parses batched announces and skips aspect-less entries', () => { const rows = parseAnnounceActivityRows({ announces: [ { destination_hash: 'aaa', hops: 1 }, - { destination_hash: 'bbb', display_name: 'Bob', hops: 2 }, + { + destination_hash: 'bbb', + display_name: 'Bob', + hops: 2, + aspect: 'lxmf.delivery', + identity_hash: 'id_b', + }, + ], + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + destination_hash: 'bbb', + aspect: 'lxmf.delivery', + identity_hash: 'id_b', + hops: 2, + }); + }); + + it('parses batched announces array payload with aspects', () => { + const rows = parseAnnounceActivityRows({ + announces: [ + { destination_hash: 'aaa', aspect: 'rrc.hub', hops: 1 }, + { destination_hash: 'bbb', aspect: 'nomadnetwork.node', hops: 2 }, ], }); expect(rows).toHaveLength(2); expect(rows.map((r) => r.destination_hash)).toEqual(['aaa', 'bbb']); + expect(rows.map((r) => r.aspect)).toEqual(['rrc.hub', 'nomadnetwork.node']); }); }); describe('announce-bus pressure activity gate', () => { afterEach(() => { resetReticulumIdentityActivityBatchForTests(); + useReticulumIdentityActivityStore.setState({ byDestination: new Map() }); vi.unstubAllGlobals(); }); @@ -104,4 +137,38 @@ describe('announce-bus pressure activity gate', () => { vi.useRealTimers(); } }); + + it('named-aspect upsert drops in-memory unknown placeholder for the same destination', async () => { + vi.useFakeTimers(); + const upsertBatch = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', { + electronAPI: { + db: { + upsertReticulumIdentityActivityBatch: upsertBatch, + upsertReticulumIdentityActivity: vi.fn(), + }, + }, + }); + try { + setReticulumAnnounceBusPressureActive(false); + const store = useReticulumIdentityActivityStore.getState(); + await store.upsertActivity({ + destination_hash: 'aabbccdd', + aspect: 'unknown', + last_seen: 1, + }); + await store.upsertActivity({ + destination_hash: 'aabbccdd', + aspect: 'lxmf.delivery', + identity_hash: 'id1', + last_seen: 2, + }); + await vi.advanceTimersByTimeAsync(600); + const rows = useReticulumIdentityActivityStore.getState().getActivity('aabbccdd'); + expect(rows.map((r) => r.aspect)).toEqual(['lxmf.delivery']); + expect(rows[0]?.identity_hash).toBe('id1'); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/renderer/stores/reticulumIdentityActivityStore.ts b/src/renderer/stores/reticulumIdentityActivityStore.ts index 5c11a41df..41d8bcede 100644 --- a/src/renderer/stores/reticulumIdentityActivityStore.ts +++ b/src/renderer/stores/reticulumIdentityActivityStore.ts @@ -154,7 +154,11 @@ export const useReticulumIdentityActivityStore = create { const next = new Map(s.byDestination); const prev = next.get(key) ?? []; - const filtered = prev.filter((r) => r.aspect !== normalized.aspect); + // Named aspects replace the legacy "unknown" placeholder for this destination. + const dropUnknown = normalized.aspect !== 'unknown'; + const filtered = prev.filter( + (r) => r.aspect !== normalized.aspect && !(dropUnknown && r.aspect === 'unknown'), + ); next.set(key, [normalized, ...filtered]); return { byDestination: trimMapToMaxSize(next, MAX_RETICULUM_IDENTITY_DESTINATIONS) }; }); @@ -192,7 +196,8 @@ function parseOneAnnounceActivityRow(p: Record): ReticulumIdent if (typeof a === 'string' && a.trim()) aspects.push(a.trim()); } } - if (aspects.length === 0) aspects.push('unknown'); + // No aspect → no identity-activity rows (do not invent "unknown"). + if (aspects.length === 0) return []; return aspects.map((aspect) => ({ destination_hash: destinationHash, aspect, diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 1a1d0e654..470fd18be 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -490,6 +490,195 @@ describe('reticulumPeerStore', () => { expect(useReticulumPeerStore.getState().getPeer('missing')).toBeUndefined(); }); + it('getPeer overlays live peer route fields onto a saved contact', () => { + const hash = 'aa'.repeat(16); + useReticulumPeerStore.setState({ + peers: new Map([ + [ + hash, + { + destination_hash: hash, + hops: 2, + interface: 'RMAP World', + path_hash: 'bb'.repeat(16), + via_hash: 'bb'.repeat(16), + }, + ], + ]), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'Saved', + last_heard: 100, + is_contact: true, + hops: null, + interface: null, + }, + ], + ]), + history: new Map(), + }); + const peer = useReticulumPeerStore.getState().getPeer(hash); + expect(peer?.display_name).toBe('Saved'); + expect(peer?.hops).toBe(2); + expect(peer?.interface).toBe('RMAP World'); + expect(peer?.path_hash).toBe('bb'.repeat(16)); + }); + + it('updatePeer seeds peers from contact-only rows', () => { + const hash = 'cc'.repeat(16); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'ContactOnly', + last_heard: 50, + is_contact: true, + }, + ], + ]), + history: new Map(), + }); + useReticulumPeerStore.getState().updatePeer(hash, { hops: 3, interface: 'tcp' }); + expect(useReticulumPeerStore.getState().peers.get(hash)?.hops).toBe(3); + expect(useReticulumPeerStore.getState().contacts.get(hash)?.hops).toBe(3); + expect(useReticulumPeerStore.getState().contacts.get(hash)?.interface).toBe('tcp'); + }); + + it('peer patches flush route fields onto matching contacts', () => { + vi.useFakeTimers(); + const hash = 'dd'.repeat(16); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'Patched', + last_heard: 1, + is_contact: true, + }, + ], + ]), + history: new Map(), + peersRevision: 0, + }); + applyReticulumPeersUpdatedPatches({ + patches: [ + { + destination_hash: hash, + hops: 4, + interface: 'Aurora', + path_hash: 'ee'.repeat(16), + via_hash: 'ee'.repeat(16), + last_seen: 99, + }, + ], + }); + applyReticulumPeerPatchesNow([]); + expect(useReticulumPeerStore.getState().contacts.get(hash)?.hops).toBe(4); + expect(useReticulumPeerStore.getState().contacts.get(hash)?.interface).toBe('Aurora'); + expect(useReticulumPeerStore.getState().contacts.get(hash)?.via_hash).toBe('ee'.repeat(16)); + expect(useReticulumPeerStore.getState().getPeer(hash)?.path_hash).toBe('ee'.repeat(16)); + vi.useRealTimers(); + }); + + it('applyReticulumPeerActivePathSlot updates contact route from paths result', async () => { + const { applyReticulumPeerActivePathSlot } = await import('./reticulumPeerStore'); + const hash = 'ff'.repeat(16); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'SlotPeer', + last_heard: 1, + is_contact: true, + }, + ], + ]), + history: new Map(), + }); + const applied = applyReticulumPeerActivePathSlot(hash, { + ok: true, + paths: [ + { + active: true, + expired: false, + hops: 2, + via_hash: '11'.repeat(16), + interface: 'RMAP World', + interface_id: 1, + medium: 'network', + timestamp: 1234, + expires: null, + }, + ], + }); + expect(applied).toBe(true); + expect(useReticulumPeerStore.getState().getPeer(hash)).toMatchObject({ + hops: 2, + interface: 'RMAP World', + path_hash: '11'.repeat(16), + via_hash: '11'.repeat(16), + }); + }); + + it('applyReticulumPeerActivePathSlot preserves last_seen when slot timestamp is null', async () => { + const { applyReticulumPeerActivePathSlot } = await import('./reticulumPeerStore'); + const hash = 'ee'.repeat(16); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'KeepSeen', + last_heard: 1, + is_contact: true, + last_seen: 4242, + hops: 1, + interface: 'OldIface', + }, + ], + ]), + history: new Map(), + }); + const applied = applyReticulumPeerActivePathSlot(hash, { + ok: true, + paths: [ + { + active: true, + expired: false, + hops: 3, + via_hash: '22'.repeat(16), + interface: 'NewIface', + interface_id: 2, + medium: 'rf', + timestamp: null, + expires: null, + }, + ], + }); + expect(applied).toBe(true); + expect(useReticulumPeerStore.getState().getPeer(hash)).toMatchObject({ + hops: 3, + interface: 'NewIface', + path_hash: '22'.repeat(16), + via_hash: '22'.repeat(16), + last_seen: 4242, + }); + }); + it('clearPeers empties peers, contacts, and history', () => { useReticulumPeerStore.getState().replacePeers([{ destination_hash: 'aa' }]); useReticulumPeerStore.getState().replaceContacts([{ destination_hash: 'bb', last_heard: 1 }]); diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index 89226dc57..77b9f5eb4 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -10,6 +10,10 @@ import { resolveReticulumDestinationHash, reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; +import { + activeReticulumPathSlot, + type ReticulumPathSlot, +} from '@/renderer/lib/reticulum/reticulumPathMedium'; import { MAX_MESH_ENTITY_CAP } from '@/renderer/lib/sessionMemoryCaps'; import { useNodeStore } from '@/renderer/stores/nodeStore'; import { @@ -114,6 +118,39 @@ function peerDisplayName(peer: ReticulumPeer): string { return peer.destination_hash.slice(0, 12); } +/** Overlay live path-table route fields onto contact/history (or peer) rows. */ +export function mergeReticulumPeerRouteFields( + base: T, + live: ReticulumPeer | undefined | null, +): T { + if (!live) return base; + const hops = live.hops ?? base.hops; + const iface = live.interface ?? base.interface; + const path_hash = live.path_hash ?? base.path_hash; + const via_hash = live.via_hash ?? base.via_hash; + const last_seen = live.last_seen ?? base.last_seen; + const path_hops = live.path_hops ?? base.path_hops; + if ( + hops === base.hops && + iface === base.interface && + path_hash === base.path_hash && + via_hash === base.via_hash && + last_seen === base.last_seen && + path_hops === base.path_hops + ) { + return base; + } + return { + ...base, + hops, + interface: iface, + path_hash, + via_hash, + last_seen, + path_hops, + }; +} + /** Prefer wire/LXMF names from node store when path-table peers only have hashes. */ export function resolveReticulumPeerLabel( peer: ReticulumPeer, @@ -555,22 +592,24 @@ export const useReticulumPeerStore = create((set, get) updatePeer: (hash, partial) => { const key = normalizeHash(hash); + if (!key) return; set((s) => { + const contact = s.contacts.get(key); + const hist = s.history.get(key); + const existing = s.peers.get(key); + // Seed from contact/history so probe/path can patch contact-only hashes. + const seed = existing ?? contact ?? hist ?? { destination_hash: key }; const peers = new Map(s.peers); - const existing = peers.get(key); - if (!existing) return s; - peers.set(key, { ...existing, ...partial, destination_hash: key }); + peers.set(key, { ...seed, ...partial, destination_hash: key }); const contacts = new Map(s.contacts); - const contact = contacts.get(key); if (contact) { contacts.set(key, { ...contact, ...partial, destination_hash: key }); } const history = new Map(s.history); - const hist = history.get(key); if (hist) { history.set(key, { ...hist, ...partial, destination_hash: key }); } - return { peers, contacts, history }; + return { peers, contacts, history, peersRevision: s.peersRevision + 1 }; }); }, @@ -742,7 +781,11 @@ export const useReticulumPeerStore = create((set, get) getPeer: (hash) => { const key = normalizeHash(hash); - return get().contacts.get(key) ?? get().history.get(key) ?? get().peers.get(key); + const live = get().peers.get(key); + const base = get().contacts.get(key) ?? get().history.get(key) ?? live; + if (!base) return undefined; + if (base === live) return base; + return mergeReticulumPeerRouteFields(base, live); }, getDisplayName: (peer) => peerDisplayName(peer), @@ -761,6 +804,40 @@ export const useReticulumPeerStore = create((set, get) }, })); +/** Route fields from an active (or first usable) `/paths` slot onto the peer store. */ +export function applyReticulumPeerActivePathSlot( + hash: string, + pathsResult: { + ok: boolean; + paths: ReticulumPathSlot[]; + }, +): boolean { + if (!pathsResult.ok || pathsResult.paths.length === 0) return false; + const slot = activeReticulumPathSlot(pathsResult.paths); + if (!slot) return false; + const via = slot.via_hash?.trim() ? slot.via_hash.trim().toLowerCase() : null; + const store = useReticulumPeerStore.getState(); + const existing = store.getPeer(hash); + const base = existing ?? { destination_hash: hash }; + // Null/missing slot fields must not wipe known-good route data (same as peer patches). + const merged = mergeReticulumPeerRouteFields(base, { + destination_hash: hash, + hops: slot.hops, + interface: slot.interface, + path_hash: via, + via_hash: via, + last_seen: slot.timestamp ?? undefined, + }); + store.updatePeer(hash, { + hops: merged.hops, + interface: merged.interface, + path_hash: merged.path_hash, + via_hash: merged.via_hash, + last_seen: merged.last_seen, + }); + return true; +} + let pendingPeerPatches = new Map(); let peerPatchFlushTimer: ReturnType | null = null; let lastFullSnapshotFingerprint: string | null = null; @@ -783,14 +860,27 @@ function flushPendingPeerPatches(): void { const max = readReticulumDestinationCap(); useReticulumPeerStore.setState((s) => { const next = new Map(s.peers); + const contacts = new Map(s.contacts); + const history = new Map(s.history); for (const [hash, peer] of batch) { const existing = next.get(hash); - next.set(hash, { ...existing, ...peer, destination_hash: hash }); + const merged = { ...existing, ...peer, destination_hash: hash }; + next.set(hash, merged); + const contact = contacts.get(hash); + if (contact) { + contacts.set(hash, mergeReticulumPeerRouteFields(contact, merged)); + } + const hist = history.get(hash); + if (hist) { + history.set(hash, mergeReticulumPeerRouteFields(hist, merged)); + } } const capped = - next.size > max ? capReticulumPeerMaps(next, s.contacts, s.history, max).peers : next; + next.size > max ? capReticulumPeerMaps(next, contacts, history, max).peers : next; return { peers: capped, + contacts, + history, lastRefreshAt: Date.now(), peersRevision: s.peersRevision + 1, };