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:
parent
f54c853128
commit
0eb5c258f5
@ -5,6 +5,7 @@ use crate::mesh::message_types::{
|
||||
Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MeshMessageType,
|
||||
MessageKey, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope,
|
||||
};
|
||||
use crate::mesh::types::radio_transport_label;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
@ -429,17 +430,6 @@ impl RpcHandler {
|
||||
.put(&bytes, &mime, filename.clone(), None, false)
|
||||
.await?;
|
||||
|
||||
let content = ContentInlinePayload {
|
||||
mime: mime.clone(),
|
||||
filename: filename.clone(),
|
||||
caption: caption.clone(),
|
||||
bytes,
|
||||
};
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
let payload = message_types::encode_payload(&content)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let display = match (&filename, &caption) {
|
||||
(Some(f), Some(c)) => format!("📎 {} — {}", f, c),
|
||||
(Some(f), None) => format!("📎 {}", f),
|
||||
@ -447,7 +437,8 @@ impl RpcHandler {
|
||||
(None, None) => format!("📎 {} ({} bytes)", mime, meta.size),
|
||||
};
|
||||
// Render as a content_ref card on the sender side (UI already knows
|
||||
// how to draw it from cid + mime + filename + size).
|
||||
// how to draw it from cid + mime + filename + size) regardless of
|
||||
// which wire format actually goes out — this is a local-only mirror.
|
||||
let typed_json = serde_json::json!({
|
||||
"cid": meta.cid,
|
||||
"size": meta.size,
|
||||
@ -456,27 +447,60 @@ impl RpcHandler {
|
||||
"caption": caption,
|
||||
"inline": true,
|
||||
});
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
|
||||
let msg = if use_resource_transfer {
|
||||
svc.send_content_resource(
|
||||
// A stock (non-archy) peer can't decode our typed-envelope wire
|
||||
// format — send images to them via LXMF's native FIELD_IMAGE
|
||||
// instead, so they actually see the photo (Sideband/NomadNet).
|
||||
let is_archy = svc.is_archy_peer(contact_id).await;
|
||||
let native_image = !is_archy
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& mime.starts_with("image/");
|
||||
|
||||
let msg = if native_image {
|
||||
svc.send_native_image(contact_id, &mime, bytes, caption.clone())
|
||||
.await?;
|
||||
svc.record_sent_typed(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
Some(radio_transport_label(device_type).to_string()),
|
||||
true, // Reticulum/LXMF is unconditionally E2E on every send
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
let content = ContentInlinePayload {
|
||||
mime: mime.clone(),
|
||||
filename: filename.clone(),
|
||||
caption: caption.clone(),
|
||||
bytes,
|
||||
};
|
||||
let payload = message_types::encode_payload(&content)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
if use_resource_transfer {
|
||||
svc.send_content_resource(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
|
||||
@ -340,6 +340,9 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
|
||||
hops: 0xff,
|
||||
last_advert: 0,
|
||||
reachable: true,
|
||||
// Stamped fresh from `peer_pubkeys` in `get_contacts` once a real
|
||||
// contact refresh runs; unknown at synthesis time here.
|
||||
pkc_capable: false,
|
||||
};
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
@ -394,6 +397,7 @@ pub(super) async fn store_plain_message_with_encryption(
|
||||
encrypted: bool,
|
||||
) {
|
||||
let msg_id = state.next_id().await;
|
||||
let radio_transport = radio_transport_label(state.status.read().await.device_type);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
@ -403,7 +407,7 @@ pub(super) async fn store_plain_message_with_encryption(
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted,
|
||||
transport: Some("lora".to_string()),
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
@ -561,6 +565,9 @@ pub(super) async fn handle_identity_received(
|
||||
last_advert: 0,
|
||||
// We just heard this peer's identity advert, so it's reachable.
|
||||
reachable: true,
|
||||
// PKC capability is tracked by the radio driver's get_contacts(), not
|
||||
// known at identity-advert time.
|
||||
pkc_capable: false,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
@ -627,6 +634,7 @@ pub(super) async fn handle_received_message(
|
||||
.map(|p| p.advert_name.clone());
|
||||
|
||||
let msg_id = state.next_id().await;
|
||||
let radio_transport = radio_transport_label(state.status.read().await.device_type);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
@ -636,7 +644,7 @@ pub(super) async fn handle_received_message(
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted,
|
||||
transport: Some("lora".to_string()),
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
|
||||
@ -73,10 +73,12 @@ pub(super) async fn handle_typed_message(
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Radio-delivered → "lora". Stamp after dispatch (see stamp helper).
|
||||
// Radio-delivered → the active device's transport label ("lora" or
|
||||
// "reticulum"). Stamp after dispatch (see stamp helper).
|
||||
let before = max_message_id(state).await;
|
||||
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await;
|
||||
stamp_received_transport(state, sender_contact_id, before, "lora", false).await;
|
||||
let radio_transport = radio_transport_label(state.status.read().await.device_type);
|
||||
stamp_received_transport(state, sender_contact_id, before, radio_transport, false).await;
|
||||
}
|
||||
|
||||
/// Highest stored message id right now. Paired with `stamp_received_transport`
|
||||
|
||||
@ -73,6 +73,15 @@ pub enum MeshCommand {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Native LXMF `FIELD_IMAGE` send — Reticulum-only, for a stock
|
||||
/// (non-archy) peer that can't decode our typed envelope. See
|
||||
/// `MeshRadioDevice::send_native_image`.
|
||||
SendNativeImage {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
mime: String,
|
||||
bytes: Vec<u8>,
|
||||
caption: Option<String>,
|
||||
},
|
||||
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||
|
||||
@ -126,6 +126,26 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an image via native LXMF `FIELD_IMAGE` — Reticulum-only, for a
|
||||
/// stock (non-archy) peer that can't decode our typed envelope. See
|
||||
/// `ReticulumLink::send_native_image`.
|
||||
async fn send_native_image(
|
||||
&mut self,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
caption: Option<&str>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(_) | Self::Meshtastic(_) => {
|
||||
anyhow::bail!("Native image send is Reticulum-only")
|
||||
}
|
||||
Self::Reticulum(device) => {
|
||||
device.send_native_image(dest_pubkey_prefix, mime, bytes, caption).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `data` over a dedicated RNS Resource transfer instead of the
|
||||
/// small-payload "content" path — only Reticulum has anything resembling
|
||||
/// this (a native large-binary transfer protocol over a `RNS.Link`).
|
||||
@ -1111,6 +1131,30 @@ async fn handle_send_command(
|
||||
);
|
||||
}
|
||||
}
|
||||
MeshCommand::SendNativeImage {
|
||||
dest_pubkey_prefix,
|
||||
mime,
|
||||
bytes,
|
||||
caption,
|
||||
} => {
|
||||
if let Err(e) = device
|
||||
.send_native_image(&dest_pubkey_prefix, &mime, &bytes, caption.as_deref())
|
||||
.await
|
||||
{
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Failed to send native image: {}", e
|
||||
);
|
||||
} else {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
len = bytes.len(),
|
||||
"Sent native LXMF image"
|
||||
);
|
||||
}
|
||||
}
|
||||
MeshCommand::BroadcastChannel { channel, payload } => {
|
||||
if let Err(e) = device.send_channel_text(channel, &payload).await {
|
||||
*consecutive_write_failures += 1;
|
||||
|
||||
@ -691,7 +691,20 @@ impl MeshtasticDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
// Stamp E2E capability per contact from `peer_pubkeys` (the real
|
||||
// Curve25519 keys learned from NodeInfo / inbound PKC packets), keyed by
|
||||
// node-num — which is exactly the contacts map key. This lets the send
|
||||
// path mark a Sent DM to ANY PKC-capable peer (e.g. a stock device that
|
||||
// shared its key) as E2E, not just archipelago peers.
|
||||
Ok(self
|
||||
.contacts
|
||||
.iter()
|
||||
.map(|(num, c)| {
|
||||
let mut c = c.clone();
|
||||
c.pkc_capable = self.peer_is_pkc_capable(*num);
|
||||
c
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
@ -740,10 +753,9 @@ impl MeshtasticDevice {
|
||||
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||
/// the shared device interface (which would break meshcore hot-swap).
|
||||
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||
/// the channel PSK. Consumed by `get_contacts` to stamp `ParsedContact
|
||||
/// .pkc_capable`, so the send path can mark a Sent DM to any PKC-capable
|
||||
/// peer as E2E (not just archipelago peers).
|
||||
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||
self.peer_pubkeys
|
||||
.get(&node_num)
|
||||
@ -921,6 +933,8 @@ impl MeshtasticDevice {
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
||||
pkc_capable: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1008,6 +1022,8 @@ fn packet_to_inbound_frame(
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
||||
pkc_capable: false,
|
||||
});
|
||||
|
||||
// Channel broadcast (e.g. the default public LongFast channel, or any other
|
||||
|
||||
@ -192,16 +192,28 @@ pub struct MessageKey {
|
||||
// ─── Wire Envelope ──────────────────────────────────────────────────────
|
||||
|
||||
/// CBOR wire envelope wrapping any typed message.
|
||||
///
|
||||
/// `v`/`sig` MUST use `compact_bytes`/`compact_bytes_opt` — this is the
|
||||
/// envelope EVERY message type wraps its payload in, so plain derived
|
||||
/// `Vec<u8>` encoding here (one CBOR integer per byte instead of a native
|
||||
/// byte string) bloats every single message on the wire, not just
|
||||
/// attachments. Root-caused live: a small ReadReceipt (tiny inner payload)
|
||||
/// crossed the 140-byte single-frame threshold purely from this envelope's
|
||||
/// own array-of-ints tax on `v`, triggering MC-chunked send to a Reticulum
|
||||
/// peer whose chunks then failed to reassemble — surfaced as raw
|
||||
/// `MC000...` fragments in the chat instead of a receipt. Fix this here,
|
||||
/// not just on individual payload structs like `ContentInlinePayload`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypedEnvelope {
|
||||
/// Message type.
|
||||
pub t: u8,
|
||||
/// Payload bytes (type-specific CBOR or raw data).
|
||||
#[serde(with = "compact_bytes")]
|
||||
pub v: Vec<u8>,
|
||||
/// Unix timestamp (seconds since epoch).
|
||||
pub ts: u32,
|
||||
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", with = "compact_bytes_opt")]
|
||||
pub sig: Option<Vec<u8>>,
|
||||
/// Message sequence number (per-sender, monotonically increasing).
|
||||
#[serde(default)]
|
||||
@ -526,6 +538,86 @@ pub struct ContentRefPayload {
|
||||
pub cap_exp: u64,
|
||||
}
|
||||
|
||||
/// Serde's blanket `Serialize`/`Deserialize` for `Vec<u8>` goes through
|
||||
/// `serialize_seq`/one CBOR integer per byte, NOT CBOR's native byte-string
|
||||
/// type — measured ~3.5x wire bloat on a real attachment send (4746 raw
|
||||
/// bytes -> 16638-byte CBOR envelope) before this fix. `serialize_bytes`
|
||||
/// maps to CBOR major type 2 (compact byte string) instead. Only apply this
|
||||
/// to fields that never need JSON round-tripping to the frontend (this one
|
||||
/// is CBOR-wire-only — the frontend gets `cid`/`size`/`mime` metadata built
|
||||
/// by hand, never the raw bytes, see typed_messages.rs's `typed_json`).
|
||||
mod compact_bytes {
|
||||
use serde::{Deserializer, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_bytes(v)
|
||||
}
|
||||
|
||||
struct BytesVisitor;
|
||||
impl<'de> serde::de::Visitor<'de> for BytesVisitor {
|
||||
type Value = Vec<u8>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a byte string")
|
||||
}
|
||||
fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Vec<u8>, E> {
|
||||
Ok(v.to_vec())
|
||||
}
|
||||
fn visit_borrowed_bytes<E: serde::de::Error>(self, v: &'de [u8]) -> Result<Vec<u8>, E> {
|
||||
Ok(v.to_vec())
|
||||
}
|
||||
fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Vec<u8>, E> {
|
||||
Ok(v)
|
||||
}
|
||||
// ciborium's non-self-describing byte-string decode path visits a
|
||||
// seq of u8 in some configurations rather than calling visit_bytes
|
||||
// directly — accept that too so this is robust to the reader mode.
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
|
||||
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
|
||||
while let Some(byte) = seq.next_element::<u8>()? {
|
||||
out.push(byte);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
// NOT deserialize_bytes: ciborium's deserialize_bytes only succeeds
|
||||
// when the byte string fits its small internal scratch buffer —
|
||||
// anything bigger (any real attachment) falls through to an
|
||||
// "invalid type: bytes, expected bytes" error despite the CBOR
|
||||
// header being genuinely Bytes. deserialize_byte_buf streams
|
||||
// segments into an unbounded Vec instead (confirmed against
|
||||
// ciborium 0.2.2's de/mod.rs — deserialize_bytes's `Header::Bytes(Some(len))
|
||||
// if len <= self.scratch.len()` guard vs deserialize_byte_buf's
|
||||
// unconditional `Header::Bytes(len)` streaming path).
|
||||
d.deserialize_byte_buf(BytesVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Option<Vec<u8>>` variant of `compact_bytes` — for wire-only optional byte
|
||||
/// fields (e.g. `TypedEnvelope.sig`) that never need JSON round-tripping.
|
||||
/// Not the same as `base64_opt_bytes` below, which exists specifically
|
||||
/// because `ContentRefPayload.thumb_bytes` DOES need a JSON-friendly (string)
|
||||
/// form for the frontend's `data:` URL — this one stays fully binary.
|
||||
mod compact_bytes_opt {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(bytes) => s.serialize_bytes(bytes),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct Wrapper(#[serde(with = "super::compact_bytes")] Vec<u8>);
|
||||
let opt: Option<Wrapper> = Option::deserialize(d)?;
|
||||
Ok(opt.map(|w| w.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline attachment payload — file bytes carried directly in the envelope.
|
||||
/// Used when the file is small enough to chunk over LoRa and the peer has no
|
||||
/// Tor path. Receiver writes `bytes` to its local BlobStore on reassembly
|
||||
@ -537,6 +629,7 @@ pub struct ContentInlinePayload {
|
||||
pub filename: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
#[serde(with = "compact_bytes")]
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@ -630,6 +723,59 @@ pub fn decode_payload<T: for<'a> Deserialize<'a>>(data: &[u8]) -> Result<T> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_envelope_of_a_small_payload_stays_under_single_frame_budget() {
|
||||
// Regression test: a ReadReceipt (tiny inner payload — one MessageKey)
|
||||
// wrapped in TypedEnvelope crossed the 140-byte single-LoRa-frame
|
||||
// threshold purely from the OUTER envelope's own `v: Vec<u8>` field
|
||||
// using array-of-ints CBOR encoding, live-observed forcing an
|
||||
// unnecessary MC-chunked send whose chunks then failed to reassemble
|
||||
// over Reticulum (surfaced as raw `MC000...` garbage in the chat).
|
||||
let receipt = ReadReceiptPayload {
|
||||
up_to: MessageKey {
|
||||
sender_pubkey: "b550de818bb907047aad60d368668b3815ce2fcb9fc35d8040bb21c5c6217ccc"
|
||||
.to_string(),
|
||||
sender_seq: 42,
|
||||
},
|
||||
};
|
||||
let payload = encode_payload(&receipt).unwrap();
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(1);
|
||||
let wire = envelope.to_wire().unwrap();
|
||||
assert!(
|
||||
wire.len() < 140,
|
||||
"a ReadReceipt envelope should fit one LoRa frame (<140B), got {} bytes — \
|
||||
TypedEnvelope.v is bloating again",
|
||||
wire.len()
|
||||
);
|
||||
let decoded = TypedEnvelope::from_wire(&wire).unwrap();
|
||||
let decoded_receipt: ReadReceiptPayload = decode_payload(&decoded.v).unwrap();
|
||||
assert_eq!(decoded_receipt.up_to, receipt.up_to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_inline_bytes_use_compact_cbor_encoding() {
|
||||
// Regression test: Vec<u8> without #[serde(with = "compact_bytes")]
|
||||
// serializes as one CBOR integer per byte (~3.5x bloat, measured on
|
||||
// a real send: 4746 raw bytes -> 16638-byte wire envelope). Compact
|
||||
// encoding should stay close to the raw size, not balloon with it.
|
||||
let raw = vec![0xABu8; 4746];
|
||||
let payload = ContentInlinePayload {
|
||||
mime: "image/jpeg".to_string(),
|
||||
filename: None,
|
||||
caption: None,
|
||||
bytes: raw.clone(),
|
||||
};
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
assert!(
|
||||
encoded.len() < raw.len() + 200,
|
||||
"expected compact encoding close to {} raw bytes, got {} wire bytes",
|
||||
raw.len(),
|
||||
encoded.len()
|
||||
);
|
||||
let decoded: ContentInlinePayload = decode_payload(&encoded).unwrap();
|
||||
assert_eq!(decoded.bytes, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typed_envelope_wire_roundtrip() {
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Text, b"hello mesh".to_vec());
|
||||
|
||||
@ -1241,6 +1241,39 @@ impl MeshService {
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Send an image via native LXMF `FIELD_IMAGE` instead of our own typed
|
||||
/// envelope — for a stock (non-archy) peer that can't decode our CBOR
|
||||
/// wire format. Caller (the RPC layer) gates this on
|
||||
/// `!is_archy_peer(contact_id)`; low-level "just send the bytes" shape
|
||||
/// mirroring `send_raw_payload` — does NOT record a Sent MeshMessage
|
||||
/// itself, callers use `record_sent_typed` same as the typed-envelope
|
||||
/// paths so the Sent card renders identically regardless of which wire
|
||||
/// format actually went out.
|
||||
pub async fn send_native_image(
|
||||
&self,
|
||||
contact_id: u32,
|
||||
mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
caption: Option<String>,
|
||||
) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
self.state
|
||||
.send_cmd(listener::MeshCommand::SendNativeImage {
|
||||
dest_pubkey_prefix: dest_prefix,
|
||||
mime: mime.to_string(),
|
||||
bytes,
|
||||
caption,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
|
||||
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path
|
||||
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline`
|
||||
@ -1696,7 +1729,7 @@ impl MeshService {
|
||||
/// only once we've learned their archipelago identity (DID or x25519 key,
|
||||
/// from federation seeding or an identity exchange). Stock clients have
|
||||
/// neither, so we send them plain text rather than typed envelopes.
|
||||
async fn is_archy_peer(&self, contact_id: u32) -> bool {
|
||||
pub(crate) async fn is_archy_peer(&self, contact_id: u32) -> bool {
|
||||
if contact_id & 0x8000_0000 != 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -391,6 +391,11 @@ pub struct ParsedContact {
|
||||
pub contact_type: u8,
|
||||
pub path_len: u8,
|
||||
pub flags: u8,
|
||||
/// Whether this contact is end-to-end (PKI / Curve25519) capable. Only the
|
||||
/// Meshtastic adapter sets this (true once we've learned the peer's real
|
||||
/// NodeInfo public key, so the firmware delivers DMs PKC-encrypted). Meshcore
|
||||
/// contacts leave it `false` — their E2E status is tracked per-message.
|
||||
pub pkc_capable: bool,
|
||||
}
|
||||
|
||||
/// Parse RESP_CONTACT (0x03) response.
|
||||
@ -433,6 +438,8 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
contact_type,
|
||||
path_len,
|
||||
flags,
|
||||
// Meshcore tracks E2E per message, not per contact.
|
||||
pkc_capable: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
//! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero
|
||||
//! changes to route them.
|
||||
|
||||
use super::message_types::{self, ContentInlinePayload, MeshMessageType, TypedEnvelope};
|
||||
use super::protocol::{self, InboundFrame, ParsedContact};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
@ -327,6 +328,41 @@ impl ReticulumLink {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an image to a peer via LXMF's native `FIELD_IMAGE`, instead of our
|
||||
/// own typed-envelope wire format — for a stock Sideband/NomadNet peer
|
||||
/// (not an archy contact), which has no way to decode our CBOR envelope.
|
||||
/// Caller (the RPC layer) gates this on `is_archy_peer(contact_id) ==
|
||||
/// false`; archy peers keep using `send_text_msg`/`send_resource` with
|
||||
/// the typed envelope so rich fields (caption, cid, thumb) survive.
|
||||
pub async fn send_native_image(
|
||||
&mut self,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
caption: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
self.send_rpc(serde_json::json!({
|
||||
"cmd": "send",
|
||||
"dest_hash": hex::encode(dest_hash),
|
||||
"content": caption.unwrap_or(""),
|
||||
"method": "direct",
|
||||
"image_format": mime_to_lxmf_format(mime),
|
||||
"image_b64": B64.encode(bytes),
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send `data` (typically an already-built typed-envelope wire blob) to a
|
||||
/// peer over a dedicated RNS Resource transfer instead of the small LXMF
|
||||
/// "content" path `send_text_msg` uses — for payloads too large for the
|
||||
@ -526,6 +562,51 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, source_hash);
|
||||
|
||||
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
||||
// carries photos/files in native LXMF fields, not our own
|
||||
// typed-envelope wire format. Check those FIRST: if present,
|
||||
// build the SAME ContentInline typed envelope our own
|
||||
// attachment pipeline uses, so it renders identically in the
|
||||
// UI (dispatch.rs's existing ContentInline handling, zero new
|
||||
// frontend code) instead of the plain text bytes below.
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let caption = ev.get("content").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
|
||||
if let (Some(fmt), Some(b64)) = (
|
||||
ev.get("image_format").and_then(Value::as_str),
|
||||
ev.get("image_b64").and_then(Value::as_str),
|
||||
) {
|
||||
if let Ok(bytes) = B64.decode(b64) {
|
||||
match build_content_inline_frame(&prefix, image_format_to_mime(fmt), None, caption, bytes) {
|
||||
Ok(frame) => {
|
||||
self.inbound.push_back(frame);
|
||||
return;
|
||||
}
|
||||
Err(e) => warn!("Failed to build native image frame: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(filename), Some(b64)) = (
|
||||
ev.get("attachment_filename").and_then(Value::as_str),
|
||||
ev.get("attachment_b64").and_then(Value::as_str),
|
||||
) {
|
||||
if let Ok(bytes) = B64.decode(b64) {
|
||||
match build_content_inline_frame(
|
||||
&prefix,
|
||||
"application/octet-stream",
|
||||
Some(filename),
|
||||
caption,
|
||||
bytes,
|
||||
) {
|
||||
Ok(frame) => {
|
||||
self.inbound.push_back(frame);
|
||||
return;
|
||||
}
|
||||
Err(e) => warn!("Failed to build native attachment frame: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = ev
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
@ -609,6 +690,55 @@ fn build_synthetic_frame(sender_prefix: &[u8; 6], payload: &[u8]) -> InboundFram
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a native LXMF attachment (image field or file-attachments field, from
|
||||
/// a stock Sideband/NomadNet peer — see the `Some("recv")` branch above) as
|
||||
/// the SAME `ContentInline` typed envelope our own attachment pipeline
|
||||
/// produces, so it renders identically in the UI via the existing
|
||||
/// `dispatch.rs` `ContentInline` handling — no new frontend code needed.
|
||||
fn build_content_inline_frame(
|
||||
sender_prefix: &[u8; 6],
|
||||
mime: &str,
|
||||
filename: Option<&str>,
|
||||
caption: Option<&str>,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<InboundFrame> {
|
||||
let payload = ContentInlinePayload {
|
||||
mime: mime.to_string(),
|
||||
filename: filename.map(str::to_string),
|
||||
caption: caption.map(str::to_string),
|
||||
bytes,
|
||||
};
|
||||
let encoded = message_types::encode_payload(&payload)?;
|
||||
let wire = TypedEnvelope::new(MeshMessageType::ContentInline, encoded).to_wire()?;
|
||||
Ok(build_synthetic_frame(sender_prefix, &wire))
|
||||
}
|
||||
|
||||
/// Map an LXMF `FIELD_IMAGE` format string (Sideband uses bare extensions
|
||||
/// like "png"/"jpg"/"webp", confirmed against its own source) to a MIME type
|
||||
/// the frontend's `isImageMime`/`<img>` rendering already understands.
|
||||
fn image_format_to_mime(fmt: &str) -> &'static str {
|
||||
match fmt.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
_ => "image/png",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `image_format_to_mime`, for `send_native_image` — our attach
|
||||
/// pipeline always compresses to JPEG (`imageCompression.ts`) except the
|
||||
/// 'original' preset, so this covers the mimes that can actually reach here.
|
||||
fn mime_to_lxmf_format(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/bmp" => "bmp",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a stable `u32` contact id from the 16-byte RNS destination hash,
|
||||
/// masked to the low (non-federation-synthetic) id space. Sibling to
|
||||
/// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()`
|
||||
|
||||
@ -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
|
||||
|
||||
**Goal:** archy↔archy text over Meshtastic LoRa must DELIVER and show the E2E pill,
|
||||
|
||||
@ -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<string | null>(null)
|
||||
const fetchingCids = ref<Set<string>>(new Set())
|
||||
const fetchedUrls = ref<Map<string, string>>(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 {
|
||||
<button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment">✕</button>
|
||||
</div>
|
||||
<div class="mesh-chat-compose-row">
|
||||
<label
|
||||
v-if="activeChatPeer"
|
||||
class="glass-button mesh-chat-attach-btn"
|
||||
:class="{ 'is-busy': attaching }"
|
||||
:title="attaching ? 'uploading…' : 'Attach file'"
|
||||
>
|
||||
<input type="file" @change="handleAttachFile" style="display:none;" :disabled="attaching" />
|
||||
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>📎</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="activeChatPeer"
|
||||
type="button"
|
||||
class="glass-button mesh-chat-record-btn"
|
||||
:class="{ 'is-recording': isRecordingVoice }"
|
||||
:disabled="attaching"
|
||||
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
|
||||
@pointerdown.prevent="startVoiceRecording"
|
||||
@pointerup.prevent="stopVoiceRecording"
|
||||
@pointerleave="stopVoiceRecordingIfActive"
|
||||
>
|
||||
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>🎤</span>
|
||||
</button>
|
||||
<div v-if="activeChatPeer" class="mesh-attach-menu-anchor">
|
||||
<Transition name="mesh-attach-stack">
|
||||
<div v-if="showAttachMenu" class="mesh-attach-stack">
|
||||
<label
|
||||
class="glass-button mesh-chat-attach-btn"
|
||||
:class="{ 'is-busy': attaching }"
|
||||
:title="attaching ? 'uploading…' : 'Attach file'"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@change="(e) => { showAttachMenu = false; handleAttachFile(e) }"
|
||||
style="display:none;"
|
||||
:disabled="attaching"
|
||||
/>
|
||||
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>📎</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="glass-button mesh-chat-record-btn"
|
||||
:class="{ 'is-recording': isRecordingVoice }"
|
||||
:disabled="attaching"
|
||||
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
|
||||
@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
|
||||
v-model="messageText"
|
||||
class="mesh-chat-input"
|
||||
|
||||
@ -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-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); } }
|
||||
|
||||
/* "+" 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:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
|
||||
@ -202,10 +202,11 @@ class ReticulumDaemon:
|
||||
|
||||
# ---- RNS-thread callbacks → asyncio ----
|
||||
def _on_lxmf_delivery(self, message):
|
||||
import LXMF
|
||||
try:
|
||||
app_data = b""
|
||||
src = message.source_hash.hex() if message.source_hash else ""
|
||||
self._emit_threadsafe({
|
||||
event = {
|
||||
"event": "recv",
|
||||
"source_hash": src,
|
||||
"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 "",
|
||||
"app_data": app_data.hex(),
|
||||
"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
|
||||
self._emit_threadsafe({"event": "error", "where": "delivery", "detail": str(e)})
|
||||
|
||||
@ -293,9 +314,17 @@ class ReticulumDaemon:
|
||||
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
|
||||
"propagated": LXMF.LXMessage.PROPAGATED}.get(
|
||||
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,
|
||||
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(
|
||||
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "delivered",
|
||||
"id": m.hash.hex() if m.hash else ""}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user