diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 631407c0..7ea1ba09 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -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!( diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index 96de2f50..a8dc8c24 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -340,6 +340,9 @@ pub(super) async fn resolve_peer(state: &Arc, 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, diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 02cf090e..7e469461 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -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` diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index e6a6b2f1..85b416ad 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -73,6 +73,15 @@ pub enum MeshCommand { dest_pubkey_prefix: [u8; 6], payload: Vec, }, + /// 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, + caption: Option, + }, /// 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 diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index e2ead014..46966e03 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -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; diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 85ec7a9b..3fcaeedf 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -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 diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index fde0e21e..9b9b36b7 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -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` 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, /// 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>, /// 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` 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(v: &[u8], s: S) -> Result { + s.serialize_bytes(v) + } + + struct BytesVisitor; + impl<'de> serde::de::Visitor<'de> for BytesVisitor { + type Value = Vec; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a byte string") + } + fn visit_bytes(self, v: &[u8]) -> Result, E> { + Ok(v.to_vec()) + } + fn visit_borrowed_bytes(self, v: &'de [u8]) -> Result, E> { + Ok(v.to_vec()) + } + fn visit_byte_buf(self, v: Vec) -> Result, 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>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(byte) = seq.next_element::()? { + out.push(byte); + } + Ok(out) + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, 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>` 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(v: &Option>, s: S) -> Result { + match v { + Some(bytes) => s.serialize_bytes(bytes), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + #[derive(Deserialize)] + struct Wrapper(#[serde(with = "super::compact_bytes")] Vec); + let opt: Option = 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub caption: Option, + #[serde(with = "compact_bytes")] pub bytes: Vec, } @@ -630,6 +723,59 @@ pub fn decode_payload Deserialize<'a>>(data: &[u8]) -> Result { 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` 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 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()); diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index c0b0ee92..c915f7b3 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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, + caption: Option, + ) -> 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; } diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index a394101a..06b9f6ba 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -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 { contact_type, path_len, flags, + // Meshcore tracks E2E per message, not per contact. + pkc_capable: false, }) } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index 5ffc997e..b25c8da8 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -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, +) -> Result { + 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`/`` 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()` diff --git a/docs/SESSION-1.8.0-OTA-PROGRESS.md b/docs/SESSION-1.8.0-OTA-PROGRESS.md index 8eee403a..aa76288d 100644 --- a/docs/SESSION-1.8.0-OTA-PROGRESS.md +++ b/docs/SESSION-1.8.0-OTA-PROGRESS.md @@ -4,6 +4,136 @@ Updated: 2026-06-30 --- +## ▢️▢️▢️▢️ LIVE CHECKPOINT 2026-06-30 (evening) β€” #17 deployed + verified on .198/.228 + +**#17 (3ccc / stock-peer E2E pill) is now built, deployed, and live-verified** on `.198` and +`.228` only (`.116` skipped per the hardware notice below β€” its radio is mid-reflash to RNode). + +- Built release binary **sha `b1d695fc626a7382`** from the working tree (`cargo check` + + `cargo test -p archipelago mesh::` both green, 99 passed/0 failed/1 ignored, right before + building β€” tree was settled, no collision with the Reticulum agent's concurrent edits). +- Deployed via stop/swap/start to `.198` (192.168.1.198) and `.228` (192.168.1.228), sha256 + confirmed matching on both, `systemctl is-active` = `active` on both (`.228` took its usual + ~couple-minute convergence β€” heavy resilience node, unrelated bitcoind/fedimint container + startup noise in the logs during that window, no mesh errors). +- **Live-verified the actual fix**, not just deploy: on `.198`, `mesh.peers` shows + `"advert_name":"Meshtastic 3ccc", "pkc_capable":true`, and `mesh.send` to 3ccc + (`contact_id:1128152268`) now returns **`"encrypted":true`** β€” confirms the + `archy || peer_pkc_capable(contact_id)` TX fix is live, not just compiled. +- `.228`'s RPC password in memory (`password123`) was stale β€” user confirmed the correct + password is `ThisIsWeb54321@` (same as `.198`/`.116`, i.e. fully unified now). Re-verified via + RPC: `mesh.peers` shows 3ccc `pkc_capable:true`, and `mesh.send` to 3ccc returns + `"encrypted":true` β€” #17 confirmed live on `.228` too, not just `.198`. + +**NOT yet done:** push commit to gitea-vps2 (still uncommitted in the working tree, by design β€” +shares the tree with the Reticulum agent's uncommitted work); user on-device confirmation that +the E2E pill actually renders in the Mesh UI for 3ccc. + +--- + +## πŸ› οΈ HARDWARE NOTICE 2026-06-30 (~16:30) β€” .116's Heltec V3 is being repurposed + +**The Reticulum agent is reflashing .116's Heltec V3 (the board on `/dev/ttyUSB0`, currently +.116's live Meshtastic radio) to RNode firmware**, with explicit user approval, to unblock the +Reticulum Phase-0 hardware gates (real RNode needed; see `docs/RETICULUM-TRANSPORT-PROGRESS.md`). +This was user-confirmed specifically because it takes .116 offline as a Meshtastic radio. + +**Effect on this workstream: do all on-device Meshtastic testing on .198 and .228 only β€” .116 no +longer has a Meshtastic-firmware radio attached once this lands.** `cargo check`/`cargo test +-p archipelago` were both confirmed clean (99/99 mesh tests) right before the reflash started, so +the earlier "wait for their edit to settle" blocker above is cleared β€” software-side it's safe to +build/test/deploy; only .116's *physical radio role* changed. + +--- + +## ▢️▢️▢️ LIVE CHECKPOINT 2026-06-30 (later PM, ~15:50) β€” READ THIS FIRST IF RESUMING + +**#17 (3ccc / stock-peer E2E pill) is CODE-COMPLETE in the working tree**, isolated +to `meshtastic.rs`/`protocol.rs`/`types.rs`/`mod.rs` as planned (no `session.rs` +transport-plumbing changes from this side): +- `ParsedContact.pkc_capable` (`protocol.rs`) + `MeshPeer.pkc_capable` (`types.rs`), + both `#[serde(default)]`/defaulted `false` at every construction site. +- `MeshtasticDevice::get_contacts()` now stamps `pkc_capable` per contact from the + existing `peer_is_pkc_capable(node_num)` seam (de-`allow(dead_code)`'d). +- `listener/session.rs::refresh_contacts` ORs the new value into `MeshPeer.pkc_capable` + (capability only grows, never cleared by a transient refresh) β€” this IS a touch of + session.rs, but additive/non-colliding with the Reticulum device-enum match arms + already there; did not touch transport plumbing/routing. +- `mod.rs::MeshService::send_message` now does `archy || self.peer_pkc_capable(contact_id)` + for the Sent-row `encrypted` flag (was `archy`-only before). +- Verified via `cargo check -p archipelago --bin archipelago` (clean, exit 0) **before** + the other agent's latest edit landed. + +**NOT YET DONE:** rebuild release binary β†’ redeploy 5 nodes β†’ push β†’ user on-device test +(same as #16, both still pending live verification). + +**⚠️ BLOCKED right now β€” do not build/deploy/push until this clears:** the Reticulum +agent is actively mid-edit in the *same* working tree. A `cargo test` run right after +the clean `cargo check` above failed with a real (but transient, not mine) signature +mismatch: `session.rs::auto_detect_and_open` / `run_mesh_session` were observed with a +new `device_kind: Option` param that `listener/mod.rs`'s call site didn't +have yet β€” a normal in-flight snapshot of their work, not a regression to fix here. +**Action on resume: re-run `cargo check` first; if it's clean, the other agent's edit +has settled and it's safe to proceed to build/test/deploy. If still broken, wait β€” +do not stash, revert, or patch their in-progress session.rs/listener/mod.rs changes** +(see memory `feedback_concurrent_agent_tree.md`). Also: building/deploying right now +would bundle their not-yet-finished `reticulum.rs` wiring into the binary β€” confirm +with the user before shipping a combined build, since only the meshtastic `#17` piece +has been asked for/owned by this session. + +--- + +## ▢️▢️ LIVE CHECKPOINT 2026-06-30 (late PM) β€” READ THIS FIRST + +**Fleet state:** all **5 test nodes** on binary **`38c456b0bacec3c4`** + frontend +**`Mesh-CAkPgvLo.js`**, `archipelago` active on each: +`.116`, `.198`, `.228` (LAN, archipelago@ + `~/.ssh/archipelago-deploy`), +`100.72.136.5`, `100.89.209.89` (Tailscale, same key β€” installed this session; +SSH user `archipelago` / pw `ThisIsWeb54321@`; NOPASSWD sudo on all 5). + +**Shipped this session (commit `12e7990b` on `main`, pushed to gitea-vps2):** +- βœ… **#16 public-channel routing** β€” inbound Meshtastic text to `BROADCAST_NUM` + now files under the **public channel thread** (contact_id `u32::MAX - idx`), + attributed to its real sender, instead of polluting per-sender DM threads. + Directed text (`to == our node`) still routes to the DM thread (regression test + `packet_to_inbound_frame_directed_dm_stays_a_contact_message`). `send_channel_text` + now sets `MeshPacket.channel` so archy TX's on channel 0 (public). + Code: `meshtastic.rs` (`packet_to_inbound_frame`, `parse_mesh_packet` to/channel, + `send_channel_text`), `protocol.rs` (`RESP_MESHTASTIC_CHANNEL_TEXT = 0x70`), + `listener/frames.rs` (handler + sender attribution), `Mesh.vue` (`senderLabelFor`). + Tests green (95 mesh tests). **Pending: user on-device test with the radios.** + +**Push access:** `main` is a PROTECTED branch on gitea-vps2. Direct push uses the +dedicated **`ai`** account via remote **`gitea-ai`** (`git push gitea-ai main`). +See memory `reference_gitea_ai_push_account.md`. + +**Coordination:** another agent owns **Reticulum** (`reticulum-daemon/` + Rust +transport wiring). DO NOT touch `mesh/listener/session.rs` transport plumbing or +`mod.rs` routing in ways that collide. Keep #17 work isolated to `meshtastic.rs` +RX/TX + (if needed) the sent-row encrypted flag. + +### βœ… CODE-COMPLETE (not yet deployed/tested live) β€” #17 (3ccc / stock-peer E2E pill) +Goal: DMs **to and from** a PKC-capable stock peer (3ccc, NodeInfo public_key +key_len=32 confirmed) must show the E2E pill. +- **RX side is already correct:** `parse_mesh_packet` reads `public_key` (field 16) + + `pki_encrypted` (field 17) per the MeshPacket proto; the directed-DM RX path + promotes to `RESP_CONTACT_MSG_V3_E2E` when `pki_encrypted`. (Verify live.) +- **TX bug (root cause) β€” FIXED:** `mod.rs::send_message` now records the Sent row + with `encrypted = archy || peer_pkc_capable(contact_id)`. `peer_is_pkc_capable` + (meshtastic.rs) is wired out via `get_contacts()` β†’ `ParsedContact.pkc_capable` β†’ + `refresh_contacts` (session.rs) β†’ `MeshPeer.pkc_capable` β†’ `MeshService::peer_pkc_capable`. + See the LIVE CHECKPOINT at the top of this file for the exact touch points. +- NEXT STEP when resuming: confirm `cargo check` is clean (the other agent's + Reticulum work shares this tree and may be mid-edit β€” see top checkpoint), then + rebuild β†’ redeploy 5 nodes β†’ push β†’ user test (same pending step as #16). + +**Remaining open after #17:** #12 (provisioning robustness β€” HOLD, session.rs churn +risks reticulum collision), #8 (Device-tab settings panel + reboot button β€” RPC +`mesh.reboot-radio` already exists), #6 (onboarding modal), #7 (.116 re-verify), +#14 (RSSI/SNR per-contact indicator), #15 (peer-location map, POSITION_APP portnum=3). + +--- + ## ▢️ RESUME HERE β€” archy↔archy LoRa (2026-06-30 PM) β€” READ FIRST **Goal:** archy↔archy text over Meshtastic LoRa must DELIVER and show the E2E pill, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index a21da110..d1e75e30 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -1276,6 +1276,17 @@ function clearPendingAttachment() { // ── ContentRef attach + fetch (Phase 3b) ────────────────────────────────── const attaching = ref(false) +// Compose row's "+" attach menu β€” a single toggle button that pops open a +// small vertical stack (attach file / voice message) instead of cramming +// each option in as its own always-visible button (was overflowing). +const showAttachMenu = ref(false) +function closeAttachMenuOnOutsideClick(ev: MouseEvent) { + if (!showAttachMenu.value) return + const target = ev.target as HTMLElement + if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false +} +onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick)) +onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick)) const attachError = ref(null) const fetchingCids = ref>(new Set()) const fetchedUrls = ref>(new Map()) @@ -1509,6 +1520,14 @@ let voiceChunks: Blob[] = [] async function startVoiceRecording() { if (isRecordingVoice.value || attaching.value || !activeChatPeer.value) return + // navigator.mediaDevices is only exposed by the browser in a secure context + // (HTTPS, or localhost) β€” undefined entirely on plain-HTTP LAN nodes like + // .116, which throws a confusing "Cannot read properties of undefined" + // instead of the permission-style error handled below. + if (!navigator.mediaDevices?.getUserMedia) { + attachError.value = 'Voice messages need HTTPS (or localhost) β€” the browser blocks microphone access on plain HTTP' + return + } try { voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch (e) { @@ -2099,30 +2118,48 @@ function isImageMime(mime?: string): boolean {
- - +
+ +
+ + +
+
+ +