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
parent f54c853128
commit 0eb5c258f5
14 changed files with 694 additions and 63 deletions

View File

@ -5,6 +5,7 @@ use crate::mesh::message_types::{
Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MeshMessageType, Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MeshMessageType,
MessageKey, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope, MessageKey, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope,
}; };
use crate::mesh::types::radio_transport_label;
use anyhow::Result; use anyhow::Result;
use tracing::info; use tracing::info;
@ -429,17 +430,6 @@ impl RpcHandler {
.put(&bytes, &mime, filename.clone(), None, false) .put(&bytes, &mime, filename.clone(), None, false)
.await?; .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) { let display = match (&filename, &caption) {
(Some(f), Some(c)) => format!("📎 {}{}", f, c), (Some(f), Some(c)) => format!("📎 {}{}", f, c),
(Some(f), None) => format!("📎 {}", f), (Some(f), None) => format!("📎 {}", f),
@ -447,7 +437,8 @@ impl RpcHandler {
(None, None) => format!("📎 {} ({} bytes)", mime, meta.size), (None, None) => format!("📎 {} ({} bytes)", mime, meta.size),
}; };
// Render as a content_ref card on the sender side (UI already knows // 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!({ let typed_json = serde_json::json!({
"cid": meta.cid, "cid": meta.cid,
"size": meta.size, "size": meta.size,
@ -456,27 +447,60 @@ impl RpcHandler {
"caption": caption, "caption": caption,
"inline": true, "inline": true,
}); });
let seq = svc.next_send_seq(contact_id).await;
let msg = if use_resource_transfer { // A stock (non-archy) peer can't decode our typed-envelope wire
svc.send_content_resource( // 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, contact_id,
wire,
"content_ref", "content_ref",
&display, &display,
Some(typed_json), Some(typed_json),
seq, seq,
Some(radio_transport_label(device_type).to_string()),
true, // Reticulum/LXMF is unconditionally E2E on every send
) )
.await? .await
} else { } else {
svc.send_typed_wire( let content = ContentInlinePayload {
contact_id, mime: mime.clone(),
wire, filename: filename.clone(),
"content_ref", caption: caption.clone(),
&display, bytes,
Some(typed_json), };
seq, let payload = message_types::encode_payload(&content)?;
) let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
.await? 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!( info!(

View File

@ -340,6 +340,9 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
hops: 0xff, hops: 0xff,
last_advert: 0, last_advert: 0,
reachable: true, 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 is_new = {
let mut peers = state.peers.write().await; let mut peers = state.peers.write().await;
@ -394,6 +397,7 @@ pub(super) async fn store_plain_message_with_encryption(
encrypted: bool, encrypted: bool,
) { ) {
let msg_id = state.next_id().await; let msg_id = state.next_id().await;
let radio_transport = radio_transport_label(state.status.read().await.device_type);
let msg = MeshMessage { let msg = MeshMessage {
id: msg_id, id: msg_id,
direction: MessageDirection::Received, direction: MessageDirection::Received,
@ -403,7 +407,7 @@ pub(super) async fn store_plain_message_with_encryption(
timestamp: chrono::Utc::now().to_rfc3339(), timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true, delivered: true,
encrypted, encrypted,
transport: Some("lora".to_string()), transport: Some(radio_transport.to_string()),
message_type: "text".to_string(), message_type: "text".to_string(),
typed_payload: None, typed_payload: None,
sender_pubkey: None, sender_pubkey: None,
@ -561,6 +565,9 @@ pub(super) async fn handle_identity_received(
last_advert: 0, last_advert: 0,
// We just heard this peer's identity advert, so it's reachable. // We just heard this peer's identity advert, so it's reachable.
reachable: true, 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 = { let is_new = {
@ -627,6 +634,7 @@ pub(super) async fn handle_received_message(
.map(|p| p.advert_name.clone()); .map(|p| p.advert_name.clone());
let msg_id = state.next_id().await; let msg_id = state.next_id().await;
let radio_transport = radio_transport_label(state.status.read().await.device_type);
let msg = MeshMessage { let msg = MeshMessage {
id: msg_id, id: msg_id,
direction: MessageDirection::Received, direction: MessageDirection::Received,
@ -636,7 +644,7 @@ pub(super) async fn handle_received_message(
timestamp: chrono::Utc::now().to_rfc3339(), timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true, delivered: true,
encrypted, encrypted,
transport: Some("lora".to_string()), transport: Some(radio_transport.to_string()),
message_type: "text".to_string(), message_type: "text".to_string(),
typed_payload: None, typed_payload: None,
sender_pubkey: None, sender_pubkey: None,

View File

@ -73,10 +73,12 @@ pub(super) async fn handle_typed_message(
return; 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; let before = max_message_id(state).await;
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).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` /// Highest stored message id right now. Paired with `stamp_received_transport`

View File

@ -73,6 +73,15 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6], dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>, 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 /// 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 /// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's /// — never MC-chunked — because stock clients can't reassemble archy's

View File

@ -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 /// Send `data` over a dedicated RNS Resource transfer instead of the
/// small-payload "content" path — only Reticulum has anything resembling /// small-payload "content" path — only Reticulum has anything resembling
/// this (a native large-binary transfer protocol over a `RNS.Link`). /// 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 } => { MeshCommand::BroadcastChannel { channel, payload } => {
if let Err(e) = device.send_channel_text(channel, &payload).await { if let Err(e) = device.send_channel_text(channel, &payload).await {
*consecutive_write_failures += 1; *consecutive_write_failures += 1;

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<()> { 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 /// 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 /// 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 /// 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 /// the channel PSK. Consumed by `get_contacts` to stamp `ParsedContact
/// distinguish a true E2E DM from a channel-encrypted one without changing /// .pkc_capable`, so the send path can mark a Sent DM to any PKC-capable
/// the shared device interface (which would break meshcore hot-swap). /// peer as E2E (not just archipelago peers).
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool { pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
self.peer_pubkeys self.peer_pubkeys
.get(&node_num) .get(&node_num)
@ -921,6 +933,8 @@ impl MeshtasticDevice {
contact_type: 1, contact_type: 1,
path_len: 0xff, path_len: 0xff,
flags: 0, 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, contact_type: 1,
path_len: 0xff, path_len: 0xff,
flags: 0, 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 // Channel broadcast (e.g. the default public LongFast channel, or any other

View File

@ -192,16 +192,28 @@ pub struct MessageKey {
// ─── Wire Envelope ────────────────────────────────────────────────────── // ─── Wire Envelope ──────────────────────────────────────────────────────
/// CBOR wire envelope wrapping any typed message. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypedEnvelope { pub struct TypedEnvelope {
/// Message type. /// Message type.
pub t: u8, pub t: u8,
/// Payload bytes (type-specific CBOR or raw data). /// Payload bytes (type-specific CBOR or raw data).
#[serde(with = "compact_bytes")]
pub v: Vec<u8>, pub v: Vec<u8>,
/// Unix timestamp (seconds since epoch). /// Unix timestamp (seconds since epoch).
pub ts: u32, pub ts: u32,
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages. /// 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>>, pub sig: Option<Vec<u8>>,
/// Message sequence number (per-sender, monotonically increasing). /// Message sequence number (per-sender, monotonically increasing).
#[serde(default)] #[serde(default)]
@ -526,6 +538,86 @@ pub struct ContentRefPayload {
pub cap_exp: u64, 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. /// 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 /// 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 /// Tor path. Receiver writes `bytes` to its local BlobStore on reassembly
@ -537,6 +629,7 @@ pub struct ContentInlinePayload {
pub filename: Option<String>, pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>, pub caption: Option<String>,
#[serde(with = "compact_bytes")]
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
} }
@ -630,6 +723,59 @@ pub fn decode_payload<T: for<'a> Deserialize<'a>>(data: &[u8]) -> Result<T> {
mod tests { mod tests {
use super::*; 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] #[test]
fn test_typed_envelope_wire_roundtrip() { fn test_typed_envelope_wire_roundtrip() {
let envelope = TypedEnvelope::new(MeshMessageType::Text, b"hello mesh".to_vec()); let envelope = TypedEnvelope::new(MeshMessageType::Text, b"hello mesh".to_vec());

View File

@ -1241,6 +1241,39 @@ impl MeshService {
.await) .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 /// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path /// (`MeshCommand::SendResource`) instead of the small inline-chunk path
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline` /// `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, /// only once we've learned their archipelago identity (DID or x25519 key,
/// from federation seeding or an identity exchange). Stock clients have /// from federation seeding or an identity exchange). Stock clients have
/// neither, so we send them plain text rather than typed envelopes. /// 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 { if contact_id & 0x8000_0000 != 0 {
return true; return true;
} }

View File

@ -391,6 +391,11 @@ pub struct ParsedContact {
pub contact_type: u8, pub contact_type: u8,
pub path_len: u8, pub path_len: u8,
pub flags: 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. /// Parse RESP_CONTACT (0x03) response.
@ -433,6 +438,8 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
contact_type, contact_type,
path_len, path_len,
flags, flags,
// Meshcore tracks E2E per message, not per contact.
pkc_capable: false,
}) })
} }

View File

@ -21,6 +21,7 @@
//! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero //! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero
//! changes to route them. //! changes to route them.
use super::message_types::{self, ContentInlinePayload, MeshMessageType, TypedEnvelope};
use super::protocol::{self, InboundFrame, ParsedContact}; use super::protocol::{self, InboundFrame, ParsedContact};
use super::types::DeviceInfo; use super::types::DeviceInfo;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -327,6 +328,41 @@ impl ReticulumLink {
.await .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 /// Send `data` (typically an already-built typed-envelope wire blob) to a
/// peer over a dedicated RNS Resource transfer instead of the small LXMF /// peer over a dedicated RNS Resource transfer instead of the small LXMF
/// "content" path `send_text_msg` uses — for payloads too large for the /// "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(); let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash); 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 let content = ev
.get("content") .get("content")
.and_then(Value::as_str) .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, /// Derive a stable `u32` contact id from the 16-byte RNS destination hash,
/// masked to the low (non-federation-synthetic) id space. Sibling to /// masked to the low (non-federation-synthetic) id space. Sibling to
/// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()` /// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()`

View File

@ -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<DeviceType>` 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 ## ▶️ 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, **Goal:** archy↔archy text over Meshtastic LoRa must DELIVER and show the E2E pill,

View File

@ -1276,6 +1276,17 @@ function clearPendingAttachment() {
// ContentRef attach + fetch (Phase 3b) // ContentRef attach + fetch (Phase 3b)
const attaching = ref(false) 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<string | null>(null) const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set()) const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map()) const fetchedUrls = ref<Map<string, string>>(new Map())
@ -1509,6 +1520,14 @@ let voiceChunks: Blob[] = []
async function startVoiceRecording() { async function startVoiceRecording() {
if (isRecordingVoice.value || attaching.value || !activeChatPeer.value) return 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 { try {
voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true }) voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) { } catch (e) {
@ -2099,30 +2118,48 @@ function isImageMime(mime?: string): boolean {
<button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment"></button> <button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment"></button>
</div> </div>
<div class="mesh-chat-compose-row"> <div class="mesh-chat-compose-row">
<label <div v-if="activeChatPeer" class="mesh-attach-menu-anchor">
v-if="activeChatPeer" <Transition name="mesh-attach-stack">
class="glass-button mesh-chat-attach-btn" <div v-if="showAttachMenu" class="mesh-attach-stack">
:class="{ 'is-busy': attaching }" <label
:title="attaching ? 'uploading…' : 'Attach file'" class="glass-button mesh-chat-attach-btn"
> :class="{ 'is-busy': attaching }"
<input type="file" @change="handleAttachFile" style="display:none;" :disabled="attaching" /> :title="attaching ? 'uploading…' : 'Attach file'"
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span> >
<span v-else>📎</span> <input
</label> type="file"
<button @change="(e) => { showAttachMenu = false; handleAttachFile(e) }"
v-if="activeChatPeer" style="display:none;"
type="button" :disabled="attaching"
class="glass-button mesh-chat-record-btn" />
:class="{ 'is-recording': isRecordingVoice }" <span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
:disabled="attaching" <span v-else>📎</span>
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'" </label>
@pointerdown.prevent="startVoiceRecording" <button
@pointerup.prevent="stopVoiceRecording" type="button"
@pointerleave="stopVoiceRecordingIfActive" class="glass-button mesh-chat-record-btn"
> :class="{ 'is-recording': isRecordingVoice }"
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span> :disabled="attaching"
<span v-else>🎤</span> :title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
</button> @pointerdown.prevent="startVoiceRecording"
@pointerup.prevent="() => { stopVoiceRecording(); showAttachMenu = false }"
@pointerleave="() => { stopVoiceRecordingIfActive(); showAttachMenu = false }"
>
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>🎤</span>
</button>
</div>
</Transition>
<button
type="button"
class="glass-button mesh-chat-plus-btn"
:class="{ 'is-open': showAttachMenu }"
title="Attach"
@click="showAttachMenu = !showAttachMenu"
>
+
</button>
</div>
<input <input
v-model="messageText" v-model="messageText"
class="mesh-chat-input" class="mesh-chat-input"

View File

@ -472,6 +472,22 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; } .mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
.mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; } .mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; }
@keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } } @keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } }
/* "+" attach menu replaces individually-visible attach/record buttons
(was overflowing the compose row) with one toggle + an animated stack. */
.mesh-attach-menu-anchor { position: relative; flex-shrink: 0; }
.mesh-chat-plus-btn { font-size: 1.3rem; line-height: 1; font-weight: 300; transition: transform 0.2s ease; }
.mesh-chat-plus-btn.is-open { transform: rotate(45deg); background: rgba(255,255,255,0.14); }
.mesh-attach-stack {
position: absolute; bottom: calc(100% + 8px); left: 0;
display: flex; flex-direction: column; gap: 8px;
}
.mesh-attach-stack-enter-active, .mesh-attach-stack-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.mesh-attach-stack-enter-from, .mesh-attach-stack-leave-to {
opacity: 0; transform: translateY(8px) scale(0.9);
}
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); } .mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; } .mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }

View File

@ -202,10 +202,11 @@ class ReticulumDaemon:
# ---- RNS-thread callbacks → asyncio ---- # ---- RNS-thread callbacks → asyncio ----
def _on_lxmf_delivery(self, message): def _on_lxmf_delivery(self, message):
import LXMF
try: try:
app_data = b"" app_data = b""
src = message.source_hash.hex() if message.source_hash else "" src = message.source_hash.hex() if message.source_hash else ""
self._emit_threadsafe({ event = {
"event": "recv", "event": "recv",
"source_hash": src, "source_hash": src,
"content": message.content_as_string() if hasattr(message, "content_as_string") "content": message.content_as_string() if hasattr(message, "content_as_string")
@ -213,7 +214,27 @@ class ReticulumDaemon:
"title": message.title_as_string() if hasattr(message, "title_as_string") else "", "title": message.title_as_string() if hasattr(message, "title_as_string") else "",
"app_data": app_data.hex(), "app_data": app_data.hex(),
"stamp": getattr(message, "timestamp", None), "stamp": getattr(message, "timestamp", None),
}) }
# Native LXMF attachment fields (Sideband/NomadNet/stock clients use
# these, NOT our own typed-envelope wire format) — a stock client's
# photo/voice-memo/file arrives here, not in `content`, which is why
# it was previously dropped silently (content was just blank/space).
# See LXMF field format confirmed against Sideband's own source
# (sbapp/sideband/core.py): FIELD_IMAGE = [format_str, bytes],
# FIELD_AUDIO = [mode_byte, bytes], FIELD_FILE_ATTACHMENTS =
# [[filename, bytes], ...].
fields = getattr(message, "fields", None) or {}
if LXMF.FIELD_IMAGE in fields:
fmt, img_bytes = fields[LXMF.FIELD_IMAGE]
event["image_format"] = str(fmt)
event["image_b64"] = base64.b64encode(bytes(img_bytes)).decode("ascii")
if LXMF.FIELD_FILE_ATTACHMENTS in fields:
attachments = fields[LXMF.FIELD_FILE_ATTACHMENTS]
if attachments:
filename, file_bytes = attachments[0]
event["attachment_filename"] = str(filename)
event["attachment_b64"] = base64.b64encode(bytes(file_bytes)).decode("ascii")
self._emit_threadsafe(event)
except Exception as e: # never let a callback kill the RNS thread except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "delivery", "detail": str(e)}) self._emit_threadsafe({"event": "error", "where": "delivery", "detail": str(e)})
@ -293,9 +314,17 @@ class ReticulumDaemon:
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC, "opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
"propagated": LXMF.LXMessage.PROPAGATED}.get( "propagated": LXMF.LXMessage.PROPAGATED}.get(
req.get("method", "direct"), LXMF.LXMessage.DIRECT) req.get("method", "direct"), LXMF.LXMessage.DIRECT)
# Native LXMF FIELD_IMAGE — for a stock Sideband/NomadNet peer, which
# has no idea how to decode our own typed-envelope wire format. Rust
# only sets these two keys when the peer isn't an archy contact (see
# `is_archy_peer` gating in typed_messages.rs); [format, bytes] is the
# wire shape confirmed against Sideband's own source.
fields = {}
if req.get("image_b64") and req.get("image_format"):
fields[LXMF.FIELD_IMAGE] = [req["image_format"], base64.b64decode(req["image_b64"])]
msg = LXMF.LXMessage(dest, self.delivery_destination, msg = LXMF.LXMessage(dest, self.delivery_destination,
req.get("content", ""), req.get("title", ""), req.get("content", ""), req.get("title", ""),
desired_method=method) desired_method=method, fields=fields or None)
msg.register_delivery_callback(lambda m: self._emit_threadsafe( msg.register_delivery_callback(lambda m: self._emit_threadsafe(
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "delivered", {"event": "delivered", "dest_hash": req["dest_hash"], "state": "delivered",
"id": m.hash.hex() if m.hash else ""})) "id": m.hash.hex() if m.hash else ""}))