fix(mesh): radio-first transport policy + attachments to merged contacts route via the radio twin
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m50s
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m50s
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 <noreply@anthropic.com>
This commit is contained in:
parent
c4f24f3efa
commit
79c3cc5947
@ -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,
|
||||
|
||||
@ -2194,8 +2194,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button
|
||||
class="mesh-typed-content-download-btn"
|
||||
title="Download"
|
||||
aria-label="Download image"
|
||||
@click="downloadAttachment(msg.typed_payload as any)"
|
||||
>⬇</button>
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
||||
@ -2219,10 +2225,19 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
@click="openMeshLightbox(msg.typed_payload as any)"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
class="mesh-typed-content-fetch-btn"
|
||||
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||
@click="handleFetchContent(msg.typed_payload as any)"
|
||||
>
|
||||
<span
|
||||
v-if="fetchingCids.has(msg.typed_payload.cid)"
|
||||
class="mesh-refresh-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@ -374,13 +374,35 @@
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||
.mesh-typed-content-download-btn {
|
||||
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
|
||||
position: absolute; bottom: 8px; right: 8px;
|
||||
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
|
||||
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
|
||||
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
|
||||
replaced collapsed to its text width inside the narrow mobile bubble and
|
||||
looked squashed — this is a full-width glass pill in the house style. */
|
||||
.mesh-typed-content-fetch-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
|
||||
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
|
||||
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user