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:
co-authored by
Claude Sonnet 5
parent
f54c853128
commit
0eb5c258f5
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()`
|
||||
|
||||
Reference in New Issue
Block a user