Compare commits

..

3 Commits

Author SHA1 Message Date
archipelago
3589c3a6b9 Merge archy-hwconfig into main — hw-config flash-firmware flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m3s
Brings the hw-config branch (radio firmware flashing modal step 3,
flasher packaging + PyInstaller runtime hook, self-update hardening)
onto main, already reconciled with the probe/dedup/name work via
fb1f4bf0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:02:25 -04:00
archipelago
0aa3941c40 feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m29s
- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
  peer is federation-reachable — mesh.transport-advice now returns
  has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
  the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
  Teleport to body so the backdrop dims the FULL viewport — rendered
  in-place they sat inside a transformed glass panel that trapped
  position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
  SNR/RSSI quality for LoRa transports, overlay/circuit shape for
  FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
  12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
  clears ALL twins of the merged conversation (badge sums every
  contact_id — clearing just the clicked one left it stuck), and only
  once the chat has scrolled to the latest messages; scrolled up into
  history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
  overflowed the fixed button width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:01:36 -04:00
archipelago
e62f911810 fix(mesh): Reticulum resource transfers actually deliver — 4 root causes
E2E-verified both directions dev-box<->x250 over real RF (5KB image ~60s):

1. Sender daemon never called link.identify() — receiver's
   get_remote_identity() was None, so every arrived transfer carried an
   empty source_hash and the Rust side dropped it (now also warns
   instead of silently vanishing it).
2. Receiver treated resource.data as bytes, but RNS hands a concluded
   Resource's data as a file-like BufferedReader — b64encode raised
   TypeError and the transfer was lost even when attributed.
3. Radio twins of merged contacts carry the peer's Archipelago ed25519
   key as pubkey_hex, not an RNS hash — prefix lookup could never match
   ('Unknown Reticulum prefix', observed live). resolve_dest_hash now
   falls back to matching the announce-bound arch_pubkey_hex.
4. The daemon RPC socket kept asyncio's default 64KiB line limit; any
   attachment >~48KB overflowed it and tore down the whole daemon
   connection ('reticulum-daemon is gone'). Raised to 16MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:00:44 -04:00
6 changed files with 344 additions and 79 deletions

View File

@ -575,14 +575,17 @@ impl RpcHandler {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let has_tor = peer_pubkey_hex
.as_ref()
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
.unwrap_or(false)
|| peer_did
.as_ref()
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let fed_node = nodes.iter().find(|n| {
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|| peer_did.as_deref() == Some(n.did.as_str())
});
let has_tor = fed_node.is_some();
// Distinct FIPS capability so the frontend can offer FIPS as its own
// pill (not just a generic "Tor") — the dial layer still picks
// FIPS-first with Tor fallback on the actual send; these are honest
// capability labels, with `last_transport` saying what worked last.
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
@ -618,6 +621,8 @@ impl RpcHandler {
"tier": tier,
"est_seconds": est_seconds,
"has_tor": has_tor,
"has_fips": has_fips,
"last_transport": last_transport,
"reason": reason,
"size": size,
"mesh_auto_max": MESH_AUTO_MAX,

View File

@ -525,6 +525,28 @@ impl ReticulumLink {
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
}
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
/// destination hash. Two shapes arrive here depending on how the contact
/// record was born (observed live 2026-07-28, image-to-merged-contact):
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
/// announce) — direct `prefix_to_hash` hit;
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
/// no dest-hash match possible, so fall back to scanning peers whose
/// announce-bound `arch_pubkey_hex` starts with the prefix.
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
if let Some(hash) = self.prefix_to_hash.get(prefix) {
return Some(*hash);
}
let hex_prefix = hex::encode(prefix);
self.peers.values().find_map(|p| {
p.arch_pubkey_hex
.as_deref()
.filter(|arch| arch.starts_with(&hex_prefix))
.map(|_| p.dest_hash)
})
}
/// Best-effort sync write of the current peer table — called after any
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
/// not per-message) so a blocking write here is a fine trade for keeping
@ -601,16 +623,12 @@ impl ReticulumLink {
dest_pubkey_prefix: &[u8; 6],
payload: &[u8],
) -> Result<()> {
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
// Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small
// enough for the single-frame path) are raw binary CBOR, not text.
// `from_utf8_lossy` would irreversibly mangle them since `content`
@ -645,16 +663,12 @@ impl ReticulumLink {
caption: Option<&str>,
) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
self.send_rpc(serde_json::json!({
"cmd": "send",
"dest_hash": hex::encode(dest_hash),
@ -677,16 +691,12 @@ impl ReticulumLink {
/// `handle_event`, not awaited here.
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let req_id = self.next_resource_id();
self.send_rpc(serde_json::json!({
"cmd": "send_resource",
@ -1023,10 +1033,19 @@ impl ReticulumLink {
.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
return;
};
let source_hex = ev
.get("source_hash")
.and_then(Value::as_str)
.unwrap_or_default();
let Ok(source_hash) = parse_hash16(source_hex) else {
// An empty source_hash means the sender's link wasn't
// identified (pre-identify daemon build on the far end) —
// the blob is undeliverable without attribution, but say
// so instead of vanishing it.
warn!(
source = source_hex,
"Dropping inbound Reticulum resource without valid source identity"
);
return;
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();

View File

@ -281,8 +281,14 @@ export const useMeshStore = defineStore('mesh', () => {
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(null)
// Contact ids of the chat currently on screen — ALL twins of the merged
// conversation, not just the clicked row's id, since the unread badge sums
// across every underlying contact_id (a message can land on any twin).
const viewingChatIds = ref<number[]>([])
// Whether the open chat is scrolled to (near) the bottom. New messages only
// auto-clear as read while the latest messages are actually in view —
// standard chat-app semantics; scrolled-up-in-history still counts unread.
const viewingAtBottom = ref(true)
// Total unread count for nav badge
const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
@ -444,8 +450,11 @@ export const useMeshStore = defineStore('mesh', () => {
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
)
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat
if (msg.peer_contact_id !== viewingChatId.value) {
// Don't count as unread if we're currently viewing that chat AND the
// bottom (latest messages) is in view — i.e. the user actually sees it.
const seenLive =
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
if (!seenLive) {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
@ -573,13 +582,15 @@ export const useMeshStore = defineStore('mesh', () => {
federatedPositions.value = next
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
function markChatRead(contactId: number | number[]) {
const ids = Array.isArray(contactId) ? contactId : [contactId]
viewingChatIds.value = ids
for (const id of ids) delete unreadCounts.value[id]
}
function clearViewingChat() {
viewingChatId.value = null
viewingChatIds.value = []
viewingAtBottom.value = true
}
async function sendMessage(contactId: number, message: string) {
@ -724,6 +735,8 @@ export const useMeshStore = defineStore('mesh', () => {
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
est_seconds: number
has_tor: boolean
has_fips: boolean
last_transport: string | null
reason: string
size: number
mesh_auto_max: number
@ -1042,6 +1055,7 @@ export const useMeshStore = defineStore('mesh', () => {
error,
sending,
unreadCounts,
viewingAtBottom,
totalUnread,
nodePositions,
federatedPositions,

View File

@ -896,8 +896,14 @@ function openChat(peer: MeshPeer) {
messageText.value = ''
activeTab.value = 'chat'
mobileShowChat.value = true
mesh.markChatRead(peer.contact_id)
nextTick(() => scrollChatToBottom())
nextTick(() => {
// Clear unread only after the chat has rendered and auto-scrolled to the
// latest messages ("you've actually seen them"), and clear ALL twins of
// the merged conversation the badge sums across every contact_id, so
// clearing just the clicked row's id left the badge stuck.
scrollChatToBottom()
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
})
}
function openChannelChat(channel: { index: number; name: string }) {
@ -1009,9 +1015,25 @@ async function handleSendMessage() {
function scrollChatToBottom() {
if (chatScrollEl.value) {
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
mesh.viewingAtBottom = true
}
}
// Scroll-position read tracking: while the latest messages are in view the
// open conversation stays "read" (new arrivals don't badge); scrolled up into
// history, incoming messages accumulate unread like any normal chat app
// scrolling back down clears them.
function onChatScroll() {
const el = chatScrollEl.value
if (!el) return
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
mesh.viewingAtBottom = nearBottom
if (nearBottom && activeMergedPeer.value) {
mesh.markChatRead(activeMergedPeer.value.contact_ids)
}
scheduleReadReceipt()
}
// Keep the compose field focused after a send so the user can keep typing
// without re-clicking it (Enter re-focuses natively, but the Send button
// click otherwise leaves focus on the button).
@ -1136,14 +1158,15 @@ interface PendingReply {
}
const pendingReply = ref<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(null)
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function openActionMenu(msgId: number, ev?: Event) {
ev?.stopPropagation()
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
reactionPickerForId.value = null
}
function closeActionMenu() {
actionMenuForId.value = null
reactionPickerForId.value = null
}
function handleDocClickForMenu(ev: MouseEvent) {
if (actionMenuForId.value === null) return
@ -1253,6 +1276,43 @@ function scheduleReadReceipt() {
}, 400)
}
// Hop visualization modal click a message's transport pill to see how it
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
// not stored per-message) labelled as such in the modal.
const hopVizMsg = ref<MeshMessage | null>(null)
const hopVizPeer = computed<MeshPeer | null>(() => {
const m = hopVizMsg.value
if (!m) return null
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
})
function hopVizHops(): number | null {
const p = hopVizPeer.value
if (!p || p.hops == null || p.hops === 0xff) return null
return p.hops
}
function signalQualityLabel(snr: number | null, rssi: number | null): string {
if (snr == null && rssi == null) return 'signal unknown'
if (snr != null) {
if (snr > 5) return 'excellent signal'
if (snr > 0) return 'good signal'
if (snr > -10) return 'fair signal'
return 'weak signal'
}
if (rssi != null) {
if (rssi > -80) return 'excellent signal'
if (rssi > -100) return 'good signal'
if (rssi > -115) return 'fair signal'
return 'weak signal'
}
return 'signal unknown'
}
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
// the action menu stays compact and a larger emoji palette fits.
const reactionPickerForId = ref<number | null>(null)
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
async function reactTo(msg: MeshMessage, emoji: string) {
const key = messageKeyFor(msg)
@ -1440,6 +1500,14 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
const imageQualityChoice = ref<{ file: File } | null>(null)
const imageQualityEstimates = ref<Map<string, string>>(new Map())
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
// Transport pills at the bottom of the image modal: when the peer is also a
// federation node (FIPS overlay and/or Tor), the user can send the image that
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
// fallback the pills are capability labels, defaulted to whichever of the
// two actually worked last for this peer).
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
function formatEstSeconds(seconds: number): string {
if (seconds < 60) return `~${seconds}s`
@ -1449,6 +1517,8 @@ function formatEstSeconds(seconds: number): string {
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
imageQualityChoice.value = { file }
imageQualityEstimates.value = new Map()
imageSendTransport.value = 'lora'
imageTransportOptions.value = { fips: false, tor: false }
// Fire off estimates for all presets in parallel each preset's nominal
// target size (or the real file size for 'original') against the SAME
// mesh.transport-advice RPC the non-image attach flow already uses.
@ -1460,6 +1530,7 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
const label =
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
} catch {
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
}
@ -1588,12 +1659,18 @@ async function handleAttachFile(ev: Event) {
attaching.value = true
attachError.value = null
try {
let forcedTransport: 'fips' | 'tor' | null = null
if (file.type.startsWith('image/')) {
const preset = await openImageQualityDialog(file, peer.contact_id)
if (!preset) return // user cancelled
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
file = await compressImage(file, preset)
}
if (!(await sendFileViaBestTransport(file, peer))) return
if (forcedTransport) {
// User picked a FIPS/Tor pill in the image modal skip the LoRa
// advice/chooser flow entirely and ship via the content-ref path.
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
} else if (!(await sendFileViaBestTransport(file, peer))) return
messageText.value = ''
nextTick(() => scrollChatToBottom())
} catch (e) {
@ -1889,9 +1966,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
</button>
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
<!-- While refreshing show ONLY the spinner spinner + "Refreshing…"
together overflow the button's fixed width. -->
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
<template v-if="!refreshing">Refresh</template>
</button>
</div>
@ -2082,7 +2161,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div>
</div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello!
</div>
@ -2252,7 +2331,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@ -2283,17 +2362,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
<button
v-for="emoji in QUICK_REACTIONS"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
class="mesh-chat-action-btn"
:class="{ active: reactionPickerForId === msg.id }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
@click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
>😀 React </button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button>
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
<button
v-for="emoji in REACTION_PALETTE"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
</div>
@ -2432,7 +2519,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</Teleport>
<!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. -->
(inline-chunked) and Tor. User picks which path to send it over.
Teleported to body so the fixed backdrop covers the FULL viewport
rendered in place it sits inside a transformed/filtered glass panel,
which traps position:fixed to just the right chat panel. -->
<Teleport to="body">
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
@ -2461,10 +2552,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
</div>
</div>
</Teleport>
<!-- Image quality-picker modal: shown before sending an image attachment.
Each preset shows its nominal size target + a transfer-time estimate
from the same mesh.transport-advice RPC the file-attach flow uses. -->
from the same mesh.transport-advice RPC the file-attach flow uses.
Teleported to body see the transport chooser above. -->
<Teleport to="body">
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">🖼 Choose Image Quality</h3>
@ -2481,12 +2575,91 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>
<span class="mesh-transport-icon">🖼</span>
<span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span>
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
<span class="mesh-transport-meta">{{
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
}}</span>
</button>
</div>
<!-- Transport pills: LoRa is always available (it's a mesh chat);
FIPS/Tor appear when the peer is a reachable federation node,
letting the user send the image that way instead. -->
<div
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
class="mesh-image-transport-row"
>
<span class="mesh-image-transport-caption">Send via</span>
<button
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'lora' }"
@click="imageSendTransport = 'lora'"
>📡 LoRa</button>
<button
v-if="imageTransportOptions.fips"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'fips' }"
@click="imageSendTransport = 'fips'"
> FIPS</button>
<button
v-if="imageTransportOptions.tor"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'tor' }"
@click="imageSendTransport = 'tor'"
>🧅 Tor</button>
</div>
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
</div>
</div>
</Teleport>
<!-- Hop visualization modal: click a message's transport pill to see how
it traveled radio hops + live signal for LoRa transports, overlay/
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
<Teleport to="body">
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) }} route</h3>
<p class="mesh-transport-sub">
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
</p>
<div class="mesh-hopviz-chain">
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
</div>
<template v-if="hopVizMsg.transport === 'tor'">
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
</template>
<template v-else-if="hopVizMsg.transport === 'fips'">
<div class="mesh-hopviz-link"> FIPS overlay · direct peer-to-peer</div>
</template>
<template v-else>
<div class="mesh-hopviz-link">
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node"></span>
</span>
</div>
</template>
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
</div>
</div>
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
</div>
<div class="mesh-hopviz-signal">
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
</div>
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
</div>
</div>
</Teleport>
<!-- The "mesh device detected" setup flow is now the global
MeshDeviceSetupModal mounted in App.vue (fires on every page,

View File

@ -581,3 +581,27 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
.mesh-transport-cancel:hover { color: #fff; }
/* Transport pills at the bottom of the image quality modal pick LoRa vs
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
/* Hop visualization modal + clickable transport pill */
.mesh-chat-transport-clickable { cursor: pointer; }
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.mesh-hopviz-icon { font-size: 1.8rem; }
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
/* Reaction dropdown inside the message action menu */
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }

View File

@ -437,13 +437,29 @@ class ReticulumDaemon:
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"archy", "resource",
)
link = RNS.Link(out_dest)
# established_callback identifies us to the peer BEFORE any Resource
# goes out: the receiving daemon attributes an inbound resource via
# link.get_remote_identity() (see _on_resource_received), which is
# None unless the initiator calls identify() — without this every
# transfer arrives with an empty source_hash and the Rust side can't
# route it to a contact (observed live 2026-07-28: 5KB image sent,
# concluded COMPLETE, never surfaced on the receiving node).
link = RNS.Link(
out_dest,
established_callback=lambda lk: self._on_out_link_established(
resource_hash, lk
),
)
link.set_link_closed_callback(
lambda lk: self.links.pop(resource_hash, None)
)
self.links[resource_hash] = link
return link, resource_hash
def _on_out_link_established(self, resource_hash: bytes, link):
link.identify(self.identity)
self._flush_pending_resource_sends(resource_hash, link)
def _send_resource(self, req: dict):
import RNS
req_id = req.get("id", "")
@ -463,14 +479,14 @@ class ReticulumDaemon:
if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id)
else:
# Link is establishing — queue and flush from the established
# callback. set_link_established_callback only fires once per Link
# object (the cache is reused across sends), so re-set it here to
# make sure THIS send's queue entry gets flushed too.
# Link is establishing — queue; the creation-time established
# callback (_on_out_link_established) identifies us then flushes
# the queue. Re-check afterwards to close the race where the
# link went ACTIVE between the status check and the append
# (the callback fires once, on the RNS thread).
self.pending_resource_sends[resource_hash].append((data, req_id))
link.set_link_established_callback(
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
)
if link.status == RNS.Link.ACTIVE:
self._flush_pending_resource_sends(resource_hash, link)
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
import RNS
@ -518,10 +534,17 @@ class ReticulumDaemon:
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
if identity is not None else ""
)
# RNS hands a concluded Resource's data as a file-like object
# (BufferedReader over the assembled stream), not bytes —
# observed live 2026-07-28: b64encode raised "a bytes-like
# object is required" and every received transfer was lost.
data = resource.data
if hasattr(data, "read"):
data = data.read()
self._emit_threadsafe({
"event": "resource_recv",
"source_hash": source_hash,
"data_b64": base64.b64encode(resource.data).decode("ascii"),
"data_b64": base64.b64encode(data).decode("ascii"),
})
except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "resource_recv",
@ -531,7 +554,14 @@ class ReticulumDaemon:
sock_path = self.args.socket
if os.path.exists(sock_path):
os.unlink(sock_path)
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
# limit: asyncio's default per-line StreamReader cap is 64KiB, but
# send_resource requests carry the whole payload base64-encoded on one
# JSON line — a ~48KB+ attachment overflows the default and the raised
# LimitOverrunError tears down the RPC connection (the Rust side then
# sees "reticulum-daemon is gone" and restarts the whole session).
server = await asyncio.start_unix_server(
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
)
os.chmod(sock_path, 0o600)
self.announce()
async with server: