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 05:03:18 -04:00
parent c4855526fe
commit 1bce694ebb
37 changed files with 1260 additions and 208 deletions

View File

@ -146,7 +146,9 @@ impl ApiHandler {
Ok(content_server::ServeResult::Forbidden) => Ok(build_response( Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"application/json", "application/json",
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#), hyper::Body::from(
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)), )),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response( Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,

View File

@ -260,6 +260,20 @@ impl RpcHandler {
})); }));
} }
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
// the host's federation peers only — federate first"). Surface that to
// the user instead of a bare "Peer returned: 403 Forbidden".
if response.status() == reqwest::StatusCode::FORBIDDEN {
let status = response.status();
let body: serde_json::Value = response.json().await.unwrap_or_default();
let msg = body
.get("error")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Peer returned: {status}"));
return Err(anyhow::anyhow!(msg));
}
if !response.status().is_success() { if !response.status().is_success() {
return Err(anyhow::anyhow!("Peer returned: {}", response.status())); return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
} }
@ -463,12 +477,16 @@ impl RpcHandler {
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
// give Tor a short-but-real window (onion circuits need a few seconds).
let path = format!("/content/{}/invoice", content_id); let path = format!("/content/{}/invoice", content_id);
let (response, _transport) = let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get() .send_get()
.await .await
{ {
@ -524,11 +542,15 @@ impl RpcHandler {
} }
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
// FIPS and keep a short Tor window; an unreachable peer just reads as
// "not yet paid" and the UI polls again.
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash); let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
let (response, _transport) = let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get() .send_get()
.await .await
{ {
@ -652,12 +674,15 @@ impl RpcHandler {
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Issuing an address is a tiny request/response — fast-fail FIPS, short
// Tor window (same budget shape as the invoice path, #6).
let path = format!("/content/{}/onchain", content_id); let path = format!("/content/{}/onchain", content_id);
let (response, _transport) = let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get() .send_get()
.await .await
{ {
@ -715,7 +740,8 @@ impl RpcHandler {
let (response, _transport) = let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get() .send_get()
.await .await
{ {

View File

@ -156,6 +156,35 @@ impl RpcHandler {
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side /// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode /// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
/// it as hex so it can be used as a stable lookup key and passed in URLs. /// it as hex so it can be used as a stable lookup key and passed in URLs.
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
/// backend is still in initial block download — otherwise the `/v1/invoices`
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
/// `Some(false)` only when LND is reachable AND explicitly not synced;
/// `None` when we couldn't tell (let the mint attempt proceed and report its
/// own error rather than guess "syncing").
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.ok()?;
let body: serde_json::Value = resp.json().await.ok()?;
body.get("synced_to_chain").and_then(|v| v.as_bool())
}
/// Error returned when the node can't mint a Lightning invoice because its
/// Bitcoin backend is still syncing. Kept as one string so every invoice
/// entry point surfaces the same clear, user-facing reason.
fn syncing_invoice_err() -> anyhow::Error {
anyhow::anyhow!(
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
)
}
pub(crate) async fn create_invoice( pub(crate) async fn create_invoice(
&self, &self,
amount_sats: i64, amount_sats: i64,
@ -175,9 +204,12 @@ impl RpcHandler {
}); });
// LND's REST endpoint can briefly drop/reset connections under load // LND's REST endpoint can briefly drop/reset connections under load
// (swap pressure, just-restarted, TLS handshake races), which used to // (swap pressure, just-restarted, TLS handshake races), which used to
// hard-fail the buy-file invoice with an opaque 503. Retry the send a // hard-fail the buy-file invoice with an opaque 503. Retry on a
// few times with short backoff so a transient blip doesn't surface as // CONNECTION error with short backoff so a transient blip doesn't
// a payment failure. The surrounding error now carries the real cause. // surface as a payment failure. A *timeout* is NOT retried: it means LND
// accepted the connection but isn't answering the mint (e.g. a degraded
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
// after the first hang and let the caller surface the real reason.
let mut last_err: Option<anyhow::Error> = None; let mut last_err: Option<anyhow::Error> = None;
let mut resp = None; let mut resp = None;
for attempt in 0..3u32 { for attempt in 0..3u32 {
@ -193,10 +225,14 @@ impl RpcHandler {
break; break;
} }
Err(e) => { Err(e) => {
let timed_out = e.is_timeout();
last_err = Some(anyhow::anyhow!( last_err = Some(anyhow::anyhow!(
"LND REST connect failed (attempt {}): {e}", "LND REST send failed (attempt {}): {e}",
attempt + 1 attempt + 1
)); ));
if timed_out {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await; tokio::time::sleep(std::time::Duration::from_millis(400)).await;
} }
} }
@ -204,9 +240,15 @@ impl RpcHandler {
let resp = match resp { let resp = match resp {
Some(r) => r, Some(r) => r,
None => { None => {
// If LND is reachable but explicitly not synced to chain, say so —
// it's the most common reason a just-restored/syncing node can't
// mint. Otherwise surface the underlying transport error.
if self.lnd_chain_synced().await == Some(false) {
return Err(Self::syncing_invoice_err());
}
return Err(last_err.unwrap_or_else(|| { return Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!("Failed to reach LND REST to create invoice") anyhow::anyhow!("Failed to reach LND REST to create invoice")
})) }));
} }
}; };
@ -385,13 +427,23 @@ impl RpcHandler {
"memo": memo, "memo": memo,
}); });
let resp = client let resp = match client
.post(format!("{LND_REST_BASE_URL}/v1/invoices")) .post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body) .json(&invoice_body)
.send() .send()
.await .await
.context("Failed to create invoice")?; {
Ok(r) => r,
Err(e) => {
// A hung/failed mint while LND is explicitly not synced to chain
// gets a clear, user-facing reason instead of an opaque error.
if self.lnd_chain_synced().await == Some(false) {
return Err(Self::syncing_invoice_err());
}
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
}
};
let status = resp.status(); let status = resp.status();
let body: serde_json::Value = resp let body: serde_json::Value = resp

View File

@ -14,12 +14,12 @@ impl RpcHandler {
pub(in crate::api::rpc) async fn handle_mesh_assistant_status( pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
&self, &self,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let cfg = { let (cfg, denied_askers) = {
let service = self.mesh_service.read().await; let service = self.mesh_service.read().await;
let svc = service let svc = service
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.assistant_config().await (svc.assistant_config().await, svc.assistant_denied_askers().await)
}; };
let (ollama_detected, models) = detect_ollama().await; let (ollama_detected, models) = detect_ollama().await;
@ -37,6 +37,7 @@ impl RpcHandler {
"ollama_detected": ollama_detected, "ollama_detected": ollama_detected,
"claude_available": claude_available, "claude_available": claude_available,
"models": models, "models": models,
"denied_askers": denied_askers,
})) }))
} }

View File

@ -22,11 +22,23 @@ use tracing::{debug, warn};
const CACHE_REFRESH_SECS: u64 = 5; const CACHE_REFRESH_SECS: u64 = 5;
const CACHE_ERROR_BACKOFF_SECS: u64 = 5; const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
// Grace window before a failing poll marks the snapshot "stale" for the UI.
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
// several polls failed in a row — do we surface the banner.
const STALE_GRACE_MS: u64 = 20_000;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct BitcoinNodeStatus { pub struct BitcoinNodeStatus {
pub ok: bool, pub ok: bool,
pub stale: bool, pub stale: bool,
pub updated_at_ms: u64, pub updated_at_ms: u64,
// Server-computed age of the snapshot, filled in at serve time. The browser
// must not derive this itself (Date.now() - updated_at_ms) because that
// compares the browser clock against this node's clock — any skew made a
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
pub age_ms: u64,
pub error: Option<String>, pub error: Option<String>,
pub blockchain_info: Option<serde_json::Value>, pub blockchain_info: Option<serde_json::Value>,
pub network_info: Option<serde_json::Value>, pub network_info: Option<serde_json::Value>,
@ -40,6 +52,7 @@ impl Default for BitcoinNodeStatus {
ok: false, ok: false,
stale: false, stale: false,
updated_at_ms: 0, updated_at_ms: 0,
age_ms: 0,
error: Some("Connecting to Bitcoin node...".to_string()), error: Some("Connecting to Bitcoin node...".to_string()),
blockchain_info: None, blockchain_info: None,
network_info: None, network_info: None,
@ -128,7 +141,11 @@ pub fn spawn_status_cache() {
if cached.blockchain_info.is_some() { if cached.blockchain_info.is_some() {
cached.ok = false; cached.ok = false;
cached.stale = true; // Only flip to "stale" once the last good snapshot is older
// than the grace window. A brief RPC gap on a busy node keeps
// showing last-known state silently instead of a banner flicker.
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
cached.error = Some(friendly_transient_error(true, &err_msg)); cached.error = Some(friendly_transient_error(true, &err_msg));
} else { } else {
*cached = BitcoinNodeStatus { *cached = BitcoinNodeStatus {
@ -148,12 +165,22 @@ pub fn spawn_status_cache() {
} }
pub async fn get_bitcoin_status() -> BitcoinNodeStatus { pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
cache().read().await.clone() let mut status = cache().read().await.clone();
// Compute age here (server clock only) so the browser never has to subtract
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
if status.updated_at_ms > 0 {
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
}
status
} }
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> { async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
// but correctly; too tight a timeout turned working-but-slow polls into
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8)) .timeout(Duration::from_secs(12))
.build() .build()
.context("build Bitcoin status HTTP client")?; .context("build Bitcoin status HTTP client")?;
@ -172,6 +199,7 @@ async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
ok: true, ok: true,
stale: false, stale: false,
updated_at_ms: now_ms(), updated_at_ms: now_ms(),
age_ms: 0,
error: None, error: None,
blockchain_info: Some(blockchain_info), blockchain_info: Some(blockchain_info),
network_info: network_info.ok(), network_info: network_info.ok(),

View File

@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
], ],
pre_start: None, pre_start: None,
bind_mounts: &[], bind_mounts: &[],
ports: &[(18083, 80)], // Host networking so the app's own nginx can proxy the archipelago backend
host_network: false, // same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
// previous bridge + 18083→80 mapping forced the browser to fetch the
// backend cross-origin from the app's port, which depended on the host
// nginx route + a CORS Origin/Host match and broke on http-only nodes
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
ports: &[],
host_network: true,
}]; }];
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec { const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
@ -439,12 +446,15 @@ mod tests {
} }
#[test] #[test]
fn lnd_ui_uses_port_mapping_not_host_port_80() { fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
// app can proxy the archipelago backend same-origin instead of fetching
// it cross-origin from its app port — see the spec comment for why.
let spec = &LND_UI[0]; let spec = &LND_UI[0];
let u = build_unit(spec, "localhost/lnd-ui:latest"); let u = build_unit(spec, "localhost/lnd-ui:latest");
assert_eq!(u.name, "archy-lnd-ui"); assert_eq!(u.name, "archy-lnd-ui");
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge")); assert!(matches!(u.network, NetworkMode::Host));
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]); assert!(u.ports.is_empty());
} }
#[test] #[test]

View File

@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/fedimint/fedimint".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(),
tier: "", tier: "",
}, },
"fedimint-clientd" | "fmcd" => AppMetadata {
title: "Fedimint Client".to_string(),
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
icon: "/assets/img/app-icons/fedimint.png".to_string(),
repo: "https://github.com/minmoto/fmcd".to_string(),
tier: "",
},
"morphos" | "morphos-server" => AppMetadata { "morphos" | "morphos-server" => AppMetadata {
title: "Morphos".to_string(), title: "Morphos".to_string(),
description: "Self-hosted file converter".to_string(), description: "Self-hosted file converter".to_string(),

View File

@ -308,6 +308,14 @@ pub struct PeerRequest<'a> {
pub path: &'a str, pub path: &'a str,
pub headers: Vec<(&'a str, String)>, pub headers: Vec<(&'a str, String)>,
pub timeout: std::time::Duration, pub timeout: std::time::Duration,
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>, pub service: Option<crate::settings::transport::PeerService>,
} }
@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> {
path, path,
headers: Vec::new(), headers: Vec::new(),
timeout: std::time::Duration::from_secs(30), timeout: std::time::Duration::from_secs(30),
fips_timeout: None,
service: None, service: None,
} }
} }
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
/// budget. Use on short request/response calls (invoice, status); leave
/// unset for large downloads that legitimately need a long FIPS transfer.
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
self.fips_timeout = Some(t);
self
}
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
/// overall request timeout.
fn fips_attempt_timeout(&self) -> std::time::Duration {
self.fips_timeout.unwrap_or(self.timeout)
}
/// Tie this request to a user-configurable service preference. If /// Tie this request to a user-configurable service preference. If
/// the user has set that service to `Fips` or `Tor`, the builder /// the user has set that service to `Fips` or `Tor`, the builder
/// respects it. /// respects it.
@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> {
} }
}; };
let url = format!("{}{}", base, self.path); let url = format!("{}{}", base, self.path);
let c = client_with_timeout(self.timeout); let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.post(&url).json(body); let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); rb = rb.header(*k, v);
@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> {
} }
}; };
let url = format!("{}{}", base, self.path); let url = format!("{}{}", base, self.path);
let c = client_with_timeout(self.timeout); let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.get(&url); let mut rb = c.get(&url);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); rb = rb.header(*k, v);

View File

@ -57,24 +57,32 @@ pub(super) enum AssistReply {
/// Entry point: gate the query, run the model, send the answer back via the /// 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. /// requested reply path. Spawned off the radio loop so it never blocks.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_assist( pub(super) async fn run_assist(
prompt: String, prompt: String,
model_override: Option<String>, model_override: Option<String>,
req_id: u64, req_id: u64,
asker_contact_id: u32, asker_contact_id: u32,
sender_name: String, 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, reply: AssistReply,
state: Arc<MeshState>, state: Arc<MeshState>,
) { ) {
let asker = asker_contact_id; let asker = asker_contact_id;
// Trust + block gate. // Trust + block gate.
if !is_sender_allowed(&state, asker).await { if !is_sender_allowed(&state, asker, authenticated).await {
warn!( warn!(
from = asker, from = asker,
name = %sender_name, name = %sender_name,
"AssistQuery denied — sender not permitted by assistant policy" "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. // Silent on the wire (no airtime spent on denials); surface to the UI.
let _ = state let _ = state
.event_tx .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. /// 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. /// Always denies user-blocked contacts. Identity-based allows (the per-contact
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool { /// 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 (pubkey_hex, did) = {
let peers = state.peers.read().await; let peers = state.peers.read().await;
match peers.get(&sender_contact_id) { 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), 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 // Explicit per-contact allowlist: a listed pubkey may ask regardless of the
// the trusted_only policy (block check above still wins). // trusted_only policy — but only when the message is authenticated, so a
if let Some(ref pk) = pubkey_hex { // spoofed packet claiming an allowlisted key can't slip through.
let allowed = state.assistant.read().await.allowed_contacts.clone(); if authenticated {
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) { if let Some(ref pk) = pubkey_hex {
return true; 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; 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) let nodes = crate::federation::load_nodes(&state.data_dir)
.await .await
.unwrap_or_default(); .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. /// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
/// Returns (text_to_send, was_truncated). /// Returns (text_to_send, was_truncated).
fn cap_reply(answer: &str) -> (String, bool) { fn cap_reply(answer: &str) -> (String, bool) {

View File

@ -382,8 +382,13 @@ pub(super) async fn store_plain_message(
let name = peer_name.to_string(); let name = peer_name.to_string();
let st = Arc::clone(state); let st = Arc::clone(state);
tokio::spawn(async move { tokio::spawn(async move {
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st) // A bare plain-text channel `!ai` carries no signature, so it
.await; // 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())]), advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
did: Some(did.to_string()), did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.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), x25519_pubkey: Some(x25519_bytes),
rssi: Some(rssi), rssi: Some(rssi),
snr: None, snr: None,

View File

@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
sender_name: &str, sender_name: &str,
envelope: TypedEnvelope, 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() { if envelope.sig.is_some() {
let peer_pubkey = state let peer_pubkey = state
.peers .peers
.read() .read()
.await .await
.get(&sender_contact_id) .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(|hex_str| hex::decode(hex_str).ok())
.and_then(|bytes| { .and_then(|bytes| {
if bytes.len() == 32 { if bytes.len() == 32 {
@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
}); });
if let Some(vk) = peer_pubkey { if let Some(vk) = peer_pubkey {
match envelope.verify_signature(&vk) { match envelope.verify_signature(&vk) {
Ok(true) => {} Ok(true) => {
authenticated = true;
}
Ok(false) => { Ok(false) => {
warn!( warn!(
peer = sender_contact_id, peer = sender_contact_id,
@ -701,6 +711,7 @@ pub(crate) async fn handle_typed_envelope_direct(
req_id, req_id,
cid, cid,
name, name,
authenticated,
super::assist::AssistReply::ChatText { contact_id: cid }, super::assist::AssistReply::ChatText { contact_id: cid },
st, st,
) )
@ -748,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct(
query.req_id, query.req_id,
sender_contact_id, sender_contact_id,
name, name,
authenticated,
super::assist::AssistReply::Typed { super::assist::AssistReply::Typed {
contact_id: sender_contact_id, contact_id: sender_contact_id,
}, },

View File

@ -153,6 +153,28 @@ pub struct MeshState {
/// Contact-ids with an AI query currently being answered. Caps each asker to /// 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. /// one in-flight query so a peer can't flood the node's compute / airtime.
pub assist_inflight: RwLock<HashSet<u32>>, 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. /// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
@ -248,6 +270,7 @@ impl MeshState {
assistant: RwLock::new(assistant), assistant: RwLock::new(assistant),
data_dir, data_dir,
assist_inflight: RwLock::new(HashSet::new()), assist_inflight: RwLock::new(HashSet::new()),
assist_denied: RwLock::new(VecDeque::new()),
}); });
(state, rx, cmd_rx) (state, rx, cmd_rx)
} }

View File

@ -380,6 +380,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
advert_name: contact.advert_name.clone(), advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()), did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.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), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
rssi: None, rssi: None,
snr: None, snr: None,

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 /// high half of u32 space to avoid collision. Both the receive path
/// (`inject_typed_from_federation`) and the startup pre-seed use this /// (`inject_typed_from_federation`) and the startup pre-seed use this
/// formula so they always produce the same id for the same peer. /// 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 { pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default(); let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
if bytes.len() < 4 { if bytes.len() < 4 {
@ -77,6 +83,9 @@ pub(crate) async fn upsert_federation_peer(
advert_name: display_name, advert_name: display_name,
did: Some(did.to_string()), did: Some(did.to_string()),
pubkey_hex: Some(archipelago_pubkey_hex.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), x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
rssi: existing.as_ref().and_then(|p| p.rssi), rssi: existing.as_ref().and_then(|p| p.rssi),
snr: existing.as_ref().and_then(|p| p.snr), snr: existing.as_ref().and_then(|p| p.snr),
@ -1433,6 +1442,12 @@ impl MeshService {
self.state.assistant.read().await.clone() 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 /// Update the mesh-AI assistant settings live (no listener restart) and
/// persist them to the mesh config. `model: Some(None)` clears the override /// persist them to the mesh config. `model: Some(None)` clears the override
/// (falls back to the built-in default); `None` leaves a field unchanged. /// (falls back to the built-in default); `None` leaves a field unchanged.

View File

@ -32,8 +32,18 @@ pub struct MeshPeer {
pub advert_name: String, pub advert_name: String,
/// Archipelago DID (did:key:z...) if identity was received. /// Archipelago DID (did:key:z...) if identity was received.
pub did: Option<String>, 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>, 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. /// X25519 public key (32 bytes) for key agreement.
#[serde(skip)] #[serde(skip)]
pub x25519_pubkey: Option<[u8; 32]>, pub x25519_pubkey: Option<[u8; 32]>,
@ -56,6 +66,19 @@ pub struct MeshPeer {
pub reachable: bool, 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. /// Direction of a mesh message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@ -191,3 +214,51 @@ pub enum MeshEvent {
text: String, 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"));
}
}

View File

@ -737,6 +737,15 @@
return response.json(); return response.json();
} }
// Snapshot age in ms. Prefer the server-computed age_ms (single clock, no
// skew). Fall back to the old browser-vs-server subtraction only for an
// older backend that doesn't send age_ms. Mixing clocks was why the
// "reconnecting…" banner could stick on nodes whose clock drifted.
function snapshotAgeMs(status) {
if (typeof status.age_ms === 'number') return status.age_ms;
return status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
}
function cookieValue(name) { function cookieValue(name) {
return document.cookie return document.cookie
.split('; ') .split('; ')
@ -1127,7 +1136,7 @@
const rpcEl = document.getElementById('settingsRpc'); const rpcEl = document.getElementById('settingsRpc');
if (rpcEl) { if (rpcEl) {
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443)); const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY; const statusAgeMs = snapshotAgeMs(status);
const displayStale = status.stale === true && statusAgeMs > 30000; const displayStale = status.stale === true && statusAgeMs > 30000;
rpcEl.textContent = displayStale rpcEl.textContent = displayStale
? `Reconnecting on port ${port}` ? `Reconnecting on port ${port}`
@ -1143,7 +1152,7 @@
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0); const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024; const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
const previousBlockCount = lastBlockCount; const previousBlockCount = lastBlockCount;
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY; const statusAgeMs = snapshotAgeMs(status);
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount; const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000; const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;

View File

@ -22,6 +22,6 @@ RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \ mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \ /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp /var/cache/nginx/scgi_temp
EXPOSE 80 EXPOSE 18083
ENTRYPOINT [] ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -433,8 +433,12 @@
const host = window.location.hostname; const host = window.location.hostname;
function getBackendUrl() { function getBackendUrl() {
// Same-origin by default: the app's own nginx (:18083) proxies these
// paths to the archipelago backend, so a relative base ('') avoids
// any cross-origin/CORS issues (which broke this on http-only nodes).
// ?backend=http://HOST:5678 still overrides for local dev.
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return params.get('backend') || (window.location.protocol + '//' + window.location.hostname); return params.get('backend') || '';
} }
function setSettingsTab(tabId) { function setSettingsTab(tabId) {
@ -499,42 +503,36 @@
async function loadLogs() { async function loadLogs() {
const logsContent = document.getElementById('logsContent'); const logsContent = document.getElementById('logsContent');
const backendUrl = getBackendUrl(); const backendUrl = getBackendUrl();
if (backendUrl) { logsContent.textContent = 'Loading logs...';
logsContent.textContent = 'Loading logs...'; try {
try { const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' });
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200'); if (!res.ok) throw new Error(res.statusText);
if (!res.ok) throw new Error(res.statusText); const json = await res.json();
const json = await res.json(); const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
const lines = json.result || json.logs || (Array.isArray(json) ? json : []); logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines); } catch (e) {
} catch (e) { logsContent.textContent = 'Could not load logs: ' + e.message;
logsContent.textContent = 'Could not load logs: ' + e.message;
}
} else {
logsContent.textContent = 'Open this app with ?backend=http://HOST:5678 to load logs from the server.';
} }
} }
async function fetchLiveData() { async function fetchLiveData() {
const backendUrl = getBackendUrl(); const backendUrl = getBackendUrl();
const data = { channelCount: 0, restReachable: false, grpcReachable: false }; const data = { channelCount: 0, restReachable: false, grpcReachable: false };
if (backendUrl) { try {
try { const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo', { credentials: 'include' });
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo'); if (getinfoRes.ok) {
if (getinfoRes.ok) { data.getinfo = await getinfoRes.json();
data.getinfo = await getinfoRes.json(); data.restReachable = true;
data.restReachable = true; }
} } catch (_) {}
} catch (_) {} try {
try { const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels', { credentials: 'include' });
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels'); if (chRes.ok) {
if (chRes.ok) { const ch = await chRes.json();
const ch = await chRes.json(); data.channelCount = (ch.channels && ch.channels.length) || 0;
data.channelCount = (ch.channels && ch.channels.length) || 0; }
} } catch (_) {}
} catch (_) {} data.grpcReachable = data.restReachable;
data.grpcReachable = data.restReachable;
}
applyLiveData(data); applyLiveData(data);
} }
@ -629,7 +627,7 @@
async function fetchConnectInfo() { async function fetchConnectInfo() {
try { try {
const resp = await fetch(window.location.protocol + '//' + window.location.hostname + '/lnd-connect-info', { credentials: 'include' }); const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json(); const data = await resp.json();
if (data.cert_base64url) { if (data.cert_base64url) {

View File

@ -1,13 +1,63 @@
server { server {
listen 80; # Host-networked: listen on the app's own port directly (NOT 80, which the
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
listen 18083;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# lnd-connect-info is fetched via absolute URL path, # Proxy the archipelago backend same-origin so the browser never makes a
# handled by the host nginx backend at :5678 directly # cross-origin request (no CORS, no host-nginx route dependency). The app is
# served on this node's :18083; cookies are scoped by host (not port), so the
# browser already carries the `session` (HttpOnly) and `csrf_token` cookies
# set by the main UI. We forward both, plus the X-CSRF-Token header, to the
# backend on 127.0.0.1:5678 (reachable because this container is host-networked).
#
# This mirrors fips-ui / electrs-ui. The old bridge + 18083→80 mapping forced
# cross-origin fetches that broke on http-only nodes (blank fields, QR
# "failed to fetch").
location = /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location /proxy/lnd/ {
proxy_pass http://127.0.0.1:5678/proxy/lnd/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
add_header Cache-Control "no-store";
}
location /api/container/logs {
proxy_pass http://127.0.0.1:5678/api/container/logs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
add_header Cache-Control "no-store";
}
location / { location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }

View File

@ -0,0 +1,109 @@
# Session handoff — 2026-06-18
> **UPDATE (later same day): ALL OPEN ITEMS RESOLVED + DEPLOYED** (v1.7.99-alpha → .116 + .198).
> - **#6 Pay-with-QR timeout** — real bug (both LNDs confirmed healthy by user). FIPS-first dial ate the whole budget before the working Tor fallback ran. Added `PeerRequest.fips_timeout` cap (`fips/dial.rs`); invoice/onchain request+status calls fast-fail FIPS (6s) + short Tor window (25s/15s); frontend ceilings 60s→45s. Large downloads keep the full FIPS timeout.
> - **#7 `!ai` gate** — added denied-asker capture (`MeshState.assist_denied`/`DeniedAsker`, `assist.rs::record_denied`) → `mesh.assistant-status.denied_askers` → "Recently denied" list with one-click Allow in `MeshAssistantPanel.vue`.
> - **#8 peer-file 403** — NOT a DID reset. Asymmetric federation: .198 had .116 trusted but .116 never added .198. Re-federated (.198 → .116 `nodes.json`, trusted). **Verified:** .116 `/content/<peersonly>` = 403 w/o DID, **200 (177KB png) with .198's DID**. Plus clearer 403 message + client surfaces the body. Listing left visible ("locked preview", user's choice).
> - **Dual-ecash receive** — active modal is `ReceiveBitcoinModal.vue` (not the commented-out `Web5SendReceiveModals.vue`); already used dual-detect `wallet.ecash-receive`, fixed Cashu-only wording.
> - **fedimint-clientd icon**`docker_packages.rs` arm → `fedimint.png` + `fedimint-clientd.png` asset.
> - **Cashu → 🥜**`HomeWalletCard.vue`.
>
> Deploy notes confirmed: binary swap needs atomic `mv` over the running file (`cp` → "Text file busy"); frontend rsync WITHOUT `--delete` to preserve the `aiui/` subdir in `/opt/archipelago/web-ui`.
Resume point for the multi-issue bug-fix + deploy session on **.116** (archi-thinkpad,
local dev/validation node) and **.198** (resilience node). Work was done in
`~/Projects/archy`. A separate agent's **fedimint dual-ecash** work landed as commit
`4288ae78` during the session (don't re-touch `wallet.rs` / `fedimint_client.rs` /
`prod_orchestrator.rs` / `Web5SendReceiveModals.vue` without checking with them).
## DEPLOY STATUS — done
A surgical deploy (binary + frontend + 2 companion images, **not** the .228-centric
`deploy-to-target.sh`, to avoid clobbering .116's custom nginx) shipped to BOTH nodes:
- **.116**: new binary `/usr/local/bin/archipelago` (backup at `archipelago.bak-pre-deploy-*`),
frontend at `/opt/archipelago/web-ui`, `localhost/{lnd-ui,bitcoin-ui}:latest` rebuilt,
`:local` tags dropped. Verified: `/bitcoin-status` serves `age_ms`; lnd-ui on `Network=host`
listening 18083; `/lnd-connect-info` → 200; both companion containers carry new index.html.
- **.198**: same (binary copied — .198 has **no Rust toolchain**, only npm+podman, so
build-on-.116-then-copy is mandatory). Verified identically. Force-recreated both companions.
Build notes: release build ~9 min (opt-level 3). Frontend vite outDir = `web/dist/neode-ui/`
(NOT `neode-ui/dist`). Companion images: `ensure_image_present` only builds if image ABSENT,
and prefers `localhost/<base>:local` over `:latest` — so to ship docker changes you must drop
`:local` and rebuild `:latest`, then the reconciler (`needs_repair` compares rendered quadlet
unit vs disk) recreates containers. bitcoin-ui needed an explicit `systemctl --user restart`
(its quadlet unit text didn't change, so the reconciler didn't auto-recreate it).
## FIXED & DEPLOYED
1. **Mesh chat/peer double-scroll**`useControllerNav.ts` (wheel scrolls container under
pointer, not focused el) + `Mesh.vue` (`@wheel.stop.prevent`).
2. **Second-level cloud folder zoom**`CloudFolder.vue` direction-aware
(`cloud-zoom-forward`/`-back`, matched depth-forward/back magnitudes 0.75↔1.2).
3. **"FIPS Mesh" → "Fuck IPs Mesh"** — `FipsNetworkCard.vue`, `Server.vue`.
4. **.116 connect-wallet QR "failed to fetch"** — lnd-ui migrated to host-network +
same-origin nginx proxy: `companion.rs` (host_network:true, ports:[]),
`docker/lnd-ui/{Dockerfile(EXPOSE 18083),nginx.conf(listen 18083 + proxy /lnd-connect-info,
/proxy/lnd/, /api/container/logs to 127.0.0.1:5678),index.html(getBackendUrl()→'' relative,
credentials:'include')}`. ROOT CAUSE was a cross-origin CORS failure (page on :18083 fetching
:80). Verified working in incognito; the user's earlier "still broken" was a **stale cached
old page**. Unit test `lnd_ui_uses_host_network` passes.
5. **.198 Bitcoin Knots stale "reconnecting" banner** — `bitcoin_status.rs` (new server-computed
`age_ms` field so the browser never subtracts across clocks; 20s `STALE_GRACE_MS` before
flipping stale; RPC timeout 8s→12s) + `docker/bitcoin-ui/index.html` (`snapshotAgeMs()` uses
server `age_ms`, falls back to old calc). Two root causes: browser/node clock skew + no grace
on single failed polls (swap-thrash node).
## OPEN ISSUES (diagnosed, NOT fixed)
6. **"Pay with QR" → request timeout** — full invoice chain intact (hardened in `790da4bd`);
60s timeout = seller node never answers (unreachable transport or hung LND). Runtime, needs
2 live nodes to repro. NOT a code defect found.
7. **`!ai` not working** — DIAGNOSED, config fix (awaiting user policy decision). Assistant is
`assistant_trusted_only:true` (`/var/lib/archipelago/mesh-config.json`). The trust gate
`is_sender_allowed` (mesh/listener/assist.rs) only matches askers by archipelago pubkey/DID
against federation-Trusted `nodes.json`, but RADIO (meshcore) askers present a firmware key,
not the archipelago identity, so they're silently denied (journal: "AssistQuery denied … from=15
name=Arch Optiplex"; federation contact_id ≥ 0x80000000, low ids = radio). Claude key + model
(`claude-opus-4-8`) tested HTTP 200 — NOT the problem. FIX: disable trusted_only, or add the
asker's presented key to the allowlist. Full notes in memory `project_mesh_ai_trusted_only_gate`.
8. **Peer-file download .116→.198 "Access denied — federation peer required"** — NEW, NOT yet
fixed. Gate at `content.rs:149` (returns on `content_server::ServeResult::Forbidden`). The
requesting node isn't recognized as an authorized federation peer by the content server /
per-file sharing ACL. User's strong hypothesis: a **DID/identity reset** changed a node's DID,
so the sharing ACL / nodes.json holds the OLD identity and no longer matches. User also notes
the file is still VISIBLE in the listing (so listing and download use different identity checks
— inconsistency to investigate). NEXT: read `content_server` Forbidden logic, compare the
requester DID/pubkey vs what's stored; check both nodes' `server_info`/identity vs each other's
`federation/nodes.json`. Same THEME as #7 (identity matching) but a different mechanism.
## NEW FRONTEND REQUESTS (not started — batch into one frontend rebuild+redeploy)
- **`fedimint-clientd.svg` 404** — new fedimint core-app (`public/catalog.json:294`) has no icon.
App-icon convention `/assets/img/app-icons/<id>.png` (default) — add a `fedimint-clientd` icon
(there's an existing `fedimint.png` to reuse/adapt). The 404 requests `.svg` so check the
catalog/curated-icon entry.
- **Cashu icon → cashew emoji** (🥜) — change the cashu wallet icon to a cashew nut emoji.
- **Receive ecash should support BOTH fedimint + cashu paste** — currently the ecash receive
only mentions Cashu for pasting a token; user expected the paste box to redeem both Cashu AND
Fedimint ecash. Lives in the fedimint agent's recently-committed dual-ecash UI
(`Web5SendReceiveModals.vue` / `Web5Wallet.vue` / `WalletSettingsModal.vue`) — investigate what
they built before changing.
- **Console noise** (lower priority): `cdn.tailwindcss.com` production warning in lnd-ui +
bitcoin-ui (uses Tailwind CDN); `api/app-catalog` 502 (check if persistent). Latent backend
nicety: `/lnd-connect-info` emits a DOUBLED `Access-Control-Allow-Origin` (backend empty ACAO
+ main-nginx `add_header $http_origin`) — harmless on the new same-origin page but should drop
the backend's redundant CORS since lnd-ui now fetches same-origin.
## ENV QUICK-REF
- .116 archi-thinkpad: data `/var/lib/archipelago`, nginx root `/opt/archipelago/web-ui`,
http :80 + custom nginx-proxy-manager; user reaches UI via Tailscale `100.69.68.39` AND LAN.
Deploy SSH key `~/.ssh/archipelago-deploy` is passphraseless; SSH-to-self + .198 work non-interactively.
- .198: `ssh archipelago@192.168.1.198` (passwordless sudo), podman+npm, NO cargo.
- Companion build-dir precedence: `/opt/archipelago/docker` > `~/archy/docker` > `~/Projects/archy/docker`.
- Uncommitted working-tree changes (mine, not yet committed): the 11 files for fixes #1#5.

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -1,12 +1,10 @@
<template> <template>
<!-- Spacer to prevent content from being hidden behind the player -->
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
<Teleport to="body"> <Teleport to="body">
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-if="audioPlayer.currentName.value" v-if="audioPlayer.currentName.value"
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar" ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
> >
<!-- Progress bar (clickable) --> <!-- Progress bar (clickable) -->
<div <div
@ -60,9 +58,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer' import { useAudioPlayer } from '@/composables/useAudioPlayer'
const audioPlayer = useAudioPlayer() const audioPlayer = useAudioPlayer()
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
// reserve space for it (the same mechanism the mobile tab bar uses). This is
// what pushes the rest of the site up instead of letting the fixed bar overlap
// and block the bottom controls on desktop AND mobile, on every page.
function setPlayerHeightVar() {
if (typeof document === 'undefined') return
const h = barEl.value?.offsetHeight || 60
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
document.documentElement.classList.add('audio-active')
}
function clearPlayerHeightVar() {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--audio-player-height', '0px')
document.documentElement.classList.remove('audio-active')
}
watch(() => audioPlayer.currentName.value, (name) => {
if (name) {
nextTick(setPlayerHeightVar)
} else {
clearPlayerHeightVar()
}
}, { immediate: true })
onBeforeUnmount(clearPlayerHeightVar)
function togglePlay() { function togglePlay() {
if (audioPlayer.playing.value) { if (audioPlayer.playing.value) {
@ -90,6 +117,10 @@ function formatTime(seconds: number): string {
<style scoped> <style scoped>
.audio-player-bar { .audio-player-bar {
/* Sit directly above the mobile tab bar (its height is published as
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
resolves to 0px and the bar docks flush to the bottom of the viewport. */
bottom: var(--mobile-tab-bar-height, 0px);
background: rgba(15, 15, 15, 0.55); background: rgba(15, 15, 15, 0.55);
backdrop-filter: blur(24px) saturate(1.4); backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4);

View File

@ -471,6 +471,9 @@ onUnmounted(() => {
.mesh-map-toggle { .mesh-map-toggle {
width: 36px; width: 36px;
height: 20px; height: 20px;
/* The global mobile rule forces buttons to min-height:44px, which stretches
this switch and pushes the knob off-centre. Pin it back to the pill size. */
min-height: 20px !important;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);

View File

@ -47,7 +47,7 @@
<div v-if="receiveMethod === 'ecash'"> <div v-if="receiveMethod === 'ecash'">
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label> <label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea> <textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div> </div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div> <div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div> </div>
@ -119,7 +119,7 @@ async function receive() {
if (receiveMethod.value === 'lightning') { if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return } if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({ const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice', method: 'lnd.createinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined }, params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
}) })
invoiceResult.value = res.payment_request invoiceResult.value = res.payment_request
@ -133,11 +133,16 @@ async function receive() {
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:')) nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else { } else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return } if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
await rpcClient.call<{ amount_sats: number }>({ // The backend auto-detects the token type: a Cashu token (cashuA/B) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
method: 'wallet.ecash-receive', method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() }, params: { token: ecashToken.value.trim() },
}) })
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess') const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
emit('received') emit('received')
} }
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -152,11 +152,10 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
const { playing: audioPlaying, currentSrc } = useAudioPlayer() const { playing: audioPlaying, currentSrc } = useAudioPlayer()
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value) const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
const aspectClass = computed(() => { // Uniform card cover ratio across every file type so folders, images, videos
if (isImage.value || isVideo.value) return 'aspect-square' // and documents all render at the same height in the grid (previously images/
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]' // videos were square while folders were 4/3, giving a ragged, mismatched grid).
return 'aspect-square' const aspectClass = computed(() => 'aspect-[4/3]')
})
const coverBg = computed(() => { const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10' if (props.item.isDir) return 'bg-amber-500/10'

View File

@ -613,9 +613,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ─── Scroll Support ──────────────────────────────────────── // ─── Scroll Support ────────────────────────────────────────
function handleWheel(e: WheelEvent) { function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null // Scroll the container UNDER THE POINTER, not the focused element. Real
if (!active) return // wheel events always target the element beneath the cursor, so walking up
let p = active.parentElement // from e.target matches native behaviour. Using document.activeElement here
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
// peer list, still focused after a click) instead of the panel actually
// being hovered — producing a double-scroll where both moved at once.
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
if (!start) return
let p: HTMLElement | null = start
while (p) { while (p) {
const style = getComputedStyle(p) const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) { if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {

View File

@ -127,6 +127,13 @@ export interface BlockHeader {
announced_by: string announced_by: string
} }
export interface DeniedAsker {
contact_id: number
name: string
pubkey_hex: string | null
at: string
}
export interface AssistantStatus { export interface AssistantStatus {
enabled: boolean enabled: boolean
model: string | null model: string | null
@ -137,6 +144,7 @@ export interface AssistantStatus {
ollama_detected: boolean ollama_detected: boolean
claude_available: boolean claude_available: boolean
models: string[] models: string[]
denied_askers?: DeniedAsker[]
} }
export interface ScheduledMessage { export interface ScheduledMessage {

View File

@ -130,19 +130,45 @@ select:focus-visible {
} }
} }
/* Scroll container bottom padding — desktop breathing room */ /* Height of the global audio player bar 0 unless it is visible. Set on
<html> by GlobalAudioPlayer.vue. Scroll containers add it to their bottom
padding so the fixed player pushes content up instead of covering it. */
:root {
--audio-player-height: 0px;
}
/* Scroll container bottom padding desktop breathing room. (On desktop the
audio player instead shrinks the whole #main-content area see the
html.audio-active rule below so no player offset is added here.) */
.mobile-scroll-pad, .mobile-scroll-pad,
.mobile-scroll-pad-back { .mobile-scroll-pad-back {
padding-bottom: 6rem; padding-bottom: 6rem;
} }
/* Audio player docked: shrink the whole interface into the space above it so
the entire view scales up (like the AIUI iframe) instead of just gaining
scroll padding. On desktop the player spans full width and BOTH the sidebar
and the main content scale into the reduced height above it. Mobile keeps the
tab-bar + player handled via .mobile-scroll-pad padding. */
@media (min-width: 768px) {
html.audio-active .dashboard-view {
height: calc(100dvh - var(--audio-player-height, 0px));
min-height: 0;
overflow: hidden;
}
/* Sidebar uses h-screen (100vh) — pin it to the reduced container height. */
html.audio-active .dashboard-view [data-controller-zone="sidebar"] {
height: 100%;
}
}
/* Mobile: override with tab bar clearance */ /* Mobile: override with tab bar clearance */
@media (max-width: 767px) { @media (max-width: 767px) {
.mobile-scroll-pad { .mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
} }
.mobile-scroll-pad-back { .mobile-scroll-pad-back {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
} }
/* Safe area top padding for all mobile content views. /* Safe area top padding for all mobile content views.
@ -174,11 +200,11 @@ select:focus-visible {
} }
.mobile-scroll-pad { .mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
} }
.mobile-scroll-pad-back { .mobile-scroll-pad-back {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
} }
.mobile-safe-top { .mobile-safe-top {
@ -525,6 +551,7 @@ input[type="radio"]:active + * {
position: relative; position: relative;
} }
/* On mobile browsers, cap chat height to the dynamic viewport to prevent /* On mobile browsers, cap chat height to the dynamic viewport to prevent
content extending behind browser chrome (address bar / toolbar). */ content extending behind browser chrome (address bar / toolbar). */
@media (max-width: 767px) { @media (max-width: 767px) {
@ -555,8 +582,8 @@ input[type="radio"]:active + * {
context) stay above the tab bar instead of sliding underneath it. */ context) stay above the tab bar instead of sliding underneath it. */
@media (max-width: 767px) { @media (max-width: 767px) {
.chat-iframe-mobile { .chat-iframe-mobile {
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important; height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important; height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
flex: none; flex: none;
} }
} }
@ -1926,18 +1953,19 @@ html.modal-scroll-locked .dashboard-scroll-panel {
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
} }
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */ /* Mobile floating back/close button (always 8px above tab bar and above
the audio player too when it is showing, so it never hides behind it) */
.mobile-back-btn { .mobile-back-btn {
position: fixed; position: fixed;
left: 1rem; left: 1rem;
right: 1rem; right: 1rem;
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px); bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
z-index: 40; z-index: 40;
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
} }
.mobile-filter-btn { .mobile-filter-btn {
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px); bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
} }

View File

@ -95,9 +95,11 @@
/> />
<!-- Re-key on the current folder path so the depth/zoom animation replays <!-- Re-key on the current folder path so the depth/zoom animation replays
at every level (folder subfolder ), not just on first entry. at every level (folder subfolder ), not just on first entry.
Only the file content zooms; the header + breadcrumb nav above stay The transition name flips with navigation direction so descending
fixed in place. --> zooms forward and going back up zooms in reverse matching the
<Transition name="cloud-zoom" mode="out-in"> cloud folder route transition. Only the file content zooms; the
header + breadcrumb nav above stay fixed in place. -->
<Transition :name="folderTransition" mode="out-in">
<FileGrid <FileGrid
:key="cloudStore.currentPath" :key="cloudStore.currentPath"
:items="cloudStore.sortedItems" :items="cloudStore.sortedItems"
@ -177,6 +179,19 @@ const cloudStore = useCloudStore()
const viewMode = ref<'list' | 'grid'>('grid') const viewMode = ref<'list' | 'grid'>('grid')
const audioPlayer = useAudioPlayer() const audioPlayer = useAudioPlayer()
// Direction-aware folder zoom: descending into a subfolder plays the same
// "depth-forward" feel as the cloud folder route transition (new arrives from
// depth, current zooms out toward the viewer); navigating back up plays its
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
let prevFolderDepth = -1
watch(() => cloudStore.currentPath, (path) => {
const depth = path.split('/').filter(Boolean).length
// First render (prevFolderDepth === -1) defaults to forward.
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
prevFolderDepth = depth
})
const iframeLoaded = ref(false) const iframeLoaded = ref(false)
const uploading = ref(false) const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string) const folderId = computed(() => route.params.folderId as string)
@ -395,37 +410,60 @@ function goBack() {
</script> </script>
<!-- Not scoped: the transition classes are applied to the FileGrid child's root <!-- Not scoped: the transition classes are applied to the FileGrid child's root
element, which lives outside this component's style scope. Matches the element, which lives outside this component's style scope. Mirrors the
`depth-forward` route transition feel (zoom in from depth + blur). --> `depth-forward` / `depth-back` route transitions (same scale magnitudes +
blur) so descending into a folder and going back up feel identical to the
cloud folder route change. -->
<style> <style>
.cloud-zoom-enter-active, .cloud-zoom-forward-enter-active,
.cloud-zoom-leave-active { .cloud-zoom-forward-leave-active,
.cloud-zoom-back-enter-active,
.cloud-zoom-back-leave-active {
transition: transition:
opacity 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94); filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center; transform-origin: center center;
will-change: opacity, transform, filter; will-change: opacity, transform, filter;
} }
/* New folder zooms in from depth */
.cloud-zoom-enter-from { /* Forward (into a deeper folder): new folder arrives from depth while the
current one zooms out toward the viewer matches depth-forward. */
.cloud-zoom-forward-enter-from {
opacity: 0; opacity: 0;
transform: scale(0.82); transform: scale(0.75);
filter: blur(4px); filter: blur(4px);
} }
/* Previous folder recedes forward as it leaves */ .cloud-zoom-forward-leave-to {
.cloud-zoom-leave-to {
opacity: 0; opacity: 0;
transform: scale(1.12); transform: scale(1.2);
filter: blur(6px); filter: blur(8px);
} }
/* Back (up to a parent folder): the mirror new folder shrinks in from the
front while the current one recedes into depth matches depth-back. */
.cloud-zoom-back-enter-from {
opacity: 0;
transform: scale(1.2);
filter: blur(8px);
}
.cloud-zoom-back-leave-to {
opacity: 0;
transform: scale(0.75);
filter: blur(4px);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cloud-zoom-enter-active, .cloud-zoom-forward-enter-active,
.cloud-zoom-leave-active { .cloud-zoom-forward-leave-active,
.cloud-zoom-back-enter-active,
.cloud-zoom-back-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.cloud-zoom-enter-from, .cloud-zoom-forward-enter-from,
.cloud-zoom-leave-to { .cloud-zoom-forward-leave-to,
.cloud-zoom-back-enter-from,
.cloud-zoom-back-leave-to {
transform: none; transform: none;
filter: none; filter: none;
} }

View File

@ -70,6 +70,8 @@
tabindex="-1" tabindex="-1"
@pointerenter="activateMainScroll" @pointerenter="activateMainScroll"
@wheel.capture="activateMainScroll" @wheel.capture="activateMainScroll"
@touchstart.passive="onContentTouchStart"
@touchend.passive="onContentTouchEnd"
> >
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20"> <div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
<!-- Controller zone entry point - no switcher --> <!-- Controller zone entry point - no switcher -->
@ -253,6 +255,72 @@ function activateMainScroll() {
} }
} }
// Swipe left/right to move between the mobile top tabs
// Apps screen: My Apps App Store Services
// Web5 screen: Web5 Cloud Network Mesh
// Only active while the matching tab strip is actually showing (mobile).
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
const APPS_TABS: TabTarget[] = [
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
]
const NET_TABS: TabTarget[] = [
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
]
function activeAppsKey(): string {
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
return ''
}
function activeNetKey(): string {
const p = route.path
if (p.startsWith('/dashboard/web5')) return 'web5'
if (p.startsWith('/dashboard/cloud')) return 'cloud'
if (p.startsWith('/dashboard/server')) return 'server'
if (p.startsWith('/dashboard/mesh')) return 'mesh'
return ''
}
let touchStartX = 0
let touchStartY = 0
let touchStartTime = 0
function onContentTouchStart(e: TouchEvent) {
const t = e.touches[0]
if (!t) return
touchStartX = t.clientX
touchStartY = t.clientY
touchStartTime = e.timeStamp
}
function onContentTouchEnd(e: TouchEvent) {
const t = e.changedTouches[0]
if (!t) return
const dx = t.clientX - touchStartX
const dy = t.clientY - touchStartY
const dt = e.timeStamp - touchStartTime
// Clear horizontal flick: far enough, mostly sideways, and quick.
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
const nav = mobileNavRef.value
if (!nav) return
let tabs: TabTarget[] | null = null
let activeKey = ''
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
if (!tabs) return
const idx = tabs.findIndex(tb => tb.key === activeKey)
if (idx < 0) return
const next = idx + (dx < 0 ? 1 : -1) // swipe left next tab, right previous
if (next < 0 || next >= tabs.length) return
router.push(tabs[next].to).catch(() => {})
}
watch(() => route.path, (newPath) => { watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath) const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value const wasAppDetails = showAltBackground.value

View File

@ -38,6 +38,8 @@ const configuring = ref(false)
const connectingDevice = ref<string | null>(null) const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null) const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false) const mobileShowChat = ref(false)
// Device status panel starts collapsed on mobile (expandable via its header).
const deviceExpanded = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
let wsUnsub: (() => void) | null = null let wsUnsub: (() => void) | null = null
@ -261,6 +263,15 @@ const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('cha
// Tools tab for 3rd column on wide desktop and mobile below-chat // Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin') const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
// 'chat' shows the peers list / active conversation; the rest swap the pane to
// that tool. Selecting a tool leaves any open conversation.
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
mobileTab.value = tab
if (tab !== 'chat') mobileShowChat.value = false
}
// Panel visibility computeds // Panel visibility computeds
const showChatPanel = computed(() => const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value) activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
@ -268,28 +279,29 @@ const showChatPanel = computed(() =>
const showBitcoinPanel = computed(() => { const showBitcoinPanel = computed(() => {
if (isVeryWideDesktop.value) return true if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin' if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin' if (isMobile.value) return mobileTab.value === 'bitcoin'
return activeTab.value === 'bitcoin' return activeTab.value === 'bitcoin'
}) })
const showDeadmanPanel = computed(() => { const showDeadmanPanel = computed(() => {
if (isVeryWideDesktop.value) return true if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman' if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman' if (isMobile.value) return mobileTab.value === 'deadman'
return activeTab.value === 'deadman' return activeTab.value === 'deadman'
}) })
const showAssistantPanel = computed(() => { const showAssistantPanel = computed(() => {
if (isVeryWideDesktop.value) return true if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'assistant' if (isWideDesktop.value) return toolsTab.value === 'assistant'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant' if (isMobile.value) return mobileTab.value === 'assistant'
return activeTab.value === 'assistant' return activeTab.value === 'assistant'
}) })
const showMapPanel = computed(() => { const showMapPanel = computed(() => {
if (isVeryWideDesktop.value) return true if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map' if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map' if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map' return activeTab.value === 'map'
}) })
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value) // Mobile: tool pane shows whenever a non-chat tab is active.
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value) const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
// Fetch session status when active peer changes // Fetch session status when active peer changes
@ -313,10 +325,25 @@ async function handleToggleOffGrid() {
} finally { togglingOffGrid.value = false } } finally { togglingOffGrid.value = false }
} }
// Track the on-screen keyboard height (mobile) so the conversation pane + back
// button can sit just above it fixed elements ignore the keyboard otherwise
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
function updateKeyboardInset() {
const vv = window.visualViewport
if (!vv) return
const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
}
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
document.addEventListener('click', handleDocClickForMenu) document.addEventListener('click', handleDocClickForMenu)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession) window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateKeyboardInset)
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
updateKeyboardInset()
}
loadPendingFromSession() loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
refreshOutboxCount() refreshOutboxCount()
@ -355,6 +382,11 @@ onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleDocClickForMenu) document.removeEventListener('click', handleDocClickForMenu)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession) window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
}
document.documentElement.style.removeProperty('--keyboard-inset')
if (pollInterval) clearInterval(pollInterval) if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null } if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
if (wsUnsub) { wsUnsub(); wsUnsub = null } if (wsUnsub) { wsUnsub(); wsUnsub = null }
@ -885,10 +917,11 @@ function scrollChatToBottom() {
} }
// Wheel over the chat must scroll ONLY the chat never leak to the contacts // Wheel over the chat must scroll ONLY the chat never leak to the contacts
// list or the page. CSS overscroll-behavior wasn't enough (the leak happens // list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
// even when the chat doesn't overflow), so consume the wheel and apply it to // from reaching the global controller-nav wheel handler (which would otherwise
// the chat container directly. Used with `@wheel.prevent` so the default // also scroll whatever container is focused, e.g. the peer list after a click),
// (page/contacts) scroll never fires. // and `.prevent` stops the native page scroll. We then apply the delta to the
// chat container directly.
function onChatWheel(e: WheelEvent) { function onChatWheel(e: WheelEvent) {
const el = chatScrollEl.value const el = chatScrollEl.value
if (!el) return if (!el) return
@ -1354,12 +1387,15 @@ function isImageMime(mime?: string): boolean {
<!-- Responsive column layout --> <!-- Responsive column layout -->
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }"> <div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
<!-- LEFT COLUMN: Status + Peers --> <!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }"> <div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
<!-- Device Status --> <!-- Device Status -->
<div data-controller-container tabindex="0" class="glass-card mesh-status-card"> <div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
<div class="mesh-status-header"> <div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" /> <div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2> <h2 class="mesh-section-title">Device</h2>
<svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div> </div>
<div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div> <div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>
@ -1543,7 +1579,7 @@ function isImageMime(mime?: string): boolean {
</div> </div>
<!-- RIGHT COLUMN: Tabbed panels --> <!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }"> <div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
<!-- Tab bar (medium desktop only) --> <!-- Tab bar (medium desktop only) -->
<div v-if="showTabBar" class="mesh-tab-bar"> <div v-if="showTabBar" class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button> <button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
@ -1561,15 +1597,29 @@ function isImageMime(mime?: string): boolean {
</div> </div>
<!-- Chat Panel --> <!-- Chat Panel -->
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card"> <div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
<div v-if="!hasActiveChat" class="mesh-chat-empty"> <div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div> <div class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p> <p>Select a peer or channel to chat</p>
<p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p> <p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
</div> </div>
<template v-else> <template v-else>
<!-- Mobile: floating back button (shared glass pill style), pinned
above the tab bar replaces the in-header arrow so the back
control is no longer crammed inside the chat container. -->
<Teleport to="body">
<button
type="button"
class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
@click="closeChat"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</button>
</Teleport>
<div class="mesh-chat-header"> <div class="mesh-chat-header">
<button class="mesh-chat-back" @click="closeChat">&larr;</button>
<div class="mesh-chat-header-info"> <div class="mesh-chat-header-info">
<div class="mesh-chat-header-name"> <div class="mesh-chat-header-name">
<template v-if="renamingActive"> <template v-if="renamingActive">
@ -1604,7 +1654,7 @@ function isImageMime(mime?: string): boolean {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span> <span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div> </div>
</div> </div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.prevent="onChatWheel"> <div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages"> <div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello! No messages yet. Say hello!
</div> </div>
@ -1851,17 +1901,6 @@ function isImageMime(mime?: string): boolean {
<!-- Mobile tools: show under peers list on first view --> <!-- Mobile tools: show under peers list on first view -->
<div v-if="showMobileTools" class="mesh-mobile-tools"> <div v-if="showMobileTools" class="mesh-mobile-tools">
<div class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
AI
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
</div>
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div> <div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<MeshBitcoinPanel v-if="showBitcoinPanel" /> <MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" /> <MeshDeadmanPanel v-if="showDeadmanPanel" />
@ -1869,6 +1908,23 @@ function isImageMime(mime?: string): boolean {
</div> </div>
</div> </div>
<!-- Mobile: floating tab strip pinned above the global tab bar (same
placement as the mobile back button). Switches the whole pane between
the chat and each tool. Hidden while an individual conversation is open
(the back button takes over there). -->
<Teleport to="body">
<div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
<button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
</div>
</Teleport>
<!-- Transport chooser modal: shown when attachment size fits both mesh <!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. --> (inline-chunked) and Tor. User picks which path to send it over. -->
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')"> <div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">

View File

@ -316,14 +316,14 @@
<button <button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying" :disabled="lnPaying || onchainPaying"
@click="payWithInvoice" @click="openQrPay"
> >
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg> </svg>
<span> <span>
<span class="block text-base text-white">Pay from another wallet (QR)</span> <span class="block text-base text-white">Pay from another wallet (QR)</span>
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span> <span class="block text-sm text-white/50">Scan an on-chain or Lightning QR with any wallet</span>
</span> </span>
</button> </button>
@ -344,40 +344,84 @@
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p> <p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
</div> </div>
<!-- Step 2: Lightning invoice --> <!-- Step 2: pay from another wallet tabbed QR (on-chain default) -->
<div v-else class="text-center"> <div v-else>
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3"> <!-- Method tabs, styled like the wallet Send/Receive modal -->
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <button
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" /> v-for="m in (['onchain', 'lightning'] as const)"
</svg> :key="m"
<span class="text-sm text-white/70">Requesting invoice from seller</span> @click="selectQrTab(m)"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="qrTab === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : 'Lightning' }}</button>
</div> </div>
<div v-else-if="invoiceData"> <!-- On-chain QR -->
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3"> <div v-if="qrTab === 'onchain'" class="text-center">
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" /> <div v-if="onchainWaiting && !onchainData" class="py-10 flex flex-col items-center gap-3">
</div> <svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg> </svg>
Waiting for payment <span class="text-sm text-white/70">Requesting an address from the seller</span>
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
</button>
</div> </div>
<div v-else-if="onchainData">
<div v-if="onchainQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="onchainQr" alt="On-chain payment QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ onchainData.amount_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ onchainData.address }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyOnchain">
{{ onchainCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-[10px] text-white/40 mt-2">Needs 1 confirmation before the file unlocks.</p>
</div>
<p v-if="onchainError" class="text-sm text-red-400 mt-3">{{ onchainError }}</p>
</div>
<!-- Lightning invoice QR -->
<div v-else class="text-center">
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<span class="text-sm text-white/70">Requesting invoice from seller</span>
</div>
<div v-else-if="invoiceData">
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
</div> </div>
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
<button <button
v-if="invoiceError" class="glass-button px-4 py-2 rounded-lg text-sm mt-4 w-full"
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
@click="payMode = 'choose'" @click="payMode = 'choose'"
> >
Back Back
@ -453,12 +497,21 @@ const audioPlayer = useAudioPlayer()
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that // wallet (instant), or a Lightning invoice drawn on the SELLER's node that
// they can pay from any external wallet by scanning a QR. // they can pay from any external wallet by scanning a QR.
const payItem = ref<CatalogItem | null>(null) const payItem = ref<CatalogItem | null>(null)
const payMode = ref<'choose' | 'invoice'>('choose') const payMode = ref<'choose' | 'qr'>('choose')
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
// on-chain first (the default).
const qrTab = ref<'onchain' | 'lightning'>('onchain')
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null) const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
const invoiceQr = ref('') const invoiceQr = ref('')
const invoiceWaiting = ref(false) const invoiceWaiting = ref(false)
const invoiceError = ref('') const invoiceError = ref('')
const invoiceCopied = ref(false) const invoiceCopied = ref(false)
// On-chain QR (pay the seller's address from any external wallet).
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
const onchainQr = ref('')
const onchainWaiting = ref(false)
const onchainError = ref('')
const onchainCopied = ref(false)
const lnPaying = ref(false) const lnPaying = ref(false)
const lnError = ref('') const lnError = ref('')
const onchainPaying = ref(false) const onchainPaying = ref(false)
@ -660,11 +713,17 @@ async function downloadFile(item: CatalogItem) {
function openPayModal(item: CatalogItem) { function openPayModal(item: CatalogItem) {
payItem.value = item payItem.value = item
payMode.value = 'choose' payMode.value = 'choose'
qrTab.value = 'onchain'
invoiceData.value = null invoiceData.value = null
invoiceQr.value = '' invoiceQr.value = ''
invoiceWaiting.value = false invoiceWaiting.value = false
invoiceError.value = '' invoiceError.value = ''
invoiceCopied.value = false invoiceCopied.value = false
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = false
onchainError.value = ''
onchainCopied.value = false
lnPaying.value = false lnPaying.value = false
lnError.value = '' lnError.value = ''
onchainPaying.value = false onchainPaying.value = false
@ -675,9 +734,102 @@ function closePayModal() {
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null } if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
payItem.value = null payItem.value = null
invoiceWaiting.value = false invoiceWaiting.value = false
onchainWaiting.value = false
onchainPaying.value = false onchainPaying.value = false
} }
/**
* Open the "pay from another wallet" view: tabbed QR like the wallet's
* Send/Receive modal, defaulting to the on-chain tab (so a QR is shown
* immediately for any external wallet).
*/
function openQrPay() {
payMode.value = 'qr'
qrTab.value = 'onchain'
invoiceData.value = null
invoiceQr.value = ''
invoiceError.value = ''
invoiceWaiting.value = false
onchainData.value = null
onchainQr.value = ''
onchainError.value = ''
loadOnchainQr()
}
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
* resuming its payment poll if it was already loaded (so switching back and
* forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return
qrTab.value = tab
if (tab === 'onchain') {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
if (!onchainData.value && !onchainWaiting.value) {
loadOnchainQr()
} else if (onchainData.value && !onchainPaying.value) {
onchainPaying.value = true
pollOnchain(onchainData.value.address)
}
} else {
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
onchainPaying.value = false
if (!invoiceData.value && !invoiceWaiting.value) {
payWithInvoice()
} else if (invoiceData.value) {
scheduleInvoicePoll()
}
}
}
/**
* On-chain QR: ask the seller for a fresh address + amount, render a
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
* lands, then release the file (the address is the gate token).
*/
async function loadOnchainQr() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
onchainError.value = ''
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = true
try {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
method: 'content.request-onchain',
params: { onion, content_id: item.id },
timeout: 45000,
})
if (!req?.address || !req?.amount_sats) {
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
onchainWaiting.value = false
return
}
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
const btc = (req.amount_sats / 1e8).toFixed(8)
try {
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
} catch {
onchainQr.value = '' // fall back to showing the raw address
}
onchainWaiting.value = false
onchainPaying.value = true // "waiting for payment" drives the poll loop
pollOnchain(req.address)
} catch (e: unknown) {
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
onchainWaiting.value = false
}
}
async function copyOnchain() {
if (!onchainData.value) return
try {
await navigator.clipboard.writeText(onchainData.value.address)
onchainCopied.value = true
setTimeout(() => { onchainCopied.value = false }, 1500)
} catch { /* clipboard denied */ }
}
/** /**
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address + * Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects * amount, broadcast with lnd.sendcoins, then poll the seller until it detects
@ -695,7 +847,7 @@ async function payOnchain() {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
method: 'content.request-onchain', method: 'content.request-onchain',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 60000, timeout: 45000,
}) })
if (!req?.address || !req?.amount_sats) { if (!req?.address || !req?.amount_sats) {
lnError.value = req?.error || 'The seller could not provide an on-chain address.' lnError.value = req?.error || 'The seller could not provide an on-chain address.'
@ -805,14 +957,14 @@ async function payWithInvoice() {
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return if (!item || !onion) return
payMode.value = 'invoice' payMode.value = 'qr'
invoiceError.value = '' invoiceError.value = ''
invoiceWaiting.value = true invoiceWaiting.value = true
try { try {
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({ const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice', method: 'content.request-invoice',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 60000, timeout: 45000,
}) })
if (!res?.bolt11 || !res?.payment_hash) { if (!res?.bolt11 || !res?.payment_hash) {
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).' invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
@ -849,7 +1001,7 @@ async function payWithLightning() {
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({ const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice', method: 'content.request-invoice',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 60000, timeout: 45000,
}) })
if (!inv?.bolt11 || !inv?.payment_hash) { if (!inv?.bolt11 || !inv?.payment_hash) {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).' lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'

View File

@ -136,7 +136,7 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> <svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">FIPS Mesh</span> <span class="text-white/80 text-sm">Fuck IPs Mesh</span>
</div> </div>
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span> <span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
</div> </div>

View File

@ -122,9 +122,7 @@
</div> </div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-white/80">Cashu</span> <span class="text-sm text-white/80">Cashu</span>
</div> </div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span> <span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>

View File

@ -88,6 +88,22 @@ function addPubkey() {
apply({ allowed_contacts: allowedContacts.value }) apply({ allowed_contacts: allowedContacts.value })
} }
// Radio/peers who recently tried `!ai` and were turned away by the policy.
// Surfaced so the operator can one-click allow them instead of digging through
// the journal for the firmware key. Hide any we've since allowed.
const deniedAskers = computed(() =>
(status.value?.denied_askers ?? []).filter(
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
),
)
function allowDenied(pubkey: string | null) {
if (!pubkey) return
if (!isAllowed(pubkey)) {
allowedContacts.value = [...allowedContacts.value, pubkey]
apply({ allowed_contacts: allowedContacts.value })
}
}
onMounted(() => { onMounted(() => {
mesh.fetchAssistantStatus() mesh.fetchAssistantStatus()
}) })
@ -138,7 +154,6 @@ function onPolicy() {
<template> <template>
<div class="glass-card mesh-assistant-panel"> <div class="glass-card mesh-assistant-panel">
<h3 class="mesh-panel-title">AI Assistant</h3> <h3 class="mesh-panel-title">AI Assistant</h3>
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
<!-- Backend chooser --> <!-- Backend chooser -->
<div class="mesh-assistant-field"> <div class="mesh-assistant-field">
@ -206,10 +221,8 @@ function onPolicy() {
<option value="trusted">Trusted nodes only</option> <option value="trusted">Trusted nodes only</option>
<option value="anyone">Anyone on the mesh</option> <option value="anyone">Anyone on the mesh</option>
</select> </select>
<p class="text-xs text-white/40 mt-1"> <p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
{{ policy === 'anyone' Any peer can spend this node's AI budget + airtime.
? 'Any peer can spend this node\'s AI budget + airtime.'
: 'Only federation-trusted peers may ask.' }}
</p> </p>
</div> </div>
@ -217,9 +230,6 @@ function onPolicy() {
policy is "trusted only" and they aren't federation-trusted. --> policy is "trusted only" and they aren't federation-trusted. -->
<div class="mesh-assistant-field"> <div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Always allow these contacts</label> <label class="mesh-bitcoin-label">Always allow these contacts</label>
<p class="text-xs text-white/40 mb-2">
Listed contacts can use <code>!ai</code> regardless of the policy above.
</p>
<div v-if="contactOptions.length === 0" class="text-xs text-white/40"> <div v-if="contactOptions.length === 0" class="text-xs text-white/40">
No contacts yet they appear here once you have mesh/federation contacts. No contacts yet they appear here once you have mesh/federation contacts.
</div> </div>
@ -256,6 +266,38 @@ function onPolicy() {
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p> <p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
</div> </div>
<!-- Recently denied askers: someone tried !ai but the policy turned them
away. Show who, and offer a one-click Allow when we know their key. -->
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Recently denied</label>
<p class="text-xs text-white/40 mb-2">
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
</p>
<div class="mesh-assistant-allowlist">
<div
v-for="d in deniedAskers"
:key="d.contact_id + (d.pubkey_hex || '')"
class="mesh-assistant-allow-row"
>
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
{{ d.name || ('#' + d.contact_id) }}
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}</span>
</span>
<button
v-if="d.pubkey_hex"
type="button"
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
@click="allowDenied(d.pubkey_hex)"
>
Allow
</button>
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
no key
</span>
</div>
</div>
</div>
<p class="text-xs text-white/50 mt-2"> <p class="text-xs text-white/50 mt-2">
Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel. Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel.
</p> </p>

View File

@ -62,6 +62,10 @@
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); } .mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); } .mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; } .mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
/* Collapse chevron only used on mobile (hidden on desktop, where the Device
panel is always expanded). Kept small and pushed to the far right. */
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; } .mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; } .mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
@ -90,7 +94,7 @@
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; } .mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); } .mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); } .mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; } .mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; }
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; } .mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; } .mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
.mesh-peer-reach.is-reachable { background: #34d399; } .mesh-peer-reach.is-reachable { background: #34d399; }
@ -120,7 +124,9 @@
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; } .mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); } .mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); }
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } .mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; } /* Floating mobile back button (Teleported to body). Hidden by default; only
shown in the single-column mobile mesh layout (see media query below). */
.mesh-chat-mobile-back { display: none; }
.mesh-chat-header-info { flex: 1; min-width: 0; } .mesh-chat-header-info { flex: 1; min-width: 0; }
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; } .mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; } .mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
@ -155,22 +161,115 @@
@keyframes mesh-send-spin { to { transform: rotate(360deg); } } @keyframes mesh-send-spin { to { transform: rotate(360deg); } }
.mesh-mobile-back-btn { display: none; } .mesh-mobile-back-btn { display: none; }
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
1279px block flips it to flex and the placement mirrors the mobile back
button (pinned above the global tab bar + audio player). */
.mesh-mobile-tabbar {
display: none;
position: fixed;
left: 12px;
right: 12px;
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
z-index: 40;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.mesh-mtab {
flex: 1 1 0;
min-width: 0;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
@media (max-width: 1279px) { @media (max-width: 1279px) {
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; } .mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
.mesh-columns { flex-direction: column; overflow: visible; } .mesh-columns { flex-direction: column; overflow: visible; }
.mesh-left { width: 100%; overflow: visible; } .mesh-left { width: 100%; overflow: visible; }
.mesh-right { min-height: auto; overflow: visible; } .mesh-right { min-height: auto; overflow: visible; }
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; } .mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
/* Single-column mobile mesh: one fixed, internally-scrolling pane that
fills the space between the top tab strip and the floating mesh tab bar.
The page itself never scrolls; each pane scrolls inside its own bounds.
Fixed positioning is relative to the full-height perspective container, so
the offsets line up with the body-teleported tab bar / back button. */
.mesh-left,
.mesh-mobile-tools,
.mesh-chat-card.mesh-chat-card-active {
position: fixed;
left: 12px;
right: 12px;
/* width:auto so left+right govern the box .mesh-left otherwise carries a
fixed 380px width that ignores `right` and overflows the screen. */
width: auto;
box-sizing: border-box;
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
height: auto;
min-height: 0;
max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
z-index: 30;
}
/* Active conversation: the floating tabs are hidden here and the back button
takes the standard spot above the tab bar, so the chat window fills down to
just above the back pill (back pill 44px at +8, plus a 16px gap). When the
keyboard is up it covers the tab bar, so anchor to whichever is taller the
bottom controls or the keyboard so the window sits right above both. */
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
overflow: hidden; /* the messages list inside does the scrolling */
}
.mesh-tools-wrapper { display: none !important; } .mesh-tools-wrapper { display: none !important; }
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; } .mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; } /* The active tool fills the whole fixed pane (no fixed cap that would leave a
.mesh-mobile-tools :deep(.mesh-bitcoin-panel), bottom margin); the panel itself scrolls if its content is taller. */
.mesh-mobile-tools :deep(.mesh-assistant-panel), .mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; } .mesh-mobile-tools .mesh-bitcoin-panel,
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; } .mesh-mobile-tools .mesh-assistant-panel,
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); } .mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
.mesh-chat-back { display: block; } /* In a conversation the tabs are hidden, so the back pill sits just above the
tab bar or above the keyboard when it's up, whichever is taller. */
.mesh-chat-mobile-back { display: flex; }
.mesh-chat-mobile-back.mobile-back-btn {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
}
/* Floating mesh tab strip — same placement logic as the mobile back button. */
.mesh-mobile-tabbar { display: flex; }
.mobile-hidden { display: none !important; } .mobile-hidden { display: none !important; }
/* Device panel is a collapsible/expandable accordion on mobile (starts
collapsed). Show the chevron, make the header tappable, and hide the body
when collapsed. */
.mesh-status-chevron { display: block; }
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
:deep(.mesh-bitcoin-panel), :deep(.mesh-bitcoin-panel),
:deep(.mesh-assistant-panel), :deep(.mesh-assistant-panel),
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; } :deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
@ -181,6 +280,14 @@
.mesh-view { .mesh-view {
padding: 24px; padding: 24px;
} }
/* In this range the desktop sidebar (256px) is still shown. The in-pane
fixed elements are positioned relative to the main content area (their
perspective containing block), so they already clear the sidebar but the
body-teleported floating bars are viewport-relative, so nudge them right. */
.mesh-mobile-tabbar,
.mesh-chat-mobile-back.mobile-back-btn {
left: 268px;
}
} }
@media (max-width: 920px) { @media (max-width: 920px) {

View File

@ -8,7 +8,7 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2"> <div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2> <h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel"> <div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span> <span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>