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
+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()`