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
@@ -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