From 11038cdcc99ecf3ceb9f4eb632359d24bd20d0cd Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 29 Jun 2026 04:29:25 -0400 Subject: [PATCH] feat(mesh,ui): per-message transport pill (Mesh/FIPS/Tor) + fix E2E pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/archipelago/src/mesh/listener/decode.rs | 4 ++ .../archipelago/src/mesh/listener/dispatch.rs | 49 ++++++++++++++ core/archipelago/src/mesh/mod.rs | 64 ++++++++++++++++++- core/archipelago/src/mesh/types.rs | 6 ++ neode-ui/src/stores/mesh.ts | 3 + neode-ui/src/views/Mesh.vue | 12 ++++ neode-ui/src/views/mesh/mesh-styles.css | 5 ++ 7 files changed, 141 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index ef1d2dbd..21647a44 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -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, diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 1d5b201c..064a2df3 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -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) -> 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, + 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 diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index da0649e9..2b7bf7e8 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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, sender_seq: u64, + transport: Option, + 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, diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index ae529ea8..e34e9df1 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -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, /// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...). #[serde(default = "default_message_type")] pub message_type: String, diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 9bb95e5f..b4a272e9 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -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 | null diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index cc79db2a..33186b70 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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 {
{{ msg.plaintext }}
+ {{ transportLabel(msg) }} E2E (edited) ✓✓ diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index e75e2494..e7f7383b 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -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; }