fix(mesh): Meshtastic 3ccc pkc_capable pill + Sideband image interop + critical CBOR wire-bloat fix

Merges in the meshtastic agent's now-finished work alongside this session's
continuation: stock-peer (3ccc) PKI-capability is now stamped through
get_contacts -> refresh_contacts -> MeshPeer.pkc_capable, so a directed DM to/from
a PKC-capable stock Meshtastic peer correctly shows the E2E pill on the Sent row,
not just received messages. Confirmed live: .198 sees "Meshtastic 3ccc" with
pkc_capable=true.

Also fixes two real interop/correctness bugs found while live-testing the
Reticulum <-> Sideband link:
  - Receive: the daemon only ever read LXMF's plain-text content, silently
    dropping native FIELD_IMAGE/FIELD_FILE_ATTACHMENTS fields — a stock
    Sideband/NomadNet photo vanished into a blank-space message. Now decoded
    into the same ContentInline typed envelope our own attachments use.
  - Send: images to a non-archy (stock) peer now use native LXMF FIELD_IMAGE
    instead of our own opaque CBOR wire format, which Sideband can't decode.
  - Root cause of a garbled MC-chunk-fragment bug: TypedEnvelope.v/.sig (the
    OUTER wrapper every message type uses) serialized raw bytes as a CBOR
    array-of-integers instead of a native byte string, bloating every
    message on the wire ~2-3.5x — enough to push even a tiny ReadReceipt
    over the 140-byte single-frame chunking threshold. Root-caused by
    reading ciborium's deserializer source directly (deserialize_bytes only
    works within its internal scratch buffer; deserialize_byte_buf streams
    unbounded).

Frontend: consolidated the attach/record buttons into a single animated "+"
menu (was overflowing the compose row).

857/857 tests pass. Verified live across all 5 deploy-roster nodes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 22:07:45 -04:00
co-authored by Claude Sonnet 5
parent f54c853128
commit 0eb5c258f5
14 changed files with 694 additions and 63 deletions
@@ -5,6 +5,7 @@ use crate::mesh::message_types::{
Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MeshMessageType,
MessageKey, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope,
};
use crate::mesh::types::radio_transport_label;
use anyhow::Result;
use tracing::info;
@@ -429,17 +430,6 @@ impl RpcHandler {
.put(&bytes, &mime, filename.clone(), None, false)
.await?;
let content = ContentInlinePayload {
mime: mime.clone(),
filename: filename.clone(),
caption: caption.clone(),
bytes,
};
let seq = svc.next_send_seq(contact_id).await;
let payload = message_types::encode_payload(&content)?;
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let display = match (&filename, &caption) {
(Some(f), Some(c)) => format!("📎 {}{}", f, c),
(Some(f), None) => format!("📎 {}", f),
@@ -447,7 +437,8 @@ impl RpcHandler {
(None, None) => format!("📎 {} ({} bytes)", mime, meta.size),
};
// Render as a content_ref card on the sender side (UI already knows
// how to draw it from cid + mime + filename + size).
// how to draw it from cid + mime + filename + size) regardless of
// which wire format actually goes out — this is a local-only mirror.
let typed_json = serde_json::json!({
"cid": meta.cid,
"size": meta.size,
@@ -456,27 +447,60 @@ impl RpcHandler {
"caption": caption,
"inline": true,
});
let seq = svc.next_send_seq(contact_id).await;
let msg = if use_resource_transfer {
svc.send_content_resource(
// A stock (non-archy) peer can't decode our typed-envelope wire
// format — send images to them via LXMF's native FIELD_IMAGE
// instead, so they actually see the photo (Sideband/NomadNet).
let is_archy = svc.is_archy_peer(contact_id).await;
let native_image = !is_archy
&& device_type == crate::mesh::types::DeviceType::Reticulum
&& mime.starts_with("image/");
let msg = if native_image {
svc.send_native_image(contact_id, &mime, bytes, caption.clone())
.await?;
svc.record_sent_typed(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
Some(radio_transport_label(device_type).to_string()),
true, // Reticulum/LXMF is unconditionally E2E on every send
)
.await?
.await
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
let content = ContentInlinePayload {
mime: mime.clone(),
filename: filename.clone(),
caption: caption.clone(),
bytes,
};
let payload = message_types::encode_payload(&content)?;
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let wire = envelope.to_wire()?;
if use_resource_transfer {
svc.send_content_resource(
contact_id,
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?
}
};
info!(
+10 -2
View File
@@ -340,6 +340,9 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
hops: 0xff,
last_advert: 0,
reachable: true,
// Stamped fresh from `peer_pubkeys` in `get_contacts` once a real
// contact refresh runs; unknown at synthesis time here.
pkc_capable: false,
};
let is_new = {
let mut peers = state.peers.write().await;
@@ -394,6 +397,7 @@ pub(super) async fn store_plain_message_with_encryption(
encrypted: bool,
) {
let msg_id = state.next_id().await;
let radio_transport = radio_transport_label(state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
@@ -403,7 +407,7 @@ pub(super) async fn store_plain_message_with_encryption(
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@@ -561,6 +565,9 @@ pub(super) async fn handle_identity_received(
last_advert: 0,
// We just heard this peer's identity advert, so it's reachable.
reachable: true,
// PKC capability is tracked by the radio driver's get_contacts(), not
// known at identity-advert time.
pkc_capable: false,
};
let is_new = {
@@ -627,6 +634,7 @@ pub(super) async fn handle_received_message(
.map(|p| p.advert_name.clone());
let msg_id = state.next_id().await;
let radio_transport = radio_transport_label(state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
@@ -636,7 +644,7 @@ pub(super) async fn handle_received_message(
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@@ -73,10 +73,12 @@ pub(super) async fn handle_typed_message(
return;
}
};
// Radio-delivered → "lora". Stamp after dispatch (see stamp helper).
// Radio-delivered → the active device's transport label ("lora" or
// "reticulum"). Stamp after dispatch (see stamp helper).
let before = max_message_id(state).await;
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await;
stamp_received_transport(state, sender_contact_id, before, "lora", false).await;
let radio_transport = radio_transport_label(state.status.read().await.device_type);
stamp_received_transport(state, sender_contact_id, before, radio_transport, false).await;
}
/// Highest stored message id right now. Paired with `stamp_received_transport`
@@ -73,6 +73,15 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Native LXMF `FIELD_IMAGE` send — Reticulum-only, for a stock
/// (non-archy) peer that can't decode our typed envelope. See
/// `MeshRadioDevice::send_native_image`.
SendNativeImage {
dest_pubkey_prefix: [u8; 6],
mime: String,
bytes: Vec<u8>,
caption: Option<String>,
},
/// Send PLAIN text as one or more native meshcore DMs to a stock client
/// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's
@@ -126,6 +126,26 @@ impl MeshRadioDevice {
}
}
/// Send an image via native LXMF `FIELD_IMAGE` — Reticulum-only, for a
/// stock (non-archy) peer that can't decode our typed envelope. See
/// `ReticulumLink::send_native_image`.
async fn send_native_image(
&mut self,
dest_pubkey_prefix: &[u8; 6],
mime: &str,
bytes: &[u8],
caption: Option<&str>,
) -> Result<()> {
match self {
Self::Meshcore(_) | Self::Meshtastic(_) => {
anyhow::bail!("Native image send is Reticulum-only")
}
Self::Reticulum(device) => {
device.send_native_image(dest_pubkey_prefix, mime, bytes, caption).await
}
}
}
/// Send `data` over a dedicated RNS Resource transfer instead of the
/// small-payload "content" path — only Reticulum has anything resembling
/// this (a native large-binary transfer protocol over a `RNS.Link`).
@@ -1111,6 +1131,30 @@ async fn handle_send_command(
);
}
}
MeshCommand::SendNativeImage {
dest_pubkey_prefix,
mime,
bytes,
caption,
} => {
if let Err(e) = device
.send_native_image(&dest_pubkey_prefix, &mime, &bytes, caption.as_deref())
.await
{
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Failed to send native image: {}", e
);
} else {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
len = bytes.len(),
"Sent native LXMF image"
);
}
}
MeshCommand::BroadcastChannel { channel, payload } => {
if let Err(e) = device.send_channel_text(channel, &payload).await {
*consecutive_write_failures += 1;
+21 -5
View File
@@ -691,7 +691,20 @@ impl MeshtasticDevice {
}
}
}
Ok(self.contacts.values().cloned().collect())
// Stamp E2E capability per contact from `peer_pubkeys` (the real
// Curve25519 keys learned from NodeInfo / inbound PKC packets), keyed by
// node-num — which is exactly the contacts map key. This lets the send
// path mark a Sent DM to ANY PKC-capable peer (e.g. a stock device that
// shared its key) as E2E, not just archipelago peers.
Ok(self
.contacts
.iter()
.map(|(num, c)| {
let mut c = c.clone();
c.pkc_capable = self.peer_is_pkc_capable(*num);
c
})
.collect())
}
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
@@ -740,10 +753,9 @@ impl MeshtasticDevice {
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
/// distinguish a true E2E DM from a channel-encrypted one without changing
/// the shared device interface (which would break meshcore hot-swap).
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
/// the channel PSK. Consumed by `get_contacts` to stamp `ParsedContact
/// .pkc_capable`, so the send path can mark a Sent DM to any PKC-capable
/// peer as E2E (not just archipelago peers).
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
self.peer_pubkeys
.get(&node_num)
@@ -921,6 +933,8 @@ impl MeshtasticDevice {
contact_type: 1,
path_len: 0xff,
flags: 0,
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
pkc_capable: false,
},
);
}
@@ -1008,6 +1022,8 @@ fn packet_to_inbound_frame(
contact_type: 1,
path_len: 0xff,
flags: 0,
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
pkc_capable: false,
});
// Channel broadcast (e.g. the default public LongFast channel, or any other
+147 -1
View File
@@ -192,16 +192,28 @@ pub struct MessageKey {
// ─── Wire Envelope ──────────────────────────────────────────────────────
/// CBOR wire envelope wrapping any typed message.
///
/// `v`/`sig` MUST use `compact_bytes`/`compact_bytes_opt` — this is the
/// envelope EVERY message type wraps its payload in, so plain derived
/// `Vec<u8>` encoding here (one CBOR integer per byte instead of a native
/// byte string) bloats every single message on the wire, not just
/// attachments. Root-caused live: a small ReadReceipt (tiny inner payload)
/// crossed the 140-byte single-frame threshold purely from this envelope's
/// own array-of-ints tax on `v`, triggering MC-chunked send to a Reticulum
/// peer whose chunks then failed to reassemble — surfaced as raw
/// `MC000...` fragments in the chat instead of a receipt. Fix this here,
/// not just on individual payload structs like `ContentInlinePayload`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypedEnvelope {
/// Message type.
pub t: u8,
/// Payload bytes (type-specific CBOR or raw data).
#[serde(with = "compact_bytes")]
pub v: Vec<u8>,
/// Unix timestamp (seconds since epoch).
pub ts: u32,
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none", with = "compact_bytes_opt")]
pub sig: Option<Vec<u8>>,
/// Message sequence number (per-sender, monotonically increasing).
#[serde(default)]
@@ -526,6 +538,86 @@ pub struct ContentRefPayload {
pub cap_exp: u64,
}
/// Serde's blanket `Serialize`/`Deserialize` for `Vec<u8>` goes through
/// `serialize_seq`/one CBOR integer per byte, NOT CBOR's native byte-string
/// type — measured ~3.5x wire bloat on a real attachment send (4746 raw
/// bytes -> 16638-byte CBOR envelope) before this fix. `serialize_bytes`
/// maps to CBOR major type 2 (compact byte string) instead. Only apply this
/// to fields that never need JSON round-tripping to the frontend (this one
/// is CBOR-wire-only — the frontend gets `cid`/`size`/`mime` metadata built
/// by hand, never the raw bytes, see typed_messages.rs's `typed_json`).
mod compact_bytes {
use serde::{Deserializer, Serializer};
use std::fmt;
pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_bytes(v)
}
struct BytesVisitor;
impl<'de> serde::de::Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a byte string")
}
fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Vec<u8>, E> {
Ok(v.to_vec())
}
fn visit_borrowed_bytes<E: serde::de::Error>(self, v: &'de [u8]) -> Result<Vec<u8>, E> {
Ok(v.to_vec())
}
fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Vec<u8>, E> {
Ok(v)
}
// ciborium's non-self-describing byte-string decode path visits a
// seq of u8 in some configurations rather than calling visit_bytes
// directly — accept that too so this is robust to the reader mode.
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
while let Some(byte) = seq.next_element::<u8>()? {
out.push(byte);
}
Ok(out)
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
// NOT deserialize_bytes: ciborium's deserialize_bytes only succeeds
// when the byte string fits its small internal scratch buffer —
// anything bigger (any real attachment) falls through to an
// "invalid type: bytes, expected bytes" error despite the CBOR
// header being genuinely Bytes. deserialize_byte_buf streams
// segments into an unbounded Vec instead (confirmed against
// ciborium 0.2.2's de/mod.rs — deserialize_bytes's `Header::Bytes(Some(len))
// if len <= self.scratch.len()` guard vs deserialize_byte_buf's
// unconditional `Header::Bytes(len)` streaming path).
d.deserialize_byte_buf(BytesVisitor)
}
}
/// `Option<Vec<u8>>` variant of `compact_bytes` — for wire-only optional byte
/// fields (e.g. `TypedEnvelope.sig`) that never need JSON round-tripping.
/// Not the same as `base64_opt_bytes` below, which exists specifically
/// because `ContentRefPayload.thumb_bytes` DOES need a JSON-friendly (string)
/// form for the frontend's `data:` URL — this one stays fully binary.
mod compact_bytes_opt {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(bytes) => s.serialize_bytes(bytes),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
#[derive(Deserialize)]
struct Wrapper(#[serde(with = "super::compact_bytes")] Vec<u8>);
let opt: Option<Wrapper> = Option::deserialize(d)?;
Ok(opt.map(|w| w.0))
}
}
/// Inline attachment payload — file bytes carried directly in the envelope.
/// Used when the file is small enough to chunk over LoRa and the peer has no
/// Tor path. Receiver writes `bytes` to its local BlobStore on reassembly
@@ -537,6 +629,7 @@ pub struct ContentInlinePayload {
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(with = "compact_bytes")]
pub bytes: Vec<u8>,
}
@@ -630,6 +723,59 @@ pub fn decode_payload<T: for<'a> Deserialize<'a>>(data: &[u8]) -> Result<T> {
mod tests {
use super::*;
#[test]
fn typed_envelope_of_a_small_payload_stays_under_single_frame_budget() {
// Regression test: a ReadReceipt (tiny inner payload — one MessageKey)
// wrapped in TypedEnvelope crossed the 140-byte single-LoRa-frame
// threshold purely from the OUTER envelope's own `v: Vec<u8>` field
// using array-of-ints CBOR encoding, live-observed forcing an
// unnecessary MC-chunked send whose chunks then failed to reassemble
// over Reticulum (surfaced as raw `MC000...` garbage in the chat).
let receipt = ReadReceiptPayload {
up_to: MessageKey {
sender_pubkey: "b550de818bb907047aad60d368668b3815ce2fcb9fc35d8040bb21c5c6217ccc"
.to_string(),
sender_seq: 42,
},
};
let payload = encode_payload(&receipt).unwrap();
let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(1);
let wire = envelope.to_wire().unwrap();
assert!(
wire.len() < 140,
"a ReadReceipt envelope should fit one LoRa frame (<140B), got {} bytes — \
TypedEnvelope.v is bloating again",
wire.len()
);
let decoded = TypedEnvelope::from_wire(&wire).unwrap();
let decoded_receipt: ReadReceiptPayload = decode_payload(&decoded.v).unwrap();
assert_eq!(decoded_receipt.up_to, receipt.up_to);
}
#[test]
fn content_inline_bytes_use_compact_cbor_encoding() {
// Regression test: Vec<u8> without #[serde(with = "compact_bytes")]
// serializes as one CBOR integer per byte (~3.5x bloat, measured on
// a real send: 4746 raw bytes -> 16638-byte wire envelope). Compact
// encoding should stay close to the raw size, not balloon with it.
let raw = vec![0xABu8; 4746];
let payload = ContentInlinePayload {
mime: "image/jpeg".to_string(),
filename: None,
caption: None,
bytes: raw.clone(),
};
let encoded = encode_payload(&payload).unwrap();
assert!(
encoded.len() < raw.len() + 200,
"expected compact encoding close to {} raw bytes, got {} wire bytes",
raw.len(),
encoded.len()
);
let decoded: ContentInlinePayload = decode_payload(&encoded).unwrap();
assert_eq!(decoded.bytes, raw);
}
#[test]
fn test_typed_envelope_wire_roundtrip() {
let envelope = TypedEnvelope::new(MeshMessageType::Text, b"hello mesh".to_vec());
+34 -1
View File
@@ -1241,6 +1241,39 @@ impl MeshService {
.await)
}
/// Send an image via native LXMF `FIELD_IMAGE` instead of our own typed
/// envelope — for a stock (non-archy) peer that can't decode our CBOR
/// wire format. Caller (the RPC layer) gates this on
/// `!is_archy_peer(contact_id)`; low-level "just send the bytes" shape
/// mirroring `send_raw_payload` — does NOT record a Sent MeshMessage
/// itself, callers use `record_sent_typed` same as the typed-envelope
/// paths so the Sent card renders identically regardless of which wire
/// format actually went out.
pub async fn send_native_image(
&self,
contact_id: u32,
mime: &str,
bytes: Vec<u8>,
caption: Option<String>,
) -> Result<()> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeImage {
dest_pubkey_prefix: dest_prefix,
mime: mime.to_string(),
bytes,
caption,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
Ok(())
}
/// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline`
@@ -1696,7 +1729,7 @@ impl MeshService {
/// only once we've learned their archipelago identity (DID or x25519 key,
/// from federation seeding or an identity exchange). Stock clients have
/// neither, so we send them plain text rather than typed envelopes.
async fn is_archy_peer(&self, contact_id: u32) -> bool {
pub(crate) async fn is_archy_peer(&self, contact_id: u32) -> bool {
if contact_id & 0x8000_0000 != 0 {
return true;
}
+7
View File
@@ -391,6 +391,11 @@ pub struct ParsedContact {
pub contact_type: u8,
pub path_len: u8,
pub flags: u8,
/// Whether this contact is end-to-end (PKI / Curve25519) capable. Only the
/// Meshtastic adapter sets this (true once we've learned the peer's real
/// NodeInfo public key, so the firmware delivers DMs PKC-encrypted). Meshcore
/// contacts leave it `false` — their E2E status is tracked per-message.
pub pkc_capable: bool,
}
/// Parse RESP_CONTACT (0x03) response.
@@ -433,6 +438,8 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
contact_type,
path_len,
flags,
// Meshcore tracks E2E per message, not per contact.
pkc_capable: false,
})
}
+130
View File
@@ -21,6 +21,7 @@
//! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero
//! changes to route them.
use super::message_types::{self, ContentInlinePayload, MeshMessageType, TypedEnvelope};
use super::protocol::{self, InboundFrame, ParsedContact};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
@@ -327,6 +328,41 @@ impl ReticulumLink {
.await
}
/// Send an image to a peer via LXMF's native `FIELD_IMAGE`, instead of our
/// own typed-envelope wire format — for a stock Sideband/NomadNet peer
/// (not an archy contact), which has no way to decode our CBOR envelope.
/// Caller (the RPC layer) gates this on `is_archy_peer(contact_id) ==
/// false`; archy peers keep using `send_text_msg`/`send_resource` with
/// the typed envelope so rich fields (caption, cid, thumb) survive.
pub async fn send_native_image(
&mut self,
dest_pubkey_prefix: &[u8; 6],
mime: &str,
bytes: &[u8],
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)
)
})?;
self.send_rpc(serde_json::json!({
"cmd": "send",
"dest_hash": hex::encode(dest_hash),
"content": caption.unwrap_or(""),
"method": "direct",
"image_format": mime_to_lxmf_format(mime),
"image_b64": B64.encode(bytes),
}))
.await
}
/// Send `data` (typically an already-built typed-envelope wire blob) to a
/// peer over a dedicated RNS Resource transfer instead of the small LXMF
/// "content" path `send_text_msg` uses — for payloads too large for the
@@ -526,6 +562,51 @@ impl ReticulumLink {
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
// carries photos/files in native LXMF fields, not our own
// typed-envelope wire format. Check those FIRST: if present,
// build the SAME ContentInline typed envelope our own
// attachment pipeline uses, so it renders identically in the
// UI (dispatch.rs's existing ContentInline handling, zero new
// frontend code) instead of the plain text bytes below.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let caption = ev.get("content").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
if let (Some(fmt), Some(b64)) = (
ev.get("image_format").and_then(Value::as_str),
ev.get("image_b64").and_then(Value::as_str),
) {
if let Ok(bytes) = B64.decode(b64) {
match build_content_inline_frame(&prefix, image_format_to_mime(fmt), None, caption, bytes) {
Ok(frame) => {
self.inbound.push_back(frame);
return;
}
Err(e) => warn!("Failed to build native image frame: {}", e),
}
}
}
if let (Some(filename), Some(b64)) = (
ev.get("attachment_filename").and_then(Value::as_str),
ev.get("attachment_b64").and_then(Value::as_str),
) {
if let Ok(bytes) = B64.decode(b64) {
match build_content_inline_frame(
&prefix,
"application/octet-stream",
Some(filename),
caption,
bytes,
) {
Ok(frame) => {
self.inbound.push_back(frame);
return;
}
Err(e) => warn!("Failed to build native attachment frame: {}", e),
}
}
}
let content = ev
.get("content")
.and_then(Value::as_str)
@@ -609,6 +690,55 @@ fn build_synthetic_frame(sender_prefix: &[u8; 6], payload: &[u8]) -> InboundFram
}
}
/// Wrap a native LXMF attachment (image field or file-attachments field, from
/// a stock Sideband/NomadNet peer — see the `Some("recv")` branch above) as
/// the SAME `ContentInline` typed envelope our own attachment pipeline
/// produces, so it renders identically in the UI via the existing
/// `dispatch.rs` `ContentInline` handling — no new frontend code needed.
fn build_content_inline_frame(
sender_prefix: &[u8; 6],
mime: &str,
filename: Option<&str>,
caption: Option<&str>,
bytes: Vec<u8>,
) -> Result<InboundFrame> {
let payload = ContentInlinePayload {
mime: mime.to_string(),
filename: filename.map(str::to_string),
caption: caption.map(str::to_string),
bytes,
};
let encoded = message_types::encode_payload(&payload)?;
let wire = TypedEnvelope::new(MeshMessageType::ContentInline, encoded).to_wire()?;
Ok(build_synthetic_frame(sender_prefix, &wire))
}
/// Map an LXMF `FIELD_IMAGE` format string (Sideband uses bare extensions
/// like "png"/"jpg"/"webp", confirmed against its own source) to a MIME type
/// the frontend's `isImageMime`/`<img>` rendering already understands.
fn image_format_to_mime(fmt: &str) -> &'static str {
match fmt.trim_start_matches('.').to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
_ => "image/png",
}
}
/// Inverse of `image_format_to_mime`, for `send_native_image` — our attach
/// pipeline always compresses to JPEG (`imageCompression.ts`) except the
/// 'original' preset, so this covers the mimes that can actually reach here.
fn mime_to_lxmf_format(mime: &str) -> &'static str {
match mime {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "png",
}
}
/// Derive a stable `u32` contact id from the 16-byte RNS destination hash,
/// masked to the low (non-federation-synthetic) id space. Sibling to
/// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()`