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:
archipelago
2026-06-19 09:52:26 -04:00
co-authored by Claude Opus 4.8
parent c4855526fe
commit 1bce694ebb
37 changed files with 1260 additions and 208 deletions
+72 -12
View File
@@ -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) {
+11 -2
View File
@@ -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,
+15 -3
View File
@@ -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,
},
+23
View File
@@ -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,
+15
View File
@@ -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.
+72 -1
View File
@@ -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"));
}
}