From 79c3cc594703a4485c5397b40dbcf4ef54ae4db3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 28 Jul 2026 18:16:40 -0400 Subject: [PATCH] fix(mesh): radio-first transport policy + attachments to merged contacts route via the radio twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same twin-resolution gap, found live while testing images between archi-dev-box and archy-x250-dev: - peer_dest_prefix resolved the given contact row's own pubkey. For the UI's merged conversation (the federation-synthetic id) that's the Archipelago ed25519 identity key, NOT a radio routing key — so every Reticulum resource send (images/files over LoRa) failed with 'Unknown Reticulum prefix' while the UI showed the message as sent. It now resolves through the radio twin (same arch identity, radio-range id). - send_typed_wire sent EVERY federation-synthetic contact over the federation path (FIPS→Tor), even with the same node one LoRa hop away. Policy per operator: LoRa first when the payload fits and the radio twin is reachable, then FIPS, then Tor. Verified live: text to the merged contact now logs 'Radio-first routing' and lands with transport=reticulum on the peer. Also restyles the mesh-chat attachment download controls: the pre-fetch button was a bare .btn that squished to text width in the narrow mobile bubble; now a full-width glass pill with a download icon and fetch spinner, and the on-image overlay swaps the emoji glyph for a crisp SVG in a properly-sized glass circle. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/mod.rs | 59 ++++++++++++++++++++++++- neode-ui/src/views/Mesh.vue | 19 +++++++- neode-ui/src/views/mesh/mesh-styles.css | 32 +++++++++++--- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 48cbb314..325b32d1 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1153,7 +1153,32 @@ impl MeshService { let peer = peers .get(&contact_id) .ok_or_else(|| anyhow::anyhow!("Peer not found"))?; - let pubkey_hex = peer + // Cross-transport twin resolution: callers frequently hold the + // FEDERATION twin's contact_id (the UI's merged conversation row), + // whose pubkey_hex is the Archipelago ed25519 key — NOT a radio + // routing key. Sending a Reticulum resource with that prefix fails + // with "Unknown Reticulum prefix" (observed live 2026-07-28, + // image-over-LoRa to a merged contact). Route via the radio twin — + // same arch identity, radio-range id — whose pubkey_hex is the + // actual over-the-air routing key (RNS dest hash / firmware key). + let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE { + peer.arch_pubkey_hex + .as_deref() + .and_then(|arch| { + peers.values().find(|p| { + p.contact_id < FEDERATION_CONTACT_ID_BASE + && p.arch_pubkey_hex.as_deref() == Some(arch) + }) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "Peer is federation-only (no radio twin) — not reachable over the radio" + ) + })? + } else { + peer + }; + let pubkey_hex = radio_peer .pubkey_hex .as_ref() .ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?; @@ -1291,12 +1316,44 @@ impl MeshService { .map(|p| !p.reachable && p.arch_pubkey_hex.is_some()) .unwrap_or(false) }; + // Transport policy: LoRa first when it can actually carry the message, + // then FIPS, then Tor. A federation-synthetic id (what the UI's merged + // conversation holds) used to ALWAYS take the federation path, even + // when the very same node was sitting one LoRa hop away — so chats + // between two radio-equipped nodes silently rode FIPS/Tor. If the + // federation contact has a REACHABLE radio twin (same archipelago + // identity, radio-range id) and the payload fits the radio, skip the + // federation branch: the fall-through LoRa path twin-resolves the + // routing key via peer_dest_prefix. + let device_connected = self.state.status.read().await.device_connected; + let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && { + let peers = self.state.peers.read().await; + peers + .get(&contact_id) + .and_then(|p| p.arch_pubkey_hex.clone()) + .map(|arch| { + peers.values().any(|p| { + p.contact_id < FEDERATION_CONTACT_ID_BASE + && p.reachable + && p.arch_pubkey_hex.as_deref() == Some(arch.as_str()) + }) + }) + .unwrap_or(false) + }; let mesh_only_mode = load_config(&self.data_dir) .await .ok() .and_then(|cfg| cfg.mesh_only_mode) .unwrap_or(false); + if radio_twin_reachable && !mesh_only_mode { + tracing::info!( + contact_id, + bytes = wire.len(), + "Radio-first routing: federation contact has a reachable radio twin — sending over LoRa" + ); + } if !mesh_only_mode + && !radio_twin_reachable && (is_federation_synthetic || exceeds_lora || radio_federated_unreachable) { // Resolve the peer's pubkey/did. Prefer the live mesh peer table, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 91bcec7a..69a2ee4f 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -2194,8 +2194,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { + > + +