feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes
UI (this session): - Global audio player now scales the whole interface into the space above it on desktop (sidebar + main) and docks directly above the tab bar on mobile; it stays visible while navigating. - Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip with a single fixed, internally-scrolling pane (page no longer scrolls); tabs hide while a conversation is open; floating back button; collapsible Device panel (starts collapsed); keyboard-aware conversation sizing via VisualViewport so the chat sits just above the keyboard. - Cloud file grid: uniform 4/3 card heights (folders + images match). - Swipe left/right switches tabs on the Apps and Web5 screens. - Map tool fills its pane (no bottom gap); fix skewed Share Location toggle on mobile (global min-height rule was deforming the switch). - Trim redundant helper copy from the mesh AI tab. Also bundles pre-existing in-progress work that was already in the tree: mesh listener/session + wallet + container + bitcoin-status backend changes, docker UI updates, and assorted other UI tweaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c4855526fe
commit
1bce694ebb
@@ -57,24 +57,32 @@ pub(super) enum AssistReply {
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
// Whether the asker's message was cryptographically authenticated (a
|
||||
// verified signature, or arrival over the federation transport). Required
|
||||
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker).await {
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Record who was turned away so the operator can find + allow them from
|
||||
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||
record_denied(&state, asker, &sender_name).await;
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
@@ -155,13 +163,25 @@ pub(super) async fn run_assist(
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
||||
/// federation-Trusted match on the peer's pubkey or DID.
|
||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
||||
///
|
||||
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||
/// i.e. the asker's message carried a signature that verified against its known
|
||||
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||
async fn is_sender_allowed(
|
||||
state: &Arc<MeshState>,
|
||||
sender_contact_id: u32,
|
||||
authenticated: bool,
|
||||
) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
@@ -180,12 +200,15 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of
|
||||
// the trusted_only policy (block check above still wins).
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
|
||||
// trusted_only policy — but only when the message is authenticated, so a
|
||||
// spoofed packet claiming an allowlisted key can't slip through.
|
||||
if authenticated {
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +216,14 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only: match against the federation trust list.
|
||||
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||
if !authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against the federation trust list by the asker's verified archipelago
|
||||
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -203,6 +233,36 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||
/// who recently tried, without unbounded growth from a spammer.
|
||||
const MAX_DENIED_ASKERS: usize = 25;
|
||||
|
||||
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||
let pubkey_hex = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&asker_contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
};
|
||||
let entry = super::DeniedAsker {
|
||||
contact_id: asker_contact_id,
|
||||
name: sender_name.to_string(),
|
||||
pubkey_hex,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let mut denied = state.assist_denied.write().await;
|
||||
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||
denied.push_front(entry);
|
||||
denied.truncate(MAX_DENIED_ASKERS);
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
|
||||
@@ -382,8 +382,13 @@ pub(super) async fn store_plain_message(
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st)
|
||||
.await;
|
||||
// A bare plain-text channel `!ai` carries no signature, so it
|
||||
// is NOT authenticated — under trusted_only it'll be denied,
|
||||
// and it can only be answered under the "anyone" policy.
|
||||
super::assist::run_assist(
|
||||
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -484,6 +489,10 @@ pub(super) async fn handle_identity_received(
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
snr: None,
|
||||
|
||||
@@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
sender_name: &str,
|
||||
envelope: TypedEnvelope,
|
||||
) {
|
||||
// Verify envelope signature if present, using the sender's known Ed25519 key
|
||||
// Verify the envelope signature (if present) against the sender's known
|
||||
// Ed25519 key, and record whether the sender is cryptographically
|
||||
// authenticated. A federation peer (synthetic high-half contact_id) arrived
|
||||
// over the Tor relay, which verifies the sender signature upstream before
|
||||
// injecting here, so it counts as authenticated. This flag gates the
|
||||
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
|
||||
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
|
||||
if envelope.sig.is_some() {
|
||||
let peer_pubkey = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&sender_contact_id)
|
||||
.and_then(|p| p.pubkey_hex.as_ref())
|
||||
// Verify against the bound archipelago identity key, not the
|
||||
// firmware routing key — only the former is what the peer signs with.
|
||||
.and_then(|p| p.identity_pubkey_hex())
|
||||
.and_then(|hex_str| hex::decode(hex_str).ok())
|
||||
.and_then(|bytes| {
|
||||
if bytes.len() == 32 {
|
||||
@@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
});
|
||||
if let Some(vk) = peer_pubkey {
|
||||
match envelope.verify_signature(&vk) {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
authenticated = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
peer = sender_contact_id,
|
||||
@@ -701,6 +711,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
req_id,
|
||||
cid,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||
st,
|
||||
)
|
||||
@@ -748,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
|
||||
@@ -153,6 +153,28 @@ pub struct MeshState {
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||
/// UI can surface them and let the operator one-click allow their key.
|
||||
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||
}
|
||||
|
||||
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||
/// the operator can add their key to the allowlist without hunting the journal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeniedAsker {
|
||||
/// Meshcore contact id of the asker.
|
||||
pub contact_id: u32,
|
||||
/// Best-known display name (advert name) at denial time.
|
||||
pub name: String,
|
||||
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// ISO-8601 timestamp of the (most recent) denial.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
@@ -248,6 +270,7 @@ impl MeshState {
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
assist_denied: RwLock::new(VecDeque::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
@@ -380,6 +380,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
// Preserve any archipelago identity bound by an earlier
|
||||
// identity advert — NEVER overwrite it with the firmware
|
||||
// contact key, or a signed `!ai` query from this peer would
|
||||
// fail authentication after the next contact refresh.
|
||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
rssi: None,
|
||||
snr: None,
|
||||
|
||||
@@ -46,6 +46,12 @@ const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
/// high half of u32 space to avoid collision. Both the receive path
|
||||
/// (`inject_typed_from_federation`) and the startup pre-seed use this
|
||||
/// formula so they always produce the same id for the same peer.
|
||||
/// Mesh contacts at or above this id are synthetic federation peers (the high
|
||||
/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id
|
||||
/// space, so this bit cleanly distinguishes "arrived over the authenticated
|
||||
/// federation transport" from "heard over the radio".
|
||||
pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000;
|
||||
|
||||
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
|
||||
if bytes.len() < 4 {
|
||||
@@ -77,6 +83,9 @@ pub(crate) async fn upsert_federation_peer(
|
||||
advert_name: display_name,
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
// Federation peers are authenticated by the Tor relay upstream; their
|
||||
// archipelago key is known, so bind it as the identity key too.
|
||||
arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
|
||||
rssi: existing.as_ref().and_then(|p| p.rssi),
|
||||
snr: existing.as_ref().and_then(|p| p.snr),
|
||||
@@ -1433,6 +1442,12 @@ impl MeshService {
|
||||
self.state.assistant.read().await.clone()
|
||||
}
|
||||
|
||||
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
|
||||
/// them. Cleared implicitly as new denials rotate older ones out.
|
||||
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
|
||||
self.state.assist_denied.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
/// persist them to the mesh config. `model: Some(None)` clears the override
|
||||
/// (falls back to the built-in default); `None` leaves a field unchanged.
|
||||
|
||||
@@ -32,8 +32,18 @@ pub struct MeshPeer {
|
||||
pub advert_name: String,
|
||||
/// Archipelago DID (did:key:z...) if identity was received.
|
||||
pub did: Option<String>,
|
||||
/// Ed25519 public key hex if identity was received.
|
||||
/// Routing key hex. For a radio (meshcore) peer this is the firmware
|
||||
/// contact public key used to address outbound DMs; for a federation-
|
||||
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
|
||||
/// for authentication — see `arch_pubkey_hex`.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// Verified archipelago ed25519 identity key hex, bound from a signed
|
||||
/// identity advert (`handle_identity_received`) or federation seeding.
|
||||
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
|
||||
/// with the firmware routing key, so it stays stable for the `!ai` auth
|
||||
/// gate, envelope signature verification, and federation-trust matching.
|
||||
#[serde(default)]
|
||||
pub arch_pubkey_hex: Option<String>,
|
||||
/// X25519 public key (32 bytes) for key agreement.
|
||||
#[serde(skip)]
|
||||
pub x25519_pubkey: Option<[u8; 32]>,
|
||||
@@ -56,6 +66,19 @@ pub struct MeshPeer {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
|
||||
/// envelope signature verification): the verified archipelago identity key
|
||||
/// if one is bound, otherwise the routing key. Never use the firmware
|
||||
/// routing key for auth when an archipelago identity is known — a radio
|
||||
/// peer's firmware key won't match its `nodes.json` archipelago key.
|
||||
pub fn identity_pubkey_hex(&self) -> Option<&str> {
|
||||
self.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.or(self.pubkey_hex.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of a mesh message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -191,3 +214,51 @@ pub enum MeshEvent {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id: 1,
|
||||
advert_name: "Test".into(),
|
||||
did: None,
|
||||
pubkey_hex: routing.map(|s| s.to_string()),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
|
||||
// A radio peer that sent an identity advert: routing key is the firmware
|
||||
// contact key, but auth must use the bound archipelago key.
|
||||
let p = peer(Some("archkey"), Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_falls_back_to_routing_key_when_no_advert() {
|
||||
// A plain peer with no archipelago identity bound: fall back to whatever
|
||||
// key we have (federation peers carry the arch key in pubkey_hex).
|
||||
let p = peer(None, Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
|
||||
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_style_routing_update_does_not_change_identity() {
|
||||
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
|
||||
// firmware key while arch_pubkey_hex (identity) is preserved.
|
||||
let mut p = peer(Some("archkey"), Some("firmware-old"));
|
||||
p.pubkey_hex = Some("firmware-new".into());
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user