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:
parent
169ff2e2cd
commit
11038cdcc9
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">✓✓</span>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user