Compare commits
5 Commits
f636c5d505
...
0ac67f5092
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac67f5092 | ||
|
|
837cc02812 | ||
|
|
1bce694ebb | ||
|
|
c4855526fe | ||
|
|
298595069d |
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::wallet::{ecash, profits};
|
use crate::wallet::{ecash, fedimint_client, profits};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||||
|
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
|
||||||
|
/// string is routed to the Fedimint reissue path.
|
||||||
|
fn is_cashu_token(token: &str) -> bool {
|
||||||
|
token.trim_start().starts_with("cashu")
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||||
@ -129,11 +136,27 @@ impl RpcHandler {
|
|||||||
let token = params
|
let token = params
|
||||||
.get("token")
|
.get("token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
||||||
|
|
||||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
|
||||||
|
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||||
|
// sidecar). Detect by prefix and route accordingly.
|
||||||
|
if is_cashu_token(token) {
|
||||||
|
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"received_sats": amount,
|
||||||
|
"kind": "cashu",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (amount, federation_id) =
|
||||||
|
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"received_sats": amount,
|
"received_sats": amount,
|
||||||
|
"kind": "fedimint",
|
||||||
|
"federation_id": federation_id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -70,6 +70,10 @@ fn is_required_baseline_app(app_id: &str) -> bool {
|
|||||||
| "mempool"
|
| "mempool"
|
||||||
| "archy-mempool-db"
|
| "archy-mempool-db"
|
||||||
| "filebrowser"
|
| "filebrowser"
|
||||||
|
// fmcd: bundled on every node so the wallet's Fedimint side works
|
||||||
|
// out of the box (auto-joins the default federation). Self-heals if
|
||||||
|
// removed, like the other baseline services.
|
||||||
|
| "fedimint-clientd"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2728,6 +2732,13 @@ impl ProdContainerOrchestrator {
|
|||||||
.await
|
.await
|
||||||
.context("ensuring bitcoin tx-relay credentials")?;
|
.context("ensuring bitcoin tx-relay credentials")?;
|
||||||
}
|
}
|
||||||
|
if app_id == "fedimint-clientd" {
|
||||||
|
// The fmcd container's secret_env (fmcd-password) and the wallet
|
||||||
|
// bridge both read this; generate it before secret_env resolves.
|
||||||
|
crate::wallet::fedimint_client::ensure_fmcd_password(&self.secrets_dir)
|
||||||
|
.await
|
||||||
|
.context("ensuring fmcd password secret")?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -254,11 +254,57 @@ pub(crate) async fn notify_join(
|
|||||||
"params": params,
|
"params": params,
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1")
|
// Deliver the notification in the BACKGROUND with retries, and return
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
// immediately. Two reasons:
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
|
||||||
.send_json(&body)
|
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
|
||||||
.await;
|
// the whole join until FIPS timed out, surfacing as "Request timeout"
|
||||||
|
// in the UI even though the local membership was already saved.
|
||||||
|
// 2. If this single best-effort POST failed, the inviter never learned
|
||||||
|
// about us → asymmetric federation (they couldn't see us). Retrying in
|
||||||
|
// the background until it lands makes federation converge to symmetric.
|
||||||
|
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
|
||||||
|
// answers an onion in ~3-5s) is reached quickly on each attempt.
|
||||||
|
let remote_onion = remote_onion.to_string();
|
||||||
|
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
|
||||||
|
// peer that is briefly unreachable (restarting, publishing its onion)
|
||||||
|
// without hammering it.
|
||||||
|
for attempt in 1..=5u32 {
|
||||||
|
let res = crate::fips::dial::PeerRequest::new(
|
||||||
|
remote_fips_npub.as_deref(),
|
||||||
|
&remote_onion,
|
||||||
|
"/rpc/v1",
|
||||||
|
)
|
||||||
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.send_json(&body)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
|
tracing::info!(
|
||||||
|
attempt,
|
||||||
|
transport = %transport,
|
||||||
|
"peer-joined notification delivered to inviter"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok((resp, _)) => tracing::warn!(
|
||||||
|
attempt,
|
||||||
|
status = %resp.status(),
|
||||||
|
"peer-joined notification rejected; will retry"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
onion = %remote_onion,
|
||||||
|
"peer-joined notification gave up after retries — peer may not see us until next sync"
|
||||||
|
);
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,9 @@ mod types;
|
|||||||
|
|
||||||
// Re-export all public items so `crate::federation::*` continues to work.
|
// Re-export all public items so `crate::federation::*` continues to work.
|
||||||
pub use invites::{accept_invite, create_invite};
|
pub use invites::{accept_invite, create_invite};
|
||||||
|
// Crate-internal: used by the periodic federation auto-sync to re-assert
|
||||||
|
// membership to peers that don't list us back (asymmetry self-heal).
|
||||||
|
pub(crate) use invites::notify_join;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||||
|
|||||||
@ -33,6 +33,12 @@ pub async fn sync_with_peer(
|
|||||||
.header("X-Federation-Sig", signature)
|
.header("X-Federation-Sig", signature)
|
||||||
.header("X-Federation-Timestamp", timestamp)
|
.header("X-Federation-Timestamp", timestamp)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
|
||||||
|
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
|
||||||
|
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
|
||||||
|
// budget. Without this, a state sync to a FIPS-unreachable peer "took
|
||||||
|
// ages" and join/sync appeared to time out even though Tor was healthy.
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_json(&body)
|
.send_json(&body)
|
||||||
.await
|
.await
|
||||||
.context("Failed to reach federated peer")?;
|
.context("Failed to reach federated peer")?;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -42,6 +42,14 @@ pub struct MeshtasticDevice {
|
|||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
contacts: HashMap<u32, ParsedContact>,
|
contacts: HashMap<u32, ParsedContact>,
|
||||||
|
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
|
||||||
|
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
|
||||||
|
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
|
||||||
|
/// the synthetic node-num-derived routing key that `send_text_msg` relies
|
||||||
|
/// on — we must not overwrite that or unicast routing breaks. This map only
|
||||||
|
/// records which peers are PKC-capable, so we can tell a true end-to-end
|
||||||
|
/// (PKI) DM from a channel-PSK fallback.
|
||||||
|
peer_pubkeys: HashMap<u32, Vec<u8>>,
|
||||||
device_path: String,
|
device_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +76,7 @@ impl MeshtasticDevice {
|
|||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
contacts: HashMap::new(),
|
contacts: HashMap::new(),
|
||||||
|
peer_pubkeys: HashMap::new(),
|
||||||
device_path: path.to_string(),
|
device_path: path.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -150,12 +159,32 @@ impl MeshtasticDevice {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix,
|
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
|
||||||
/// so there's no direct unicast mapping here. Best-effort fallback to a
|
/// numeric node-id in their first 4 bytes (little-endian, see
|
||||||
/// channel send keeps the device interface uniform; native unicast is only
|
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
|
||||||
/// meaningful on the Meshcore transport.
|
/// destination node number. We send a directed MeshPacket (`to` = node num)
|
||||||
pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
|
||||||
self.send_channel_text(0, msg).await
|
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
|
||||||
|
/// as a real DM (only the recipient's client surfaces it) instead of
|
||||||
|
/// polluting the shared primary channel where every node would see it.
|
||||||
|
///
|
||||||
|
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
|
||||||
|
/// synthetic key routed here by mistake), fall back to a channel send so the
|
||||||
|
/// device interface stays uniform and the message still goes out.
|
||||||
|
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||||
|
let node_num = u32::from_le_bytes([
|
||||||
|
dest_pubkey_prefix[0],
|
||||||
|
dest_pubkey_prefix[1],
|
||||||
|
dest_pubkey_prefix[2],
|
||||||
|
dest_pubkey_prefix[3],
|
||||||
|
]);
|
||||||
|
if node_num == 0 || node_num == BROADCAST_NUM {
|
||||||
|
return self.send_channel_text(0, msg).await;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(msg);
|
||||||
|
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
|
||||||
|
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||||
@ -214,6 +243,19 @@ impl MeshtasticDevice {
|
|||||||
Ok(self.handle_from_radio(&frame))
|
Ok(self.handle_from_radio(&frame))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||||
|
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||||
|
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||||
|
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||||
|
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||||
|
/// the shared device interface (which would break meshcore hot-swap).
|
||||||
|
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||||
|
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||||
|
self.peer_pubkeys
|
||||||
|
.get(&node_num)
|
||||||
|
.is_some_and(|k| !k.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn advert_name(&self) -> Option<String> {
|
pub fn advert_name(&self) -> Option<String> {
|
||||||
self.long_name
|
self.long_name
|
||||||
.clone()
|
.clone()
|
||||||
@ -286,6 +328,15 @@ impl MeshtasticDevice {
|
|||||||
|
|
||||||
fn update_node_info(&mut self, data: &[u8]) {
|
fn update_node_info(&mut self, data: &[u8]) {
|
||||||
if let Some(node) = parse_node_info(data) {
|
if let Some(node) = parse_node_info(data) {
|
||||||
|
if let Some(pk) = node.public_key.as_ref() {
|
||||||
|
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
|
||||||
|
debug!(
|
||||||
|
node = node.num,
|
||||||
|
key_len = pk.len(),
|
||||||
|
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let key = synthetic_pubkey(node.num);
|
let key = synthetic_pubkey(node.num);
|
||||||
let name = node
|
let name = node
|
||||||
.long_name
|
.long_name
|
||||||
@ -318,6 +369,18 @@ impl MeshtasticDevice {
|
|||||||
if Some(from) == self.node_num {
|
if Some(from) == self.node_num {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||||
|
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||||
|
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||||
|
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||||
|
// device interface stays identical to meshcore's and the two remain
|
||||||
|
// hot-swappable behind the mesh listener.
|
||||||
|
if let Some(pk) = packet.public_key.as_ref() {
|
||||||
|
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||||
|
}
|
||||||
|
if packet.pki_encrypted {
|
||||||
|
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||||
|
}
|
||||||
let from_key = synthetic_pubkey(from);
|
let from_key = synthetic_pubkey(from);
|
||||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||||
@ -444,6 +507,7 @@ struct ParsedNode {
|
|||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
last_heard: Option<u32>,
|
last_heard: Option<u32>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||||
@ -454,6 +518,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
|||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
last_heard: None,
|
last_heard: None,
|
||||||
|
public_key: None,
|
||||||
};
|
};
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
@ -466,6 +531,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
|||||||
node.id = user.id;
|
node.id = user.id;
|
||||||
node.long_name = user.long_name;
|
node.long_name = user.long_name;
|
||||||
node.short_name = user.short_name;
|
node.short_name = user.short_name;
|
||||||
|
node.public_key = user.public_key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||||
@ -483,6 +549,7 @@ struct ParsedUser {
|
|||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||||
@ -491,6 +558,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
|||||||
id: None,
|
id: None,
|
||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
|
public_key: None,
|
||||||
};
|
};
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
@ -499,6 +567,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
|||||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||||
|
// User.public_key (field 8): the peer's Curve25519 key. Its presence
|
||||||
|
// means the radio can PKC-encrypt DMs to this node end-to-end.
|
||||||
|
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -509,18 +580,28 @@ struct ParsedPacket {
|
|||||||
from: Option<u32>,
|
from: Option<u32>,
|
||||||
portnum: u32,
|
portnum: u32,
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
|
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||||
|
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||||
|
/// rather than via the shared channel PSK.
|
||||||
|
pki_encrypted: bool,
|
||||||
|
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
let mut from = None;
|
let mut from = None;
|
||||||
let mut decoded = None;
|
let mut decoded = None;
|
||||||
|
let mut pki_encrypted = false;
|
||||||
|
let mut public_key = None;
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
idx = next;
|
idx = next;
|
||||||
match (field, value) {
|
match (field, value) {
|
||||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||||
|
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||||
|
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -541,6 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
|||||||
from,
|
from,
|
||||||
portnum,
|
portnum,
|
||||||
payload,
|
payload,
|
||||||
|
pki_encrypted,
|
||||||
|
public_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -428,6 +428,99 @@ impl Server {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic federation auto-sync. Pulls every federated peer's state on a
|
||||||
|
// timer so renamed nodes and roster changes propagate WITHOUT a manual
|
||||||
|
// "Sync" click. Each sync now fast-fails a dead FIPS path and falls back
|
||||||
|
// to Tor (~3-5s), so a full pass over a handful of peers is quick.
|
||||||
|
{
|
||||||
|
let data_dir = config.data_dir.clone();
|
||||||
|
let state = state_manager.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Delay the first pass so Tor/onion publishing settles after boot.
|
||||||
|
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(90));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let nodes = match crate::federation::load_nodes(&data_dir).await {
|
||||||
|
Ok(n) if !n.is_empty() => n,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let (snap, _) = state.get_snapshot().await;
|
||||||
|
let local_did =
|
||||||
|
match crate::identity::did_key_from_pubkey_hex(&snap.server_info.pubkey) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let identity_dir = data_dir.join("identity");
|
||||||
|
let node_identity =
|
||||||
|
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
// Our own identity, for re-asserting membership to any peer
|
||||||
|
// that doesn't list us back (asymmetry self-heal, below).
|
||||||
|
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
|
||||||
|
let local_pubkey = snap.server_info.pubkey.clone();
|
||||||
|
let local_name = snap.server_info.name.clone();
|
||||||
|
let local_fips_npub =
|
||||||
|
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||||
|
let mut ok = 0usize;
|
||||||
|
let mut healed = 0usize;
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trust_level == crate::federation::TrustLevel::Untrusted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match crate::federation::sync_with_peer(&data_dir, node, &local_did, |b| {
|
||||||
|
node_identity.sign(b)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
ok += 1;
|
||||||
|
// Asymmetry self-heal: if this peer's exported
|
||||||
|
// trusted list doesn't include us, our original
|
||||||
|
// peer-joined never landed (e.g. it was sent
|
||||||
|
// before the reliable-notify fix, or the peer was
|
||||||
|
// down). Re-assert membership over the now
|
||||||
|
// FIPS-fast-failing/Tor path so they add us back.
|
||||||
|
// Without this, a node that joined everyone stays
|
||||||
|
// invisible to the whole fleet until a manual
|
||||||
|
// re-add (the "X250-EXP missing everywhere" case).
|
||||||
|
let they_list_us = state
|
||||||
|
.federated_peers
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.did == local_did);
|
||||||
|
if !they_list_us && !local_onion.is_empty() {
|
||||||
|
crate::federation::notify_join(
|
||||||
|
&node.onion,
|
||||||
|
node.fips_npub.as_deref(),
|
||||||
|
&local_did,
|
||||||
|
&local_onion,
|
||||||
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|
local_name.as_deref(),
|
||||||
|
|b| node_identity.sign(b),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
healed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
synced = ok,
|
||||||
|
reasserted = healed,
|
||||||
|
total = nodes.len(),
|
||||||
|
"federation auto-sync pass complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize container scanner — discovers installed apps from Podman/Docker
|
// Initialize container scanner — discovers installed apps from Podman/Docker
|
||||||
{
|
{
|
||||||
let scanner = create_docker_scanner(&config).await?;
|
let scanner = create_docker_scanner(&config).await?;
|
||||||
|
|||||||
@ -49,6 +49,39 @@ pub struct FederationRegistry {
|
|||||||
|
|
||||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
||||||
|
|
||||||
|
/// Shared HTTP-Basic password between the fmcd container and this bridge. The
|
||||||
|
/// fedimint-clientd manifest reads it via `secret_env: fmcd-password`, resolved
|
||||||
|
/// from `<data_dir>/secrets/`; the bridge reads the same file in `from_node`.
|
||||||
|
const FMCD_PASSWORD_SECRET: &str = "fmcd-password";
|
||||||
|
|
||||||
|
/// Generate the fmcd Basic-auth password once, so the fmcd container
|
||||||
|
/// (`secret_env: fmcd-password`) and this bridge (`from_node`) agree on it.
|
||||||
|
/// Idempotent: a non-empty existing secret is left untouched. Mirrors the
|
||||||
|
/// bitcoin-rpc secret pattern (random hex, 0600). Called from the orchestrator's
|
||||||
|
/// `ensure_app_secrets` before the container's `secret_env` is resolved.
|
||||||
|
pub async fn ensure_fmcd_password(secrets_dir: &Path) -> Result<()> {
|
||||||
|
let path = secrets_dir.join(FMCD_PASSWORD_SECRET);
|
||||||
|
if let Ok(existing) = fs::read_to_string(&path).await {
|
||||||
|
if !existing.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::create_dir_all(secrets_dir)
|
||||||
|
.await
|
||||||
|
.context("creating secrets dir for fmcd password")?;
|
||||||
|
let bytes: [u8; 16] = rand::random();
|
||||||
|
let password = hex::encode(bytes);
|
||||||
|
fs::write(&path, &password)
|
||||||
|
.await
|
||||||
|
.context("writing fmcd password secret")?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||||
let path = data_dir.join(REGISTRY_FILE);
|
let path = data_dir.join(REGISTRY_FILE);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@ -102,6 +135,32 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||||
|
/// per-federation, but a token only validates against the federation that
|
||||||
|
/// minted it, so we try each joined federation (default first) and return the
|
||||||
|
/// first that accepts the notes, along with its id. Errors clearly when the
|
||||||
|
/// fmcd sidecar isn't installed or no federation is joined — the Cashu path is
|
||||||
|
/// handled separately by the caller.
|
||||||
|
pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> {
|
||||||
|
// Make sure at least the default federation is tracked before we try.
|
||||||
|
let _ = ensure_default_federation(data_dir).await;
|
||||||
|
|
||||||
|
let client = FedimintClient::from_node(data_dir).await?;
|
||||||
|
let reg = load_registry(data_dir).await?;
|
||||||
|
if reg.federations.is_empty() {
|
||||||
|
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_err = None;
|
||||||
|
for fed in ®.federations {
|
||||||
|
match client.reissue(&fed.federation_id, notes).await {
|
||||||
|
Ok(sats) => return Ok((sats, fed.federation_id.clone())),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTP client for a `fedimint-clientd` instance.
|
/// HTTP client for a `fedimint-clientd` instance.
|
||||||
pub struct FedimintClient {
|
pub struct FedimintClient {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@ -135,14 +194,25 @@ impl FedimintClient {
|
|||||||
let password = match std::env::var("FMCD_PASSWORD") {
|
let password = match std::env::var("FMCD_PASSWORD") {
|
||||||
Ok(p) if !p.is_empty() => p,
|
Ok(p) if !p.is_empty() => p,
|
||||||
_ => {
|
_ => {
|
||||||
let secret = data_dir.join("fmcd").join("password");
|
// The shared secret the fmcd container also reads (manifest
|
||||||
fs::read_to_string(&secret)
|
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
||||||
.await
|
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
||||||
.map(|s| s.trim().to_string())
|
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
||||||
.context(
|
let legacy = data_dir.join("fmcd").join("password");
|
||||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
let mut found = None;
|
||||||
fmcd/password secret). Install the Fedimint client app.",
|
for candidate in [shared, legacy] {
|
||||||
)?
|
if let Ok(s) = fs::read_to_string(&candidate).await {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
if !s.is_empty() {
|
||||||
|
found = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.context(
|
||||||
|
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||||
|
fmcd-password secret). Install the Fedimint client app.",
|
||||||
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Self::new(&base_url, &password)
|
Self::new(&base_url, &password)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;"]
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
docs/session-handoff-2026-06-18.md
Normal file
109
docs/session-handoff-2026-06-18.md
Normal 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.
|
||||||
BIN
neode-ui/public/assets/img/app-icons/fedimint-clientd.png
Normal file
BIN
neode-ui/public/assets/img/app-icons/fedimint-clientd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@ -79,7 +79,10 @@ import { ref, onMounted, watch } from 'vue'
|
|||||||
import * as QRCode from 'qrcode'
|
import * as QRCode from 'qrcode'
|
||||||
|
|
||||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||||
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
|
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||||
|
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||||
|
// (publicly reachable) rather than the local node's /packages copy.
|
||||||
|
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk.zip'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,74 @@ 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
|
||||||
|
const target = tabs[next]
|
||||||
|
if (!target) return
|
||||||
|
router.push(target.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
|
||||||
|
|||||||
@ -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">📡</div>
|
<div class="mesh-chat-empty-icon">📡</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">←</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')">
|
||||||
|
|||||||
@ -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?).'
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 <question></code> on the mesh channel.
|
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -157,8 +157,8 @@
|
|||||||
|
|
||||||
<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">Paste ecash token</label>
|
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
|
||||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
|
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -487,11 +487,12 @@ async function unifiedReceive() {
|
|||||||
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await rpcClient.call<{ received_sats: number }>({
|
const res = await rpcClient.call<{ received_sats: number; kind?: string }>({
|
||||||
method: 'wallet.ecash-receive',
|
method: 'wallet.ecash-receive',
|
||||||
params: { token: ecashReceiveToken.value.trim() },
|
params: { token: ecashReceiveToken.value.trim() },
|
||||||
})
|
})
|
||||||
ecashReceiveResult.value = `Received ${res.received_sats} sats!`
|
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||||
|
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
|
||||||
ecashReceiveToken.value = ''
|
ecashReceiveToken.value = ''
|
||||||
emit('balancesChanged')
|
emit('balancesChanged')
|
||||||
}
|
}
|
||||||
|
|||||||
264
tests/multinode/meshtastic.sh
Executable file
264
tests/multinode/meshtastic.sh
Executable file
@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/multinode/meshtastic.sh — two-/three-radio Meshtastic parity harness.
|
||||||
|
#
|
||||||
|
# Validates that Meshtastic radios have the SAME mesh-tab features Meshcore got,
|
||||||
|
# done over the real wire. It drives 2 (optionally 3) archipelago nodes, each
|
||||||
|
# with a Meshtastic radio attached, and exercises the full message pipeline:
|
||||||
|
#
|
||||||
|
# 1. detect — each node reports a connected meshtastic device
|
||||||
|
# 2. discover — A sees B as a peer (NodeInfo discovery), and vice-versa
|
||||||
|
# 3. dm — A → B direct message round-trips (native unicast)
|
||||||
|
# 4. privacy — a third listener C does NOT see the A→B DM (proves the
|
||||||
|
# directed-unicast fix: DMs are not broadcast on the channel)
|
||||||
|
# 5. channel — A's channel broadcast IS seen by both B and C
|
||||||
|
# 6. typed — a typed envelope (reaction) round-trips with message_type set
|
||||||
|
# 7. assistant — (optional) an !ai query gets a PRIVATE reply, not a channel
|
||||||
|
# blast (gated on ASSIST=1 + assistant enabled on B)
|
||||||
|
# 8. reachable — reports each peer's `reachable`/`last_advert` so the ambiguous
|
||||||
|
# Meshtastic reachability semantics can be eyeballed on-air
|
||||||
|
# before anyone "fixes" them
|
||||||
|
#
|
||||||
|
# The privacy test (4) is the on-air proof of the meshtastic.rs send_text_msg
|
||||||
|
# unicast change. Without it, A→B DMs land on every node's channel feed.
|
||||||
|
#
|
||||||
|
# Nodes override via env (each must have a Meshtastic radio on the SAME LoRa
|
||||||
|
# channel/region so they can actually hear each other):
|
||||||
|
# MA_URL MA_PW node A (sender) default .116 http / ThisIsWeb54321@
|
||||||
|
# MB_URL MB_PW node B (receiver) default .228 https / password123
|
||||||
|
# MC_URL MC_PW node C (eavesdrop) OPTIONAL — enables privacy test (4)
|
||||||
|
#
|
||||||
|
# MB_NAME B's mesh node name, if A's peer list is ambiguous (>1 peer)
|
||||||
|
# PROP_WAIT seconds to wait for LoRa propagation per step (default 45)
|
||||||
|
# ASSIST set =1 to run the assistant private-reply test (7)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tests/multinode/meshtastic.sh
|
||||||
|
# MA_URL=http://192.168.1.116 MB_URL=https://192.168.1.228 \
|
||||||
|
# MC_URL=https://192.168.1.198 tests/multinode/meshtastic.sh
|
||||||
|
#
|
||||||
|
# Requires: curl, jq. Exit code = number of failed assertions (0 = all green).
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/multinode.bash
|
||||||
|
source "$HERE/lib/multinode.bash"
|
||||||
|
|
||||||
|
# ── node registration ──────────────────────────────────────────────────────
|
||||||
|
MA_URL="${MA_URL:-http://192.168.1.116}"; MA_PW="${MA_PW:-ThisIsWeb54321@}"
|
||||||
|
MB_URL="${MB_URL:-https://192.168.1.228}"; MB_PW="${MB_PW:-password123}"
|
||||||
|
MC_URL="${MC_URL:-}"; MC_PW="${MC_PW:-password123}"
|
||||||
|
PROP_WAIT="${PROP_WAIT:-45}"
|
||||||
|
MB_NAME="${MB_NAME:-}"
|
||||||
|
ASSIST="${ASSIST:-0}"
|
||||||
|
|
||||||
|
node_register A "$MA_URL" "$MA_PW"
|
||||||
|
node_register B "$MB_URL" "$MB_PW"
|
||||||
|
HAVE_C=0
|
||||||
|
if [[ -n "$MC_URL" ]]; then node_register C "$MC_URL" "$MC_PW"; HAVE_C=1; fi
|
||||||
|
|
||||||
|
# ── tiny assert framework (mirrors smoke.sh) ───────────────────────────────
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||||
|
red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||||
|
yellow() { printf '\033[33m%s\033[0m' "$*"; }
|
||||||
|
else
|
||||||
|
green() { printf '%s' "$*"; }; red() { printf '%s' "$*"; }; yellow() { printf '%s' "$*"; }
|
||||||
|
fi
|
||||||
|
PASS=0; FAIL=0; SKIP=0; declare -a FAILED_NAMES
|
||||||
|
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
|
||||||
|
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
|
||||||
|
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "${2:-skipped}"; SKIP=$((SKIP+1)); }
|
||||||
|
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
|
||||||
|
section() { printf '\n%s\n' "$(yellow "── $* ──")"; }
|
||||||
|
|
||||||
|
# nonce for this run so message matches can't collide with stale history
|
||||||
|
NONCE="mtparity-$$-${RANDOM}"
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# mesh_connected HANDLE -> "true" if a meshtastic device is connected
|
||||||
|
mesh_connected() {
|
||||||
|
local s; s=$(node_result "$1" mesh.status 2>/dev/null) || { echo false; return; }
|
||||||
|
local conn type
|
||||||
|
conn=$(echo "$s" | jq -r '.device_connected // false')
|
||||||
|
type=$(echo "$s" | jq -r '.device_type // "unknown"')
|
||||||
|
[[ "$conn" == "true" && "$type" == "meshtastic" ]] && echo true || echo false
|
||||||
|
}
|
||||||
|
|
||||||
|
# self_name HANDLE -> this node's meshtastic long-name (from firmware_version)
|
||||||
|
self_name() {
|
||||||
|
node_result "$1" mesh.status 2>/dev/null | jq -r '.firmware_version // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
# contact_id_for HANDLE NAME -> the contact_id of the peer whose advert_name
|
||||||
|
# matches NAME (case-insensitive substring); empty if not found / ambiguous.
|
||||||
|
contact_id_for() {
|
||||||
|
local h="$1" want="$2"
|
||||||
|
node_result "$h" mesh.peers 2>/dev/null | jq -r --arg w "$want" '
|
||||||
|
[.peers[] | select((.advert_name // "" | ascii_downcase)
|
||||||
|
| contains($w | ascii_downcase))] as $m
|
||||||
|
| if ($m|length)==1 then ($m[0].contact_id|tostring) else "" end'
|
||||||
|
}
|
||||||
|
|
||||||
|
# peer_count_excl_self HANDLE -> number of peers
|
||||||
|
peer_count() { node_result "$1" mesh.peers 2>/dev/null | jq -r '.count // 0'; }
|
||||||
|
|
||||||
|
# saw_text HANDLE NEEDLE [direction] -> "true" if a message whose plaintext
|
||||||
|
# contains NEEDLE exists (optionally filtered to a direction: sent/received)
|
||||||
|
saw_text() {
|
||||||
|
local h="$1" needle="$2" dir="${3:-}"
|
||||||
|
node_result "$h" mesh.messages '{"limit":200}' 2>/dev/null | jq -r --arg n "$needle" --arg d "$dir" '
|
||||||
|
[.messages[] | select((.plaintext // "") | contains($n))
|
||||||
|
| select($d=="" or (.direction==$d))] | length > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_text HANDLE NEEDLE — poll up to PROP_WAIT for a received message
|
||||||
|
wait_text() {
|
||||||
|
local h="$1" needle="$2" waited=0
|
||||||
|
while (( waited < PROP_WAIT )); do
|
||||||
|
[[ "$(saw_text "$h" "$needle" received)" == "true" ]] && return 0
|
||||||
|
sleep 3; waited=$((waited+3))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── login ──────────────────────────────────────────────────────────────────
|
||||||
|
section "login"
|
||||||
|
node_login A && ok "A login ($MA_URL)" || { no "A unreachable ($MA_URL)"; echo; exit 1; }
|
||||||
|
node_login B && ok "B login ($MB_URL)" || { no "B unreachable ($MB_URL)"; echo; exit 1; }
|
||||||
|
if (( HAVE_C )); then
|
||||||
|
node_login C && ok "C login ($MC_URL)" || { skip "C login" "unreachable — privacy test disabled"; HAVE_C=0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 1. detect ──────────────────────────────────────────────────────────────
|
||||||
|
section "1. device detection"
|
||||||
|
A_CONN=$(mesh_connected A); B_CONN=$(mesh_connected B)
|
||||||
|
assert_true "A has a connected meshtastic radio" "$A_CONN"
|
||||||
|
assert_true "B has a connected meshtastic radio" "$B_CONN"
|
||||||
|
if [[ "$A_CONN" != "true" || "$B_CONN" != "true" ]]; then
|
||||||
|
printf '\n%s\n' "$(yellow 'Both A and B need a Meshtastic radio attached & mesh enabled.')"
|
||||||
|
printf '%s\n' "$(yellow 'Aborting on-air tests; see mesh.status output above.')"
|
||||||
|
echo; printf 'PASS=%d FAIL=%d SKIP=%d\n' "$PASS" "$FAIL" "$SKIP"; exit "$FAIL"
|
||||||
|
fi
|
||||||
|
A_NAME=$(self_name A); B_NAME=$(self_name B)
|
||||||
|
printf ' A=%s B=%s\n' "${A_NAME:-?}" "${B_NAME:-?}"
|
||||||
|
[[ -n "$MB_NAME" ]] && B_NAME="$MB_NAME"
|
||||||
|
|
||||||
|
# ── 2. peer discovery ──────────────────────────────────────────────────────
|
||||||
|
section "2. peer discovery (NodeInfo)"
|
||||||
|
DISCO=0; waited=0
|
||||||
|
while (( waited < PROP_WAIT )); do
|
||||||
|
CID=$(contact_id_for A "${B_NAME:-Meshtastic}")
|
||||||
|
[[ -n "$CID" ]] && { DISCO=1; break; }
|
||||||
|
# fall back: any single non-channel peer
|
||||||
|
if [[ -z "$MB_NAME" && "$(peer_count A)" == "1" ]]; then
|
||||||
|
CID=$(node_result A mesh.peers | jq -r '.peers[0].contact_id'); DISCO=1; break
|
||||||
|
fi
|
||||||
|
sleep 3; waited=$((waited+3))
|
||||||
|
done
|
||||||
|
if (( DISCO )); then ok "A discovered B as a peer (contact_id=$CID)"
|
||||||
|
else
|
||||||
|
no "A did not discover B within ${PROP_WAIT}s"
|
||||||
|
printf ' A peers: %s\n' "$(node_result A mesh.peers | jq -c '.peers[]? | {contact_id,advert_name}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. direct message round-trip ───────────────────────────────────────────
|
||||||
|
section "3. direct message (native unicast)"
|
||||||
|
if (( DISCO )); then
|
||||||
|
DM="$NONCE-dm hello-from-A"
|
||||||
|
if node_result A mesh.send "$(jq -nc --argjson c "$CID" --arg m "$DM" '{contact_id:$c,message:$m}')" >/dev/null; then
|
||||||
|
ok "A sent DM to B (contact_id=$CID)"
|
||||||
|
if wait_text B "$NONCE-dm"; then ok "B received the DM"
|
||||||
|
else no "B did not receive the DM within ${PROP_WAIT}s"; fi
|
||||||
|
else no "mesh.send failed on A"; fi
|
||||||
|
else skip "DM round-trip" "B not discovered"; fi
|
||||||
|
|
||||||
|
# ── 4. privacy: third node must NOT see the DM ─────────────────────────────
|
||||||
|
section "4. DM privacy (directed, not broadcast)"
|
||||||
|
if (( HAVE_C )) && (( DISCO )); then
|
||||||
|
C_CONN=$(mesh_connected C)
|
||||||
|
if [[ "$C_CONN" != "true" ]]; then
|
||||||
|
skip "DM privacy" "C has no meshtastic radio"
|
||||||
|
else
|
||||||
|
# Give C the same window the DM had to propagate, then assert absence.
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
if [[ "$(saw_text C "$NONCE-dm")" == "true" ]]; then
|
||||||
|
no "C (eavesdropper) saw the A→B DM — it is being BROADCAST, not unicast"
|
||||||
|
else
|
||||||
|
ok "C did NOT see the A→B DM (directed unicast confirmed)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "DM privacy" "needs MC_URL (third radio) + discovered peer"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. channel broadcast reaches everyone ──────────────────────────────────
|
||||||
|
section "5. channel broadcast"
|
||||||
|
CH="$NONCE-chan broadcast-to-all"
|
||||||
|
if node_result A mesh.send-channel "$(jq -nc --arg m "$CH" '{channel:0,message:$m}')" >/dev/null; then
|
||||||
|
ok "A sent a channel broadcast"
|
||||||
|
if wait_text B "$NONCE-chan"; then ok "B received the broadcast"; else no "B missed the broadcast"; fi
|
||||||
|
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||||
|
if [[ "$(saw_text C "$NONCE-chan")" == "true" ]]; then ok "C also received the broadcast"
|
||||||
|
else no "C missed the broadcast (it should reach all channel members)"; fi
|
||||||
|
fi
|
||||||
|
else no "mesh.send-channel failed on A"; fi
|
||||||
|
|
||||||
|
# ── 6. typed envelope round-trip ───────────────────────────────────────────
|
||||||
|
section "6. typed message (reaction envelope)"
|
||||||
|
if (( DISCO )); then
|
||||||
|
# A reaction is the smallest typed envelope; it should arrive with a
|
||||||
|
# non-"text" message_type, proving the typed pipeline works over Meshtastic.
|
||||||
|
REACT_PARAMS=$(jq -nc --argjson c "$CID" --arg n "$NONCE" \
|
||||||
|
'{contact_id:$c, emoji:"👍", target_seq:0, note:$n}')
|
||||||
|
if node_result A mesh.send-reaction "$REACT_PARAMS" >/dev/null 2>&1; then
|
||||||
|
ok "A sent a reaction (typed envelope)"
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
TYPED=$(node_result B mesh.messages '{"limit":200}' 2>/dev/null \
|
||||||
|
| jq -r '[.messages[] | select(.message_type != null and .message_type != "text")] | length > 0')
|
||||||
|
assert_true "B received a non-text typed message" "$TYPED"
|
||||||
|
else
|
||||||
|
skip "typed message" "mesh.send-reaction rejected params (check handler signature)"
|
||||||
|
fi
|
||||||
|
else skip "typed message" "B not discovered"; fi
|
||||||
|
|
||||||
|
# ── 7. assistant private reply (optional) ──────────────────────────────────
|
||||||
|
section "7. AI assistant private reply (optional)"
|
||||||
|
if [[ "$ASSIST" == "1" ]] && (( DISCO )); then
|
||||||
|
AST=$(node_result B mesh.assistant-status 2>/dev/null | jq -r '.enabled // false')
|
||||||
|
if [[ "$AST" != "true" ]]; then
|
||||||
|
skip "assistant reply" "assistant not enabled on B"
|
||||||
|
else
|
||||||
|
Q="$NONCE-ai !ai are you there"
|
||||||
|
node_result A mesh.send-channel "$(jq -nc --arg m "$Q" '{channel:0,message:$m}')" >/dev/null
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
# A should get a private DM reply; C (if present) should NOT.
|
||||||
|
if [[ "$(saw_text A "$NONCE-ai-reply")" == "true" || "$(node_result A mesh.messages '{"limit":50}' | jq -r '[.messages[]|select(.direction=="received")]|length>0')" == "true" ]]; then
|
||||||
|
ok "A received an assistant reply"
|
||||||
|
else
|
||||||
|
no "A did not receive an assistant reply within ${PROP_WAIT}s"
|
||||||
|
fi
|
||||||
|
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||||
|
# heuristic: the reply text shouldn't be on C's channel feed
|
||||||
|
skip "assistant reply privacy" "eyeball C's feed — automated check is heuristic"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "assistant reply" "set ASSIST=1 and enable the assistant on B to run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 8. reachability snapshot (report-only) ─────────────────────────────────
|
||||||
|
section "8. reachability snapshot (report-only)"
|
||||||
|
node_result A mesh.peers 2>/dev/null | jq -r '.peers[]?
|
||||||
|
| " \(.advert_name // "?") reachable=\(.reachable) last_advert=\(.last_advert // 0)"'
|
||||||
|
printf '%s\n' "$(yellow ' NOTE: Meshtastic flood-routes; path_len is always 0xff, so `reachable`')"
|
||||||
|
printf '%s\n' "$(yellow ' may read true even for stale nodes. Confirm desired semantics here')"
|
||||||
|
printf '%s\n' "$(yellow ' before changing the refresh_contacts reachability rule.')"
|
||||||
|
|
||||||
|
# ── summary ────────────────────────────────────────────────────────────────
|
||||||
|
section "summary"
|
||||||
|
printf 'PASS=%s FAIL=%s SKIP=%s\n' "$(green "$PASS")" "$( ((FAIL)) && red "$FAIL" || green 0 )" "$(yellow "$SKIP")"
|
||||||
|
if (( FAIL )); then
|
||||||
|
printf 'failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||||
|
fi
|
||||||
|
exit "$FAIL"
|
||||||
Loading…
x
Reference in New Issue
Block a user