From af3775921a53973f918e29401b5f530f0b3172f8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 14:46:32 -0600 Subject: [PATCH 1/2] feat: MeshCore and Columba LXMF QR interop Emit and ingest official meshcore:// contact/channel URIs and Columba lxma:// identity cards so shared QRs work across mesh-client and other apps; expose sidecar public keys for lxma emit and register-known on import. --- docs/reticulum-sidecar-ipc.md | 35 +-- docs/reticulum.md | 2 +- reticulum-sidecar/src/api/identity.rs | 26 ++- reticulum-sidecar/src/api/mod.rs | 4 + reticulum-sidecar/src/stack/identity_apply.rs | 13 ++ reticulum-sidecar/src/stack/live.rs | 54 +++++ reticulum-sidecar/src/stack/lxmf_outbound.rs | 12 + reticulum-sidecar/src/stack/mod.rs | 63 ++++++ reticulum-sidecar/src/stack/persistence.rs | 2 + reticulum-sidecar/src/stack/topology.rs | 5 + reticulum-sidecar/src/stack/types.rs | 3 + src/renderer/components/NodeDetailModal.tsx | 41 ++++ src/renderer/components/RadioPanel.tsx | 117 ++++++++++ .../components/ReticulumNetworkPanel.tsx | 55 +++-- .../ReticulumPeerDetailModal.test.tsx | 42 ++++ .../components/ReticulumPeerDetailModal.tsx | 6 +- .../hooks/useMeshClientDeepLink.test.tsx | 115 +++++++++- src/renderer/hooks/useMeshClientDeepLink.tsx | 183 ++++++++++++--- .../lib/meshClientDeepLinkApply.test.ts | 115 ++++++++++ src/renderer/lib/meshClientDeepLinkApply.ts | 128 +++++++++++ .../reticulum/reticulumSidecarReads.test.ts | 34 +++ .../lib/reticulum/reticulumSidecarReads.ts | 44 +++- .../reticulum/useReticulumSidecarApi.test.ts | 1 + .../lib/reticulum/useReticulumSidecarApi.ts | 2 + src/renderer/locales/cs/translation.json | 21 +- src/renderer/locales/de/translation.json | 21 +- src/renderer/locales/en/translation.json | 17 +- src/renderer/locales/es/translation.json | 21 +- src/renderer/locales/fr/translation.json | 21 +- src/renderer/locales/id/translation.json | 21 +- src/renderer/locales/it/translation.json | 21 +- src/renderer/locales/ja/translation.json | 21 +- src/renderer/locales/ko/translation.json | 21 +- src/renderer/locales/nl/translation.json | 21 +- src/renderer/locales/pl/translation.json | 21 +- src/renderer/locales/pt-BR/translation.json | 21 +- src/renderer/locales/ru/translation.json | 21 +- src/renderer/locales/tr/translation.json | 21 +- src/renderer/locales/uk/translation.json | 21 +- src/renderer/locales/zh/translation.json | 21 +- src/renderer/stores/reticulumIdentityStore.ts | 2 + src/renderer/stores/reticulumPeerStore.ts | 9 + src/shared/meshClientDeepLink.test.ts | 211 ++++++++++++++++++ src/shared/meshClientDeepLink.ts | 131 ++++++++++- src/shared/reticulum-types.ts | 3 + 45 files changed, 1673 insertions(+), 117 deletions(-) create mode 100644 src/renderer/lib/meshClientDeepLinkApply.test.ts create mode 100644 src/renderer/lib/meshClientDeepLinkApply.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index b081bf9ef..3cc515e3b 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -17,15 +17,16 @@ Electron main validates proxy paths: must start with `/api/v1/` (no `..` segment ### Identity -| Method | Path | Body / notes | Response | -| ------ | --------------------------------- | -------------------------------------------------- | --------------------------------------------------------- | -| GET | `/api/v1/identity/status` | | `{ configured, identity_hash, lxmf_hash, display_name? }` | -| POST | `/api/v1/identity/generate` | `{ display_name?, replace? }` | `{ ok, mnemonic?, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/import` | `{ mnemonic, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/import-backup` | `{ backup, passphrase?, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash, metadata_only? }` | -| POST | `/api/v1/identity/import-private` | `{ private_key, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/export` | `{ passphrase }` | `{ ok, backup? }` | -| POST | `/api/v1/identity/display-name` | `{ display_name }` | `{ ok }` | +| Method | Path | Body / notes | Response | +| ------ | --------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| GET | `/api/v1/identity/status` | | `{ configured, identity_hash, lxmf_hash, display_name?, public_key? }` | +| POST | `/api/v1/identity/register-known` | `{ destination_hash, public_key }` | `{ ok }` (registers 64-byte pubkey for Direct LXMF / Columba QR) | +| POST | `/api/v1/identity/generate` | `{ display_name?, replace? }` | `{ ok, mnemonic?, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/import` | `{ mnemonic, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/import-backup` | `{ backup, passphrase?, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash, metadata_only? }` | +| POST | `/api/v1/identity/import-private` | `{ private_key, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/export` | `{ passphrase }` | `{ ok, backup? }` | +| POST | `/api/v1/identity/display-name` | `{ display_name }` | `{ ok }` | ### Interfaces @@ -97,14 +98,14 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ ### Peers, topology, and propagation -| Method | Path | Body / notes | Response | -| ------ | ---------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/peers` | `?refresh=1` optional | `{ peers: [] }` — live path table when `rns-stack` enabled; without `refresh=1` may serve a short-TTL maintenance cache; `refresh=1`/`true` forces live `GetPathTable` (manual Refresh). `display_name` overlayed from contacts/Nomad/announce label cache | -| POST | `/api/v1/peers/{hash}/path` | | `{ ok }` — emits `peers_updated` WS on success | -| POST | `/api/v1/peers/{hash}/probe` | | `{ ok, hops? }` live; `{ ok, mode, hash }` stub — emits `peers_updated` on success | -| POST | `/api/v1/ping` | `{ destination_hash }` | `{ ok, rtt_ms? }` | -| GET | `/api/v1/topology` | | `{ nodes, edges, total?, shown?, truncated? }` — `via_hash` is the immediate RNS next hop (transport id); sidecar infers `self → relay` when needed | -| GET | `/api/v1/rmap/discovered` | | `{ discovered: RmapDiscoveredWireRow[] }` — local RMAP v4 heard interfaces (7-day TTL eviction in rsReticulum DiscoveryStore) | +| Method | Path | Body / notes | Response | +| ------ | ---------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/peers` | `?refresh=1` optional | `{ peers: [] }` — live path table when `rns-stack` enabled; without `refresh=1` may serve a short-TTL maintenance cache; `refresh=1`/`true` forces live `GetPathTable` (manual Refresh). `display_name` overlayed from contacts/Nomad/announce label cache; optional `public_key` (128 hex) when known from announces | +| POST | `/api/v1/peers/{hash}/path` | | `{ ok }` — emits `peers_updated` WS on success | +| POST | `/api/v1/peers/{hash}/probe` | | `{ ok, hops? }` live; `{ ok, mode, hash }` stub — emits `peers_updated` on success | +| POST | `/api/v1/ping` | `{ destination_hash }` | `{ ok, rtt_ms? }` | +| GET | `/api/v1/topology` | | `{ nodes, edges, total?, shown?, truncated? }` — `via_hash` is the immediate RNS next hop (transport id); sidecar infers `self → relay` when needed | +| GET | `/api/v1/rmap/discovered` | | `{ discovered: RmapDiscoveredWireRow[] }` — local RMAP v4 heard interfaces (7-day TTL eviction in rsReticulum DiscoveryStore) | **`RmapDiscoveredWireRow` fields** (see `src/shared/reticulum-types.ts`): `discovery_hash`, `transport_id`, `discovery_name`, `interface_type`, `latitude`, `longitude`, `height`, `transport_enabled`, `reachable_on`, LoRa RF fields (`frequency`, `bandwidth`, `spreading_factor`, …), `hops`, `stamp_value`, `discovered`, `last_heard`, `heard_count`, `status` (`available`/`stale`/`unknown`), `has_coordinates`. Renderer caps at 2,000 newest rows with client-side TTL eviction. diff --git a/docs/reticulum.md b/docs/reticulum.md index 475eb910e..d92a75efa 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -262,7 +262,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Identity:** generate BIP-39 recovery phrase, import **private key** (paste or file picker via `reticulum:showIdentityImportDialog`), import **backup JSON**, export with passphrase, display name; **replace identity** confirm when keys already exist (`replace: true` on generate/import) - **Identity slots:** Network panel lists local slots (`GET /api/v1/identities`), create / switch / delete (`POST /api/v1/identities`, `/switch`, `/delete`). Create/switch are serialized and commit the active pointer only after the working key is applied; the sidecar restarts after a successful change. Soft cap **16** slots; display names are sanitized (control chars rejected, max 128 chars). -- **Identity / contact QR:** share via `QrCodeImage` (`lxm://identity/…`, `lxm://contact/…`); ingest via paste/file/camera (`QrIngestControl`). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); external contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. +- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl`) including `lxma://` (registers pubkey + saved contact). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). External contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. - **Peer fingerprint verification:** Peer detail can mark a contact verified (pins `verified_identity_hash` + `verified_at` in SQLite via `db:setReticulumDestinationVerified`) and warns on mismatch when the live announce hash drifts. - **Header self label:** when configured, the app header shows your Network **display name** (`reticulumSelfNodeLabel.ts`) — not a hash-prefix stub; omit the `Node:` label when no real name is set - **Identity vault:** optional passcode (minimum 8 characters) to encrypt secrets in the main process; unlock is rate-limited diff --git a/reticulum-sidecar/src/api/identity.rs b/reticulum-sidecar/src/api/identity.rs index 364be2252..2bf35d39a 100644 --- a/reticulum-sidecar/src/api/identity.rs +++ b/reticulum-sidecar/src/api/identity.rs @@ -51,12 +51,34 @@ pub struct DisplayNameBody { pub async fn identity_status(State(stack): State>) -> Json { let id = stack.identity_status().await; - Json(serde_json::json!({ + let public_key = stack.identity_public_key_hex().await; + let mut body = serde_json::json!({ "configured": id.configured, "identity_hash": id.identity_hash, "lxmf_hash": id.lxmf_hash, "display_name": id.display_name, - })) + }); + if let Some(pk) = public_key { + body["public_key"] = serde_json::Value::String(pk); + } + Json(body) +} + +#[derive(Deserialize)] +pub struct RegisterKnownBody { + pub destination_hash: String, + pub public_key: String, +} + +/// Register a peer LXMF destination public key (Columba `lxma://` import). +pub async fn identity_register_known( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.register_known_identity(&body.destination_hash, &body.public_key) { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } } /// Generate a new identity. The response includes the mnemonic **once** so the diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index c777ffb51..f53da1483 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -32,6 +32,10 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/status", get(status::status)) .route("/api/v1/app/info", get(status::app_info)) .route("/api/v1/identity/status", get(identity::identity_status)) + .route( + "/api/v1/identity/register-known", + post(identity::identity_register_known), + ) .route( "/api/v1/identity/generate", post(identity::identity_generate), diff --git a/reticulum-sidecar/src/stack/identity_apply.rs b/reticulum-sidecar/src/stack/identity_apply.rs index 1d04510dc..bd47afac6 100644 --- a/reticulum-sidecar/src/stack/identity_apply.rs +++ b/reticulum-sidecar/src/stack/identity_apply.rs @@ -258,6 +258,19 @@ mod tests { assert_eq!(state.identity.lxmf_hash, expected.lxmf_hash); } + #[test] + fn applied_identity_exposes_64_byte_public_key() { + let (_root, config_dir, storage_dir) = temp_dirs(); + let (identity, _) = generate_identity_with_mnemonic().unwrap(); + let expected = hex::encode(identity.get_public_key()); + assert_eq!(expected.len(), 128); + let mut state = PersistedState::default_empty(); + apply_unified_identity(&mut state, &config_dir, &storage_dir, &identity, None, None) + .unwrap(); + let loaded = load_identity_from_file(&config_dir).unwrap(); + assert_eq!(hex::encode(loaded.get_public_key()), expected); + } + #[test] fn reconcile_fixes_stale_json() { let (_root, config_dir, storage_dir) = temp_dirs(); diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 70b2d9514..67e6b53b4 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2300,11 +2300,26 @@ impl LiveBridge { .ok() .map(|c| c.clone()) .unwrap_or_default(); + let pubkey_lookup = outbound + .lock() + .ok() + .map(|d| { + entries + .iter() + .filter_map(|e| { + let destination_hash = hex::encode(e.hash); + d.public_key_for(&destination_hash) + .map(|pk| (destination_hash, hex::encode(pk))) + }) + .collect::>() + }) + .unwrap_or_default(); let peer_rows: Vec = entries .iter() .map(|e| { let destination_hash = hex::encode(e.hash); let display_name = name_lookup.get(&destination_hash).cloned(); + let public_key = pubkey_lookup.get(&destination_hash).cloned(); PeerRow { destination_hash, display_name, @@ -2313,6 +2328,7 @@ impl LiveBridge { interface: Some(e.interface.clone()), path_hash: e.via.map(hex::encode), via_hash: e.via.map(hex::encode), + public_key, } }) .collect(); @@ -2356,6 +2372,7 @@ impl LiveBridge { "interface": p.interface, "path_hash": p.path_hash, "via_hash": p.via_hash, + "public_key": p.public_key, }) }) .collect(); @@ -3359,6 +3376,25 @@ impl LiveBridge { .unwrap_or_default() } + /// Local identity public key as 128 lowercase hex (X25519 ∥ Ed25519). + pub fn identity_public_key_hex(&self) -> String { + hex::encode(self.identity.get_public_key()) + } + + /// Register a peer destination public key for Direct LXMF / Columba QR import. + pub fn register_known_identity( + &self, + destination_hash: &str, + public_key: [u8; 64], + ) -> Result<(), String> { + let mut driver = self + .outbound + .lock() + .map_err(|_| "outbound driver lock poisoned".to_string())?; + driver.register_identity_key(destination_hash, public_key); + Ok(()) + } + /// Fetch path-table peers. When `force` is false and the maintenance cache is /// fresher than [`PATH_PEER_CACHE_TTL`], return that snapshot (avoids a second /// GetPathTable on every automatic poll). @@ -3392,11 +3428,28 @@ impl LiveBridge { .ok() .map(|c| c.clone()) .unwrap_or_default(); + let pubkey_lookup = self + .outbound + .lock() + .ok() + .map(|d| { + // Collect known keys once per fetch to avoid locking per peer. + entries + .iter() + .filter_map(|e| { + let destination_hash = hex::encode(e.hash); + d.public_key_for(&destination_hash) + .map(|pk| (destination_hash, hex::encode(pk))) + }) + .collect::>() + }) + .unwrap_or_default(); let peers: Vec = entries .iter() .map(|e| { let destination_hash = hex::encode(e.hash); let display_name = name_lookup.get(&destination_hash).cloned(); + let public_key = pubkey_lookup.get(&destination_hash).cloned(); PeerRow { destination_hash, display_name, @@ -3405,6 +3458,7 @@ impl LiveBridge { interface: Some(e.interface.clone()), path_hash: e.via.map(hex::encode), via_hash: e.via.map(hex::encode), + public_key, } }) .collect(); diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index c827661f7..d243ee7e2 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1433,6 +1433,18 @@ mod tests { assert!(gate.should_warn(dest(4), 121.0)); } + #[test] + fn register_identity_key_is_retrievable() { + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(8); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let dest = "0123456789abcdef0123456789abcdef"; + let key = [0x7au8; 64]; + driver.register_identity_key(dest, key); + assert_eq!(driver.public_key_for(dest), Some(key)); + assert_eq!(driver.public_key_for(&dest.to_uppercase()), Some(key)); + } + #[test] fn pin_identity_survives_eviction_flood() { let identity = Identity::new(); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 10c8462f8..20de242c9 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -453,6 +453,58 @@ impl StackHandle { } } + /// Local identity public key as 128 lowercase hex when identity is configured. + pub async fn identity_public_key_hex(&self) -> Option { + let configured = self.inner.read().await.identity.configured; + if !configured { + return None; + } + #[cfg(feature = "rns-stack")] + { + if let Some(live) = &self.live { + return Some(live.identity_public_key_hex()); + } + identity_apply::load_identity_from_file(&self.config_dir) + .ok() + .map(|id| hex::encode(id.get_public_key())) + } + #[cfg(not(feature = "rns-stack"))] + { + None + } + } + + /// Register a destination hash + 64-byte public key for Direct LXMF (Columba QR). + pub fn register_known_identity( + &self, + destination_hash: &str, + public_key_hex: &str, + ) -> Result<(), String> { + let dest = destination_hash.trim().to_lowercase(); + if dest.len() != 32 || !dest.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid_destination_hash".into()); + } + let key_hex = public_key_hex.trim().to_lowercase(); + if key_hex.len() != 128 || !key_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid_public_key".into()); + } + let bytes = hex::decode(&key_hex).map_err(|_| "invalid_public_key".to_string())?; + let key: [u8; 64] = bytes + .try_into() + .map_err(|_| "invalid_public_key".to_string())?; + #[cfg(feature = "rns-stack")] + { + let live = self.require_live()?; + live.register_known_identity(&dest, key)?; + Ok(()) + } + #[cfg(not(feature = "rns-stack"))] + { + let _ = (dest, key); + Err("identity operations require an rns-stack sidecar build".into()) + } + } + async fn ensure_identity_replace_allowed(&self, replace: bool) -> Result<(), String> { let configured = self.inner.read().await.identity.configured; if configured && !replace { @@ -2888,6 +2940,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let empty = merge_live_peer_fetch(&mut cache, Ok(vec![])); assert_eq!(empty.len(), 1); @@ -2909,6 +2962,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let fetched = merge_live_peer_fetch(&mut cache, Ok(vec![row.clone()])); assert_eq!(fetched.len(), 1); @@ -2926,6 +2980,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let fetched = sync_live_peer_cache(&mut cache, vec![]); assert!(fetched.is_empty()); @@ -2942,6 +2997,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let fetched = sync_live_peer_cache( &mut cache, @@ -2953,6 +3009,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }], ); assert_eq!(fetched.len(), 1); @@ -2971,6 +3028,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let fetched = sync_live_peer_cache(&mut cache, vec![row.clone()]); assert_eq!(fetched.len(), 1); @@ -3022,6 +3080,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let live = PeerRow { destination_hash: "ccdd02".into(), @@ -3031,6 +3090,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let merged = sync_live_peer_cache(&mut cache, vec![live]); assert_eq!(merged.len(), 2); @@ -3050,6 +3110,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }) .collect(); // One orphan older than TTL must be dropped even if under the count cap. @@ -3061,6 +3122,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }); let live = PeerRow { destination_hash: "aa".repeat(16), @@ -3070,6 +3132,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let merged = sync_live_peer_cache(&mut cache, vec![live]); assert!(merged.iter().any(|p| p.destination_hash == "aa".repeat(16))); diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 6a62b32d1..dbd75dab1 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -639,6 +639,7 @@ impl PersistedState { interface: None, path_hash: None, via_hash: None, + public_key: None, }); } } @@ -946,6 +947,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, } } diff --git a/reticulum-sidecar/src/stack/topology.rs b/reticulum-sidecar/src/stack/topology.rs index fff9bd81a..53a03d14e 100644 --- a/reticulum-sidecar/src/stack/topology.rs +++ b/reticulum-sidecar/src/stack/topology.rs @@ -70,6 +70,7 @@ pub fn build_topology(peers: &[PeerRow]) -> (Vec, Vec) { interface: peer.interface.clone(), path_hash: None, via_hash: None, + public_key: None, }); } } @@ -265,6 +266,7 @@ mod tests { interface: Some("tcp".into()), path_hash: via.map(str::to_string), via_hash: via.map(str::to_string), + public_key: None, } } @@ -355,6 +357,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let mut names = HashMap::new(); names.insert("abc".into(), "Alice".into()); @@ -373,6 +376,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }, PeerRow { destination_hash: "ccdd".into(), @@ -382,6 +386,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }, ]; let mut name_by_hash = build_topology_name_map( diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 1b5935eb2..8fa3fa0b9 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -93,6 +93,9 @@ pub struct PeerRow { pub path_hash: Option, #[serde(default)] pub via_hash: Option, + /// 64-byte X25519+Ed25519 public key as 128 hex chars when known from announces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 2ea81c22c..89f8e88f3 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -13,6 +13,7 @@ import { } from '@/renderer/lib/meshtasticRemoteAdminKeyStorage'; import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolIdentities'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; +import { buildMeshcoreContactAddUri, type MeshcoreContactType } from '@/shared/meshClientDeepLink'; import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; @@ -44,6 +45,7 @@ import { MESHCORE_CHAT_STUB_ID_MIN, MESHCORE_CONTACTS_CRITICAL_THRESHOLD, MESHCORE_MAX_CONTACTS, + meshcoreContactTypeFromHwModel, meshcoreTracePathLenToHops, } from '../lib/meshcoreUtils'; import { @@ -71,6 +73,7 @@ import { HelpTooltip } from './HelpTooltip'; import { MeshcoreRepeaterPasswordControls } from './MeshcoreRepeaterPasswordControls'; import { MeshcoreRouteChain } from './MeshcoreRouteChain'; import NodeInfoBody, { formatSecondsAgo } from './NodeInfoBody'; +import QrCodeImage from './QrCodeImage'; import SnrIndicator from './SnrIndicator'; const TRACE_ROUTE_UI_TIMEOUT_MS = 120_000; @@ -330,6 +333,7 @@ export default function NodeDetailModal({ meshcoreNeighborsRef.current = meshcoreNeighbors; const [exportContactPending, setExportContactPending] = useState(false); const [shareContactPending, setShareContactPending] = useState(false); + const [showMeshcoreContactQr, setShowMeshcoreContactQr] = useState(false); const [radioContactCount, setRadioContactCount] = useState(null); const [contactOnRadio, setContactOnRadio] = useState(null); const [addRemoveLoading, setAddRemoveLoading] = useState(false); @@ -467,6 +471,22 @@ export default function NodeDetailModal({ // Fetch on_radio status and contact count for MeshCore const [contactPubkey, setContactPubkey] = useState(null); + const meshcoreContactQrUri = useMemo(() => { + if (protocol !== 'meshcore' || !contactPubkey || !node) return null; + const typeRaw = meshcoreContactTypeFromHwModel(node.hw_model ?? 'Chat') ?? 1; + const type = (typeRaw >= 1 && typeRaw <= 4 ? typeRaw : 1) as MeshcoreContactType; + try { + return buildMeshcoreContactAddUri({ + name: node.long_name || node.short_name || `Node-${node.node_id.toString(16)}`, + publicKeyHex: contactPubkey, + type, + }); + } catch { + // catch-no-log-ok Invalid pubkey simply hides the share QR. + return null; + } + }, [protocol, contactPubkey, node]); + const ensureRemoteRpcAccess = useCallback( async ( nodeId: number, @@ -2095,6 +2115,27 @@ export default function NodeDetailModal({ : t('nodeDetailModal.shareContact')} )} + {protocol === 'meshcore' && meshcoreContactQrUri ? ( + + ) : null} + {protocol === 'meshcore' && showMeshcoreContactQr && meshcoreContactQrUri ? ( +
+ +
+ ) : null} {protocol === 'meshcore' && contactPubkey && contactOnRadio === false && ( + {ch.secret?.length === 16 ? ( + + ) : null} {confirmDeleteIdx === ch.index ? ( )} - {protocol === 'meshcore' && meshcoreContactQrUri ? ( + {isMeshcoreProtocol && meshcoreContactQrUri ? ( ) : null} - {protocol === 'meshcore' && showMeshcoreContactQr && meshcoreContactQrUri ? ( + {isMeshcoreProtocol && showMeshcoreContactQr && meshcoreContactQrUri ? (
{ const onChannelQr = (ev: Event) => { - const detail = (ev as CustomEvent<{ name: string; secretHex: string; regionScope?: string }>) - .detail; + const detail = ( + ev as CustomEvent<{ + name: string; + secretHex: string; + regionScope?: string; + settle?: (outcome: 'accepted' | 'rejected') => void; + }> + ).detail; if (!detail?.name || !detail.secretHex) return; - setAddingNew(true); - setEditingIdx(null); const used = new Set(channels.map((c) => c.index)); let idx = 0; while (used.has(idx) && idx <= MESHCORE_CHANNEL_INDEX_MAX) idx += 1; - setNewIdx(String(Math.min(idx, MESHCORE_CHANNEL_INDEX_MAX))); + if (idx > MESHCORE_CHANNEL_INDEX_MAX) { + addToast(t('qrIngest.meshcoreChannelNoFreeIndex'), 'error'); + detail.settle?.('rejected'); + return; + } + setAddingNew(true); + setEditingIdx(null); + setNewIdx(String(idx)); setEditName(detail.name); setEditKeyHex(detail.secretHex); if (detailsRef.current) detailsRef.current.open = true; addToast(t('qrIngest.meshcoreChannelPrefill'), 'success'); + detail.settle?.('accepted'); }; window.addEventListener('mesh-client:meshcoreChannelFromQr', onChannelQr); return () => { @@ -3826,7 +3838,6 @@ function MeshcoreChannelSection({ size={160} ariaLabel={t('radioPanel.meshcoreChannel.shareQrAria', { name: ch.name })} /> -

{uri}

); })() @@ -3852,26 +3863,30 @@ function MeshcoreChannelSection({ } if (parsed.kind === 'meshcoreContactAdd') { void (async () => { - try { - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = Number.parseInt(parsed.publicKeyHex.slice(i * 2, i * 2 + 2), 16); - } - const nodeId = pubkeyToNodeId(bytes); - await window.electronAPI.db.saveMeshcoreContact({ - node_id: nodeId, - public_key: parsed.publicKeyHex, - adv_name: parsed.name, - contact_type: parsed.type, - on_radio: 0, - }); + const result = await applyMeshcoreContactAdd(parsed, { + saveContact: async ({ nodeId, publicKeyHex, name, contactType }) => { + try { + await window.electronAPI.db.saveMeshcoreContact({ + node_id: nodeId, + public_key: publicKeyHex, + adv_name: name, + contact_type: contactType, + on_radio: 0, + }); + return true; + } catch (err) { + console.error( + '[MeshcoreChannelSection] contact QR import failed: ' + + errLikeToLogString(err), + ); + return false; + } + }, + }); + if (result.ok) { addToast(t('qrIngest.meshcoreContactImported'), 'success'); - } catch (err) { - console.error( - '[MeshcoreChannelSection] contact QR import failed: ' + - errLikeToLogString(err), - ); - addToast(t('qrIngest.meshcoreContactImportFailed'), 'error'); + } else { + addToast(t(result.errorKey), 'error'); } })(); return; diff --git a/src/renderer/components/ReticulumNetworkPanel.test.tsx b/src/renderer/components/ReticulumNetworkPanel.test.tsx index 4979c0d64..4c115c7b6 100644 --- a/src/renderer/components/ReticulumNetworkPanel.test.tsx +++ b/src/renderer/components/ReticulumNetworkPanel.test.tsx @@ -11,32 +11,47 @@ vi.mock('react-i18next', () => ({ const refreshIdentity = vi.fn(); +const identityState = vi.hoisted(() => ({ + configured: true, + identity_hash: 'abc', + lxmf_hash: 'def0123456789abcdef0123456789abc', + display_name: 'Existing Name', + public_key: null as string | null, +})); + vi.mock('@/renderer/lib/reticulum/useReticulumSidecarApi', () => ({ useReticulumSidecarApi: () => ({ sidecarApiReady: true, - identity: { - configured: true, - identity_hash: 'abc', - lxmf_hash: 'def0123456789abcdef0123456789abc', - display_name: 'Existing Name', - }, + identity: identityState, statsSummary: null, appInfo: null, refreshIdentity, }), })); +vi.mock('./QrCodeImage', () => ({ + default: ({ value, ariaLabel }: { value: string; ariaLabel?: string }) => ( + {ariaLabel} + ), +})); + vi.mock('../stores/reticulumPeerStore', () => ({ refreshReticulumPeersFromSidecar: vi.fn().mockResolvedValue([]), useReticulumPeerStore: (selector: (s: { peers: Map }) => unknown) => selector({ peers: new Map([['a', {}]]) }), })); +import { buildLxmaContactUri } from '@/shared/meshClientDeepLink'; + import { ReticulumNetworkPanel } from './ReticulumNetworkPanel'; describe('ReticulumNetworkPanel', () => { beforeEach(() => { refreshIdentity.mockReset(); + identityState.public_key = null; + identityState.lxmf_hash = 'def0123456789abcdef0123456789abc'; + identityState.identity_hash = 'abc'; + identityState.display_name = 'Existing Name'; window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { if (path === '/api/v1/stack/settings') { return Promise.resolve({ @@ -133,6 +148,17 @@ describe('ReticulumNetworkPanel', () => { ).toBeInTheDocument(); }); + it('renders lxma identity QR when public_key is 128 hex', async () => { + const user = userEvent.setup(); + const pub = 'ab'.repeat(64); + identityState.public_key = pub; + const expected = buildLxmaContactUri(identityState.lxmf_hash, pub); + render( {}} />); + await user.click(await screen.findByRole('button', { name: 'qrIngest.showIdentityQrAria' })); + const img = await screen.findByTestId('identity-qr'); + expect(img.getAttribute('data-value')).toBe(expected); + }); + it('writes full LXMF hash to clipboard via electronAPI', async () => { const user = userEvent.setup(); const writeText = vi.mocked(window.electronAPI.clipboard.writeText); diff --git a/src/renderer/components/ReticulumPeerDetailModal.test.tsx b/src/renderer/components/ReticulumPeerDetailModal.test.tsx index 042b18e87..18b768909 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.test.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.test.tsx @@ -52,6 +52,12 @@ vi.mock('../stores/reticulumPeerStore', async (importOriginal) => { }; }); +vi.mock('./QrCodeImage', () => ({ + default: ({ value, ariaLabel }: { value: string; ariaLabel?: string }) => ( + {ariaLabel} + ), +})); + import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; import ReticulumPeerDetailModal from './ReticulumPeerDetailModal'; @@ -123,10 +129,8 @@ describe('ReticulumPeerDetailModal — copy hash', () => { , ); await user.click(screen.getByRole('button', { name: 'peerDetailModal.shareContactQrAria' })); - const img = await screen.findByRole('img', { name: 'peerDetailModal.shareContactQrAria' }); - expect(img.getAttribute('src')).toBeTruthy(); - // QrCodeImage encodes value into canvas/data URL; ensure toggle works with lxma peer. - expect(screen.getByRole('button', { name: 'peerDetailModal.shareContactQrAria' })).toBeTruthy(); + const img = await screen.findByTestId('peer-qr'); + expect(img.getAttribute('data-value') ?? '').toMatch(/^lxma:\/\//); }); it('falls back to lxm://contact QR when public_key is absent', async () => { @@ -135,9 +139,8 @@ describe('ReticulumPeerDetailModal — copy hash', () => { , ); await user.click(screen.getByRole('button', { name: 'peerDetailModal.shareContactQrAria' })); - expect( - await screen.findByRole('img', { name: 'peerDetailModal.shareContactQrAria' }), - ).toBeTruthy(); + const img = await screen.findByTestId('peer-qr'); + expect(img.getAttribute('data-value') ?? '').toMatch(/^lxm:\/\/contact\//); }); it('Save as contact upserts last_heard and refreshes peers', async () => { diff --git a/src/renderer/hooks/useMeshClientDeepLink.test.tsx b/src/renderer/hooks/useMeshClientDeepLink.test.tsx index 9095c29ef..5c2ce13b1 100644 --- a/src/renderer/hooks/useMeshClientDeepLink.test.tsx +++ b/src/renderer/hooks/useMeshClientDeepLink.test.tsx @@ -128,7 +128,9 @@ describe('MeshClientDeepLinkHost', () => { await Promise.resolve(); }); expect(window.electronAPI.db.saveMeshcoreContact).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'qrIngest.confirmContactImportAction' })); + await user.click( + screen.getByRole('button', { name: 'qrIngest.confirmMeshcoreContactImportAction' }), + ); await waitFor(() => { expect(window.electronAPI.db.saveMeshcoreContact).toHaveBeenCalledWith( expect.objectContaining({ @@ -146,20 +148,27 @@ describe('MeshClientDeepLinkHost', () => { const user = userEvent.setup(); const spy = vi.fn(); window.addEventListener('mesh-client:meshcoreChannelFromQr', spy as EventListener); - render(); - const uri = `meshcore://channel/add?name=Public&secret=${MC_SECRET}`; - await act(async () => { - openUrlHandler?.(uri); - await Promise.resolve(); - }); - await user.click( - screen.getByRole('button', { name: 'qrIngest.confirmMeshcoreChannelImportAction' }), - ); - await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); - expect(addToast).toHaveBeenCalledWith('qrIngest.meshcoreChannelImported', 'success'); - window.removeEventListener('mesh-client:meshcoreChannelFromQr', spy as EventListener); + try { + render(); + const uri = `meshcore://channel/add?name=Public&secret=${MC_SECRET}`; + await act(async () => { + openUrlHandler?.(uri); + await Promise.resolve(); + }); + await user.click( + screen.getByRole('button', { name: 'qrIngest.confirmMeshcoreChannelImportAction' }), + ); + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + // No MeshcoreChannelSection consumer → deferred / queued-for-review toast; pending kept. + expect(addToast).toHaveBeenCalledWith('qrIngest.meshcoreChannelImported', 'success'); + expect( + screen.getByRole('button', { name: 'qrIngest.confirmMeshcoreChannelImportAction' }), + ).toBeTruthy(); + } finally { + window.removeEventListener('mesh-client:meshcoreChannelFromQr', spy as EventListener); + } }); it('cancel does not import', async () => { diff --git a/src/renderer/hooks/useMeshClientDeepLink.tsx b/src/renderer/hooks/useMeshClientDeepLink.tsx index c5c218759..55da58f0a 100644 --- a/src/renderer/hooks/useMeshClientDeepLink.tsx +++ b/src/renderer/hooks/useMeshClientDeepLink.tsx @@ -164,15 +164,36 @@ export function MeshClientDeepLinkHost(): ReactElement | null { } if (pending.kind === 'meshcoreChannelAdd') { const result = await applyMeshcoreChannelAdd(pending, { - applyChannel: (opts) => { - window.dispatchEvent( - new CustomEvent('mesh-client:meshcoreChannelFromQr', { detail: opts }), - ); - return Promise.resolve(true); - }, + applyChannel: (opts) => + new Promise((resolve) => { + let settled = false; + const settle = (outcome: 'accepted' | 'rejected' | 'deferred') => { + if (settled) return; + settled = true; + resolve(outcome); + }; + window.dispatchEvent( + new CustomEvent('mesh-client:meshcoreChannelFromQr', { + detail: { + ...opts, + settle: (outcome: 'accepted' | 'rejected') => { + settle(outcome); + }, + }, + }), + ); + // No MeshcoreChannelSection listener → keep pending for Radio review. + queueMicrotask(() => { + settle('deferred'); + }); + }), }); - if (result.ok) { + if (result.ok && result.deferred) { addToast(t('qrIngest.meshcoreChannelImported'), 'success'); + return; + } + if (result.ok) { + // MeshcoreChannelSection owns the prefill success toast. setPending(null); } else { addToast(t(result.errorKey), 'error'); @@ -207,7 +228,9 @@ export function MeshClientDeepLinkHost(): ReactElement | null { const actionKey = pending.kind === 'meshcoreChannelAdd' ? 'qrIngest.confirmMeshcoreChannelImportAction' - : 'qrIngest.confirmContactImportAction'; + : pending.kind === 'meshcoreContactAdd' + ? 'qrIngest.confirmMeshcoreContactImportAction' + : 'qrIngest.confirmContactImportAction'; return ( { ); }); + it('applyMeshcoreContactAdd rejects short or non-hex publicKeyHex', async () => { + const saveContact = vi.fn().mockResolvedValue(true); + const result = await applyMeshcoreContactAdd( + { name: 'N', publicKeyHex: 'zz', type: 1 }, + { saveContact }, + ); + expect(result).toEqual({ ok: false, errorKey: 'qrIngest.meshcoreContactImportFailed' }); + expect(saveContact).not.toHaveBeenCalled(); + }); + it('applyMeshcoreChannelAdd calls applyChannel dep', async () => { - const applyChannel = vi.fn().mockResolvedValue(true); + const applyChannel = vi.fn().mockResolvedValue('accepted'); const result = await applyMeshcoreChannelAdd( { name: 'Pub', secretHex: 'cd'.repeat(16) }, { applyChannel }, diff --git a/src/renderer/lib/meshClientDeepLinkApply.ts b/src/renderer/lib/meshClientDeepLinkApply.ts index d8ec8e6d1..966240827 100644 --- a/src/renderer/lib/meshClientDeepLinkApply.ts +++ b/src/renderer/lib/meshClientDeepLinkApply.ts @@ -7,10 +7,12 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { pubkeyToNodeId } from '@/renderer/lib/meshcoreUtils'; import { registerReticulumKnownIdentity } from '@/renderer/lib/reticulum/reticulumSidecarReads'; import { refreshReticulumPeersFromSidecar } from '@/renderer/stores/reticulumPeerStore'; +import { hexToBytesExact } from '@/shared/hexBytes'; import type { MeshClientDeepLink } from '@/shared/meshClientDeepLink'; export type DeepLinkApplyResult = - { ok: true; kind: MeshClientDeepLink['kind'] } | { ok: false; errorKey: string; detail?: string }; + | { ok: true; kind: MeshClientDeepLink['kind']; deferred?: boolean } + | { ok: false; errorKey: string; detail?: string }; /** Import Columba lxma:// contact: register pubkey then SQLite saved contact. */ export async function applyLxmaContactImport(opts: { @@ -86,14 +88,18 @@ export async function applyMeshcoreContactAdd( deps: MeshcoreContactApplyDeps, ): Promise { try { - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = Number.parseInt(opts.publicKeyHex.slice(i * 2, i * 2 + 2), 16); + const key = opts.publicKeyHex.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(key)) { + return { ok: false, errorKey: 'qrIngest.meshcoreContactImportFailed' }; + } + const bytes = hexToBytesExact(key, 32); + if (!bytes) { + return { ok: false, errorKey: 'qrIngest.meshcoreContactImportFailed' }; } const nodeId = pubkeyToNodeId(bytes); const ok = await deps.saveContact({ nodeId, - publicKeyHex: opts.publicKeyHex, + publicKeyHex: key, name: opts.name, contactType: opts.type, }); @@ -105,12 +111,15 @@ export async function applyMeshcoreContactAdd( } } +/** Channel prefill settle from MeshcoreChannelSection (or deferred when unmounted). */ +export type MeshcoreChannelApplyOutcome = 'accepted' | 'deferred' | 'rejected'; + export interface MeshcoreChannelApplyDeps { applyChannel: (opts: { name: string; secretHex: string; regionScope?: string; - }) => Promise; + }) => Promise; } export async function applyMeshcoreChannelAdd( @@ -118,8 +127,13 @@ export async function applyMeshcoreChannelAdd( deps: MeshcoreChannelApplyDeps, ): Promise { try { - const ok = await deps.applyChannel(opts); - if (!ok) return { ok: false, errorKey: 'qrIngest.meshcoreChannelImportFailed' }; + const outcome = await deps.applyChannel(opts); + if (outcome === 'rejected') { + return { ok: false, errorKey: 'qrIngest.meshcoreChannelImportFailed' }; + } + if (outcome === 'deferred') { + return { ok: true, kind: 'meshcoreChannelAdd', deferred: true }; + } return { ok: true, kind: 'meshcoreChannelAdd' }; } catch (err) { console.error('[applyMeshcoreChannelAdd] failed: ' + errLikeToLogString(err)); diff --git a/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts b/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts index 0c08b630a..e142bdbfb 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts @@ -184,6 +184,14 @@ describe('reticulumSidecarReads', () => { }); }); + it('registerReticulumKnownIdentity rejects malformed ok field', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyPost.mockResolvedValue({ ok: 'false' }); + await expect(registerReticulumKnownIdentity('a'.repeat(32), 'b'.repeat(128))).resolves.toEqual({ + ok: false, + }); + }); + it('fetchReticulumInterfaces caches results for a short interval', async () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); proxyGet.mockResolvedValue({ diff --git a/src/renderer/lib/reticulum/reticulumSidecarReads.ts b/src/renderer/lib/reticulum/reticulumSidecarReads.ts index e6d0ed72e..aecdc1c30 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarReads.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarReads.ts @@ -297,11 +297,20 @@ export async function registerReticulumKnownIdentity( return { ok: false, error: 'sidecar_not_running' }; } try { - const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/identity/register-known', { - destination_hash: destinationHash, - public_key: publicKeyHex, - })) as { ok?: boolean; error?: string }; - return { ok: Boolean(res.ok), error: res.error }; + const res: unknown = await window.electronAPI.reticulum.proxyPost( + '/api/v1/identity/register-known', + { + destination_hash: destinationHash, + public_key: publicKeyHex, + }, + ); + if (!res || typeof res !== 'object') { + return { ok: false, error: 'invalid_response' }; + } + const body = res as { ok?: unknown; error?: unknown }; + const ok = body.ok === true; + const error = typeof body.error === 'string' ? body.error : undefined; + return ok ? { ok: true } : { ok: false, ...(error ? { error } : {}) }; } catch (e) { // catch-no-log-ok error returned to caller for toast/UI return { ok: false, error: errLikeToLogString(e) }; diff --git a/src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts b/src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts index dd5d34cef..6393d883f 100644 --- a/src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts +++ b/src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts @@ -135,6 +135,40 @@ describe('useReticulumSidecarApi', () => { }); }); + it('refreshIdentity maps a valid 128-hex public_key into shared identity', async () => { + const pub = 'cd'.repeat(64); + getStatus.mockResolvedValue({ running: true, port: 59477, pid: 42 }); + window.electronAPI.reticulum.proxyGet = vi.fn((path: string) => { + if (path === '/api/v1/identity/status') { + return Promise.resolve({ + configured: true, + identity_hash: 'identity-hash', + lxmf_hash: 'lxmf-hash', + display_name: 'Mesh User', + public_key: pub, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => + useReticulumSidecarApi({ + connecting: false, + onStartStack, + }), + ); + + await waitFor(() => { + expect(result.current.sidecarApiReady).toBe(true); + }); + + await act(async () => { + await result.current.refreshIdentity(); + }); + + expect(result.current.identity?.public_key).toBe(pub); + }); + it('does not clear shared identity while a new hook hydrates sidecar status', async () => { let resolveStatus: ((value: { running: boolean; port: number; pid: number | null }) => void) | undefined; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 4854af75a..4e1c8f270 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4864,6 +4864,7 @@ "confirmContactImportAction": "Import kontaktu", "confirmMeshcoreContactImportTitle": "Importovat kontakt MeshCore?", "confirmMeshcoreContactImportBody": "Přidat {{name}} z QR kontaktu MeshCore ke svým kontaktům?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Importovat kanál MeshCore?", "confirmMeshcoreChannelImportBody": "Použít kanál {{name}} z QR kanálu MeshCore na kartě Rádio?", "confirmMeshcoreChannelImportAction": "Importovat kanál", @@ -4872,7 +4873,8 @@ "meshcoreContactImportFailed": "Nelze importovat kontakt MeshCore z QR", "meshcoreChannelImported": "Odkaz na kanál MeshCore přijat — kontrola na kartě Rádio", "meshcoreChannelImportFailed": "Nelze importovat kanál MeshCore z QR", - "meshcoreChannelPrefill": "Podrobnosti kanálu vyplněny z QR — uložit pro použití" + "meshcoreChannelPrefill": "Podrobnosti kanálu vyplněny z QR — uložit pro použití", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Vzdálené úseky", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index d2d1a490a..34cc6428a 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Kontakt importieren", "confirmMeshcoreContactImportTitle": "MeshCore-Kontakt importieren?", "confirmMeshcoreContactImportBody": "{{name}} von einem MeshCore-Kontakt-QR zu Ihren Kontakten hinzufügen?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "MeshCore-Kanal importieren?", "confirmMeshcoreChannelImportBody": "Kanal {{name}} von einem MeshCore-Kanal QR auf der Registerkarte Radio anwenden?", "confirmMeshcoreChannelImportAction": "Kanal importieren", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "MeshCore-Kontakt konnte nicht aus QR importiert werden", "meshcoreChannelImported": "MeshCore-Kanallink erhalten — Überprüfung auf der Registerkarte Radio", "meshcoreChannelImportFailed": "MeshCore-Kanal konnte nicht aus QR importiert werden", - "meshcoreChannelPrefill": "Kanaldetails aus QR ausgefüllt — zum Anwenden speichern" + "meshcoreChannelPrefill": "Kanaldetails aus QR ausgefüllt — zum Anwenden speichern", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Entfernte Abschnitte", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 0154d9d83..8b8b601ee 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -5065,6 +5065,7 @@ "confirmContactImportAction": "Import contact", "confirmMeshcoreContactImportTitle": "Import MeshCore contact?", "confirmMeshcoreContactImportBody": "Add {{name}} from a MeshCore contact QR to your contacts?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Import MeshCore channel?", "confirmMeshcoreChannelImportBody": "Apply channel {{name}} from a MeshCore channel QR on the Radio tab?", "confirmMeshcoreChannelImportAction": "Import channel", @@ -5073,7 +5074,8 @@ "meshcoreContactImportFailed": "Could not import MeshCore contact from QR", "meshcoreChannelImported": "MeshCore channel link received — review on the Radio tab", "meshcoreChannelImportFailed": "Could not import MeshCore channel from QR", - "meshcoreChannelPrefill": "Channel details filled from QR — save to apply" + "meshcoreChannelPrefill": "Channel details filled from QR — save to apply", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "toasts": { "firmwareAvailable": "Firmware update available: v{{version}}", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 92eaf48e3..4247d6b8e 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Contacto de importación", "confirmMeshcoreContactImportTitle": "¿Importar contacto MeshCore?", "confirmMeshcoreContactImportBody": "¿Añadir {{name}} de un QR de contacto de MeshCore a tus contactos?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "¿Importar canal MeshCore?", "confirmMeshcoreChannelImportBody": "¿Aplicar el canal {{name}} desde un QR de canal MeshCore en la pestaña Radio?", "confirmMeshcoreChannelImportAction": "Importar canal", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "No se ha podido importar el contacto de MeshCore desde QR", "meshcoreChannelImported": "Enlace de canal de MeshCore recibido — revisión en la pestaña Radio", "meshcoreChannelImportFailed": "No se ha podido importar el canal MeshCore desde QR", - "meshcoreChannelPrefill": "Detalles del canal rellenados desde QR — guardar para aplicar" + "meshcoreChannelPrefill": "Detalles del canal rellenados desde QR — guardar para aplicar", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Secciones remotas", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index ff6de8dec..a9d24df4f 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Importer un contact", "confirmMeshcoreContactImportTitle": "Importer un contact MeshCore ?", "confirmMeshcoreContactImportBody": "Ajouter {{name}} d'un QR de contact MeshCore à vos contacts ?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Importer un canal MeshCore ?", "confirmMeshcoreChannelImportBody": "Appliquer le canal {{name}} à partir d'un canal QR MeshCore dans l'onglet Radio ?", "confirmMeshcoreChannelImportAction": "Importer le nom du canal", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "Impossible d'importer le contact MeshCore à partir de QR", "meshcoreChannelImported": "Lien de canal MeshCore reçu — examen sur l'onglet Radio", "meshcoreChannelImportFailed": "Impossible d'importer le canal MeshCore à partir de QR", - "meshcoreChannelPrefill": "Détails du canal remplis à partir de QR — enregistrer pour appliquer" + "meshcoreChannelPrefill": "Détails du canal remplis à partir de QR — enregistrer pour appliquer", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Sections distantes", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 450af3876..a267a9892 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Impor kontak", "confirmMeshcoreContactImportTitle": "Impor kontak MeshCore?", "confirmMeshcoreContactImportBody": "Tambahkan {{name}} dari QR kontak MeshCore ke kontak Anda?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Impor saluran MeshCore?", "confirmMeshcoreChannelImportBody": "Terapkan saluran {{name}} dari QR saluran MeshCore di tab Radio?", "confirmMeshcoreChannelImportAction": "Impor saluran", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "Tidak dapat mengimpor kontak MeshCore dari QR", "meshcoreChannelImported": "Tautan saluran MeshCore diterima — tinjau di tab Radio", "meshcoreChannelImportFailed": "Tidak dapat mengimpor saluran MeshCore dari QR", - "meshcoreChannelPrefill": "Detail saluran diisi dari QR — simpan untuk diterapkan" + "meshcoreChannelPrefill": "Detail saluran diisi dari QR — simpan untuk diterapkan", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Bagian terpencil", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index fc53d8526..fa6c3470c 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Importa contatto", "confirmMeshcoreContactImportTitle": "Importare il contatto MeshCore?", "confirmMeshcoreContactImportBody": "Aggiungi {{name}} da un QR di contatto MeshCore ai tuoi contatti?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Importare il canale MeshCore?", "confirmMeshcoreChannelImportBody": "Vuoi applicare il canale {{name}} da un QR del canale MeshCore nella scheda Radio?", "confirmMeshcoreChannelImportAction": "Importa canale", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "Impossibile importare il contatto MeshCore da QR", "meshcoreChannelImported": "Link al canale MeshCore ricevuto — recensione nella scheda Radio", "meshcoreChannelImportFailed": "Impossibile importare il canale MeshCore da QR", - "meshcoreChannelPrefill": "Dettagli del canale compilati da QR — salva per applicare" + "meshcoreChannelPrefill": "Dettagli del canale compilati da QR — salva per applicare", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Sezioni remote", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index a422c3862..f10e92359 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "連絡先をインポートする", "confirmMeshcoreContactImportTitle": "MeshCoreの連絡先をインポートしますか?", "confirmMeshcoreContactImportBody": "MeshCoreの連絡先QRから{{name}}を連絡先に追加しますか?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "MeshCoreチャンネルをインポートしますか?", "confirmMeshcoreChannelImportBody": "ラジオタブのMeshCoreチャンネルQRからチャンネル{{name}}を適用しますか?", "confirmMeshcoreChannelImportAction": "チャンネルをインポート", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "QRからMeshCoreの連絡先をインポートできませんでした", "meshcoreChannelImported": "MeshCoreチャンネルリンクを受信しました—ラジオタブで確認してください", "meshcoreChannelImportFailed": "QRからMeshCoreチャンネルをインポートできませんでした", - "meshcoreChannelPrefill": "QRから入力されたチャンネルの詳細—保存して適用" + "meshcoreChannelPrefill": "QRから入力されたチャンネルの詳細—保存して適用", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "リモートセクション", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index a388be0ee..aa2e26a60 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "연락처 가져오기", "confirmMeshcoreContactImportTitle": "MeshCore 연락처를 가져올까요?", "confirmMeshcoreContactImportBody": "MeshCore 연락처 QR에서 {{name}} 을 (를) 연락처에 추가하시겠습니까?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "MeshCore 채널을 가져올까요?", "confirmMeshcoreChannelImportBody": "라디오 탭의 MeshCore 채널 QR에서 채널 {{name}} 을 (를) 적용하시겠습니까?", "confirmMeshcoreChannelImportAction": "채널 가져오기", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "QR에서 MeshCore 연락처를 가져올 수 없습니다", "meshcoreChannelImported": "MeshCore 채널 링크 수신 — 라디오 탭에서 검토", "meshcoreChannelImportFailed": "QR에서 MeshCore 채널을 가져올 수 없습니다", - "meshcoreChannelPrefill": "QR에서 채운 채널 세부 정보 — 적용하려면 저장" + "meshcoreChannelPrefill": "QR에서 채운 채널 세부 정보 — 적용하려면 저장", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "원격 섹션", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index a4ce10248..a8707777f 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Contactpersoon importeren", "confirmMeshcoreContactImportTitle": "MeshCore-contact importeren?", "confirmMeshcoreContactImportBody": "{{name}} van een MeshCore-contact QR toevoegen aan je contacten?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "MeshCore-kanaal importeren?", "confirmMeshcoreChannelImportBody": "Kanaal {{name}} toepassen vanaf een MeshCore-kanaal QR op het tabblad Radio?", "confirmMeshcoreChannelImportAction": "Importeer kanaal", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "Kan MeshCore-contactpersoon niet importeren uit QR", "meshcoreChannelImported": "MeshCore-kanaallink ontvangen — bekijk op het tabblad Radio", "meshcoreChannelImportFailed": "Kan MeshCore-kanaal niet importeren uit QR", - "meshcoreChannelPrefill": "Kanaalgegevens ingevuld vanuit QR — opslaan om toe te passen" + "meshcoreChannelPrefill": "Kanaalgegevens ingevuld vanuit QR — opslaan om toe te passen", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Secties op afstand", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 4c43b0545..cfa85fd3b 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4866,6 +4866,7 @@ "confirmContactImportAction": "Importuj kontakt", "confirmMeshcoreContactImportTitle": "Zaimportować kontakt MeshCore?", "confirmMeshcoreContactImportBody": "Dodać {{name}} z QR kontaktu MeshCore do swoich kontaktów?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Zaimportować kanał MeshCore?", "confirmMeshcoreChannelImportBody": "Zastosować kanał {{name}} z kanału QR MeshCore na karcie Radio?", "confirmMeshcoreChannelImportAction": "Importuj listę kanałów", @@ -4874,7 +4875,8 @@ "meshcoreContactImportFailed": "Nie można zaimportować kontaktu MeshCore z QR", "meshcoreChannelImported": "Otrzymano link do kanału MeshCore — recenzja na karcie Radio", "meshcoreChannelImportFailed": "Nie można zaimportować kanału MeshCore z QR", - "meshcoreChannelPrefill": "Szczegóły kanału wypełnione z QR — zapisz, aby zastosować" + "meshcoreChannelPrefill": "Szczegóły kanału wypełnione z QR — zapisz, aby zastosować", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Sekcje zdalne", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 94f9256cf..55487f278 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Importar contato", "confirmMeshcoreContactImportTitle": "Importar contato MeshCore?", "confirmMeshcoreContactImportBody": "Adicionar {{name}} de um QR de contato MeshCore aos seus contatos?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Importar canal MeshCore?", "confirmMeshcoreChannelImportBody": "Aplicar o canal {{name}} de um QR do canal MeshCore na guia Rádio?", "confirmMeshcoreChannelImportAction": "Canal de importação", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "Não foi possível importar o contato MeshCore do QR", "meshcoreChannelImported": "Link do canal MeshCore recebido — revisão na guia Rádio", "meshcoreChannelImportFailed": "Não foi possível importar o canal MeshCore do QR", - "meshcoreChannelPrefill": "Detalhes do canal preenchidos a partir do QR — salvar para aplicar" + "meshcoreChannelPrefill": "Detalhes do canal preenchidos a partir do QR — salvar para aplicar", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Seções remotas", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 737daa7f1..09440c744 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4864,6 +4864,7 @@ "confirmContactImportAction": "Импортировать контакт", "confirmMeshcoreContactImportTitle": "Импортировать контакт MeshCore?", "confirmMeshcoreContactImportBody": "Добавить {{name}} из QR-кода контакта MeshCore в свои контакты?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Импортировать канал MeshCore?", "confirmMeshcoreChannelImportBody": "Применить канал {{name}} из QR канала MeshCore на вкладке «Радио»?", "confirmMeshcoreChannelImportAction": "Импорт канала", @@ -4872,7 +4873,8 @@ "meshcoreContactImportFailed": "Не удалось импортировать контакт MeshCore из QR", "meshcoreChannelImported": "Получена ссылка на канал MeshCore — обзор на вкладке «Радио»", "meshcoreChannelImportFailed": "Не удалось импортировать канал MeshCore из QR", - "meshcoreChannelPrefill": "Сведения о канале заполнены из QR. Сохраните, чтобы применить" + "meshcoreChannelPrefill": "Сведения о канале заполнены из QR. Сохраните, чтобы применить", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Удаленные секции", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 831bd7874..0adebd2e9 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "Kişiyi içe aktar", "confirmMeshcoreContactImportTitle": "MeshCore kişisi içe aktarılsın mı?", "confirmMeshcoreContactImportBody": "MeshCore kişi QR'sinden {{name}} kişilerinize eklensin mi?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "MeshCore kanalı içe aktarılsın mı?", "confirmMeshcoreChannelImportBody": "Radyo sekmesindeki MeshCore kanal QR'sından {{name}} kanalı uygulansın mı?", "confirmMeshcoreChannelImportAction": "Kanalı içe aktar", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "MeshCore kişisi QR'dan içe aktarılamadı", "meshcoreChannelImported": "MeshCore kanal bağlantısı alındı — Radyo sekmesinde inceleme", "meshcoreChannelImportFailed": "MeshCore kanalı QR'dan içe aktarılamadı", - "meshcoreChannelPrefill": "QR'dan doldurulan kanal ayrıntıları — uygulamak için kaydedin" + "meshcoreChannelPrefill": "QR'dan doldurulan kanal ayrıntıları — uygulamak için kaydedin", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Uzak bölümler", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 7e72d7ec5..823faa70f 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4864,6 +4864,7 @@ "confirmContactImportAction": "Імпортувати контакт", "confirmMeshcoreContactImportTitle": "Імпортувати контакт MeshCore?", "confirmMeshcoreContactImportBody": "Додати {{name}} з QR-коду контакту MeshCore до ваших контактів?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "Імпортувати канал MeshCore?", "confirmMeshcoreChannelImportBody": "Застосувати канал {{name}} з QR-каналу MeshCore на вкладці «Радіо»?", "confirmMeshcoreChannelImportAction": "Імпортувати канал", @@ -4872,7 +4873,8 @@ "meshcoreContactImportFailed": "Не вдалося імпортувати контакт MeshCore з QR", "meshcoreChannelImported": "Отримано посилання на канал MeshCore — відгук на вкладці \"Радіо\"", "meshcoreChannelImportFailed": "Не вдалося імпортувати канал MeshCore з QR", - "meshcoreChannelPrefill": "Відомості про канал заповнено з QR — збережіть, щоб застосувати" + "meshcoreChannelPrefill": "Відомості про канал заповнено з QR — збережіть, щоб застосувати", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "Віддалені ділянки", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index f15b5e6cc..73ccbfd14 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4862,6 +4862,7 @@ "confirmContactImportAction": "导入联系人", "confirmMeshcoreContactImportTitle": "导入MeshCore联系人?", "confirmMeshcoreContactImportBody": "将MeshCore联系人QR中的{{name}}添加到您的联系人?", + "confirmMeshcoreContactImportAction": "Import MeshCore contact", "confirmMeshcoreChannelImportTitle": "导入MeshCore频道?", "confirmMeshcoreChannelImportBody": "在“广播”选项卡上应用来自MeshCore频道QR的频道{{name}} ?", "confirmMeshcoreChannelImportAction": "导入频道", @@ -4870,7 +4871,8 @@ "meshcoreContactImportFailed": "无法从二维码导入MeshCore联系人", "meshcoreChannelImported": "已收到MeshCore频道链接—在“广播”选项卡上查看", "meshcoreChannelImportFailed": "无法从二维码导入MeshCore频道", - "meshcoreChannelPrefill": "从QR填写的频道详细信息—保存以应用" + "meshcoreChannelPrefill": "从QR填写的频道详细信息—保存以应用", + "meshcoreChannelNoFreeIndex": "All MeshCore channel slots are full — delete one before importing" }, "reticulumRemote": { "navAria": "远程部分", diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 470fd18be..0e2177a12 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -380,6 +380,69 @@ describe('reticulumPeerStore', () => { expect(peer?.hops).toBe(2); }); + it('normalizes mixed-case peer public_key on incremental patches and omits malformed', () => { + const good = 'aa'.repeat(16); + const bad = 'bb'.repeat(16); + applyReticulumPeersUpdatedPatches({ + patches: [ + { + destination_hash: good, + public_key: 'AB'.repeat(64), + hops: 1, + }, + { + destination_hash: bad, + public_key: 'not-a-key', + hops: 2, + }, + ], + }); + applyReticulumPeerPatchesNow([]); + expect(useReticulumPeerStore.getState().peers.get(good)?.public_key).toBe('ab'.repeat(64)); + expect(useReticulumPeerStore.getState().peers.get(bad)?.public_key).toBeUndefined(); + }); + + it('normalizes mixed-case peer public_key on full peer refresh and omits malformed', async () => { + const goodHash = 'aa'.repeat(16); + const badHash = 'bb'.repeat(16); + vi.stubGlobal('window', { + electronAPI: { + reticulum: { + proxyGet: vi.fn((path: string) => { + if (path === '/api/v1/contacts') return Promise.resolve({ contacts: [] }); + if (path === '/api/v1/peers' || path.startsWith('/api/v1/peers?')) { + return Promise.resolve({ + peers: [ + { + destination_hash: goodHash, + hops: 1, + public_key: 'CD'.repeat(64), + }, + { + destination_hash: badHash, + hops: 2, + public_key: 'short', + }, + ], + }); + } + if (path === '/api/v1/nomadnetwork/nodes') { + return Promise.resolve({ nodes: [] }); + } + return Promise.resolve({}); + }), + }, + db: { + getReticulumDestinations: vi.fn().mockResolvedValue([]), + }, + }, + }); + + await refreshReticulumPeersFromSidecar({ forceRefresh: true }); + expect(useReticulumPeerStore.getState().peers.get(goodHash)?.public_key).toBe('cd'.repeat(64)); + expect(useReticulumPeerStore.getState().peers.get(badHash)?.public_key).toBeUndefined(); + }); + it('batches announce optimistic updates via patch buffer', () => { vi.useFakeTimers(); applyReticulumAnnounceReceivedOptimistic({ diff --git a/src/shared/meshClientDeepLink.test.ts b/src/shared/meshClientDeepLink.test.ts index df65af27a..0d36795c5 100644 --- a/src/shared/meshClientDeepLink.test.ts +++ b/src/shared/meshClientDeepLink.test.ts @@ -208,6 +208,13 @@ describe('meshClientDeepLink', () => { expect(() => buildMeshcoreContactAddUri({ name: 'A', publicKeyHex: 'ab', type: 1 })).toThrow( /public key/, ); + expect(() => + buildMeshcoreContactAddUri({ + name: ' ', + publicKeyHex: MESHCORE_PUBKEY, + type: 1, + }), + ).toThrow(/name/); expect(() => buildMeshcoreContactAddUri({ name: 'A', @@ -237,10 +244,11 @@ describe('meshClientDeepLink', () => { }); it('accepts official docs fixture', () => { + const secretHex = new URL(MESHCORE_DOC_CHANNEL).searchParams.get('secret') ?? ''; expect(classifyMeshClientDeepLink(MESHCORE_DOC_CHANNEL)).toEqual({ kind: 'meshcoreChannelAdd', name: 'Public', - secretHex: '8b3387e9c5cdea6ac9e5edbaa115cd72', + secretHex, }); expect(isForwardableMeshClientOpenUrl(MESHCORE_DOC_CHANNEL)).toBe(true); }); @@ -254,6 +262,9 @@ describe('meshClientDeepLink', () => { }); it('throws on invalid build secret', () => { + expect(() => buildMeshcoreChannelAddUri({ name: ' ', secretHex: CHANNEL_SECRET })).toThrow( + /name/, + ); expect(() => buildMeshcoreChannelAddUri({ name: 'P', secretHex: 'aa' })).toThrow( /channel secret/, ); diff --git a/src/shared/meshClientDeepLink.ts b/src/shared/meshClientDeepLink.ts index 03cc6388f..2ca3f75e5 100644 --- a/src/shared/meshClientDeepLink.ts +++ b/src/shared/meshClientDeepLink.ts @@ -73,11 +73,13 @@ export function buildMeshcoreContactAddUri(opts: { publicKeyHex: string; type: MeshcoreContactType; }): string { + const name = opts.name.trim(); + if (!name) throw new Error('invalid name'); const key = opts.publicKeyHex.trim().toLowerCase(); if (!MESHCORE_PUBKEY_RE.test(key)) throw new Error('invalid public key'); if (!isMeshcoreContactType(opts.type)) throw new Error('invalid contact type'); const params = new URLSearchParams(); - params.set('name', opts.name.trim()); + params.set('name', name); params.set('public_key', key); params.set('type', String(opts.type)); return `meshcore://contact/add?${params.toString()}`; @@ -88,10 +90,12 @@ export function buildMeshcoreChannelAddUri(opts: { secretHex: string; regionScope?: string | null; }): string { + const name = opts.name.trim(); + if (!name) throw new Error('invalid name'); const secret = opts.secretHex.trim().toLowerCase(); if (!MESHCORE_CHANNEL_SECRET_RE.test(secret)) throw new Error('invalid channel secret'); const params = new URLSearchParams(); - params.set('name', opts.name.trim()); + params.set('name', name); params.set('secret', secret); if (opts.regionScope?.trim()) params.set('region_scope', opts.regionScope.trim()); return `meshcore://channel/add?${params.toString()}`;