Compare commits
34 Commits
ff4013bd3b
...
fbed32f95d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbed32f95d | ||
|
|
87c324e36c | ||
|
|
15954aec14 | ||
|
|
1df075f7b1 | ||
|
|
265196bc2c | ||
|
|
9f1daa0f8c | ||
|
|
960e41e164 | ||
|
|
6502f13e29 | ||
|
|
c58f84c6e9 | ||
|
|
e59ea1da69 | ||
|
|
be6f06a573 | ||
|
|
8a450bb8f8 | ||
|
|
852ffc5b18 | ||
|
|
db6003aef6 | ||
|
|
b991c18e73 | ||
|
|
fb72e3e159 | ||
|
|
f339358109 | ||
|
|
df9b9905b6 | ||
|
|
e68fe071a7 | ||
|
|
04876f3bef | ||
|
|
05fc49820c | ||
|
|
cbb108a636 | ||
|
|
bfd8bc5b9a | ||
|
|
6347d16a2f | ||
|
|
b193e64ffb | ||
|
|
6a3b46b5a9 | ||
|
|
770e3386f4 | ||
|
|
8f4c32b93f | ||
|
|
30a4343b83 | ||
|
|
cad68eb941 | ||
|
|
197e351880 | ||
|
|
8213b0aee3 | ||
|
|
5f01ec31ed | ||
|
|
d86043193b |
21
CHANGELOG.md
21
CHANGELOG.md
@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.112-alpha (2026-07-22)
|
||||
|
||||
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||
|
||||
## v1.7.111-alpha (2026-07-22)
|
||||
|
||||
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
||||
|
||||
@ -9,13 +9,22 @@ app:
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
|
||||
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
|
||||
# container is actually running (Knots, Core, or any future distro archy
|
||||
# ships), so the gateway is never pinned to one node's container name.
|
||||
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
|
||||
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
derived_env:
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||
|
||||
@ -21,6 +21,11 @@ app:
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
# Resolves to whichever bitcoin container is running (Knots/Core/future
|
||||
# distro) instead of a hardcoded name — the guardian works on any node
|
||||
# regardless of which Bitcoin software it runs.
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@ -62,7 +67,8 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
|
||||
# hardcoded name — do not re-add it here.
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
|
||||
@ -412,6 +412,55 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// NEVER pay twice for content we already own (2026-07-22: a file
|
||||
// shared twice produced two catalog ids for the same bytes and the
|
||||
// buyer paid both). Guard BEFORE any ecash is minted, matching both
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/')
|
||||
== f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) = crate::content_owned::read_owned(
|
||||
&self.config.data_dir,
|
||||
&o.onion,
|
||||
&o.content_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||
// verify_payment_token accepts either, so a node whose balance lives in
|
||||
@ -590,6 +639,54 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!(
|
||||
"paid download: filed into {}",
|
||||
target.display()
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
|
||||
@ -260,6 +260,7 @@ impl RpcHandler {
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
|
||||
@ -118,6 +118,31 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||
/// with sufficient balance. Returns the notes token for the recipient
|
||||
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||
pub(super) async fn handle_wallet_fedimint_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||
let (token, federation_id) =
|
||||
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token,
|
||||
"federation_id": federation_id,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
|
||||
@ -30,9 +30,21 @@ impl RpcHandler {
|
||||
// The node's seed anchors ride along so the phone can rendezvous
|
||||
// through the same public mesh points when the node's LAN endpoint
|
||||
// isn't directly dialable (phone away from home, node behind NAT).
|
||||
let anchors = fips::anchors::load(&self.config.data_dir)
|
||||
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
// Pairing must always carry a public rendezvous point: without one the
|
||||
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||
// moment it leaves that network. This is a pairing hint only — the
|
||||
// node's own anchor file is not modified, so an operator's removal of
|
||||
// the default anchors still sticks for the node itself.
|
||||
if !anchor_list
|
||||
.iter()
|
||||
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||
{
|
||||
anchor_list.push(fips::anchors::archy_anchor());
|
||||
}
|
||||
let anchors = anchor_list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
|
||||
@ -86,7 +86,7 @@ impl RpcHandler {
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.config.nostr_relays.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
@ -106,6 +106,17 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// The relay set every handshake operation uses: the user-managed relay
|
||||
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||
/// relay edits entirely — so a sender publishing where the receiver
|
||||
/// never read was a routine, silent way for peer requests to vanish.
|
||||
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover discoverable nodes via Nostr presence events.
|
||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
@ -113,9 +124,10 @@ impl RpcHandler {
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@ -161,7 +173,7 @@ impl RpcHandler {
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@ -191,6 +203,40 @@ impl RpcHandler {
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||
/// peer request sat on the relay until the target's operator happened to
|
||||
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||
/// and nudges the websocket revision when anything new lands so open UIs
|
||||
/// refresh immediately.
|
||||
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||
match self.handle_handshake_poll().await {
|
||||
Ok(res) => {
|
||||
let new = res
|
||||
.get("new_requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let applied = res
|
||||
.get("applied_invites")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if new > 0 || applied > 0 {
|
||||
tracing::info!(
|
||||
new_requests = new,
|
||||
applied_invites = applied,
|
||||
"handshake poll: inbound peer activity"
|
||||
);
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||
// touch the relays. The poll endpoint is a hard no-op until they
|
||||
@ -207,9 +253,10 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None,
|
||||
)
|
||||
|
||||
@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
|
||||
@ -62,6 +62,14 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||
// sent the operator to journalctl for a message that was written for
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
// send, 2026-07-23).
|
||||
"Payment failed",
|
||||
"Invalid payment request",
|
||||
"Missing 'payment_request'",
|
||||
"Your Lightning node is still finishing",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
@ -151,6 +165,25 @@ mod sanitize_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_payment_errors_pass_through() {
|
||||
// LND's payment-failure reasons are written for the payer — masking
|
||||
// "invoice expired" as "Check server logs" left a user retrying a
|
||||
// dead invoice (framework-pt, 2026-07-23).
|
||||
for msg in [
|
||||
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||
"Payment failed: unable to find a path to destination",
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||
"Missing 'payment_request' parameter",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
|
||||
@ -26,6 +26,7 @@ mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
|
||||
@ -4,6 +4,7 @@ mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
|
||||
@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wyoming satellite keeper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
|
||||
///
|
||||
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
|
||||
/// renumbering — or the whole node moving to a different network — strands
|
||||
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
|
||||
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
|
||||
/// never re-resolves on its own. This keeper probes each satellite entry and,
|
||||
/// when one stops answering, sweeps the node's local /24s for the same
|
||||
/// Wyoming port and rewrites the entry to the address that answers.
|
||||
pub(crate) async fn wyoming_satellite_keeper() {
|
||||
loop {
|
||||
if home_assistant_installed().await {
|
||||
heal_wyoming_satellites().await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(ms),
|
||||
tokio::net::TcpStream::connect((host, port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
|
||||
/// excluded — satellites live on real LANs.
|
||||
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
|
||||
let Ok(out) = tokio::process::Command::new("ip")
|
||||
.args(["-4", "-o", "addr", "show", "scope", "global"])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let cidr = l.split_whitespace().nth(3)?;
|
||||
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
|
||||
let o = ip.octets();
|
||||
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
|
||||
if o[0] == 100 && (64..128).contains(&o[1]) {
|
||||
return None;
|
||||
}
|
||||
Some(ip)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sweep the /24 of every local interface for something answering `port`.
|
||||
/// First responder wins; the node's own addresses are skipped.
|
||||
async fn find_satellite(port: u16) -> Option<String> {
|
||||
let self_ips = local_ipv4_addresses().await;
|
||||
for base in self_ips.iter().map(|ip| ip.octets()) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 1..255u8 {
|
||||
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
|
||||
if self_ips.contains(&ip) {
|
||||
continue;
|
||||
}
|
||||
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
if let Ok(Some(ip)) = res {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
|
||||
/// port now answers. Stops HA before editing the store (HA flushes its own
|
||||
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
|
||||
/// again after.
|
||||
async fn heal_wyoming_satellites() {
|
||||
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
|
||||
let Some(store) = read_store(&path).await else {
|
||||
return;
|
||||
};
|
||||
let Some(entries) = store
|
||||
.get("data")
|
||||
.and_then(|d| d.get("entries"))
|
||||
.and_then(|e| e.as_array())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// (host, port, new_host) for every stranded satellite we can re-resolve.
|
||||
let mut moves: Vec<(String, u16, String)> = Vec::new();
|
||||
for e in entries {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Engines use host.containers.internal — only IP-pinned entries drift.
|
||||
if host.parse::<std::net::Ipv4Addr>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if port == 0 || tcp_alive(host, port, 1500).await {
|
||||
continue;
|
||||
}
|
||||
let Some(new_host) = find_satellite(port).await else {
|
||||
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
|
||||
continue;
|
||||
};
|
||||
if new_host != host {
|
||||
moves.push((host.to_string(), port, new_host));
|
||||
}
|
||||
}
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop HA, re-read + rewrite the store, start HA.
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
if let Some(mut store) = read_store(&path).await {
|
||||
let mut changed = false;
|
||||
if let Some(entries) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("entries"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
{
|
||||
for e in entries.iter_mut() {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let host = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if let Some((_, _, new_host)) =
|
||||
moves.iter().find(|(h, p, _)| *h == host && *p == port)
|
||||
{
|
||||
if let Some(data) = e.get_mut("data") {
|
||||
data["host"] = json!(new_host);
|
||||
}
|
||||
e["modified_at"] = json!(ha_now());
|
||||
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
write_store(&path, &store).await;
|
||||
}
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence probes + HA restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -139,9 +139,17 @@ impl RpcHandler {
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||
// operator's browser reaches this node over Tailscale/VPN or
|
||||
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||
// must advertise an address the phone can actually dial
|
||||
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||
// companion could never connect).
|
||||
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
"lan_ip": lan_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,19 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
|
||||
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
|
||||
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
|
||||
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
|
||||
// The router daemon handles routing + the ELD re-modeset nudge; this heal
|
||||
// installs the packages, group membership, script and unit on deployed nodes.
|
||||
const AUDIO_ROUTER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
|
||||
const AUDIO_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
|
||||
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
@ -794,6 +807,78 @@ pub async fn ensure_kiosk_hardened() {
|
||||
}
|
||||
}
|
||||
|
||||
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
|
||||
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
|
||||
/// under the lingering user manager — no logind seat, so no udev ACLs on
|
||||
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
|
||||
/// nudge) installed and current. No-op on nodes without the kiosk — audio
|
||||
/// only matters where media plays on an attached display.
|
||||
pub async fn ensure_audio_stack() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // no kiosk → no display audio to route
|
||||
}
|
||||
|
||||
// Package install runs via systemd-run (host_sudo), outside the service
|
||||
// sandbox — /usr and the dpkg database are read-only in our namespace.
|
||||
if fs::metadata("/usr/bin/pactl").await.is_err() {
|
||||
info!("audio: PipeWire stack missing — installing packages");
|
||||
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
|
||||
match host_sudo(&[
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"--no-install-recommends",
|
||||
"pipewire",
|
||||
"pipewire-pulse",
|
||||
"pipewire-alsa",
|
||||
"wireplumber",
|
||||
"alsa-utils",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||
Ok(s) => {
|
||||
warn!("audio: package install exited with {} — will retry next start", s);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("audio: package install failed: {:#} — will retry next start", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
|
||||
|
||||
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("audio: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
// First install on this node — bring it up now and on every boot.
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
|
||||
} else if script_changed || unit_changed {
|
||||
// Content update: restart only if it's running — never re-enable a
|
||||
// unit an operator deliberately disabled.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
|
||||
@ -108,17 +108,33 @@ impl BootReconciler {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||
{
|
||||
let failures =
|
||||
crate::container::companion::reconcile(&installed).await;
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
time::sleep(interval).await;
|
||||
// A failed repair can involve registry pulls and full
|
||||
// image builds; retrying every 30s hammered unreachable
|
||||
// registries ~174×/image/day on an offline node
|
||||
// (archy-x250-dev log sweep, 2026-07-22). Back off
|
||||
// exponentially while rounds keep failing — 30s doubling
|
||||
// to a 1h cap — and reset the moment a round is clean.
|
||||
failure_rounds = if failures.is_empty() {
|
||||
0
|
||||
} else {
|
||||
failure_rounds.saturating_add(1)
|
||||
};
|
||||
let backoff = interval
|
||||
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
|
||||
.min(Duration::from_secs(3600));
|
||||
time::sleep(backoff).await;
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
|
||||
@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
// The known Bitcoin node containers, preferred in order. Any archy
|
||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||
// Prefer a known name in priority order…
|
||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||
return hit.to_string();
|
||||
}
|
||||
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||
// future Bitcoin distribution archy ships works without editing this
|
||||
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||
// `bitcoin-ui` and `archy-*`.
|
||||
if let Some(other) = running.iter().find(|n| {
|
||||
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||
&& !n.ends_with("-ui")
|
||||
&& !n.starts_with("archy-")
|
||||
}) {
|
||||
return other.to_string();
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
|
||||
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
|
||||
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
|
||||
}
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
|
||||
// derived-env and broke Core nodes.
|
||||
|
||||
let provider = FileSecretsProvider {
|
||||
root: self.secrets_dir.clone(),
|
||||
|
||||
@ -126,12 +126,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Add a content item to the catalog.
|
||||
///
|
||||
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||
/// every call, so id-only dedup let the same file be shared twice as two
|
||||
/// separately-priced entries — and a buyer paid twice for one file
|
||||
/// (2026-07-22). Same filename → update the existing entry in place and
|
||||
/// keep its id, so existing buyers' owned records stay valid.
|
||||
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
||||
let mut catalog = load_catalog(data_dir).await?;
|
||||
if catalog.items.iter().any(|i| i.id == item.id) {
|
||||
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
||||
}
|
||||
catalog.items.push(item);
|
||||
let norm = |f: &str| f.trim_start_matches('/').to_string();
|
||||
if let Some(existing) = catalog
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|i| norm(&i.filename) == norm(&item.filename))
|
||||
{
|
||||
let keep_id = existing.id.clone();
|
||||
*existing = item;
|
||||
existing.id = keep_id;
|
||||
} else {
|
||||
catalog.items.push(item);
|
||||
}
|
||||
save_catalog(data_dir, &catalog).await?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||
|
||||
for (i, record) in containers.iter().enumerate() {
|
||||
// Skip containers that are already up — `podman start` on a running
|
||||
// container produces the noisy benign conmon "Failed to create
|
||||
// container" + cgroup Permission-denied journal pair (fleet log
|
||||
// sweep 2026-07-22).
|
||||
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||
report.recovered += 1;
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"Recovering container: {} (image: {})",
|
||||
record.name, record.image
|
||||
@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn start_existing_container(container: &str) -> bool {
|
||||
info!("Recovering stack container: {}", container);
|
||||
let timeout = match container {
|
||||
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
||||
_ => Duration::from_secs(90),
|
||||
};
|
||||
if container_state(container).await.as_deref() == Some("initialized") {
|
||||
cleanup_container_runtime_state(container).await;
|
||||
match container_state(container).await.as_deref() {
|
||||
// Already up — `podman start` on a running container makes conmon
|
||||
// try to join the live cgroup and fail with a scary "Failed to
|
||||
// create container: exit status 1" + cgroup.procs Permission denied
|
||||
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
|
||||
// these per day per node, all benign. Skip instead.
|
||||
Some("running") => return true,
|
||||
Some("initialized") => cleanup_container_runtime_state(container).await,
|
||||
_ => {}
|
||||
}
|
||||
info!("Recovering stack container: {}", container);
|
||||
match podman_output(&["start", container], timeout).await {
|
||||
Ok(output) if output.status.success() => {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
@ -386,6 +386,15 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
tokio::spawn(bootstrap::ensure_audio_stack());
|
||||
|
||||
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
|
||||
@ -366,6 +366,25 @@ pub struct DeviceProbe {
|
||||
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
||||
/// DETECT probe — no daemon spawn just to identify a stick.
|
||||
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
// The listener's reconnect loop may hold this port for ~10s of every
|
||||
// backoff cycle, and Linux happily double-opens a tty — two concurrent
|
||||
// handshakes corrupt each other into silence (observed on framework-pt:
|
||||
// every firmware "failed" while the listener was mid-cycle). Retry across
|
||||
// the listener's idle gaps instead of failing on first collision.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(Duration::from_secs(7)).await;
|
||||
}
|
||||
match probe_device_once(path).await {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("probe failed")))
|
||||
}
|
||||
|
||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
|
||||
@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
|
||||
|
||||
// ─── Response parsers ───────────────────────────────────────────────────
|
||||
|
||||
/// Decode a device/contact name from raw frame bytes, defensively.
|
||||
///
|
||||
/// Name fields sit at firmware-version-dependent offsets; when an offset
|
||||
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
|
||||
/// hand the UI replacement-character soup ("<22>\u{618}…"). Strict rules: valid
|
||||
/// UTF-8 up to the first NUL, no control characters, at least one
|
||||
/// non-whitespace char — anything else gets the caller's readable fallback.
|
||||
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
match std::str::from_utf8(&bytes[..end]) {
|
||||
Ok(s) => {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
|
||||
s.to_string()
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields — find it by scanning for printable ASCII
|
||||
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||
// differs would put binary here — decode defensively so the settings
|
||||
// panel never shows byte soup.
|
||||
let name_start = 4;
|
||||
let name = if data.len() > name_start {
|
||||
let name_end = data[name_start..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|p| name_start + p)
|
||||
.unwrap_or(data.len());
|
||||
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
||||
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
// name at data[99..131] (32 bytes)
|
||||
let name_start = 99.min(data.len());
|
||||
let name_end = (name_start + 32).min(data.len());
|
||||
let short_id = format!("{}...", &public_key_hex[..8]);
|
||||
let advert_name = if data.len() > name_start {
|
||||
String::from_utf8_lossy(&data[name_start..name_end])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
decode_mesh_name(&data[name_start..name_end], &short_id)
|
||||
} else {
|
||||
format!("{}...", &public_key_hex[..8])
|
||||
short_id
|
||||
};
|
||||
|
||||
// last_advert at data[131..135]
|
||||
|
||||
@ -572,6 +572,12 @@ pub struct DetectedDeviceInfo {
|
||||
pub pid: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
/// Unix epoch seconds of the /dev node's creation — udev recreates the
|
||||
/// node on every plug, so this changes on each replug. The UI keys its
|
||||
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
|
||||
/// unplug/replug faster than a status poll) invalidates old dismissals
|
||||
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
|
||||
pub plugged_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||
@ -579,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
let mut out = Vec::new();
|
||||
for path in detect_serial_devices().await {
|
||||
let usb = usb_info_for_tty(&path).await;
|
||||
let plugged_at = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
pid: usb.1,
|
||||
product: usb.2,
|
||||
manufacturer: usb.3,
|
||||
plugged_at,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
||||
validate_onion(onion)?;
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// 12s, not 30s: this is a liveness dot, not a data transfer. A Tor
|
||||
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@ -307,9 +307,19 @@ async fn send_handshake_message(
|
||||
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
// Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
|
||||
// so a request no relay accepted still reported ok:true to the UI and
|
||||
// the sender believed it was delivered.
|
||||
let send = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
Ok(())
|
||||
match send {
|
||||
Ok(output) if !output.success.is_empty() => Ok(()),
|
||||
Ok(output) => anyhow::bail!(
|
||||
"no relay accepted the handshake event (tried {}, all failed)",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
||||
|
||||
@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.current.fyi",
|
||||
];
|
||||
|
||||
/// The union of the boot-config relay list and the user-managed (enabled)
|
||||
/// relays — the set every nostr-facing operation should use, so senders and
|
||||
/// receivers always overlap regardless of which list the operator edited.
|
||||
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
|
||||
let mut relays: Vec<String> = config_relays.to_vec();
|
||||
if let Ok(store) = load_relays(data_dir).await {
|
||||
for r in store.relays {
|
||||
if r.enabled && !relays.contains(&r.url) {
|
||||
relays.push(r.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
relays
|
||||
}
|
||||
|
||||
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
|
||||
let path = data_dir.join(RELAYS_FILE);
|
||||
if !path.exists() {
|
||||
|
||||
@ -106,6 +106,9 @@ impl Server {
|
||||
// seconds of hitting the mempool (works whenever LND is up; retries
|
||||
// forever otherwise). User req 2026-07-22.
|
||||
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
|
||||
// LND wedge watchdog — self-heal the silent "RPC up, server never
|
||||
// ready" state instead of waiting for a human (100%-uptime req).
|
||||
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
|
||||
|
||||
// Retry Tor address in background — Tor may not be ready at startup
|
||||
if data.server_info.tor_address.is_none() {
|
||||
@ -209,9 +212,15 @@ impl Server {
|
||||
let did =
|
||||
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = config.nostr_relays.clone();
|
||||
// Merged relay set (config + user-managed) — publish presence
|
||||
// where handshake peers actually read (2026-07-22 unification).
|
||||
let data_dir_for_relays = config.data_dir.clone();
|
||||
let config_relays = config.nostr_relays.clone();
|
||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
let relays =
|
||||
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
|
||||
.await;
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
@ -253,6 +262,23 @@ impl Server {
|
||||
.await?,
|
||||
);
|
||||
|
||||
// Background handshake poll: fetch inbound nostr peer requests every
|
||||
// 5 minutes instead of only when a user presses the Federation Poll
|
||||
// button (requests used to sit on relays unseen — 2026-07-22). The
|
||||
// handler's own discoverability gate makes this a no-op until the
|
||||
// user opts in.
|
||||
{
|
||||
let rpc = api_handler.rpc_handler().clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
rpc.background_handshake_poll().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize mesh networking service (if config has enabled: true)
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<!-- Must agree with the embedding neode-ui shell (color-scheme: dark):
|
||||
browsers only honor a transparent same-origin iframe canvas when
|
||||
embedder and iframe use the SAME color-scheme — without this, the
|
||||
shell's :root{color-scheme:dark} forces this frame opaque and the
|
||||
background art behind the chat vanishes (2026-07-23). -->
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
@ -92,10 +92,16 @@ lands].
|
||||
5. ❑ Swap sticks once more with no UI interaction except "Keep As Is" — chat
|
||||
still works end-to-end afterwards (hot-swap without ceremony).
|
||||
|
||||
## F. Mobile onboarding screens (other agent — pending their push)
|
||||
## F. Companion pairing + mobile onboarding (other agent — push set #1, MERGED)
|
||||
|
||||
- ❑ (placeholder — filled in when their commits land; will be included in the
|
||||
same staged build.)
|
||||
1. ❑ Companion app: pair with the node via the new named QR (device tokens) —
|
||||
pairing completes instantly, device appears in the paired-devices list.
|
||||
2. ❑ Remote access now rides the embedded FIPS mesh (WireGuard replaced):
|
||||
with the phone OFF the node's WiFi, the companion still reaches the node.
|
||||
3. ❑ The reworked mobile onboarding/intro overlay screens flow correctly on
|
||||
first launch of the new APK (in-tarball APK is the 27MB build).
|
||||
4. ❑ (Push set #2 from the other agents is still pending — the release waits
|
||||
for it; this staged build does NOT include it yet.)
|
||||
|
||||
## G. Quick regressions
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ Query parameters:
|
||||
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
|
||||
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
|
||||
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
|
||||
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors (the node's seed-anchor list, capped at 4). The phone peers with these too so it can route to the node via the public mesh when the direct endpoint is unreachable (away from home / NAT). |
|
||||
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors, capped at 4. **The FIRST entry is the paired node itself** (`fnpub` at its current LAN host — the addr is a dial *hint*, the npub is the identity). The remaining entries are the node's seed anchors, and the node guarantees the Archipelago public anchor (vps2, `146.59.87.168:8444/tcp`) is present even if the operator trimmed their own list — so the phone can always rendezvous through the public mesh when the LAN endpoint is unreachable (away from home / NAT). |
|
||||
|
||||
Examples the web UI actually emits:
|
||||
|
||||
@ -87,6 +87,31 @@ Examples the web UI actually emits:
|
||||
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
|
||||
automatically when the LAN address stops answering.
|
||||
|
||||
## npub-first connectivity (2026-07-23 — REQUIRED app-side changes)
|
||||
|
||||
The QR used to be effectively IP-first: the app connected to `url`/`fhost` and
|
||||
broke as soon as the LAN renumbered or the phone left home. FIPS peers on
|
||||
**npubs**; IPs are only dial hints. The app must treat them that way:
|
||||
|
||||
1. **Identity = `fnpub`.** The saved server entry is keyed by the node's npub
|
||||
(fall back to origin only when the QR has no FIPS params). Re-scanning the
|
||||
same npub updates the entry even if every address changed.
|
||||
2. **Peer with the node itself as the first anchor** (`fanchors[0]`): on LAN
|
||||
this is direct p2p over FIPS (mDNS/known-endpoint dial via archy-fips-core
|
||||
v0.4+, which discovers LAN peers without any IP pinning), so it keeps
|
||||
working after DHCP renumbering.
|
||||
3. **Peer with the public anchors too** (remaining `fanchors` entries — the
|
||||
Archipelago vps2 anchor is always included by the node): away from LAN the
|
||||
phone routes to the node's npub via the public mesh and reaches the UI at
|
||||
`http://[<fip>]` (the fips0 ULA).
|
||||
4. **Address selection order** for the WebView: LAN `url` when it answers →
|
||||
ULA `fip` over the mesh otherwise. Never hard-fail because the scanned LAN
|
||||
IP stopped existing.
|
||||
|
||||
(Node-side counterpart shipped 2026-07-23: `fips.pair-info` always includes
|
||||
the vps2 public anchor, and the web UI prepends the node's self-anchor to
|
||||
`fanchors`.)
|
||||
|
||||
## Testing checklist (app side)
|
||||
|
||||
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
||||
|
||||
84
docs/tv-input-iframe-apps.md
Normal file
84
docs/tv-input-iframe-apps.md
Normal file
@ -0,0 +1,84 @@
|
||||
# TV input: keyboard/gamepad inside iframe apps — design
|
||||
|
||||
**Goal (2026-07-23, user requirement):** on a TV/kiosk node, keyboard and
|
||||
gamepad control must work *inside iframe apps* (IndeeHub, Jellyfin, fedimint
|
||||
UI, AIUI…), easily and globally — no per-app hacks.
|
||||
|
||||
## What already exists
|
||||
|
||||
- `neode-ui/src/composables/useControllerNav.ts` — a complete spatial-nav
|
||||
system for the shell: reads gamepads (`navigator.getGamepads`), moves focus
|
||||
between `data-controller-container` regions, plays nav sounds. It stops at
|
||||
iframe boundaries: nothing is forwarded into frames.
|
||||
- Keyboard focus DOES enter iframes natively (click/Tab into the frame), and a
|
||||
focused iframe receives all keys — same- or cross-origin. The gap is
|
||||
gamepad→app and deliberate focus handoff shell↔frame.
|
||||
- Kiosk Chromium is our process (archipelago-kiosk-launcher), X11, and the ISO
|
||||
already ships `xdotool`. Most app iframes are **cross-origin**
|
||||
(`http://host:port`), so shell-side script injection is impossible for them;
|
||||
only the nginx-proxied `/app/...` ones are same-origin.
|
||||
|
||||
## Recommended architecture — two layers, both global
|
||||
|
||||
### Layer 1 (OS, kiosk nodes): gamepad → virtual keyboard, kernel-level
|
||||
|
||||
A small host daemon (`archipelago-gamepad-keys`) on kiosk nodes:
|
||||
|
||||
- reads game controllers via evdev (`/dev/input/event*`, capability
|
||||
BTN_GAMEPAD), hotplug-aware (udev monitor or 5s rescan — same pattern as the
|
||||
audio router);
|
||||
- emits a **uinput virtual keyboard**: D-pad/left-stick → arrow keys, A →
|
||||
Enter, B → Escape, X → Space (play/pause), Y → `f` (fullscreen in most
|
||||
players), shoulders → Tab / Shift+Tab, Start → Enter, Select → Escape;
|
||||
- ships exactly like the audio router: `image-recipe/configs/` script + unit,
|
||||
spliced into the ISO, `include_str!` self-heal in `bootstrap.rs`, gated on
|
||||
the kiosk being installed. The `archipelago` user is in `input` group OR the
|
||||
unit runs as root (uinput needs it anyway — run as root, it's ~100 lines of
|
||||
evdev→uinput with no network).
|
||||
|
||||
Why this layer wins: the browser sees a real keyboard, so **every iframe —
|
||||
any origin, any app — just works** the way it does for a physical keyboard
|
||||
today. Video players, web games, AIUI: all of them already have keyboard
|
||||
bindings. Zero app cooperation, zero web-platform security fights.
|
||||
|
||||
### Layer 2 (shell): deliberate focus handoff into/out of frames
|
||||
|
||||
Small extension to `useControllerNav`:
|
||||
|
||||
- When spatial nav selects an app-session container and the user presses
|
||||
A/Enter: call `iframe.focus()` (works cross-origin) — keys (real or
|
||||
virtual) now flow into the app.
|
||||
- A dedicated **exit chord** the daemon maps from the gamepad (e.g. Home
|
||||
button → F12 or a rarely-used key): the shell listens with a *capturing*
|
||||
window listener; on seeing it, `iframe.blur()` + return focus to the shell
|
||||
nav. Keyboard users get the same via a documented chord (e.g. long
|
||||
Escape / Ctrl+Escape — plain Escape stays with the app, players use it).
|
||||
- Same-origin frames (the `/app/...` proxied set) can additionally get the
|
||||
full spatial-nav treatment by running the existing nav over
|
||||
`iframe.contentDocument` — nice-to-have after the layers above land.
|
||||
|
||||
### Optional layer 3 (per-app polish): postMessage contract
|
||||
|
||||
For OUR app UIs only (AIUI, fedimint, launcher pages): a tiny
|
||||
`archipelago:input` postMessage contract for semantic actions (back, home,
|
||||
context-menu) where raw keys aren't expressive enough. Documented in the app
|
||||
packaging docs; never required for an app to be usable.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
|
||||
a privileged debug port to the kiosk and a daemon↔browser coupling; the
|
||||
uinput route gets the same result at kernel level with no attack surface.
|
||||
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
|
||||
impossible for most apps, and it's exactly the per-app hack the requirement
|
||||
rules out.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `archipelago-gamepad-keys` daemon (evdev→uinput, ~python3 stdlib or small
|
||||
Rust bin) + unit + ISO splice + bootstrap self-heal. Test on Framework PT
|
||||
with any USB/BT controller.
|
||||
2. `useControllerNav`: A-button → `iframe.focus()` on the focused app session;
|
||||
exit-chord capture listener to reclaim focus.
|
||||
3. (Later) same-origin spatial nav inside `/app/...` frames; postMessage
|
||||
contract for our own app UIs.
|
||||
@ -382,6 +382,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
xorg \
|
||||
xdotool \
|
||||
chromium \
|
||||
pipewire \
|
||||
pipewire-pulse \
|
||||
pipewire-alsa \
|
||||
wireplumber \
|
||||
alsa-utils \
|
||||
unclutter \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
@ -420,7 +425,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
||||
|
||||
# Create archipelago user with password "archipelago"
|
||||
RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \
|
||||
# audio group: PipeWire runs under the lingering user manager (no logind seat
|
||||
# session), so udev's seat ACLs on /dev/snd never apply — group access is the
|
||||
# only way the kiosk's audio can open the hardware.
|
||||
RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \
|
||||
echo "archipelago:archipelago" | chpasswd && \
|
||||
echo "root:archipelago" | chpasswd && \
|
||||
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
|
||||
@ -1157,6 +1165,20 @@ if [ "$BACKEND_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bundle the Reticulum RNode daemon alongside the backend. install-to-disk
|
||||
# copies everything in archipelago/bin/ to /usr/local/bin, and the mesh
|
||||
# listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios —
|
||||
# a node imaged without it can never connect a Reticulum stick
|
||||
# (framework-pt, 2026-07-22: silent connect failures until hand-copied).
|
||||
RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}"
|
||||
if [ -f "$RETICULUM_DAEMON" ]; then
|
||||
cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||
chmod +x "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||
echo " ✅ Reticulum daemon bundled ($(du -h "$ARCH_DIR/bin/archy-reticulum-daemon" | cut -f1))"
|
||||
else
|
||||
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
echo " ⚠️ Could not capture from live server, building from source..."
|
||||
@ -2862,6 +2884,35 @@ fi
|
||||
echo "KIOSKSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
# Audio router: same splice-from-configs/ pattern as the kiosk (single source
|
||||
# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI
|
||||
# audio working: profile/default-sink routing + the ELD boot-race re-modeset.
|
||||
AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh"
|
||||
AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service"
|
||||
for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do
|
||||
if [ ! -f "$_audio_src" ]; then
|
||||
echo "ERROR: audio config file missing: $_audio_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then
|
||||
echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal."
|
||||
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
|
||||
echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'"
|
||||
cat "$AUDIO_ROUTER_SRC"
|
||||
echo "AUDIOROUTER"
|
||||
echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router"
|
||||
echo ""
|
||||
echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'"
|
||||
cat "$AUDIO_SERVICE_SRC"
|
||||
echo "AUDIOSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
||||
|
||||
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
||||
@ -3241,6 +3292,7 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de
|
||||
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
|
||||
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
|
||||
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \
|
||||
|
||||
20
image-recipe/configs/archipelago-audio-router.service
Normal file
20
image-recipe/configs/archipelago-audio-router.service
Normal file
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
|
||||
# Talks to the archipelago user's PipeWire (running under the lingering user
|
||||
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
|
||||
# after both are plausibly up. Missing/inactive units here are harmless.
|
||||
After=user@1000.service archipelago-kiosk.service
|
||||
Wants=user@1000.service
|
||||
ConditionPathExists=/usr/local/bin/archipelago-audio-router
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=archipelago
|
||||
ExecStart=/usr/local/bin/archipelago-audio-router
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
|
||||
Nice=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
145
image-recipe/configs/archipelago-audio-router.sh
Normal file
145
image-recipe/configs/archipelago-audio-router.sh
Normal file
@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# Archipelago audio router — keep audio flowing out of the display the user is
|
||||
# actually looking at.
|
||||
#
|
||||
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
|
||||
# profile priorities rank the laptop's analog output above HDMI, so a node
|
||||
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
|
||||
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
|
||||
# monitor; this daemon polls that via the card's profile availability and:
|
||||
#
|
||||
# - switches the card to the best *available* HDMI stereo profile (the
|
||||
# "+input:analog-stereo" combined variant when offered, so the mic keeps
|
||||
# working), and back to analog when HDMI is unplugged;
|
||||
# - keeps the default sink pointed at the routed output and migrates any
|
||||
# live streams so playback follows a hot-plug without a page reload;
|
||||
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
|
||||
# the real volume control; analog volume is left where the user set it).
|
||||
#
|
||||
# Runs as the archipelago user (systemd system unit with User=archipelago),
|
||||
# talks only to the user-session PipeWire; polling is a few pactl calls every
|
||||
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
|
||||
# lowest-common-denominator every TV decodes.
|
||||
|
||||
# - re-modesets an external output once when it is connected but no ELD
|
||||
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
|
||||
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
|
||||
# profile stays "available: no" forever (no sound, no error). One
|
||||
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
|
||||
|
||||
RUNTIME_DIR="/run/user/$(id -u)"
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
|
||||
export DISPLAY="${DISPLAY:-:0}"
|
||||
|
||||
# The user manager socket-activates pipewire, but after a live package install
|
||||
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
|
||||
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
|
||||
systemctl --user start wireplumber.service 2>/dev/null || true
|
||||
|
||||
LAST_SINK=""
|
||||
NUDGED_OUTPUTS=""
|
||||
|
||||
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
|
||||
# only when X answers, only when NO ELD anywhere reports a monitor, and only
|
||||
# once per connector while it stays connected (a display with no audio support
|
||||
# never produces an ELD — without the flag we would blank it every pass).
|
||||
eld_nudge_once() {
|
||||
# Any ELD already valid → audio path is live; reset the nudge memory.
|
||||
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
|
||||
NUDGED_OUTPUTS=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
local xrandr_out conn name output mode
|
||||
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
|
||||
|
||||
for conn in /sys/class/drm/card*-*/status; do
|
||||
[ -e "$conn" ] || continue
|
||||
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
|
||||
name=${conn%/status}; name=${name##*/card?-}
|
||||
case "$name" in eDP*|LVDS*) continue ;; esac
|
||||
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
|
||||
|
||||
# DRM connector names match the modesetting driver's output names.
|
||||
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
|
||||
[ -n "$output" ] || continue
|
||||
|
||||
# Keep the mode the kiosk chose (it may have capped a 4K panel);
|
||||
# --auto only as a fallback.
|
||||
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
|
||||
$1 == out { active = 1; next }
|
||||
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
|
||||
active && /^[^[:space:]]/ { active = 0 }')
|
||||
|
||||
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
|
||||
xrandr --output "$output" --off 2>/dev/null || true
|
||||
sleep 1
|
||||
if [ -n "$mode" ]; then
|
||||
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|
||||
|| xrandr --output "$output" --auto 2>/dev/null || true
|
||||
else
|
||||
xrandr --output "$output" --auto 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
route_once() {
|
||||
local cards_dump card active want sink default_sink
|
||||
cards_dump=$(pactl list cards 2>/dev/null) || return 0
|
||||
[ -n "$cards_dump" ] || return 0
|
||||
|
||||
# One line per card: "<card>\t<active>\t<wanted-profile>"
|
||||
# wanted = highest-priority available output:hdmi-stereo* profile, else
|
||||
# highest-priority available output:analog* profile.
|
||||
while IFS=$'\t' read -r card active want; do
|
||||
[ -n "$card" ] && [ -n "$want" ] || continue
|
||||
if [ "$active" != "$want" ]; then
|
||||
pactl set-card-profile "$card" "$want" 2>/dev/null || true
|
||||
fi
|
||||
done < <(printf '%s\n' "$cards_dump" | awk '
|
||||
function flush() {
|
||||
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
|
||||
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
|
||||
}
|
||||
/^Card #/ { flush() }
|
||||
/^\tName: / { card=$2 }
|
||||
/^\t\toutput:/ {
|
||||
line=$0; sub(/^\t\t/, "", line)
|
||||
prof=line; sub(/: .*/, "", prof)
|
||||
prio=0
|
||||
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
|
||||
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
|
||||
if (!avail) next
|
||||
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
|
||||
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
|
||||
}
|
||||
/^\tActive Profile: / { active=$3 }
|
||||
END { flush() }
|
||||
')
|
||||
|
||||
# Point the default sink at HDMI when one exists, else the first sink.
|
||||
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
|
||||
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
|
||||
[ -n "$sink" ] || return 0
|
||||
|
||||
default_sink=$(pactl get-default-sink 2>/dev/null)
|
||||
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
|
||||
pactl set-default-sink "$sink" 2>/dev/null || true
|
||||
pactl set-sink-mute "$sink" 0 2>/dev/null || true
|
||||
case "$sink" in
|
||||
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
|
||||
esac
|
||||
# Migrate live streams so playing audio follows the hot-plug.
|
||||
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
|
||||
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
|
||||
done
|
||||
LAST_SINK="$sink"
|
||||
fi
|
||||
}
|
||||
|
||||
while true; do
|
||||
eld_nudge_once
|
||||
route_once
|
||||
sleep 5
|
||||
done
|
||||
@ -174,6 +174,9 @@ while true; do
|
||||
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
||||
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
||||
# at all with no visible error (--noerrdialogs suppresses it).
|
||||
# OverlayScrollbar: thin auto-hiding scrollbars (the Chrome-on-a-remote-
|
||||
# device look) instead of classic X11 scrollbar chrome — a kiosk TV showed
|
||||
# a permanent fat scrollbar on scrollable views (Peers).
|
||||
# Force a DARK color-scheme preference. The main UI hardcodes its dark
|
||||
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
|
||||
# and defaults to its LIGHT variant (white panels) when the browser reports
|
||||
@ -191,6 +194,7 @@ while true; do
|
||||
--no-first-run \
|
||||
--check-for-update-interval=31536000 \
|
||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||
--enable-features=OverlayScrollbar \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-save-password-bubble \
|
||||
--disable-suggestions-service \
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!-- Dark-only app: pin native controls dark before CSS loads. -->
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
@ -2971,6 +2971,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
region: mc.enabled ? mc.lora_region : null,
|
||||
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
|
||||
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
|
||||
manage_radio: mc.manage_radio !== false,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -3088,7 +3089,32 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (params && typeof params.device_kind === 'string') cfg.device_kind = params.device_kind === 'auto' ? null : params.device_kind
|
||||
if (params && typeof params.channel_name === 'string') cfg.channel_name = params.channel_name
|
||||
if (params && typeof params.advert_name === 'string') cfg.advert_name = params.advert_name
|
||||
return res.json({ result: { configured: true, enabled: cfg.enabled, lora_region: cfg.lora_region, device_kind: cfg.device_kind, ...globalThis.__meshHeaders } })
|
||||
// Hot-swap "keep as is" (2026-07-22): false = never write config to the radio.
|
||||
if (params && typeof params.manage_radio === 'boolean') cfg.manage_radio = params.manage_radio
|
||||
return res.json({ result: { configured: true, enabled: cfg.enabled, lora_region: cfg.lora_region, device_kind: cfg.device_kind, manage_radio: cfg.manage_radio !== false, ...globalThis.__meshHeaders } })
|
||||
}
|
||||
|
||||
// Read-only firmware probe backing the hot-swap device modal's
|
||||
// current-details card (2026-07-22). Real backend opens the serial
|
||||
// port; the demo answers instantly with what's "flashed" on the
|
||||
// Heltec that mesh.status advertises.
|
||||
case 'mesh.probe-device': {
|
||||
// 900ms delay lets the modal's "Reading what's on the radio…" spinner show.
|
||||
setTimeout(() => res.json({
|
||||
result: {
|
||||
path: params?.path || '/dev/ttyUSB0',
|
||||
kind: 'meshcore',
|
||||
firmware_version: 'MeshCore v2.3.1',
|
||||
node_id: 42,
|
||||
advert_name: 'archy-228',
|
||||
region: null,
|
||||
modem_preset: null,
|
||||
primary_channel: null,
|
||||
secondary_channel: null,
|
||||
max_contacts: 100,
|
||||
},
|
||||
}), 900)
|
||||
return
|
||||
}
|
||||
|
||||
case 'mesh.send-invoice': {
|
||||
|
||||
@ -89,19 +89,43 @@ class FileBrowserClient {
|
||||
return h
|
||||
}
|
||||
|
||||
/** Don't hammer app.filebrowser-token after a failed login — one attempt
|
||||
* per cooldown window, so a broken/missing filebrowser doesn't turn every
|
||||
* poll of the Files card into a fresh login + 401 pair (console spam). */
|
||||
private _lastLoginFailure = 0
|
||||
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
|
||||
|
||||
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
|
||||
private async ensureAuth(): Promise<void> {
|
||||
if (this._authenticated && this.getAuthCookie()) return
|
||||
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
const ok = await this.login()
|
||||
if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
if (!ok) {
|
||||
this._lastLoginFailure = Date.now()
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
}
|
||||
|
||||
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
|
||||
* from app.filebrowser-token is short-lived; before this, an expired
|
||||
* cookie kept `_authenticated` true and every Files-card poll 401'd
|
||||
* forever (the console-spam bug, 2026-07-22). */
|
||||
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
await this.ensureAuth()
|
||||
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
|
||||
if (res.status === 401) {
|
||||
this._authenticated = false
|
||||
await this.ensureAuth()
|
||||
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
await this.ensureAuth()
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
|
||||
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
||||
// When File Browser isn't installed, nginx falls through to the SPA and
|
||||
// returns index.html (200, text/html); when it's down it returns 502.
|
||||
@ -133,11 +157,8 @@ class FileBrowserClient {
|
||||
* For large files (video/audio), prefer streamUrl() instead.
|
||||
*/
|
||||
async fetchBlobUrl(path: string): Promise<string> {
|
||||
await this.ensureAuth()
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
@ -171,17 +192,12 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
await this.ensureAuth()
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
const res = await fetch(
|
||||
const res = await this.authedFetch(
|
||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: file,
|
||||
},
|
||||
{ method: 'POST', body: file },
|
||||
)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
@ -190,35 +206,31 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||
await this.ensureAuth()
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
await this.ensureAuth()
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
|
||||
if (!this._authenticated || !this.getAuthCookie()) {
|
||||
const ok = await this.login()
|
||||
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
|
||||
} catch {
|
||||
// Not installed / login cooling down — the Files card shows zeros.
|
||||
return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}/api/resources/`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||
const data: FileBrowserListResponse = await res.json()
|
||||
const items = data.items || []
|
||||
@ -242,17 +254,11 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
|
||||
if (!this._authenticated || !this.getAuthCookie()) {
|
||||
const ok = await this.login()
|
||||
if (!ok) throw new Error('FileBrowser authentication failed')
|
||||
}
|
||||
if (!this.isTextFile(path)) {
|
||||
throw new Error(`Cannot read binary file: ${path}`)
|
||||
}
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const size = blob.size
|
||||
@ -266,12 +272,9 @@ class FileBrowserClient {
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...this.headers(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
||||
|
||||
@ -447,7 +447,8 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'node-check-peer',
|
||||
params: { onion },
|
||||
timeout: 35000,
|
||||
// Backend health-dial timeout is 12s — 15s here covers RPC overhead.
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -104,11 +104,11 @@ function handleClickOutside(e: MouseEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('pointerdown', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('pointerdown', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -8,10 +8,16 @@
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<!-- Column layout (2026-07-22 modal contract): title row and the
|
||||
optional #header slot (tabs) stay pinned at the top, the #footer
|
||||
slot (action buttons) stays pinned at the bottom, and ONLY the
|
||||
default slot scrolls. Callers that previously made the whole
|
||||
card scroll via contentClass keep working — the inner region
|
||||
simply never lets the card overflow. -->
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="glass-card p-6 w-full relative z-10"
|
||||
:class="[maxWidth, contentClass]"
|
||||
class="glass-card p-6 w-full relative z-10 flex flex-col"
|
||||
:class="[maxWidth, contentClass, defaultMaxH]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.stop
|
||||
@ -28,8 +34,15 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
<div v-if="$slots.header" class="shrink-0">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="shrink-0 pt-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -60,6 +73,13 @@ const emit = defineEmits<{
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const zClass = computed(() => props.zIndex)
|
||||
// The pinned-footer layout needs a height bound or tall content pushes the
|
||||
// footer off-screen anyway. Callers that set their own max-h (e.g. the
|
||||
// Transactions modal's visual-viewport calc on mobile) keep authority —
|
||||
// adding a second max-h class would make the CSS winner order-dependent.
|
||||
const defaultMaxH = computed(() =>
|
||||
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
|
||||
)
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
|
||||
@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
|
||||
})
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
|
||||
function isTailnetIp(host: string): boolean {
|
||||
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
|
||||
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
|
||||
}
|
||||
|
||||
/**
|
||||
* The server URL the companion app should connect to. The demo advertises its
|
||||
* public https origin; a real node advertises whatever address the browser is
|
||||
* already using — except on the kiosk, where that is localhost and useless to
|
||||
* a phone, so fall back to the node's mDNS .local name.
|
||||
* public https origin; a real node advertises the browser's own origin ONLY
|
||||
* when a phone could plausibly reach it too. Two origins that a phone on the
|
||||
* LAN can never dial get substituted with the node's real LAN address:
|
||||
* - localhost/127.0.0.1 (the kiosk browses itself)
|
||||
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
|
||||
* QR carried one of these on 2026-07-22 and the companion sat there
|
||||
* dialing an IP the phone had no route to)
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
const { hostname, origin } = window.location
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||
const phoneUnreachable =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
|
||||
if (!phoneUnreachable) return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
||||
method: 'system.get-hostname',
|
||||
})
|
||||
// Prefer the LAN IP (phones resolve it without mDNS support — Android
|
||||
// notoriously lacks .local); fall back to the mDNS name.
|
||||
if (res?.lan_ip) return `http://${res.lan_ip}`
|
||||
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||
} catch {
|
||||
// RPC unavailable — fall through to the (localhost) origin
|
||||
// RPC unavailable — fall through to the (unreachable) origin; the app
|
||||
// still lets the user edit the address by hand.
|
||||
}
|
||||
return origin
|
||||
}
|
||||
@ -330,9 +348,19 @@ async function buildPairingUrl(): Promise<string> {
|
||||
if (info.udp_port) params.set('fudp', String(info.udp_port))
|
||||
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
|
||||
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
|
||||
// Cap keeps the QR at a camera-friendly density; the node lists its
|
||||
// most reachable anchors first.
|
||||
const anchors = (info.anchors || []).slice(0, 4)
|
||||
// FIRST entry is the paired node ITSELF: the phone peers with it by
|
||||
// npub (the fhost addr is only a dial hint), so LAN contact is direct
|
||||
// p2p over FIPS and survives DHCP renumbering; the node's public
|
||||
// anchors follow for reaching it away from home. Cap keeps the QR at a
|
||||
// camera-friendly density; the node lists its most reachable anchors
|
||||
// first.
|
||||
const selfAnchor = info.tcp_port
|
||||
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
|
||||
: []
|
||||
const anchors = [
|
||||
...selfAnchor,
|
||||
...(info.anchors || []).filter((a) => a.npub !== info.npub),
|
||||
].slice(0, 4)
|
||||
if (anchors.length) {
|
||||
params.set(
|
||||
'fanchors',
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<!-- z-3600: this consent prompt can be triggered from INSIDE another
|
||||
modal (e.g. Wallet Settings → Channels → funding tx), so it must sit
|
||||
above the standard modal layer (3000) but below the app overlay (4000). -->
|
||||
<BaseModal
|
||||
:show="!!explorer.pendingTx.value"
|
||||
title="Open on an external explorer?"
|
||||
max-width="max-w-md"
|
||||
z-index="z-[3600]"
|
||||
@close="explorer.cancelPending()"
|
||||
>
|
||||
<!-- Same visual language as the uninstall keep-your-data warning: amber
|
||||
|
||||
@ -131,10 +131,10 @@ async function loadIdentities() {
|
||||
|
||||
onMounted(() => {
|
||||
loadIdentities()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
document.addEventListener('pointerdown', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
document.removeEventListener('pointerdown', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -301,7 +301,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
defineProps<{ compact?: boolean }>()
|
||||
|
||||
@ -390,10 +390,14 @@ function fundingTxid(ch: Channel): string {
|
||||
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
|
||||
}
|
||||
|
||||
const txExplorer = useTxExplorer()
|
||||
function openInMempool(txid: string) {
|
||||
if (!txid) return
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
|
||||
// Same routing as every other tx link (missed in the first pass —
|
||||
// 2026-07-22): local Mempool app when it's running, otherwise the saved
|
||||
// external explorer, with the first-time consent modal setting it up and
|
||||
// then opening the tx.
|
||||
txExplorer.openTx(txid)
|
||||
}
|
||||
|
||||
function capacityPercent(amount: number, capacity: number): number {
|
||||
|
||||
@ -1,14 +1,63 @@
|
||||
<template>
|
||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||
<template v-if="confirming">
|
||||
<div class="mb-3 p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Method</span>
|
||||
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
|
||||
</div>
|
||||
<div v-if="dest.trim()" class="mb-1">
|
||||
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
|
||||
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share — sats leave your balance when it's redeemed.</p>
|
||||
</div>
|
||||
|
||||
<!-- Live balance impact -->
|
||||
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
|
||||
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '…' : confirmBalance.toLocaleString() + ' sats' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50 flex items-center gap-2">
|
||||
Amount
|
||||
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-white/80">−{{ confirmAmount.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Balance after</span>
|
||||
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
|
||||
{{ confirmBalance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
|
||||
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
|
||||
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
|
||||
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : 'Confirm & Send' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Method tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
|
||||
v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] as const)"
|
||||
:key="m"
|
||||
@click="sendMethod = m"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
|
||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
@ -49,8 +98,13 @@
|
||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
||||
<!-- QR so the recipient can scan the token straight off this screen
|
||||
(animated multi-frame not needed: qrcode handles these sizes). -->
|
||||
<div class="flex justify-center my-2">
|
||||
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
||||
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||
</div>
|
||||
@ -75,15 +129,16 @@
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : t('common.send') }}
|
||||
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ t('common.send') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@ -93,7 +148,9 @@ const { t } = useI18n()
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
||||
// 'auto' remains in the type for the effectiveMethod logic but is no longer
|
||||
// offered as a tab (hidden per operator request 2026-07-22).
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
|
||||
const amount = ref<number>(0)
|
||||
const dest = ref('')
|
||||
const processing = ref(false)
|
||||
@ -129,12 +186,95 @@ const effectiveMethod = computed(() => {
|
||||
return 'lightning'
|
||||
})
|
||||
|
||||
// --- Second-step confirmation (parity with the scan flow): review shows the
|
||||
// --- balance reduction before anything is sent or any token is minted.
|
||||
|
||||
const confirming = ref(false)
|
||||
const confirmBalance = ref<number | null>(null)
|
||||
const invoiceAmountSats = ref<number | null>(null)
|
||||
|
||||
const methodLabel = computed(() => ({
|
||||
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
|
||||
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
|
||||
}[effectiveMethod.value]))
|
||||
|
||||
const methodColor = computed(() => ({
|
||||
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
|
||||
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
|
||||
}[effectiveMethod.value]))
|
||||
|
||||
const destDisplay = computed(() => {
|
||||
const d = dest.value.trim()
|
||||
return d.length > 64 ? `${d.slice(0, 38)}…${d.slice(-18)}` : d
|
||||
})
|
||||
|
||||
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
|
||||
function parseBolt11AmountSats(invoice: string): number | null {
|
||||
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
|
||||
if (!m || !m[1]) return null
|
||||
const value = Number(m[1])
|
||||
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
|
||||
return Math.round(value * mult * 1e8)
|
||||
}
|
||||
|
||||
// An invoice-fixed amount always wins over the typed one; a sweep is priced
|
||||
// at the whole balance.
|
||||
const confirmAmount = computed(() => {
|
||||
if (isSweep.value) return confirmBalance.value ?? 0
|
||||
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
|
||||
return amount.value > 0 ? Math.floor(amount.value) : 0
|
||||
})
|
||||
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
|
||||
const insufficient = computed(() =>
|
||||
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
|
||||
)
|
||||
|
||||
async function loadConfirmBalance() {
|
||||
confirmBalance.value = null
|
||||
try {
|
||||
if (effectiveMethod.value === 'ecash') {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
|
||||
confirmBalance.value = res.balance_sats ?? 0
|
||||
} else if (effectiveMethod.value === 'fedimint') {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
|
||||
confirmBalance.value = res.balance_sats ?? 0
|
||||
} else {
|
||||
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
|
||||
confirmBalance.value = effectiveMethod.value === 'onchain'
|
||||
? (res.balance_sats ?? 0)
|
||||
: (res.channel_balance_sats ?? 0)
|
||||
}
|
||||
} catch {
|
||||
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
|
||||
}
|
||||
}
|
||||
|
||||
function review() {
|
||||
error.value = ''
|
||||
const method = effectiveMethod.value
|
||||
const d = dest.value.trim()
|
||||
if (method === 'lightning') {
|
||||
if (!d) { error.value = t('web5.pasteInvoice'); return }
|
||||
invoiceAmountSats.value = parseBolt11AmountSats(d)
|
||||
} else {
|
||||
invoiceAmountSats.value = null
|
||||
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
}
|
||||
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
|
||||
error.value = t('sendBitcoin.amountSats'); return
|
||||
}
|
||||
void loadConfirmBalance()
|
||||
confirming.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
error.value = ''
|
||||
resultTxid.value = ''
|
||||
resultHash.value = ''
|
||||
resultArk.value = ''
|
||||
ecashToken.value = ''
|
||||
confirming.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@ -142,9 +282,21 @@ function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
}
|
||||
|
||||
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
watch(ecashToken, async (token) => {
|
||||
if (!token) return
|
||||
await nextTick()
|
||||
if (!tokenQrCanvas.value) return
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
|
||||
} catch { /* QR is a convenience — the copyable text is authoritative */ }
|
||||
})
|
||||
|
||||
async function send() {
|
||||
if (processing.value) return
|
||||
if (!amount.value && !isSweep.value) return
|
||||
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
|
||||
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
@ -169,6 +321,13 @@ async function send() {
|
||||
params: { amount_sats: amount.value },
|
||||
})
|
||||
ecashToken.value = res.token
|
||||
} else if (method === 'fedimint') {
|
||||
const res = await rpcClient.call<{ token: string }>({
|
||||
method: 'wallet.fedimint-send',
|
||||
params: { amount_sats: amount.value },
|
||||
timeout: 60000,
|
||||
})
|
||||
ecashToken.value = res.token
|
||||
} else if (method === 'lightning') {
|
||||
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
||||
const res = await rpcClient.call<{ payment_hash: string }>({
|
||||
@ -187,6 +346,8 @@ async function send() {
|
||||
resultTxid.value = res.txid
|
||||
}
|
||||
emit('sent')
|
||||
// Back to the form pane so the success/token panes are visible.
|
||||
confirming.value = false
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||
} finally {
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
v-if="pane !== 'choose'"
|
||||
v-if="pane !== 'scan'"
|
||||
@click="goBack"
|
||||
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
||||
aria-label="Back"
|
||||
@ -41,63 +41,26 @@
|
||||
</div>
|
||||
|
||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||
<!-- ============ CHOOSE PANE ============ -->
|
||||
<!-- Always the first stop: the user picks camera or image upload
|
||||
every time, instead of the camera auto-starting on open. -->
|
||||
<div v-if="pane === 'choose'" key="choose">
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<button
|
||||
@click="chooseCamera"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-white">Scan with camera</span>
|
||||
</button>
|
||||
<button
|
||||
@click="uploadInput?.click()"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 7.5L12 3m0 0L7.5 7.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-white">Upload image</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onPhotoPicked"
|
||||
/>
|
||||
|
||||
<div v-if="scanStatus" class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||
{{ scanStatus }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paste fallback (also the path on camera-less nodes) -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="pasteInput"
|
||||
type="text"
|
||||
placeholder="…or paste an invoice / address / token"
|
||||
class="flex-1 input-glass font-mono text-xs"
|
||||
@keydown.enter="submitPaste"
|
||||
/>
|
||||
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCAN PANE ============ -->
|
||||
<div v-else-if="pane === 'scan'" key="scan">
|
||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||
<div v-if="pane === 'scan'" key="scan">
|
||||
<!-- Chooser interstitial — every open: the user picks live camera
|
||||
or a photo upload; neither starts until chosen. -->
|
||||
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||
the native bridge still provides a live camera -->
|
||||
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Scan with camera
|
||||
</button>
|
||||
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Upload / take a photo of the QR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
||||
source-less <video> flashes a native play glyph in Android WebViews -->
|
||||
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
||||
@ -127,17 +90,20 @@
|
||||
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||
Take photo of QR
|
||||
</button>
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="hidden"
|
||||
@change="onPhotoPicked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single always-mounted picker input, shared by the chooser and
|
||||
the in-camera fallback button -->
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="hidden"
|
||||
@change="onPhotoPicked"
|
||||
/>
|
||||
|
||||
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
|
||||
@ -305,12 +271,13 @@ import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
||||
type Pane = 'choose' | 'scan' | 'amount' | 'success'
|
||||
type Pane = 'scan' | 'amount' | 'success'
|
||||
|
||||
// JS bridge the Android companion app injects: when present, live scanning is
|
||||
// JS bridge the Android companion injects: when present, live scanning is
|
||||
// delegated to a native camera modal (styled like this one) — the WebView's
|
||||
// getUserMedia preview lags badly on phones. Decodes come back through the
|
||||
// window.__archyQr* callbacks; we mirror status lines out to the native modal.
|
||||
// getUserMedia preview lags, and over plain http it doesn't exist at all.
|
||||
// Decodes come back through the window.__archyQr* callbacks; status lines
|
||||
// (animated-QR progress, errors) mirror out to the native modal's strip.
|
||||
interface ArchipelagoQrBridge {
|
||||
open(): void
|
||||
setStatus(message: string, isError: boolean): void
|
||||
@ -333,10 +300,9 @@ useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||
useBodyScrollLock(computed(() => props.show))
|
||||
|
||||
// --- Pane state ---
|
||||
const pane = ref<Pane>('choose')
|
||||
const pane = ref<Pane>('scan')
|
||||
const direction = ref<'forward' | 'back'>('forward')
|
||||
const paneTitle = computed(() => {
|
||||
if (pane.value === 'choose') return 'Send'
|
||||
if (pane.value === 'scan') return 'Scan to send'
|
||||
if (pane.value === 'success') return 'Success'
|
||||
if (action.value === 'fedimint-join') return 'Join federation'
|
||||
@ -350,26 +316,17 @@ function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (pane.value === 'scan') {
|
||||
stopScanning()
|
||||
goTo('choose', 'back')
|
||||
} else if (pane.value === 'amount') {
|
||||
if (pane.value === 'amount') {
|
||||
error.value = ''
|
||||
goTo('choose', 'back')
|
||||
goTo('scan', 'back')
|
||||
// Only relight the camera when that's what the user chose; otherwise
|
||||
// they land back on the scan/upload chooser.
|
||||
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
|
||||
} else if (pane.value === 'success') {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chooser ---
|
||||
function chooseCamera() {
|
||||
scanStatus.value = ''
|
||||
scanStatusIsError.value = false
|
||||
if (startNativeScan()) return
|
||||
goTo('scan')
|
||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
||||
}
|
||||
|
||||
// --- Scanner ---
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const isScanning = ref(false)
|
||||
@ -377,14 +334,20 @@ const isScanning = ref(false)
|
||||
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
||||
// flash for a second on every open.
|
||||
const autoStarting = ref(false)
|
||||
const scanStatus = ref('')
|
||||
const scanStatusIsError = ref(false)
|
||||
const cameraError = ref(false)
|
||||
const pasteInput = ref('')
|
||||
const qrScanner = ref<QrScanner | null>(null)
|
||||
const animatedDecoder = useAnimatedQRDecoder()
|
||||
|
||||
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
|
||||
// nothing starts until the user picks, and Back from a later pane returns to
|
||||
// the live camera only if that's what they chose.
|
||||
const scanChoice = ref<'unset' | 'camera'>('unset')
|
||||
|
||||
function chooseCamera() {
|
||||
if (startNativeScan()) return
|
||||
scanChoice.value = 'camera'
|
||||
void nextTick(() => startScanning())
|
||||
}
|
||||
|
||||
// --- Native scanner (companion app) ---
|
||||
const hasNativeQr = !!nativeWin.ArchipelagoQr
|
||||
const nativeScanActive = ref(false)
|
||||
|
||||
function startNativeScan(): boolean {
|
||||
@ -396,14 +359,12 @@ function startNativeScan(): boolean {
|
||||
bridge.open()
|
||||
return true
|
||||
}
|
||||
|
||||
// Mirror scan status (animated-QR progress, "not recognised" errors) onto the
|
||||
// native modal's status strip while it's up.
|
||||
watch([scanStatus, scanStatusIsError], () => {
|
||||
if (nativeScanActive.value) {
|
||||
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
|
||||
}
|
||||
})
|
||||
const scanStatus = ref('')
|
||||
const scanStatusIsError = ref(false)
|
||||
const cameraError = ref(false)
|
||||
const pasteInput = ref('')
|
||||
const qrScanner = ref<QrScanner | null>(null)
|
||||
const animatedDecoder = useAnimatedQRDecoder()
|
||||
|
||||
async function startScanning() {
|
||||
cameraError.value = false
|
||||
@ -453,6 +414,14 @@ function stopScanning() {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror status lines onto the native modal while it's up — it covers the
|
||||
// page, so this strip is the only feedback the user can see.
|
||||
watch([scanStatus, scanStatusIsError], () => {
|
||||
if (nativeScanActive.value) {
|
||||
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
|
||||
}
|
||||
})
|
||||
|
||||
function submitPaste() {
|
||||
const text = pasteInput.value.trim()
|
||||
if (!text) return
|
||||
@ -464,18 +433,16 @@ function submitPaste() {
|
||||
// undefined), but <input capture> opens the native camera in any browser,
|
||||
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
|
||||
const photoInput = ref<HTMLInputElement | null>(null)
|
||||
// Chooser-pane picker: same decode path, but no capture attribute — the user
|
||||
// picks an existing image (screenshot, saved QR) rather than taking a photo.
|
||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
|
||||
|
||||
async function onPhotoPicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
scanStatusIsError.value = false
|
||||
scanStatus.value = 'Reading photo…'
|
||||
try {
|
||||
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||
handleScanned(result.data)
|
||||
handleScanned(await decodePhotoRobust(file))
|
||||
} catch {
|
||||
scanStatusIsError.value = true
|
||||
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
|
||||
@ -484,6 +451,28 @@ async function onPhotoPicked(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a QR photo with every engine we have. On the companion app the
|
||||
* photo path IS the scan path (plain-http LAN = no secure context = no
|
||||
* live camera), and Lightning invoices make DENSE codes that the wasm
|
||||
* engine's single pass often misses (reported 2026-07-22: "camera not
|
||||
* picking up the invoice"). Android WebView's native BarcodeDetector is
|
||||
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
|
||||
async function decodePhotoRobust(file: File): Promise<string> {
|
||||
try {
|
||||
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
|
||||
if (Detector) {
|
||||
const bmp = await createImageBitmap(file)
|
||||
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
|
||||
const hit = codes.find(c => c.rawValue)
|
||||
if (hit) return hit.rawValue
|
||||
}
|
||||
} catch {
|
||||
// Native detector unavailable/failed — wasm engine below.
|
||||
}
|
||||
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||
return result.data
|
||||
}
|
||||
|
||||
// --- Detection (ported from k484's scanner) ---
|
||||
const rail = ref<Rail>('lightning')
|
||||
const action = ref<Action>('pay-invoice')
|
||||
@ -773,7 +762,7 @@ async function confirmSend() {
|
||||
function resetAll() {
|
||||
stopScanning()
|
||||
animatedDecoder.reset()
|
||||
pane.value = 'choose'
|
||||
pane.value = 'scan'
|
||||
direction.value = 'forward'
|
||||
scanStatus.value = ''
|
||||
scanStatusIsError.value = false
|
||||
@ -793,10 +782,11 @@ function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Every open lands on the chooser — the camera never auto-starts.
|
||||
watch(() => props.show, (open) => {
|
||||
if (open) {
|
||||
resetAll()
|
||||
// No auto-start: the chooser interstitial owns the first move every time.
|
||||
scanChoice.value = 'unset'
|
||||
} else {
|
||||
stopScanning()
|
||||
}
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<!-- Protocol tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh]" @close="close">
|
||||
<!-- Protocol tabs — pinned via the header slot; only the pane below
|
||||
scrolls (2026-07-22 modal contract). -->
|
||||
<template #header>
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Lightning Channels ===================== -->
|
||||
<div v-show="activeTab === 'channels'">
|
||||
@ -17,9 +20,6 @@
|
||||
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
|
||||
</p>
|
||||
<LightningChannelsPanel v-if="show" compact />
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Cashu Mints ===================== -->
|
||||
@ -75,16 +75,6 @@
|
||||
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
|
||||
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
@click="saveMints"
|
||||
:disabled="savingMints || mints.length === 0"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{{ savingMints ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -133,16 +123,6 @@
|
||||
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
|
||||
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
@click="joinFederation"
|
||||
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{{ joiningFed ? 'Joining…' : 'Join federation' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
|
||||
Joining federations lands with the Fedimint client backend.
|
||||
@ -179,9 +159,6 @@
|
||||
Don't warn me each time before opening the external explorer
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Ark ===================== -->
|
||||
@ -269,16 +246,33 @@
|
||||
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
|
||||
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
@click="saveArkConfig"
|
||||
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Pinned footer (2026-07-22 modal contract): Close always, plus the
|
||||
active tab's primary action — the buttons never scroll away. -->
|
||||
<template #footer>
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
v-if="activeTab === 'cashu'"
|
||||
@click="saveMints"
|
||||
:disabled="savingMints || mints.length === 0"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ savingMints ? 'Saving…' : 'Save' }}</button>
|
||||
<button
|
||||
v-else-if="activeTab === 'fedimint'"
|
||||
@click="joinFederation"
|
||||
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ joiningFed ? 'Joining…' : 'Join federation' }}</button>
|
||||
<button
|
||||
v-else-if="activeTab === 'ark' && arkStatus?.available"
|
||||
@click="saveArkConfig"
|
||||
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
<button
|
||||
class="cloud-file-item group"
|
||||
data-controller-container
|
||||
data-controller-primary
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
>
|
||||
|
||||
@ -145,6 +145,19 @@ const errorMsg = ref<string | null>(null)
|
||||
const successMsg = ref<string | null>(null)
|
||||
|
||||
// If we have an existing item, load its state
|
||||
|
||||
/** Catalog entries store the slash-stripped path; props carry a leading
|
||||
* slash (filepath) or just the basename (filename). Normalize both sides —
|
||||
* the old exact compare never matched, so every re-share created a brand
|
||||
* new priced entry and buyers could pay twice for one file (2026-07-22). */
|
||||
function matchesThisFile(catalogFilename: string): boolean {
|
||||
const strip = (v: string) => v.replace(/^\/+/, '')
|
||||
return (
|
||||
strip(catalogFilename) === strip(props.filepath || '') ||
|
||||
strip(catalogFilename) === strip(props.filename || '')
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: Array<{
|
||||
@ -154,7 +167,7 @@ onMounted(async () => {
|
||||
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||
}> }>({ method: 'content.list-mine' })
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
(i) => matchesThisFile(i.filename)
|
||||
)
|
||||
if (match) {
|
||||
shared.value = true
|
||||
@ -188,7 +201,7 @@ async function save() {
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
(i) => matchesThisFile(i.filename)
|
||||
)
|
||||
if (match) {
|
||||
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
||||
@ -200,7 +213,7 @@ async function save() {
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
let itemId = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
(i) => matchesThisFile(i.filename)
|
||||
)?.id
|
||||
|
||||
// Add if not in catalog
|
||||
|
||||
@ -76,6 +76,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- INTEGRATION POINT (other developer, in progress): the web flasher
|
||||
for all three firmwares (MeshCore / Meshtastic / RNode) belongs
|
||||
HERE as a third action on this screen — e.g. "Flash different
|
||||
firmware" — driven by the probe result above. Per the operator:
|
||||
the flasher must live in this detection UI. -->
|
||||
<div class="flex gap-2 mt-5">
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
|
||||
@ -364,6 +364,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
e.preventDefault()
|
||||
|
||||
if (isContainer(activeEl)) {
|
||||
// Container declares its own click as THE action (e.g. a media file
|
||||
// card whose click plays it) — without this the a[href] fallback
|
||||
// below hits the card's download link and gamepad-select downloads
|
||||
// a song instead of playing it.
|
||||
if (activeEl.hasAttribute('data-controller-primary')) {
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
// Prioritised action: install button
|
||||
if (activeEl.hasAttribute('data-controller-install')) {
|
||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||
|
||||
@ -34,6 +34,8 @@ export interface MeshStatus {
|
||||
pid?: string | null
|
||||
product?: string | null
|
||||
manufacturer?: string | null
|
||||
/** Epoch seconds the /dev node appeared — changes on every replug. */
|
||||
plugged_at?: number | null
|
||||
}>
|
||||
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
|
||||
manage_radio?: boolean
|
||||
@ -288,10 +290,20 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
// Dismissals are cleared the moment the port disappears (unplug), so the
|
||||
// modal re-appears on EVERY plug-in — including swapping a different stick
|
||||
// into the same /dev path — per the hot-swap UX (2026-07-22).
|
||||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
|
||||
const dismissedDetectedPaths = ref<Set<string>>(new Set(
|
||||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
|
||||
))
|
||||
// v2: dismissals key on (path → plugged_at). udev recreates the /dev node
|
||||
// on every plug, so plugged_at changes on each replug — an old "Not Now"
|
||||
// can never suppress a NEWLY plugged stick, even when the swap happens
|
||||
// faster than a status poll (which absence-pruning alone missed) or when
|
||||
// the same /dev path is reused. v1 (a bare path set) is intentionally
|
||||
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
|
||||
// suppress anything either.
|
||||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
|
||||
const dismissedDetected = ref<Record<string, number>>(
|
||||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
|
||||
)
|
||||
function pluggedAt(s: MeshStatus, path: string): number {
|
||||
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
|
||||
}
|
||||
// Consecutive polls each candidate port has been present-but-not-connected.
|
||||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||||
@ -300,15 +312,15 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
const s = status.value
|
||||
if (!s) return []
|
||||
return (s.detected_devices || []).filter(p =>
|
||||
!dismissedDetectedPaths.value.has(p) &&
|
||||
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
||||
// The port the live session occupies is not a candidate…
|
||||
!(s.device_connected && s.device_path === p) &&
|
||||
// …and a port only qualifies once it has survived the blip debounce.
|
||||
(detectSightings.value[p] ?? 0) >= 2
|
||||
)
|
||||
})
|
||||
/** Called after every status fetch: advance sighting counters and clear
|
||||
* dismissals/counters for ports that vanished (unplug → replug reshows). */
|
||||
/** Called after every status fetch: advance sighting counters and drop
|
||||
* dismissals for ports that vanished (belt-and-braces with plugged_at). */
|
||||
function trackDetectedDevices(s: MeshStatus) {
|
||||
const present = new Set(s.detected_devices || [])
|
||||
const next: Record<string, number> = {}
|
||||
@ -319,21 +331,24 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
detectSightings.value = next
|
||||
let dirty = false
|
||||
for (const p of [...dismissedDetectedPaths.value]) {
|
||||
for (const p of Object.keys(dismissedDetected.value)) {
|
||||
if (!present.has(p)) {
|
||||
dismissedDetectedPaths.value.delete(p)
|
||||
delete dismissedDetected.value[p]
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
||||
dismissedDetected.value = { ...dismissedDetected.value }
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||
}
|
||||
}
|
||||
function dismissDetectedDevice(path: string) {
|
||||
dismissedDetectedPaths.value.add(path)
|
||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
||||
const s = status.value
|
||||
dismissedDetected.value = {
|
||||
...dismissedDetected.value,
|
||||
[path]: s ? pluggedAt(s, path) : 0,
|
||||
}
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||
}
|
||||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||||
|
||||
@ -2,6 +2,22 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* The app is dark-only — NEVER let the OS/browser theme leak into native
|
||||
controls. Without this, select popups / scrollbars / date pickers follow
|
||||
the user's OS setting (a light-mode ThinkPad rendered white dropdown
|
||||
lists inside the dark UI, 2026-07-22). color-scheme pins Chromium's
|
||||
native select popup dark; the explicit select/option rules cover
|
||||
Firefox and the closed control itself. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
select,
|
||||
select option,
|
||||
select optgroup {
|
||||
background-color: #16181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Montserrat - header font (used in neode present) */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
|
||||
@ -103,7 +103,7 @@ import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||
import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
resolveAppUrl, resolveAppTitle,
|
||||
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
@ -142,11 +142,6 @@ let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Display mode -- persisted in localStorage
|
||||
const displayMode = ref<DisplayMode>(
|
||||
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
||||
)
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
@ -156,6 +151,9 @@ const appId = computed(() => {
|
||||
return id
|
||||
})
|
||||
|
||||
// Display mode -- per-app user choice → per-app default → last global → panel
|
||||
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||
const appIcon = computed(() =>
|
||||
@ -231,6 +229,8 @@ function setMode(mode: DisplayMode) {
|
||||
}
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
// Remember the explicit pick for this app so it wins over the app default.
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||||
|
||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||
// the app keeps floating above the dashboard instead of owning the route.
|
||||
@ -367,6 +367,7 @@ function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||
displayMode.value = 'overlay'
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -148,6 +148,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Paid Files tab — everything this node has purchased ═════════════
|
||||
Source of truth is the purchase cache (content.owned-list): filename,
|
||||
type, price paid and when — the file itself was also auto-filed into
|
||||
Photos/Music/Documents at purchase time (2026-07-22). -->
|
||||
<div v-else-if="activeTab === 'paid'">
|
||||
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases…</div>
|
||||
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
Nothing purchased yet — files you buy from peers appear here and are saved into your folders automatically.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="it in paidItems"
|
||||
:key="it.onion + it.content_id"
|
||||
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
@click="viewPaidItem(it)"
|
||||
>
|
||||
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
|
||||
<p class="text-[11px] text-white/40">
|
||||
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
|
||||
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
|
||||
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||
<div v-else-if="activeTab === 'peers'">
|
||||
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
@ -374,13 +404,14 @@ const sectionCounts = ref<Record<string, number>>({})
|
||||
const countsLoading = ref(false)
|
||||
|
||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||
type TabId = 'folders' | 'mine' | 'peers'
|
||||
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
||||
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
||||
|
||||
const TABS: Array<{ id: TabId; name: string }> = [
|
||||
{ id: 'folders', name: 'Folders' },
|
||||
{ id: 'mine', name: 'My Files' },
|
||||
{ id: 'peers', name: 'Peer Files' },
|
||||
{ id: 'paid', name: 'Paid Files' },
|
||||
]
|
||||
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||
{ id: 'all', name: 'All' },
|
||||
@ -390,6 +421,37 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||
]
|
||||
|
||||
const activeTab = ref<TabId>('folders')
|
||||
|
||||
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
||||
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
||||
const paidItems = ref<PaidItem[]>([])
|
||||
const paidLoading = ref(false)
|
||||
async function loadPaidItems() {
|
||||
paidLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
|
||||
paidItems.value = (res.items || []).slice().reverse()
|
||||
} catch { paidItems.value = [] } finally { paidLoading.value = false }
|
||||
}
|
||||
async function viewPaidItem(it: PaidItem) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) return
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: res.mime_type || it.mime_type }))
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
||||
}
|
||||
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
||||
|
||||
const selectedCategory = ref<CategoryId>('all')
|
||||
const searchQuery = ref('')
|
||||
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||
@ -576,6 +638,15 @@ async function handlePlay(path: string, name: string) {
|
||||
}
|
||||
|
||||
function handlePreview(path: string, context: FileBrowserItem[]) {
|
||||
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||
const clicked = context.find(item => item.path === path)
|
||||
if (clicked) {
|
||||
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||
void handlePlay(path, clicked.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MediaLightbox filters to media internally; index within that filtered list.
|
||||
const mediaItems = context.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
|
||||
@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
function handlePreview(path: string) {
|
||||
const items = cloudStore.sortedItems
|
||||
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||
const clicked = items.find(item => item.path === path)
|
||||
if (clicked) {
|
||||
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||
void handlePlay(path, clicked.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MediaLightbox internally filters items to media only, so startIndex
|
||||
// must be the index within that filtered list
|
||||
const items = cloudStore.sortedItems
|
||||
const mediaItems = items.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
const cat = getFileCategory(ext, item.isDir)
|
||||
|
||||
@ -350,7 +350,7 @@ function updateKeyboardInset() {
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleDocClickForMenu)
|
||||
document.addEventListener('pointerdown', handleDocClickForMenu)
|
||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
||||
@ -403,7 +403,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleDocClickForMenu)
|
||||
document.removeEventListener('pointerdown', handleDocClickForMenu)
|
||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
||||
@ -1337,8 +1337,8 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
|
||||
const target = ev.target as HTMLElement
|
||||
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick))
|
||||
onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick))
|
||||
const attachError = ref<string | null>(null)
|
||||
const fetchingCids = ref<Set<string>>(new Set())
|
||||
const fetchedUrls = ref<Map<string, string>>(new Map())
|
||||
|
||||
@ -674,6 +674,13 @@ async function viewOwned(item: CatalogItem) {
|
||||
})
|
||||
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||
const mime = res.mime_type || item.mime_type
|
||||
// Audio always plays in the global bottom-bar player — never the lightbox
|
||||
// (the blob URL is intentionally not revoked while the bar plays it).
|
||||
if (mime.startsWith('audio/')) {
|
||||
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
|
||||
return
|
||||
}
|
||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
viewerMime.value = mime
|
||||
|
||||
@ -7,6 +7,23 @@ export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
/** Per-app default display mode. Used when the user hasn't explicitly picked
|
||||
* a mode for that app (an explicit pick is remembered per app and wins).
|
||||
* Apps not listed fall back to the last globally-used mode, then 'panel'. */
|
||||
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
|
||||
'indeedhub': 'fullscreen',
|
||||
}
|
||||
|
||||
/** Initial display mode for an app session: per-app user choice → per-app
|
||||
* default → last global choice → panel. */
|
||||
export function initialDisplayMode(id: string): DisplayMode {
|
||||
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
|
||||
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
|
||||
const appDefault = APP_DEFAULT_DISPLAY_MODE[id]
|
||||
if (appDefault) return appDefault
|
||||
return (localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
||||
}
|
||||
|
||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||
export const APP_PORTS: Record<string, number> = {
|
||||
...GENERATED_APP_PORTS,
|
||||
|
||||
@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean {
|
||||
// are explicit because icon extensions vary (.png / .webp / .svg).
|
||||
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
gitea: '/assets/img/app-icons/gitea.svg',
|
||||
// Apps whose icon extension isn't .png: without an explicit entry the
|
||||
// default `<id>.png` guess 404s on every render (console spam, and for
|
||||
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
|
||||
pine: '/assets/img/app-icons/pine.svg',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
'mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
||||
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
||||
// immich stack
|
||||
|
||||
@ -362,6 +362,33 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.112-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
|
||||
<span class="text-xs text-white/40">July 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.</p>
|
||||
<p>Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.</p>
|
||||
<p>Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.</p>
|
||||
<p>The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.</p>
|
||||
<p>Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.</p>
|
||||
<p>Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.</p>
|
||||
<p>Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.</p>
|
||||
<p>Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.</p>
|
||||
<p>The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.</p>
|
||||
<p>Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.</p>
|
||||
<p>The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.</p>
|
||||
<p>Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.</p>
|
||||
<p>Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.</p>
|
||||
<p>On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.</p>
|
||||
<p>Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."</p>
|
||||
<p>Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.</p>
|
||||
<p>Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.</p>
|
||||
<p>Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.111-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@ -379,19 +379,27 @@ async function loadPeers() {
|
||||
|
||||
peers.value = peerList
|
||||
observers.value = observerList
|
||||
for (const p of [...peers.value, ...observers.value]) {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachableLocal.value[p.onion] = check.reachable
|
||||
} catch {
|
||||
peerReachableLocal.value[p.onion] = false
|
||||
}
|
||||
}
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
// The list is ready — render it and re-enable Refresh NOW. The
|
||||
// reachability dots fill in as probes resolve. Before 2026-07-22 the
|
||||
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat
|
||||
// disabled for N-offline-peers × 30s ("takes ages").
|
||||
loadingPeers.value = false
|
||||
void Promise.allSettled(
|
||||
[...peers.value, ...observers.value].map(async (p) => {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachableLocal.value[p.onion] = check.reachable
|
||||
} catch {
|
||||
peerReachableLocal.value[p.onion] = false
|
||||
}
|
||||
}),
|
||||
).then(() => {
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
|
||||
|
||||
@ -73,7 +73,9 @@
|
||||
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
|
||||
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
<!-- min 14rem, otherwise scale with the viewport so a long discovery
|
||||
list uses the screen instead of cramming into a fixed 224px box -->
|
||||
<div v-else class="space-y-2 max-h-[max(14rem,45vh)] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="node in discoveredNodes"
|
||||
:key="node.nostr_pubkey"
|
||||
|
||||
@ -235,7 +235,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-else-if="getItemPrice(pItem.access) > 0"
|
||||
@click="purchaseAndDownload(pItem)"
|
||||
@click="requestPurchase(pItem)"
|
||||
:disabled="purchasingId === pItem.id"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
|
||||
>
|
||||
@ -305,6 +305,41 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Purchase confirmation — payment NEVER starts before the user confirms -->
|
||||
<Teleport to="body">
|
||||
<div v-if="pendingPurchase" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="pendingPurchase = null">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true">
|
||||
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
|
||||
<p class="text-sm text-white/70 truncate mb-4">{{ pendingPurchase.filename.split('/').pop() }}</p>
|
||||
<div class="p-3 bg-white/5 rounded-lg space-y-1.5 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Ecash balance</span>
|
||||
<span class="text-sm font-medium text-white/80">{{ pendingBalance === null ? '…' : pendingBalance.toLocaleString() + ' sats' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Price</span>
|
||||
<span class="text-sm font-medium text-white/80">−{{ getItemPrice(pendingPurchase.access).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Balance after</span>
|
||||
<span class="text-sm font-medium" :class="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access) ? 'text-red-400' : 'text-white/80'">
|
||||
{{ pendingBalance === null ? '…' : (pendingBalance - getItemPrice(pendingPurchase.access)).toLocaleString() + ' sats' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)" class="text-xs text-red-400 mb-3">Not enough ecash — fund your wallet, or buy from the Peers page to pay another way.</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="pendingPurchase = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="confirmPendingPurchase"
|
||||
:disabled="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>Pay from node ecash</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Add Content Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
|
||||
@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) {
|
||||
safeClipboardWrite(url)
|
||||
}
|
||||
|
||||
// Purchase is strictly two-step: the Buy click only opens the confirmation
|
||||
// (with the balance impact); nothing is paid until the user confirms there.
|
||||
const pendingPurchase = ref<PeerContentItem | null>(null)
|
||||
const pendingBalance = ref<number | null>(null)
|
||||
|
||||
function requestPurchase(item: PeerContentItem) {
|
||||
if (purchasingId.value) return
|
||||
pendingPurchase.value = item
|
||||
pendingBalance.value = null
|
||||
rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
||||
.then((r) => { pendingBalance.value = r?.balance_sats ?? 0 })
|
||||
.catch(() => { pendingBalance.value = null })
|
||||
}
|
||||
|
||||
async function confirmPendingPurchase() {
|
||||
const item = pendingPurchase.value
|
||||
pendingPurchase.value = null
|
||||
if (item) await purchaseAndDownload(item)
|
||||
}
|
||||
|
||||
async function purchaseAndDownload(item: PeerContentItem) {
|
||||
if (!browsePeerOnion.value || purchasingId.value) return
|
||||
const price = getItemPrice(item.access)
|
||||
@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) {
|
||||
|
||||
purchasingId.value = item.id
|
||||
try {
|
||||
// Check balance first
|
||||
try {
|
||||
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
||||
const balance = balRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
||||
|
||||
7
neode-ui/vite.preview.config.mts
Normal file
7
neode-ui/vite.preview.config.mts
Normal file
@ -0,0 +1,7 @@
|
||||
import baseConfig from './vite.config'
|
||||
const cfg = { ...(baseConfig as Record<string, unknown>) } as any
|
||||
cfg.server = { ...(cfg.server || {}), port: 8100, strictPort: true }
|
||||
const proxy = { ...(cfg.server.proxy || {}) }
|
||||
for (const k of Object.keys(proxy)) proxy[k] = { ...proxy[k], target: 'http://localhost:5959' }
|
||||
cfg.server.proxy = proxy
|
||||
export default cfg
|
||||
@ -1256,6 +1256,10 @@
|
||||
{
|
||||
"key": "FM_API_URL",
|
||||
"template": "ws://{{HOST_MDNS}}:8174"
|
||||
},
|
||||
{
|
||||
"key": "FM_BITCOIND_URL",
|
||||
"template": "http://{{BITCOIN_HOST}}:8332"
|
||||
}
|
||||
],
|
||||
"entrypoint": [
|
||||
@ -1284,7 +1288,6 @@
|
||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||
"environment": [
|
||||
"FM_DATA_DIR=/data",
|
||||
"FM_BITCOIND_URL=http://bitcoin-knots:8332",
|
||||
"FM_BITCOIND_USERNAME=archipelago",
|
||||
"FM_BITCOIN_NETWORK=bitcoin",
|
||||
"FM_BIND_P2P=0.0.0.0:8173",
|
||||
@ -1443,9 +1446,15 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi"
|
||||
"if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi"
|
||||
],
|
||||
"data_uid": "1000:1000",
|
||||
"derived_env": [
|
||||
{
|
||||
"key": "FM_BITCOIND_URL",
|
||||
"template": "http://{{BITCOIN_HOST}}:8332"
|
||||
}
|
||||
],
|
||||
"entrypoint": [
|
||||
"sh",
|
||||
"-lc"
|
||||
@ -4157,7 +4166,9 @@
|
||||
"--model",
|
||||
"base-int8",
|
||||
"--language",
|
||||
"en"
|
||||
"en",
|
||||
"--beam-size",
|
||||
"1"
|
||||
],
|
||||
"image": "docker.io/rhasspy/wyoming-whisper:3.4.1",
|
||||
"network": "archy-net",
|
||||
@ -4213,7 +4224,7 @@
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"version": "3.4.1",
|
||||
"version": "3.4.2",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
@ -4226,7 +4237,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "3.4.1"
|
||||
"version": "3.4.2"
|
||||
},
|
||||
"portainer": {
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
@ -4729,7 +4740,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "5ca0f41589ba2a905e68d79388ea819ef983689f5c88a718723280d81782d534b8a1c9b0452f062b59dae3261224b98f2f136be564176a20729c55a748025100",
|
||||
"signature": "4054b2a8659b75a3810ed153798c0e8a08db49ce6237b8ae985e87ace8a35163c24a0190074abd2a8296309ebe9dd583f153642d46d4d7f0a6a4db255743380b",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"updated": "2026-07-22"
|
||||
"updated": "2026-07-23"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user