From 937f7b71ecdfe3753b13e5175c98913d46102795 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 7 Aug 2026 12:26:00 +0000 Subject: [PATCH 1/2] fix(mesh): route send-content-inline over federation for radio-less peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh.send-content-inline always called send_typed_wire (the LoRa/radio path), which fails with "Peer is federation-only (no radio twin)" for any contact reachable only via Tor federation — reproduced sending a picture from the companion app to a federation-only peer on archy-x250-mad2. mesh.send-content already resolves the peer's federation onion and falls back to send_typed_wire_via_federation; mirror that same lookup here. Co-Authored-By: Claude Sonnet 5 --- .../src/api/rpc/mesh/typed_messages.rs | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index a6cdfda6..65c5406e 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -492,15 +492,58 @@ impl RpcHandler { ) .await? } else { - svc.send_typed_wire( - contact_id, - wire, - "content_ref", - &display, - Some(typed_json), - seq, - ) - .await? + // Federation-only peers have no radio twin for + // send_typed_wire's LoRa dest-prefix resolution — route over + // Tor federation instead, mirroring mesh.send-content's onion + // lookup, or the send fails with "Peer is federation-only (no + // radio twin)" (picture-send from a federation-only contact, + // 2026-08-07). + let federation_onion = { + let state = svc.shared_state(); + let peers = state.peers.read().await; + peers + .get(&contact_id) + .map(|p| (p.pubkey_hex.clone(), p.did.clone())) + }; + let federation_onion = match federation_onion { + Some((Some(pubkey_hex), did)) => { + let nodes = crate::federation::load_nodes(&self.config.data_dir) + .await + .unwrap_or_default(); + nodes + .iter() + .find(|n| n.pubkey == pubkey_hex) + .map(|n| n.onion.clone()) + .or_else(|| { + did.as_ref().and_then(|d| { + nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone()) + }) + }) + } + _ => None, + }; + if let Some(onion) = federation_onion { + svc.send_typed_wire_via_federation( + contact_id, + &onion, + wire, + "content_ref", + &display, + Some(typed_json), + seq, + ) + .await? + } else { + svc.send_typed_wire( + contact_id, + wire, + "content_ref", + &display, + Some(typed_json), + seq, + ) + .await? + } } }; -- 2.54.0 From 7560e0b5e67422670ac198a749eafdb6718f2d92 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 7 Aug 2026 14:43:55 +0000 Subject: [PATCH 2/2] fix(mesh): don't offer radio-only resource transfer to radio-unreachable peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first fix (federation fallback in the plain content-inline path) wasn't enough — mesh.transport-advice recommended the "resource-mesh" tier purely from our own device being Reticulum-capable, without checking that THIS peer actually has a radio route. For a federation-only contact (no radio twin) that steered the frontend into send-content-inline's Reticulum resource-transfer path, which has no dest_prefix to send to and fails with "Peer is federation-only (no radio twin)" — reproduced again on archy-x250-mad2 after deploying the first fix. Adds MeshService::has_radio_route(contact_id), and gates both the "resource-mesh" tier in mesh.transport-advice and the resource-transfer branch in mesh.send-content-inline on it. Federation-only peers now fall through to the has_tor branches, which route the frontend to mesh.send-content (already correctly federation-aware) instead. Co-Authored-By: Claude Sonnet 5 --- .../src/api/rpc/mesh/typed_messages.rs | 22 +++++++++++++++++-- core/archipelago/src/mesh/mod.rs | 13 +++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 65c5406e..53d6061f 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -405,9 +405,17 @@ impl RpcHandler { .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; let device_type = svc.shared_state().status.read().await.device_type; + // Resource transfer is a native RNS transfer over LoRa — it needs an + // actual radio route to this contact, not just a Reticulum device on + // our end. A federation-only peer with no radio twin fits the size + // and device-type checks but has no dest_prefix to send to; without + // this check the send falls into send_content_resource and fails + // with "Peer is federation-only (no radio twin)" (picture-send, + // 2026-08-07) instead of falling back to the federation path below. let use_resource_transfer = bytes.len() > INLINE_HARD_MAX && device_type == crate::mesh::types::DeviceType::Reticulum - && bytes.len() <= RETICULUM_RESOURCE_MAX; + && bytes.len() <= RETICULUM_RESOURCE_MAX + && svc.has_radio_route(contact_id).await; if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer { anyhow::bail!( @@ -633,6 +641,16 @@ impl RpcHandler { let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1); let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum; + // A Reticulum device on our end doesn't mean THIS peer is radio + // reachable — a federation-only contact (no radio twin) has no dest + // prefix for a resource transfer, even though it's small enough and + // our device type qualifies. Without this check the frontend was + // steered into mesh.send-content-inline's resource-transfer path, + // which fails with "Peer is federation-only (no radio twin)" + // (picture-send, 2026-08-07); the tier below now defers to the + // has_tor branches for such peers, which route via mesh.send-content + // (federation) instead. + let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await; let (tier, reason) = if size <= MESH_AUTO_MAX { ("auto-mesh", "Small enough to send inline over mesh") } else if size <= MESH_HARD_MAX { @@ -641,7 +659,7 @@ impl RpcHandler { } else { ("auto-mesh", "No Tor path — sending inline over mesh") } - } else if is_reticulum && size <= RETICULUM_RESOURCE_MAX { + } else if has_radio_route && size <= RETICULUM_RESOURCE_MAX { ( "resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer", diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index db40868f..2f130838 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1206,6 +1206,19 @@ impl MeshService { Ok(dest_prefix) } + /// True if `contact_id` is reachable over the mesh radio right now — the + /// same peer/twin resolution `peer_dest_prefix` performs, exposed as a + /// cheap bool so RPC handlers can gate radio-only transports (LXMF + /// native image, Reticulum resource transfer) without duplicating the + /// twin-resolution logic. A federation-only contact_id with no matching + /// radio twin returns false here — offering "resource-mesh" or native + /// image to such a peer sends it straight into `peer_dest_prefix`'s + /// "federation-only (no radio twin)" error (picture-send from a + /// federation-only contact, 2026-08-07). + pub async fn has_radio_route(&self, contact_id: u32) -> bool { + self.peer_dest_prefix(contact_id).await.is_ok() + } + /// Split an oversized wire payload into MC-framed base64 chunks and send /// each via the mesh device. Matches the receive-side reassembly in /// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`, -- 2.54.0