feat(mesh,ui): per-message transport pill (Mesh/FIPS/Tor) + fix E2E pill

Adds a per-message transport badge to archy↔archy mesh chats and fixes the
long-broken E2E badge — both meshcore and meshtastic, styled like the existing
E2E pill.

Transport pill:
- New `MeshMessage.transport` ("lora"/"fips"/"tor"), surfaced in the UI beside
  the E2E badge (Mesh.vue transportLabel() → Mesh/FIPS/Tor, mesh-styles.css).
- Sent LoRa → "lora"; sent federation → finalized to the real leg ("fips"/"tor")
  once the background send resolves (req.send_json transport), via an id-keyed
  store update.
- Received: a post-dispatch stamp on handle_typed_envelope_direct's output
  (monotonic ids) tags both transports without threading through all 20 typed-
  dispatch sites — radio wrapper stamps "lora", federation injector stamps the
  peer's last_transport ("fips"/"tor", default tor; the inbound HTTP carries no
  FIPS-vs-Tor signal).
- Plain native/channel LoRa frames → "lora"; channel broadcasts stay non-E2E.

E2E pill fix:
- `encrypted` was hardcoded false at every MeshMessage construction site, so the
  UI badge (Mesh.vue `v-if="msg.encrypted"`) never showed. Now: federation
  envelopes are E2E (identity-signed over an encrypted transport); the meshcore
  native-DM receive path already had a real `encrypted` flag (now also tagged
  with transport). meshtastic-PKI radio E2E flag threading is a noted follow-up.

Backend cargo check + frontend vue-tsc build both green. Needs a live radio +
multi-transport pass on .116/.228 to confirm end-to-end (see
project_transport_pill / project_meshtastic_parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-29 04:29:25 -04:00
parent 169ff2e2cd
commit 11038cdcc9
7 changed files with 141 additions and 2 deletions

View File

@ -343,7 +343,10 @@ pub(super) async fn store_plain_message(
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
// Plain native text only ever arrives over the radio; not E2E (it's a
// stock-client / channel plaintext frame).
encrypted: false,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@ -576,6 +579,7 @@ pub(super) async fn handle_received_message(
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,

View File

@ -34,7 +34,10 @@ async fn store_typed_message(
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
// transport + E2E are stamped post-dispatch by
// handle_typed_envelope_direct, which alone knows the receive transport.
encrypted: false,
transport: None,
message_type: type_label.to_string(),
typed_payload,
sender_pubkey,
@ -70,7 +73,53 @@ pub(super) async fn handle_typed_message(
return;
}
};
// Radio-delivered → "lora". 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;
}
/// Highest stored message id right now. Paired with `stamp_received_transport`
/// to identify messages a dispatch call just stored (ids are monotonic).
pub(crate) async fn max_message_id(state: &Arc<MeshState>) -> u64 {
state
.messages
.read()
.await
.iter()
.map(|m| m.id)
.max()
.unwrap_or(0)
}
/// Stamp the per-message transport pill (and E2E flag) onto every RECEIVED
/// message from `sender_contact_id` stored since `after_id` — i.e. the ones the
/// just-completed `handle_typed_envelope_direct` produced. This is how both the
/// radio path ("lora") and the federation path ("fips"/"tor") tag inbound
/// messages without threading transport through all 20 typed-dispatch sites.
/// `encrypted` only ever sets the flag true (a federation envelope is E2E),
/// never clears a true set elsewhere.
pub(crate) async fn stamp_received_transport(
state: &Arc<MeshState>,
sender_contact_id: u32,
after_id: u64,
transport: &str,
encrypted: bool,
) {
let mut messages = state.messages.write().await;
for m in messages.iter_mut() {
if m.id > after_id
&& matches!(m.direction, MessageDirection::Received)
&& m.peer_contact_id == sender_contact_id
{
if m.transport.is_none() {
m.transport = Some(transport.to_string());
}
if encrypted {
m.encrypted = true;
}
}
}
}
/// Dispatch a pre-decoded TypedEnvelope. Shared between the radio receive

View File

@ -1196,6 +1196,11 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
Some("lora".to_string()),
// Archy↔archy typed envelopes over LoRa are identity-signed; the
// radio E2E flag (meshtastic PKI / meshcore session) isn't
// threaded to the send side yet, so don't over-claim E2E here.
false,
)
.await)
}
@ -1251,6 +1256,11 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
// Transport is finalized below once the background send resolves
// FIPS vs Tor; mark E2E now — a federation envelope is
// identity-signed and rides an encrypted transport.
None,
true,
)
.await;
@ -1260,6 +1270,10 @@ impl MeshService {
// MeshMessage and the UI's delivery indicator tracks the receipt.
let peer_onion_owned = peer_onion.to_string();
let data_dir_owned = self.data_dir.clone();
// Finalize the Sent record's transport pill once we know which leg
// (FIPS/Tor) actually delivered it.
let state_for_transport = self.state.clone();
let sent_msg_id = msg.id;
tokio::spawn(async move {
let fips_npub =
crate::federation::fips_npub_for_onion(&data_dir_owned, &peer_onion_owned).await;
@ -1280,6 +1294,12 @@ impl MeshService {
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered");
// Tag the Sent bubble with the leg that delivered it (the
// transport pill: "fips" / "tor").
let mut messages = state_for_transport.messages.write().await;
if let Some(m) = messages.iter_mut().find(|m| m.id == sent_msg_id) {
m.transport = Some(transport.to_string());
}
}
Ok((resp, transport)) => warn!(
contact_id,
@ -1344,6 +1364,22 @@ impl MeshService {
Some(&display_name),
)
.await;
// The inbound HTTP gives no FIPS-vs-Tor signal, so label the message
// with the leg most recently used with this peer (federation storage's
// `last_transport`), defaulting to Tor. Federation envelopes are E2E
// (identity-signed over an encrypted transport).
let transport_label = {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == from_pubkey_hex)
.and_then(|n| n.last_transport.clone())
.filter(|t| t == "fips" || t == "tor")
.unwrap_or_else(|| "tor".to_string())
};
let before = listener::dispatch::max_message_id(&self.state).await;
listener::dispatch::handle_typed_envelope_direct(
&self.state,
contact_id,
@ -1351,6 +1387,14 @@ impl MeshService {
envelope,
)
.await;
listener::dispatch::stamp_received_transport(
&self.state,
contact_id,
before,
&transport_label,
true,
)
.await;
Ok(())
}
@ -1460,7 +1504,10 @@ impl MeshService {
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
// Channel broadcasts use the shared channel PSK, not per-identity
// E2E — so not an E2E message, but it does travel over the radio.
encrypted: false,
transport: Some("lora".to_string()),
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@ -1496,7 +1543,15 @@ impl MeshService {
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
return Ok(self
.record_sent_typed(contact_id, "text", text, None, seq)
.record_sent_typed(
contact_id,
"text",
text,
None,
seq,
Some("lora".to_string()),
false,
)
.await);
}
// Sign the envelope with our archipelago identity key so the receiver
@ -1543,6 +1598,8 @@ impl MeshService {
display_text: &str,
typed_payload: Option<serde_json::Value>,
sender_seq: u64,
transport: Option<String>,
encrypted: bool,
) -> MeshMessage {
let msg_id = self.state.next_id().await;
let peer_name = self
@ -1560,7 +1617,8 @@ impl MeshService {
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted: false,
encrypted,
transport,
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@ -1611,7 +1669,9 @@ impl MeshService {
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
// Plain channel broadcast over the radio (shared PSK, not E2E).
encrypted: false,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,

View File

@ -104,6 +104,12 @@ pub struct MeshMessage {
pub delivered: bool,
/// Whether the message was end-to-end encrypted.
pub encrypted: bool,
/// How this message actually traveled, for the per-message transport pill:
/// "lora" (mesh radio), "fips", or "tor". `None` until known (a Sent
/// federation message is finalized once the background send resolves the
/// transport). Surfaced in the UI beside the E2E badge.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
/// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...).
#[serde(default = "default_message_type")]
pub message_type: String,

View File

@ -78,6 +78,9 @@ export interface MeshMessage {
timestamp: string
delivered: boolean
encrypted: boolean
/// How the message traveled: "lora" (mesh radio), "fips", or "tor".
/// Drives the per-message transport pill. Absent until known.
transport?: string | null
message_type?: MeshMessageTypeLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typed_payload?: Record<string, any> | null

View File

@ -1076,6 +1076,17 @@ function isEditedMessage(msg: MeshMessage): number | null {
function isDeletedMessage(msg: MeshMessage): boolean {
return msg.message_type === 'delete' || msg.typed_payload?.deleted === true
}
/// Short label for the per-message transport pill (Mesh / FIPS / Tor), or null
/// when the transport isn't known. Covers both meshcore and meshtastic since
/// the field lives on the shared MeshMessage.
function transportLabel(msg: MeshMessage): string | null {
switch (msg.transport) {
case 'lora': return 'Mesh'
case 'fips': return 'FIPS'
case 'tor': return 'Tor'
default: return null
}
}
// Read-receipt: after render, if the bottom message is from the peer (direction='received')
// and has a MessageKey, fire mesh.send-read-receipt up to that seq. Debounced so scroll
@ -1798,6 +1809,7 @@ function isImageMime(mime?: string): boolean {
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>

View File

@ -148,6 +148,11 @@
.mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; }
.mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); }
.mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; }
/* Per-message transport pill (Mesh / FIPS / Tor), styled like the E2E badge. */
.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; }
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* Mesh/LoRa — amber */
.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */
.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */
.mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; }
.mesh-chat-compose { padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
.mesh-chat-send-error { color: #ef4444; font-size: 0.75rem; margin-bottom: 6px; }