Compare commits

..
Author SHA1 Message Date
Archipelago a2096a6148 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
68 changed files with 433 additions and 2344 deletions
@@ -741,16 +741,7 @@ private fun buildAutoLoginScript(password: String): String {
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw); setter.call(el, pw);
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true }));
// Let Vue re-render before submitting: a synchronous Enter arrives el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// while the login button is still disabled, and the web UI's
// controller-nav "Enter in input clicks the next enabled button"
// pattern then hits Replay Intro instead — restarting the intro
// cinematic on every connect (two frames = value flush + render).
requestAnimationFrame(function () {
requestAnimationFrame(function () {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
});
}, 1500); }, 1500);
})(); })();
""".trimIndent() """.trimIndent()
-12
View File
@@ -1,17 +1,5 @@
# Changelog # Changelog
## v1.7.102-alpha (2026-07-17)
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.
- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.
- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.
- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.
- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.
- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.
- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.
- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.
## v1.7.101-alpha (2026-07-15) ## v1.7.101-alpha (2026-07-15)
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip. - The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.102-alpha" version = "1.7.101-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.102-alpha" version = "1.7.101-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]
+1 -5
View File
@@ -207,11 +207,7 @@ impl ApiHandler {
hyper::Body::from(r#"{"error":"invalid backup id"}"#), hyper::Body::from(r#"{"error":"invalid backup id"}"#),
)); ));
} }
let file = self let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
.config
.data_dir
.join("backups")
.join(format!("{id}.bak"));
match tokio::fs::read(&file).await { match tokio::fs::read(&file).await {
Ok(bytes) => Ok(Response::builder() Ok(bytes) => Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
-9
View File
@@ -147,15 +147,6 @@ impl RpcHandler {
self.auth_manager.setup_user(password).await?; self.auth_manager.setup_user(password).await?;
tracing::info!("[onboarding] user setup complete"); tracing::info!("[onboarding] user setup complete");
// The install-time password must also become the OS login for the
// archipelago user — otherwise the console/SSH keeps the image default
// ("archipelago") after the user has picked a real password (#97).
// Best-effort: a failure here must not break onboarding.
match crate::auth::change_ssh_password(password).await {
Ok(()) => tracing::info!("[onboarding] system login password synced"),
Err(e) => tracing::warn!("[onboarding] system login password sync failed: {e}"),
}
// Persist the pending onboarding seed as the encrypted backup now that // Persist the pending onboarding seed as the encrypted backup now that
// a passphrase (the login password) finally exists — otherwise "Reveal // a passphrase (the login password) finally exists — otherwise "Reveal
// recovery phrase" has nothing to decrypt on this node, ever. // recovery phrase" has nothing to decrypt on this node, ever.
+18 -39
View File
@@ -95,54 +95,33 @@ impl RpcHandler {
.get("addr") .get("addr")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?; .ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
// send_all sweeps the entire confirmed on-chain balance (LND computes let amount = params
// the amount after fees); amount is required otherwise. .get("amount")
let send_all = params .and_then(|v| v.as_i64())
.get("send_all") .ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
.and_then(|v| v.as_bool())
.unwrap_or(false); if amount < 546 {
let amount = if send_all { return Err(anyhow::anyhow!(
None "Amount must be at least 546 sats (dust limit)"
} else { ));
let amount = params }
.get("amount") if amount > 21_000_000 * 100_000_000 {
.and_then(|v| v.as_i64()) return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?; }
if amount < 546 {
return Err(anyhow::anyhow!(
"Amount must be at least 546 sats (dust limit)"
));
}
if amount > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
}
Some(amount)
};
// Validate Bitcoin address format (basic: length and allowed chars) // Validate Bitcoin address format (basic: length and allowed chars)
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) { if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format")); return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
} }
info!( info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
addr = addr,
amount = amount,
send_all = send_all,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = match amount { let send_body = serde_json::json!({
Some(amount) => serde_json::json!({ "addr": addr,
"addr": addr, "amount": amount.to_string(),
"amount": amount.to_string(), });
}),
None => serde_json::json!({
"addr": addr,
"send_all": true,
}),
};
let resp = client let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions")) .post(format!("{LND_REST_BASE_URL}/v1/transactions"))
@@ -394,9 +394,9 @@ where
// under any name variant AND no install in flight — waiting cannot // under any name variant AND no install in flight — waiting cannot
// satisfy it. // satisfy it.
let some_dep_not_installed = missing.iter().any(|dep| { let some_dep_not_installed = missing.iter().any(|dep| {
!dep.containers !dep.containers.iter().any(|c| {
.iter() existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)
.any(|c| existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)) })
}); });
if some_dep_not_installed { if some_dep_not_installed {
let msg = match check_install_deps(package_id, &running) { let msg = match check_install_deps(package_id, &running) {
@@ -1140,7 +1140,8 @@ impl RpcHandler {
std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed,
); );
if let Some((downloaded, total)) = parse_pull_progress(&line) { if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await; Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
.await;
} }
} }
}); });
@@ -700,7 +700,9 @@ async fn install_stack_via_orchestrator(
// Truthful end-of-install signal, mirroring the legacy stack installers: // Truthful end-of-install signal, mirroring the legacy stack installers:
// the real readiness gate is the scanner's next sweep, this just settles // the real readiness gate is the scanner's next sweep, this just settles
// the bar at 95→100→done instead of leaving it mid-band. // the bar at 95→100→done instead of leaving it mid-band.
handler.set_install_progress(stack_name, total, total).await; handler
.set_install_progress(stack_name, total, total)
.await;
handler handler
.set_install_phase(stack_name, InstallPhase::PostInstall) .set_install_phase(stack_name, InstallPhase::PostInstall)
.await; .await;
@@ -402,16 +402,6 @@ async fn sync_hostname_side_effects(hostname: &str) {
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e), Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
} }
// The kiosk Chromium's profile lock is a symlink encoding <hostname>-<pid>;
// after a rename the stale lock reads as "another computer" holding the
// profile, Chromium refuses to start (--noerrdialogs hides the dialog), and
// the kiosk black-screens on the next boot (#98). Clear it here — Chromium
// recreates the files on launch, and the kiosk launcher pkills any running
// instance before starting a new one.
for f in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
let _ = tokio::fs::remove_file(format!("/var/lib/archipelago/chromium-kiosk/{f}")).await;
}
let republished = tokio::process::Command::new("/usr/bin/sudo") let republished = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/avahi-set-host-name", hostname]) .args(["-n", "/usr/bin/avahi-set-host-name", hostname])
.output() .output()
+1 -1
View File
@@ -360,7 +360,7 @@ fn validate_password_strength(password: &str) -> Result<()> {
/// Change the archipelago user's SSH/login password. /// Change the archipelago user's SSH/login password.
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors). /// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH. /// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> { async fn change_ssh_password(new_password: &str) -> Result<()> {
let ssh_user = let ssh_user =
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string()); std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
+2 -6
View File
@@ -763,9 +763,7 @@ mod tests {
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap(); let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap(); std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
restore_full_backup(dir.path(), &meta.id, "pass") restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
.await
.unwrap();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap(); let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "s3cret"); assert_eq!(pw, "s3cret");
@@ -786,9 +784,7 @@ mod tests {
std::fs::create_dir_all(dir.path().join("secrets")).unwrap(); std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap(); std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
restore_full_backup(dir.path(), &meta.id, "pass") restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
.await
.unwrap();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap(); let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "keep-me"); assert_eq!(pw, "keep-me");
@@ -301,33 +301,6 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
} }
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
/// write-probes are `podman exec`s into EVERY running container; running them
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
/// tick forever — a permanent conmon "Failed to create container" storm on
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
/// steady-state one: sweep each container on the first pass after it appears,
/// then at most once per hour.
fn ownership_sweep_due(name: &str) -> bool {
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
static LAST: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
> = std::sync::OnceLock::new();
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let Ok(mut map) = map.lock() else {
return true;
};
let now = std::time::Instant::now();
match map.get(name) {
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
_ => {
map.insert(name.to_string(), now);
true
}
}
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING /// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container. /// container.
/// ///
@@ -1766,11 +1739,6 @@ impl ProdContainerOrchestrator {
if crate::app_ops::lifecycle_op_in_flight(&c.name) { if crate::app_ops::lifecycle_op_in_flight(&c.name) {
continue; continue;
} }
// Throttled: first pass after the container appears, then
// hourly — not on every 30s tick (see ownership_sweep_due).
if !ownership_sweep_due(&c.name) {
continue;
}
if ensure_running_container_ownership(&c.name).await { if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover"); tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
-82
View File
@@ -1,82 +0,0 @@
# Add an existing Nostr identity to the node — UX & implementation plan
**Status:** plan only (2026-07-16), no code. Companion research: `docs/nostr-signer-login-research.md`.
## Where it lives
The **Nostr Identities** screen (`Web5Identities.vue`, backed by `identity.list` /
`identity.create`). Today every identity is **seed-derived** (`identity_manager.rs`
derives ed25519 + nostr keys from the BIP-39 master seed at an index). "Add existing"
introduces a second class of identity: one whose key material comes from *outside* the
seed.
## Two import kinds (both needed, different guarantees)
1. **Full import (nsec)** — the node holds the secret key. The identity behaves exactly
like a seed-derived one (can sign in embedded apps, publish, encrypt). NOT covered by
seed backup — flag it visibly and include it in the encrypted node backup.
2. **Linked signer (npub only)** — the node stores just the public key; signing is
delegated to the user's own signer (browser extension NIP-07, or a NIP-46 remote
signer later). Zero key custody; some features (background publishing) unavailable —
the UI should badge what works.
## The UX (matching the house style)
**Entry point:** next to "Create identity" on Nostr Identities, an **"Add existing"**
glass-button. Opens a modal with three tabs (same tab pattern as the send/receive
modals):
1. **Browser extension** (default when `window.nostr` exists)
- One button: "Connect with extension". Flow: `getPublicKey()` → show the npub +
resolved profile (kind-0 fetched via the node's relays: avatar, name — instant
recognition) → "Add this identity".
- Creates a **linked signer** identity. A challenge signature
(`signEvent` on a throwaway event) proves key possession before adding — never add
an unverified npub as "yours".
2. **Secret key (nsec)**
- Paste field (masked, `nsec1…` or hex), inline validation + derived npub preview
with the same kind-0 profile card before confirming.
- Scary-clear copy: "Your key will be stored on this node, encrypted at rest. It is
NOT part of your seed backup — back it up separately." Confirm step requires the
profile card to load or an explicit "add anyway".
- Creates a **full** identity.
3. **Public key (npub)** — watch-only
- Paste an npub for a linked identity without any signer attached yet (useful to
reserve the profile, upgrade to extension/NIP-46 signing later).
**After adding:** the identity appears in the same grid with a small origin badge —
`seed` / `imported` / `linked` — and the imported profile picture/name pulled from
relays. Everything else (picker in apps, rename, avatar) behaves uniformly.
**Removal:** existing delete flow; for `imported` identities the confirm dialog warns
the key is destroyed unless exported first (offer "Export nsec" in the identity's detail
sheet, gated behind password re-entry).
## Backend work
- `identity_manager.rs`: identity records gain `origin: Seed { index } | Imported |
Linked`, optional `nostr_secret_hex` absent for Linked. Storage: reuse the existing
encrypted identity file; imported secrets included in node backup.
- New RPCs:
- `identity.import-nostr` `{ nsec | npub, name?, verify_sig? }` → validates, derives
npub, rejects duplicates (same pubkey as any existing identity), returns the new
identity.
- `identity.fetch-profile` `{ pubkey }` → kind-0 lookup via `nostr_relays.rs` for the
preview card (frontend could also do this, but the node already has relay plumbing
and avoids CORS).
- `identity.nostr-sign` (used by the iframe NIP-07 bridge): for `Linked` identities
return a typed error the bridge translates into "ask the user's extension instead" —
phase 2; phase 1 simply hides linked identities from the in-app signer picker.
## Demo mode
Mock `identity.import-nostr` + `identity.fetch-profile` in mock-backend.js (canned
profile: picture + name for any pasted npub) so the whole add-existing flow is
demoable without real relays.
## Phasing
1. **Phase 1 (small):** nsec + npub tabs, origin badges, backup inclusion, mock.
2. **Phase 2:** extension tab with possession-proof + kind-0 preview cards everywhere.
3. **Phase 3:** NIP-46 remote-signer identities + login integration (shares the QR
plumbing from the signer-login work).
-95
View File
@@ -1,95 +0,0 @@
# Sign in to the node with a Nostr signer — research & recommendation
**Status:** research only (2026-07-16), no code. Companion plan: `docs/nostr-identity-import-plan.md`.
## What's already in the tree (and what it isn't)
The IndeeHub "sign in with signer" work is the *inverse* of this feature: the node acts
as a NIP-07 **provider** for embedded iframe apps, signing with node-held keys
(`useNostrBridge.ts` postMessage bridge → `identity.nostr-sign` etc., picker UI in
`NostrIdentityPicker.vue`). It never verifies an external signer — but the UI patterns
(picker modal, QR rendering) and the backend crypto are reusable:
- **`nostr-sdk 0.44` is already a core dependency** (`nostr_handshake.rs` runs a real
relay client) — schnorr event verification and NIP-46 client support are essentially
free on the Rust side.
- Auth today is single-password + optional TOTP, and TOTP already uses a **two-step
login** (`auth.login``auth.login.totp`) — the exact slot where a parallel
`auth.login.nostr.*` path fits.
- The node can host its own relay (strfry app), and the frontend already bundles `qrcode`.
## Candidate flows, ranked by friction
### A. Browser extension (NIP-07) — lowest friction on desktop (2 clicks)
Login page shows "Sign in with extension" when `window.nostr` exists. Server issues a
random challenge → extension signs a **kind 22242** auth event carrying the challenge →
server verifies signature + challenge + `created_at` freshness + that the pubkey is
enrolled → normal session cookie. ~50 lines of frontend, ~80 lines of Rust. No relay
involved at all.
### B. QR scan with a mobile signer (NIP-46 `nostrconnect://`) — the headline UX (scan + 1 tap)
1. Backend generates an ephemeral client keypair and renders a
`nostrconnect://<pubkey>?relay=<url>&secret=<rand>&perms=sign_event:22242&name=Archipelago` QR.
2. User scans with **Amber** (Android reference signer; Aegis/Nowser also scan;
nsec.app is paste-based; Alby is *not* a NIP-46 signer).
3. Phone connects to the relay, acks the secret; backend requests one
`sign_event:22242` over the encrypted NIP-46 channel, verifies, issues the session.
**Key architectural choice:** make the **Rust backend the NIP-46 client** (rust-nostr's
`nostr-connect` crate), talking to the relay over localhost — the browser only polls our
own RPC for "signer connected". No websocket/mixed-content issues in the Vue app.
**Relay topology:** no public relay is required by the spec — and public relays often
rate-limit ephemeral NIP-46 traffic. The node's own strfry is the ideal relay (private,
LAN-fast); the QR should carry a relay URL derived from the Host the browser used
(LAN IP / Tailscale IP — not `.local`, which Android often can't resolve).
**One empirical blocker to test first: does Amber accept plain `ws://` LAN relays?**
(Self-signed `wss://` will likely fail cert validation.) If not, route `wss://` through
the existing nginx/HTTPS cert story.
### C. Remembered NIP-46 session (persisted bunker pointer) — zero-tap repeat logins
Same as B but persists the pairing so future logins auto-approve. Adds state,
revocation surface, and "bunker offline = silent hang" failure modes. **Defer** — B
re-scans in ~5 seconds anyway.
## Recommendation
Ship **A + B behind one "Sign in with Nostr" button**; skip C for now. Password (+TOTP)
stays the permanent fallback — exactly as the user proposed, the signer is enrolled in a
step *after* password creation, never instead of it. The verification core is one shared
Rust function (sig + challenge + freshness + enrolled-pubkey → session).
- **Onboarding:** after the password (and seed) steps, an optional "Connect a signer"
card: QR (nostrconnect) + "Use browser extension" + Skip. Success enrolls the npub as
a login key.
- **Settings (next to TOTP):** list enrolled npubs (added date + method), "Add npub"
(paste, becomes usable after a challenge-verify), "Connect another signer" (same
QR/extension modal), "Remove" (requires password confirm; removing the last npub never
locks the account — password always works).
- **Libraries:** hand-roll the 22242 event for NIP-07 (window.nostr is a browser global);
rust-nostr `nostr-connect` for NIP-46. Avoid the 2.4 MB `nostr-login` JS bundle —
wrong fit for a self-hosted box (defaults to public bunkers); it's UX prior art only.
## Security notes
- Only pubkeys enrolled **while authenticated** (or during onboarding) may log in —
a simple `login_npubs` list next to the TOTP data in `auth.rs`.
- Challenge: 32-byte random, single-use, 25 min TTL, `created_at` ±60 s, deleted on
first verify attempt; pin an origin/host tag. Rate-limit like password attempts.
- The `secret` in the nostrconnect URI is a bearer token — one QR per attempt, expires
with the challenge.
- Policy call: signer approval should count as the second factor for TOTP accounts
(possession of phone/extension key), so nostr login doesn't silently bypass TOTP.
## Open questions
1. Amber + `ws://` LAN relay — needs a 10-minute on-device test before committing.
2. Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host.
3. NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles
both; verify against current Amber.
4. Track draft **NIP-97 "Login with Nostr"** (matches this UX exactly, unmerged) —
align, don't depend.
**Prior art:** no mainstream self-hosted node OS (Umbrel, Start9, Alby Hub) ships Nostr
QR login for its own UI — this would be genuinely differentiating, and every building
block is already in the tree.
@@ -254,17 +254,8 @@ container_pull() {
echo "📦 Step 1: Building root filesystem..." echo "📦 Step 1: Building root filesystem..."
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar" ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
# The cached rootfs must be invalidated when its recipe changes: a stale if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
# were added to the Dockerfile below — the cache condition never looked at
# the recipe. Hash the rootfs-defining region of this script; any edit to it
# forces a rebuild. `--rebuild` still forces one unconditionally.
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
echo " Using Docker to create Debian root filesystem..." echo " Using Docker to create Debian root filesystem..."
# Create a Dockerfile for building the rootfs # Create a Dockerfile for building the rootfs
@@ -703,7 +694,6 @@ SYSTEMDSERVICE
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR" $CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
$CONTAINER_CMD rm archipelago-rootfs-tmp $CONTAINER_CMD rm archipelago-rootfs-tmp
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)" echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
else else
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)" echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
@@ -1228,23 +1218,6 @@ else
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing" echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
fi fi
# A missing nvpn/nostr-rs-relay used to be a warning, and the resulting ISO
# shipped units that crash-looped (or silently lacked VPN signaling) on every
# install. Refuse to produce that ISO unless explicitly overridden.
MISSING_VPN_BINARIES=""
[ -f "$ARCH_DIR/bin/nvpn" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nvpn"
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
if [ -n "$MISSING_VPN_BINARIES" ]; then
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
else
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
exit 1
fi
fi
# Copy WireGuard helper script # Copy WireGuard helper script
if [ -f "$WORK_DIR/archipelago-wg" ]; then if [ -f "$WORK_DIR/archipelago-wg" ]; then
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg" cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
@@ -1380,11 +1353,9 @@ if [ "$UNBUNDLED" = "1" ]; then
# unbundled mode — their images must ride on the ISO so a fresh install # unbundled mode — their images must ride on the ISO so a fresh install
# works with no internet: FileBrowser (Cloud file manager) and fmcd # works with no internet: FileBrowser (Cloud file manager) and fmcd
# (fedimint-clientd, ecash/sats out of the box). # (fedimint-clientd, ecash/sats out of the box).
# Shipped zstd-compressed: podman load auto-detects compression, and an
# uncompressed fmcd.tar alone added ~220MB to the ISO (RC9 size regression).
CORE_BUNDLE=" CORE_BUNDLE="
${FILEBROWSER_IMAGE} filebrowser.tar.zst ${FILEBROWSER_IMAGE} filebrowser.tar
${FMCD_IMAGE} fmcd.tar.zst ${FMCD_IMAGE} fmcd.tar
" "
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
[ -n "$CORE_IMAGE" ] || continue [ -n "$CORE_IMAGE" ] || continue
@@ -1393,14 +1364,9 @@ ${FMCD_IMAGE} fmcd.tar.zst
else else
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..." echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
if container_pull "$CORE_IMAGE"; then if container_pull "$CORE_IMAGE"; then
RAW_TAR="$IMAGES_DIR/${CORE_FILE%.zst}" $CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
if $CONTAINER_CMD save "$CORE_IMAGE" -o "$RAW_TAR" 2>/dev/null && \ echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
zstd -q -T0 -15 --rm "$RAW_TAR" -o "$IMAGES_DIR/$CORE_FILE"; then
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))"
else
rm -f "$RAW_TAR" "$IMAGES_DIR/$CORE_FILE"
echo " ⚠️ Failed to save $CORE_IMAGE" echo " ⚠️ Failed to save $CORE_IMAGE"
fi
else else
echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline" echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline"
fi fi
@@ -1543,7 +1509,7 @@ done
PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman" PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman"
$PODMAN system migrate >> "$LOG_FILE" 2>&1 || true $PODMAN system migrate >> "$LOG_FILE" 2>&1 || true
for tarfile in "$IMAGES_DIR"/*.tar "$IMAGES_DIR"/*.tar.zst; do for tarfile in "$IMAGES_DIR"/*.tar; do
if [ -f "$tarfile" ]; then if [ -f "$tarfile" ]; then
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE" echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \ $PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
@@ -2554,7 +2520,7 @@ fi
if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
echo " Copying container images (this may take a moment)..." echo " Copying container images (this may take a moment)..."
mkdir -p /mnt/target/opt/archipelago/container-images mkdir -p /mnt/target/opt/archipelago/container-images
cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar* /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true
# Copy first-boot loader script and service # Copy first-boot loader script and service
mkdir -p /mnt/target/opt/archipelago/scripts mkdir -p /mnt/target/opt/archipelago/scripts
@@ -3372,11 +3338,6 @@ echo ""
echo "=== Done ===" echo "=== Done ==="
DIAGSCRIPT DIAGSCRIPT
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
# v1.7.104 shipped installs where this script was missing while its unit was
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
fi
# Systemd oneshot service for first-boot diagnostics # Systemd oneshot service for first-boot diagnostics
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC' cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
@@ -3384,8 +3345,6 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
Description=Archipelago First Boot Diagnostics Description=Archipelago First Boot Diagnostics
After=multi-user.target archipelago.service nginx.service After=multi-user.target archipelago.service nginx.service
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
[Service] [Service]
Type=oneshot Type=oneshot
@@ -106,12 +106,6 @@ fi
ARCHIPELAGO_UID=$(id -u archipelago) ARCHIPELAGO_UID=$(id -u archipelago)
while true; do while true; do
# A profile lock left by a previous boot encodes <hostname>-<pid>; after a
# hostname change (node rename) Chromium reads it as another computer
# holding the profile and refuses to start — with --noerrdialogs that is an
# invisible failure and the kiosk black-screens forever. Any Chromium that
# owned the lock is dead by now (pkill above / previous loop iteration).
rm -f /var/lib/archipelago/chromium-kiosk/Singleton{Lock,Cookie,Socket}
# XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio # XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native, # 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 # falls back to raw ALSA "default", fails to connect, and produces no audio
-3
View File
@@ -3,9 +3,6 @@ Description=Archipelago Private Nostr Relay
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
Before=nostr-vpn.service Before=nostr-vpn.service
# An ISO built without the relay binary (registry unreachable at build time)
# must not crash-loop every 3s forever — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nostr-rs-relay
[Service] [Service]
Type=simple Type=simple
-3
View File
@@ -4,9 +4,6 @@ After=network-online.target tor.service archipelago.service
Wants=network-online.target Wants=network-online.target
StartLimitIntervalSec=300 StartLimitIntervalSec=300
StartLimitBurst=10 StartLimitBurst=10
# An ISO built without the nvpn binary (registry unreachable at build time)
# must not restart-loop — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nvpn
[Service] [Service]
Type=simple Type=simple
+21 -4
View File
@@ -103,10 +103,27 @@ http {
proxy_request_buffering off; proxy_request_buffering off;
} }
# IndeeHub is no longer proxied same-origin — the sub_filter rewrite # IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
# approach broke the SPA's runtime-built asset URLs. The demo now opens # and rewrite its absolute asset paths (/assets, /, src, href) to the
# the real site (https://indee.tx1138.com/) externally instead, via # /app/indeedhub/ prefix so the SPA loads inside the iframe.
# DEMO_EXTERNAL_URLS in useDemoIntro.ts. location ^~ /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com;
proxy_set_header Accept-Encoding "";
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
sub_filter_types text/html text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/indeedhub/';
sub_filter 'src="/' 'src="/app/indeedhub/';
sub_filter "href='/" "href='/app/indeedhub/";
sub_filter "src='/" "src='/app/indeedhub/";
sub_filter 'from"/' 'from"/app/indeedhub/';
sub_filter 'url(/' 'url(/app/indeedhub/';
}
# Mempool is NOT proxied upstream anymore — the mock backend serves a # Mempool is NOT proxied upstream anymore — the mock backend serves a
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js), # branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
+17 -106
View File
@@ -2032,7 +2032,6 @@ app.post('/rpc/v1', (req, res) => {
about: 'Self-sovereign Bitcoin node', about: 'Self-sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local', nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com', lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
}, },
}, },
{ {
@@ -2045,7 +2044,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false, is_default: false,
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98', nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz', nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' }, profile: { display_name: 'Anon' },
}, },
{ {
id: 'id-merchant', id: 'id-merchant',
@@ -2057,7 +2056,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false, is_default: false,
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def', nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' }, profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
}, },
], ],
}, },
@@ -2080,7 +2079,6 @@ app.post('/rpc/v1', (req, res) => {
profile: { profile: {
display_name: 'Archipelago Node', display_name: 'Archipelago Node',
about: 'Self-sovereign Bitcoin node', about: 'Self-sovereign Bitcoin node',
picture: '/demo-avatars/satoshi.svg',
}, },
}, },
}) })
@@ -2123,7 +2121,6 @@ app.post('/rpc/v1', (req, res) => {
about: 'Running a sovereign Bitcoin node', about: 'Running a sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local', nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com', lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
}, },
}, },
{ {
@@ -2139,7 +2136,6 @@ app.post('/rpc/v1', (req, res) => {
profile: { profile: {
display_name: 'Archy Consulting', display_name: 'Archy Consulting',
about: 'Bitcoin infrastructure services', about: 'Bitcoin infrastructure services',
picture: '/demo-avatars/business.svg',
}, },
}, },
{ {
@@ -2150,7 +2146,7 @@ app.post('/rpc/v1', (req, res) => {
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs', did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
created_at: '2026-03-01T18:00:00Z', created_at: '2026-03-01T18:00:00Z',
is_default: false, is_default: false,
profile: { picture: '/demo-avatars/anon.svg' }, profile: {},
}, },
], ],
}, },
@@ -2167,7 +2163,7 @@ app.post('/rpc/v1', (req, res) => {
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee', nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
is_default: true, created_at: '2026-01-10T08:00:00Z', is_default: true, created_at: '2026-01-10T08:00:00Z',
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' }, profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
}, },
} }
return res.json({ result: identities[id] || identities['id-primary'] }) return res.json({ result: identities[id] || identities['id-primary'] })
@@ -2239,40 +2235,12 @@ app.post('/rpc/v1', (req, res) => {
bytes_out: 642_889_310, bytes_out: 642_889_310,
} }) } })
} }
// Fake tunnel provisioning so the companion remote-access onboarding
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
// are not real; importing this config yields a harmless dead tunnel.
case 'vpn.list-peers': {
return res.json({ result: { peers: mockState.vpnPeers || [] } })
}
case 'vpn.create-peer':
case 'vpn.peer-config': {
const peerName = params?.name || 'device'
if (!mockState.vpnPeers) mockState.vpnPeers = []
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
}
const config = [
'[Interface]',
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
'Address = 10.44.0.7/32',
'DNS = 1.1.1.1',
'',
'[Peer]',
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
'Endpoint = demo.archipelago-foundation.org:51820',
'AllowedIPs = 10.44.0.0/16',
'PersistentKeepalive = 25',
].join('\n')
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
}
// Node visibility — interactive (persisted per demo session) // Node visibility — interactive (persisted per demo session)
case 'network.get-visibility': { case 'network.get-visibility': {
// No onion_address in the demo: the public showcase shouldn't display
// a Tor address (even a fake one) on the Node Visibility card.
return res.json({ result: { return res.json({ result: {
visibility: mockState.nodeVisibility, visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} }) } })
} }
case 'network.set-visibility': { case 'network.set-visibility': {
@@ -2280,6 +2248,7 @@ app.post('/rpc/v1', (req, res) => {
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
return res.json({ result: { return res.json({ result: {
visibility: mockState.nodeVisibility, visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} }) } })
} }
@@ -2382,23 +2351,6 @@ app.post('/rpc/v1', (req, res) => {
case 'streaming.list-services': { case 'streaming.list-services': {
return res.json({ result: { services: mockState.streamingServices || [] } }) return res.json({ result: { services: mockState.streamingServices || [] } })
} }
case 'streaming.list-sessions': {
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
return res.json({ result: {
sessions: [
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
],
total_active: 3,
total_revenue_sats: 770_000,
revenue_by_service: {
'content-download': 512_400,
'nostr-relay': 201_600,
'api-access': 56_000,
},
} })
}
case 'streaming.configure-service': { case 'streaming.configure-service': {
const p = params || {} const p = params || {}
const list = mockState.streamingServices || [] const list = mockState.streamingServices || []
@@ -3411,19 +3363,14 @@ app.post('/rpc/v1', (req, res) => {
} }
case 'lnd.listchannels': { case 'lnd.listchannels': {
// Shape matches the real backend: status + channel_point are required
// by the channels panel; totals feed the liquidity summary tiles.
const channels = [
{ chan_id: '840921088114688', remote_pubkey: '031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581', capacity: 1500000, local_balance: 950000, remote_balance: 550000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Olympus by ZEUS' },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
]
return res.json({ return res.json({
result: { result: {
channels, channels: [
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0), { chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0), { chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
],
}, },
}) })
} }
@@ -3467,10 +3414,7 @@ app.post('/rpc/v1', (req, res) => {
} }
case 'lnd.sendcoins': { case 'lnd.sendcoins': {
// send_all sweeps the entire on-chain balance (minus a mock fee) const amt = params?.amount || params?.amt || 50000
const amt = params?.send_all
? Math.max(0, walletState.onchain_sats - 250)
: (params?.amount || params?.amt || 50000)
walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt) walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt)
const txid = randomHex(32) const txid = randomHex(32)
walletState.transactions.unshift({ walletState.transactions.unshift({
@@ -3591,32 +3535,13 @@ app.post('/rpc/v1', (req, res) => {
} }
case 'wallet.networking-profits': { case 'wallet.networking-profits': {
// Deterministic-but-varied week of profit events for the dashboard
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
const recent = []
const nowMs = Date.now()
for (let i = 0; i < 42; i++) {
const daysAgo = (i * 3) % 7
const hour = (i * 5) % 24
recent.push({
source: profitSources[i % 3],
amount_sats: 800 + ((i * 7919) % 14000),
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
description: profitNotes[i % 3],
})
}
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
return res.json({ return res.json({
result: { result: {
total_sats: 5_231_978, total_sats: 5_231_978,
content_sales_sats: 3_180_000, content_sales_sats: 3_180_000,
routing_fees_sats: 1_281_978, routing_fees_sats: 1_281_978,
streaming_revenue_sats: 770_000,
recent,
// legacy aliases kept for older UI builds
relay_sats: 770_000, relay_sats: 770_000,
// legacy aliases kept for older UI builds
total_earned_sats: 5_231_978, total_earned_sats: 5_231_978,
total_forwarded_sats: 1_281_978, total_forwarded_sats: 1_281_978,
forward_count: 1284, forward_count: 1284,
@@ -3651,29 +3576,15 @@ app.post('/rpc/v1', (req, res) => {
} }
case 'bitcoin.getinfo': { case 'bitcoin.getinfo': {
// Demo IBD simulation: the first call of a session arms a ~90s ramp
// from 98.2% → 100% so the setup wizard can demo the live sync timer
// and the "finish setup" toast that fires when IBD completes.
// (The real backend returns { block_height, sync_progress } — a 01
// fraction — which is what the frontend reads; the bitcoin-core-style
// fields are kept for any legacy consumers.)
if (!walletState.ibd_started_at) walletState.ibd_started_at = Date.now()
const IBD_RAMP_MS = 90_000
const elapsed = Date.now() - walletState.ibd_started_at
const syncProgress = Math.min(1, 0.982 + 0.018 * (elapsed / IBD_RAMP_MS))
const tipHeight = 892451
const height = Math.round(tipHeight * syncProgress)
return res.json({ return res.json({
result: { result: {
chain: 'signet', chain: 'signet',
block_height: height, blocks: 892451,
sync_progress: syncProgress, headers: 892451,
blocks: height,
headers: tipHeight,
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58), bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
difficulty: 0.001126515290698186, difficulty: 0.001126515290698186,
mediantime: Math.floor(Date.now() / 1000) - 300, mediantime: Math.floor(Date.now() / 1000) - 300,
verificationprogress: syncProgress, verificationprogress: 1.0,
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3', chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
size_on_disk: 210_000_000, size_on_disk: 210_000_000,
pruned: false, pruned: false,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.102-alpha", "version": "1.7.101-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.102-alpha", "version": "1.7.101-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.102-alpha", "version": "1.7.101-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7e22ce;stop-opacity:1" />
<stop offset="100%" style="stop-color:#231133;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#d8b4fe;stop-opacity:0.5" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- hooded figure, face in shadow -->
<path d="M120 44 C86 44 72 76 72 106 L72 132 L168 132 L168 106 C168 76 154 44 120 44 Z" fill="url(#fig)"/>
<ellipse cx="120" cy="104" rx="30" ry="34" fill="rgba(35,17,51,0.9)"/>
<path d="M45 240 C45 182 78 150 120 150 C162 150 195 182 195 240 Z" fill="url(#fig)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1000 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#c2540a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#3b1c0a;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#fdba74;stop-opacity:0.55" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- head + suited shoulders -->
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
<!-- collar + tie -->
<path d="M104 156 L120 176 L136 156 L120 148 Z" fill="rgba(59,28,10,0.85)"/>
<path d="M115 176 L125 176 L123 214 L120 220 L117 214 Z" fill="rgba(59,28,10,0.85)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1d4ed8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#93c5fd;stop-opacity:0.55" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- head + shoulders -->
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
</svg>

Before

Width:  |  Height:  |  Size: 860 B

Binary file not shown.
+4 -15
View File
@@ -424,14 +424,10 @@ onMounted(async () => {
const { IS_DEMO } = await import('@/composables/useDemoIntro') const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && bootPath === '/') replayRequested = true if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
// Root boots always ask the backend even when this browser thinks it has const splashCandidate = !seenIntro
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete` && (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
// are per-origin browser state: after a reinstall (or another node coming
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
// a fresh install's intro / misroute it to login.
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
if (splashCandidate) { if (splashCandidate && onboardingComplete !== true) {
try { try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding') const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
// Bound the pre-splash status check: its retry ladder can spend ~30s // Bound the pre-splash status check: its retry ladder can spend ~30s
@@ -441,17 +437,10 @@ onMounted(async () => {
// the splash play (a fresh install IS the slow-backend case; onboarded // the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact). // nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro. // handleSplashComplete re-checks with full retries after the intro.
const live = await Promise.race([ onboardingComplete = await Promise.race([
checkOnboardingStatus(), checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)), new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
]) ])
if (live !== null) onboardingComplete = live
if (live === false && seenIntro) {
// Backend-confirmed fresh node behind a browser with a stale flag
// drop it so this boot (and every later one) plays the intro.
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
seenIntro = false
}
} catch { } catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
} }
@@ -66,138 +66,13 @@
<button <button
type="button" type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors" class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="advanceFromDownload" @click="showPairScreen"
> >
I've installed it I've installed it
</button> </button>
</div> </div>
</div> </div>
<!-- Screen 2 (remote access): install WireGuard -->
<div v-else-if="step === 'wireguard'" key="wireguard">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
<p class="text-sm text-white/60 leading-relaxed">
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
</p>
</div>
</div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgApkQrDataUrl"
:src="wgApkQrDataUrl"
alt="WireGuard app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="wireguardDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download WireGuard
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showWgQrScreen"
>
I've installed it
</button>
</div>
<button
type="button"
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
>
Back
</button>
</div>
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
<div v-else-if="step === 'wgqr'" key="wgqr">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
<p class="text-sm text-white/60 leading-relaxed">
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
</p>
</div>
</div>
<div class="flex justify-center mb-3">
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgQrDataUrl"
:src="wgQrDataUrl"
alt="WireGuard tunnel config QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
</div>
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<button
v-if="wgError && !wgLoading"
type="button"
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
@click="retryWgPeer"
>
Try again
</button>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
<button
v-if="wgConfig"
type="button"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
@click="downloadWgConfig"
>
Download tunnel config
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
@click="showPairScreen"
>
I'm connected
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showWireguardScreen('back')"
>
Back
</button>
</div>
<!-- Screen 2: pair the app with this node --> <!-- Screen 2: pair the app with this node -->
<div v-else key="pair"> <div v-else key="pair">
<div class="flex items-start gap-4 mb-4"> <div class="flex items-start gap-4 mb-4">
@@ -244,7 +119,7 @@
<button <button
type="button" type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors" class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="backFromPair" @click="showDownloadScreen"
> >
Back Back
</button> </button>
@@ -279,43 +154,13 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
const PAIR_SCHEME = 'archipelago://pair' const PAIR_SCHEME = 'archipelago://pair'
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org' const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
// between "I've installed it" and the pairing QR. Real nodes pair against the
// node's VPN address instead of the LAN origin; the demo walks the same steps
// with a mock tunnel config (its pairing QR still carries the public demo
// URL). Set to false to restore the original two-screen flow (instant revert).
const REMOTE_ACCESS_STEPS = true
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
// 10.44.0.0/16 core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
// the node is reachable exclusively at its tunnel address. The pairing QR
// must therefore advertise this, not the browser's LAN origin. The flow has
// the user switch the tunnel on right before pairing, so it validates.
const VPN_NODE_URL = 'http://10.44.0.1'
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
// sha256 f92971bc, mirrored from download.wireguard.com), served like the
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/wireguard.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
// Name of the auto-created VPN peer for the phone running the companion.
const WG_PEER_NAME = 'companion-phone'
const visible = ref(false) const visible = ref(false)
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download') const step = ref<'download' | 'pair'>('download')
const slideName = ref('slide-forward') const slideName = ref('slide-forward')
const qrDataUrl = ref('') const qrDataUrl = ref('')
const pairQrDataUrl = ref('') const pairQrDataUrl = ref('')
const pairingUrl = ref('') const pairingUrl = ref('')
const wgApkQrDataUrl = ref('')
const wgQrDataUrl = ref('')
const wgConfig = ref('')
const wgLoading = ref(false)
const wgError = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
const loginTransition = useLoginTransitionStore() const loginTransition = useLoginTransitionStore()
@@ -326,15 +171,7 @@ const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null let calmTicker: ReturnType<typeof setInterval> | null = null
// Running inside the companion app's own WebView (it injects this JS bridge).
// The "get the companion app" pitch is nonsense there the user is already in
// it and the WG steps mid-pairing race the phone's changing network (the
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
// companions lives in the NESMenu instead.
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
onMounted(() => { onMounted(() => {
if (IN_COMPANION_APP) return
try { try {
if (localStorage.getItem(STORAGE_KEY) !== '1') { if (localStorage.getItem(STORAGE_KEY) !== '1') {
setTimeout(maybeShow, BASE_DELAY_MS) setTimeout(maybeShow, BASE_DELAY_MS)
@@ -395,17 +232,6 @@ watch(visible, async (isVisible) => {
light: '#ffffff', light: '#ffffff',
}, },
}) })
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
}, { immediate: true, flush: 'post' }) }, { immediate: true, flush: 'post' })
/** /**
@@ -416,9 +242,6 @@ watch(visible, async (isVisible) => {
*/ */
async function resolveServerUrl(): Promise<string> { async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL if (IS_DEMO) return DEMO_SERVER_URL
// Remote-access flow: the app must connect through the tunnel the user just
// switched on the LAN origin is unreachable from outside AllowedIPs.
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
const { hostname, origin } = window.location const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
try { try {
@@ -462,130 +285,6 @@ function showDownloadScreen() {
step.value = 'download' step.value = 'download'
} }
function advanceFromDownload() {
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
else void showPairScreen()
}
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
step.value = 'wireguard'
}
function showWgQrScreen() {
slideName.value = 'slide-forward'
step.value = 'wgqr'
void loadWgPeer()
}
function backFromPair() {
if (REMOTE_ACCESS_STEPS) {
slideName.value = 'slide-back'
step.value = 'wgqr'
} else {
showDownloadScreen()
}
}
// Network-class failures: the node itself was unreachable (as opposed to the
// backend answering with an RPC error). Includes the client's own timeout.
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
// Auto-retry ladder for network-class failures. On a first install this step
// is often reached while the backend is still settling (services starting,
// backend restarting during container orchestration) a single failed fetch
// left a permanently blank QR unless the user spotted the retry button.
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
// Create (or fetch) the phone's VPN peer and render its config as a QR.
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
// looked up first so reopening the modal never duplicates it.
async function loadWgPeer() {
if (wgQrDataUrl.value || wgLoading.value) return
wgLoading.value = true
wgError.value = ''
for (let attempt = 0; ; attempt++) {
try {
await provisionWgPeer()
break
} catch (e) {
const raw = e instanceof Error ? e.message : ''
const isNetworkErr = WG_NETWORK_ERR.test(raw)
if (isNetworkErr && attempt < WG_RETRY_DELAYS_MS.length && stillOnWgStep()) {
await sleep(WG_RETRY_DELAYS_MS[attempt])
if (stillOnWgStep()) continue
}
// fetch()'s raw "Failed to fetch" means the node itself was unreachable
// after the retry ladder that's usually the phone's network mid-change
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
// Say so, and leave a Retry path instead of a dead end.
wgError.value = isNetworkErr
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
: raw || 'Failed to generate the tunnel config'
break
}
}
wgLoading.value = false
}
async function provisionWgPeer() {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => null)
// list-peers unreachable don't guess "doesn't exist": create-peer on an
// existing name would fail. Try create first, fall back to peer-config.
const exists = listed === null
? null
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
let res: { config: string; peer_ip: string }
if (exists === true) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
try {
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
} catch (e) {
// Peer already provisioned on a previous visit (list failed or raced).
const msg = e instanceof Error ? e.message : ''
if (/exist|duplicate/i.test(msg)) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
throw e
}
}
}
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
function retryWgPeer() {
wgError.value = ''
void loadWgPeer()
}
// Same-device path: hand the config to the WireGuard app as an importable
// .conf file (a phone can't scan its own screen).
function downloadWgConfig() {
if (!wgConfig.value) return
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'archipelago.conf'
a.click()
URL.revokeObjectURL(url)
}
function dismiss() { function dismiss() {
visible.value = false visible.value = false
step.value = 'download' step.value = 'download'
@@ -16,39 +16,6 @@
</div> </div>
</div> </div>
<!-- Zeus channel suggestion -->
<div class="glass-card p-4 mb-4 border border-orange-500/25">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<img
src="/assets/img/app-icons/zeus.webp"
alt="Zeus"
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
/>
<div class="flex-1 min-w-0">
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
<p class="text-white/55 text-xs leading-relaxed">
Pair your node with the Zeus mobile wallet open a channel to their Olympus node and
start sending and receiving Lightning payments from your phone.
Minimum 150,000 · maximum 1,500,000 sats.
</p>
</div>
<div class="flex sm:flex-col items-center gap-2 shrink-0">
<button
@click="openZeusChannel"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
>
Open Channel
</button>
<a
href="https://zeusln.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
>Get Zeus </a>
</div>
</div>
</div>
<!-- Open Channel Button --> <!-- Open Channel Button -->
<div class="flex justify-end mb-4"> <div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"> <button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
@@ -107,15 +74,15 @@
<span <span
class="w-2 h-2 rounded-full" class="w-2 h-2 rounded-full"
:class="{ :class="{
'bg-green-400': channelStatus(ch) === 'active', 'bg-green-400': ch.status === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open', 'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive', 'bg-red-400': ch.status === 'inactive',
}" }"
></span> ></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span> <span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div> </div>
<button <button
v-if="channelStatus(ch) !== 'pending_open'" v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)" @click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors" class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
> >
@@ -303,13 +270,8 @@ interface Channel {
local_balance: number local_balance: number
remote_balance: number remote_balance: number
active: boolean active: boolean
status?: string status: string
channel_point?: string channel_point: string
}
/** Status with a fallback derived from `active` for backends that omit it */
function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
} }
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom' type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
@@ -326,11 +288,6 @@ const error = ref<string | null>(null)
const channels = ref<Channel[]>([]) const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 }) const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
const OLYMPUS_PEER_URI =
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
const showOpenModal = ref(false) const showOpenModal = ref(false)
const defaultOpenForm = () => ({ const defaultOpenForm = () => ({
peerUri: '', peerUri: '',
@@ -340,19 +297,6 @@ const defaultOpenForm = () => ({
customConfTarget: null as number | null, customConfTarget: null as number | null,
customSatPerVbyte: null as number | null, customSatPerVbyte: null as number | null,
}) })
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
function openZeusChannel() {
openForm.value = {
...defaultOpenForm(),
peerUri: OLYMPUS_PEER_URI,
amount: 150000,
// Olympus only accepts unannounced channels
private: true,
}
openError.value = null
showOpenModal.value = true
}
const openForm = ref(defaultOpenForm()) const openForm = ref(defaultOpenForm())
const openingChannel = ref(false) const openingChannel = ref(false)
const openError = ref<string | null>(null) const openError = ref<string | null>(null)
@@ -369,7 +313,7 @@ function formatSats(sats: number): string {
} }
function fundingTxid(ch: Channel): string { function fundingTxid(ch: Channel): string {
const txid = ch.channel_point?.split(':')[0] || '' const txid = ch.channel_point.split(':')[0] || ''
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : '' return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
} }
@@ -31,9 +31,6 @@
<!-- On-chain --> <!-- On-chain -->
<div v-if="receiveMethod === 'onchain'"> <div v-if="receiveMethod === 'onchain'">
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center"> <div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p> <p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
@@ -80,7 +77,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, watch } from 'vue' import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
@@ -88,21 +85,9 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ defineProps<{ show: boolean }>()
show: boolean
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
/** Generate an on-chain address immediately when the modal opens */
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: []; received: [] }>() const emit = defineEmits<{ close: []; received: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
void receive()
}
})
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain') const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0) const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('') const invoiceMemo = ref('')
+6 -48
View File
@@ -16,30 +16,8 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-1"> <label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label> <input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
<button
v-if="sendMethod === 'onchain'"
@click="toggleSendAll"
class="text-xs px-2 py-0.5 rounded border transition-colors"
:class="sendAll
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
>
Send all funds
</button>
</div>
<input
v-model.number="amount"
type="number"
min="1"
:placeholder="sendAll ? '' : '1000'"
:disabled="sendAll"
class="w-full input-glass disabled:opacity-50"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
</div> </div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3"> <div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@@ -69,7 +47,7 @@
<div class="flex gap-3"> <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 @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</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"> <button @click="send" :disabled="processing || !amount" 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') }} {{ processing ? t('common.sending') : t('common.send') }}
</button> </button>
</div> </div>
@@ -77,7 +55,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
@@ -97,23 +75,6 @@ const resultHash = ref('')
const resultArk = ref('') const resultArk = ref('')
const ecashToken = ref('') const ecashToken = ref('')
// "Send all funds" sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
}
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
const effectiveMethod = computed(() => { const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0 const amt = amount.value || 0
@@ -137,8 +98,7 @@ function copyText(text: string) {
} }
async function send() { async function send() {
if (processing.value) return if (!amount.value || processing.value) return
if (!amount.value && !isSweep.value) return
processing.value = true processing.value = true
error.value = '' error.value = ''
ecashToken.value = '' ecashToken.value = ''
@@ -174,9 +134,7 @@ async function send() {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return } if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({ const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins', method: 'lnd.sendcoins',
params: isSweep.value params: { addr: dest.value.trim(), amount: amount.value },
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
}) })
resultTxid.value = res.txid resultTxid.value = res.txid
} }
+2 -14
View File
@@ -23,14 +23,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
<span class="text-sm text-white/90">{{ toast.message }}</span>
<button
v-if="toast.action"
@click.stop="runAction(toast)"
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
>{{ toast.action.label }} </button>
</div>
</div> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
@@ -39,15 +32,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import type { ToastItem, ToastVariant } from '@/composables/useToast' import type { ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast() const { toasts, dismiss } = useToast()
function runAction(toast: ToastItem | Readonly<ToastItem>) {
toast.action?.onClick()
dismiss(toast.id)
}
function variantClass(variant: ToastVariant): string { function variantClass(variant: ToastVariant): string {
switch (variant) { switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md' case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
@@ -216,8 +216,7 @@ async function connect() {
device_kind: form.value.deviceKind, device_kind: form.value.deviceKind,
}) })
mesh.dismissDetectedDevice(path) mesh.dismissDetectedDevice(path)
// The Mesh view lives under the dashboard shell a bare /mesh 404s. void router.push('/mesh')
void router.push('/dashboard/mesh')
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio' error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally { } finally {
-109
View File
@@ -1,109 +0,0 @@
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
/**
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
*
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
* lease, samples the sync rate, and exposes a ticking countdown so setup
* screens can show "~2h 14m remaining" that visibly counts down between
* polls. Module-level singleton every consumer sees the same state.
*/
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
export const IBD_SYNCED_AT = 99.9
const POLL_MS = 15_000
const TICK_MS = 1_000
/** Ignore rate samples older than this when estimating */
const SAMPLE_WINDOW_MS = 10 * 60_000
export const bitcoinSyncPercent = ref(0)
export const bitcoinBlockHeight = ref(0)
export const bitcoinSyncAvailable = ref(false)
export const bitcoinSyncLoaded = ref(false)
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
const etaSeconds = ref<number | null>(null)
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
export const bitcoinSyncEtaText = computed(() => {
const s = etaSeconds.value
if (s === null) return ''
if (s < 60) return 'less than a minute'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
const sec = Math.floor(s % 60)
return `${m}m ${sec}s`
})
let samples: { t: number; p: number }[] = []
let etaBase: { at: number; secs: number } | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let tickTimer: ReturnType<typeof setInterval> | null = null
let leases = 0
async function poll() {
try {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 8000,
})
const pct = (btc.sync_progress ?? 0) * 100
bitcoinSyncPercent.value = pct
bitcoinBlockHeight.value = btc.block_height ?? 0
bitcoinSyncAvailable.value = true
bitcoinSyncLoaded.value = true
const now = Date.now()
samples.push({ t: now, p: pct })
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
if (pct >= IBD_SYNCED_AT) {
etaBase = null
etaSeconds.value = 0
return
}
const first = samples[0]
if (first && now - first.t >= 10_000 && pct > first.p) {
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
}
} catch {
bitcoinSyncAvailable.value = false
}
}
function tick() {
if (!etaBase) {
if (!bitcoinSynced.value) etaSeconds.value = null
return
}
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
}
/**
* Hold a polling lease. Returns a release function call it on unmount.
* Polling only runs while at least one lease is held.
*/
export function acquireBitcoinSync(): () => void {
leases++
if (leases === 1) {
void poll()
pollTimer = setInterval(() => void poll(), POLL_MS)
tickTimer = setInterval(tick, TICK_MS)
}
let released = false
return () => {
if (released) return
released = true
leases = Math.max(0, leases - 1)
if (leases === 0) {
if (pollTimer) clearInterval(pollTimer)
if (tickTimer) clearInterval(tickTimer)
pollTimer = null
tickTimer = null
}
}
}
+6 -10
View File
@@ -26,17 +26,13 @@ export function clearDemoIntroSeen(): void {
// Only these apps actually do something in the demo (a mock UI or a real // Only these apps actually do something in the demo (a mock UI or a real
// external site). Everything else shows "No demo" on a disabled install button // external site). Everything else shows "No demo" on a disabled install button
// and is not launchable. // and is not launchable.
// IndeeHub's real site sends X-Frame-Options: SAMEORIGIN, and the old const DEMO_EXTERNAL_URLS: Record<string, string> = {}
// same-origin nginx sub_filter proxy broke its runtime-built asset URLs —
// so the demo opens the real site directly instead.
const DEMO_EXTERNAL_URLS: Record<string, string> = {
indeedhub: 'https://indee.tx1138.com/',
}
// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool // Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths // are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
// rewritten) so the frame-busting real sites can be embedded. // rewritten) so the frame-busting real sites can be embedded.
const DEMO_MOCK_UI: Record<string, string> = { const DEMO_MOCK_UI: Record<string, string> = {
indeedhub: '/app/indeedhub/',
mempool: '/app/mempool/', mempool: '/app/mempool/',
'mempool-web': '/app/mempool/', 'mempool-web': '/app/mempool/',
'bitcoin-knots': '/app/bitcoin-knots/', 'bitcoin-knots': '/app/bitcoin-knots/',
@@ -65,11 +61,11 @@ const DEMO_MOCK_UI: Record<string, string> = {
} }
/** /**
* Whether a demo app opens externally (new tab / in-app browser) because its * Whether a demo app opens in a new tab. Nothing does IndeeHub and Mempool
* real site blocks iframing (X-Frame-Options). * both load their real site directly in the in-app iframe.
*/ */
export function isDemoExternal(appId: string): boolean { export function isDemoExternal(_appId: string): boolean {
return appId in DEMO_EXTERNAL_URLS return false
} }
/** Can this app be launched/installed in the demo? */ /** Can this app be launched/installed in the demo? */
@@ -1,90 +0,0 @@
import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import { useToast } from '@/composables/useToast'
import {
acquireBitcoinSync,
bitcoinSynced,
bitcoinSyncLoaded,
} from '@/composables/useBitcoinSync'
// Session-level guard: the "finish setup" toast fires at most once per page load.
let firedThisSession = false
/**
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
* sitting on the fund-wallet / open-channel steps). Mount once in the
* dashboard layout.
*/
export function useIbdFinishWatcher() {
const goalStore = useGoalStore()
const router = useRouter()
const toast = useToast()
// A goal qualifies while it's in progress and its manual fund/channel steps
// aren't done yet. If several qualify, the first wins — finishing the shared
// fund + channel steps completes the lightning part of any of them.
const pendingLightningGoalId = computed<string | null>(() => {
if (firedThisSession) return null
for (const goal of GOALS) {
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
if (!hasFundStep) continue
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
const done = goalStore.progress[goal.id]?.completedSteps ?? []
const manualPending = goal.steps.some(
(s) => s.action !== 'install' && !done.includes(s.id),
)
if (manualPending) return goal.id
}
return null
})
// Only poll the chain while there's actually a goal waiting on it.
let release: (() => void) | null = null
watchEffect(() => {
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
if (shouldWatch && !release) {
release = acquireBitcoinSync()
} else if (!shouldWatch && release) {
// Goal finished/reset or the chain synced — stop polling.
release()
release = null
}
})
// Fire only on a REAL transition: we must have observed the chain unsynced
// at least once this session, so a node that's already synced at page load
// doesn't toast.
let sawUnsynced = false
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
if (!loaded) return
if (!synced) {
sawUnsynced = true
return
}
if (!sawUnsynced || firedThisSession) return
const goalId = pendingLightningGoalId.value
if (!goalId) return
firedThisSession = true
toast.action(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
{
label: 'Finish setup',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
},
)
if (release) {
release()
release = null
}
})
onUnmounted(() => {
if (release) {
release()
release = null
}
})
}
+2 -11
View File
@@ -2,25 +2,19 @@ import { ref, readonly } from 'vue'
export type ToastVariant = 'success' | 'error' | 'info' export type ToastVariant = 'success' | 'error' | 'info'
export interface ToastAction {
label: string
onClick: () => void
}
export interface ToastItem { export interface ToastItem {
id: number id: number
message: string message: string
variant: ToastVariant variant: ToastVariant
dismissing: boolean dismissing: boolean
action?: ToastAction
} }
const toasts = ref<ToastItem[]>([]) const toasts = ref<ToastItem[]>([])
let nextId = 0 let nextId = 0
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) { function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
const id = nextId++ const id = nextId++
toasts.value.push({ id, message, variant, dismissing: false, action }) toasts.value.push({ id, message, variant, dismissing: false })
// Auto-dismiss // Auto-dismiss
if (duration > 0) { if (duration > 0) {
@@ -48,9 +42,6 @@ export function useToast() {
success: (msg: string) => addToast(msg, 'success'), success: (msg: string) => addToast(msg, 'success'),
error: (msg: string) => addToast(msg, 'error'), error: (msg: string) => addToast(msg, 'error'),
info: (msg: string) => addToast(msg, 'info'), info: (msg: string) => addToast(msg, 'info'),
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
dismiss: dismissToast, dismiss: dismissToast,
} }
} }
+6 -44
View File
@@ -1,25 +1,4 @@
import type { GoalDefinition, GoalStep } from '@/types/goals' import type { GoalDefinition } from '@/types/goals'
/** Zeus (Olympus LSP) channel size limits, in sats */
export const ZEUS_CHANNEL_MIN_SATS = 150_000
export const ZEUS_CHANNEL_MAX_SATS = 1_500_000
export const ZEUS_ICON = '/assets/img/app-icons/zeus.webp'
/**
* Shared "fund the bitcoin wallet" step used by every Lightning goal. Gated on
* the blockchain being fully synced (IBD) the wizard shows a live sync timer
* until then, and a "Fund Wallet" receive flow after.
*/
const FUND_WALLET_STEP: GoalStep = {
id: 'fund-wallet',
title: 'Fund Your Bitcoin Wallet',
description:
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels need between 150,000 and 1,500,000 sats. Funding unlocks once your node finishes syncing the blockchain.",
action: 'fund',
isAutomatic: false,
icon: '/assets/img/app-icons/bitcoin-knots.webp',
}
export const GOALS: GoalDefinition[] = [ export const GOALS: GoalDefinition[] = [
{ {
@@ -46,17 +25,6 @@ export const GOALS: GoalDefinition[] = [
action: 'install', action: 'install',
isAutomatic: true, isAutomatic: true,
}, },
{ ...FUND_WALLET_STEP },
{
id: 'open-zeus-channel',
title: 'Open a Channel with Zeus',
description:
'Open a Lightning channel to Zeus, the mobile wallet that pairs perfectly with your node. Fund it with 150,0001,500,000 sats and your shop can accept instant Lightning payments.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
},
{ {
id: 'install-btcpay', id: 'install-btcpay',
title: 'Install BTCPay Server', title: 'Install BTCPay Server',
@@ -101,16 +69,13 @@ export const GOALS: GoalDefinition[] = [
action: 'install', action: 'install',
isAutomatic: true, isAutomatic: true,
}, },
{ ...FUND_WALLET_STEP },
{ {
id: 'open-channel', id: 'open-channel',
title: 'Open a Channel with Zeus', title: 'Open a Lightning Channel',
description: description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,0001,500,000 sats). You can then send and receive Lightning payments from your phone.', appId: 'lnd',
action: 'configure', action: 'configure',
isAutomatic: false, isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
}, },
], ],
estimatedTime: '~30 min + sync time', estimatedTime: '~30 min + sync time',
@@ -203,16 +168,13 @@ export const GOALS: GoalDefinition[] = [
action: 'install', action: 'install',
isAutomatic: true, isAutomatic: true,
}, },
{ ...FUND_WALLET_STEP },
{ {
id: 'open-channels', id: 'open-channels',
title: 'Open Payment Channels', title: 'Open Payment Channels',
description: description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,0001,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.', appId: 'lnd',
action: 'configure', action: 'configure',
isAutomatic: false, isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
}, },
{ {
id: 'verify-routing', id: 'verify-routing',
+1 -10
View File
@@ -169,16 +169,11 @@ describe('useGoalStore', () => {
expect(store.getGoalStatus('accept-payments')).toBe('not-started') expect(store.getGoalStatus('accept-payments')).toBe('not-started')
}) })
it('returns completed when all required apps run AND manual steps are done', () => { it('returns completed when all required apps are running', () => {
mockPackages['bitcoin-knots'] = { state: 'running' } mockPackages['bitcoin-knots'] = { state: 'running' }
mockPackages['lnd'] = { state: 'running' } mockPackages['lnd'] = { state: 'running' }
const store = useGoalStore() const store = useGoalStore()
// Running apps alone no longer finish a goal — manual steps must be walked
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
store.startGoal('accept-payments')
store.completeStep('accept-payments', 'open-channel')
expect(store.getGoalStatus('accept-payments')).toBe('completed') expect(store.getGoalStatus('accept-payments')).toBe('completed')
}) })
@@ -203,8 +198,6 @@ describe('useGoalStore', () => {
mockPackages['immich-server'] = { state: 'running' } mockPackages['immich-server'] = { state: 'running' }
const store = useGoalStore() const store = useGoalStore()
store.startGoal('store-photos')
store.completeStep('store-photos', 'configure-immich')
expect(store.getGoalStatus('store-photos')).toBe('completed') expect(store.getGoalStatus('store-photos')).toBe('completed')
}) })
@@ -225,8 +218,6 @@ describe('useGoalStore', () => {
mockPackages['lnd'] = { state: 'running' } mockPackages['lnd'] = { state: 'running' }
const store = useGoalStore() const store = useGoalStore()
store.startGoal('accept-payments')
store.completeStep('accept-payments', 'open-channel')
const statuses = store.goalStatuses const statuses = store.goalStatuses
expect(statuses['accept-payments']).toBe('completed') expect(statuses['accept-payments']).toBe('completed')
+2 -9
View File
@@ -88,19 +88,12 @@ export const useGoalStore = defineStore('goals', () => {
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running', ([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
), ),
) )
if (allRunning) return 'completed'
// Manual steps (fund the wallet, open a channel, configure the store…)
// must be walked through too — running apps alone don't finish a goal.
const done = progress.value[goalId]?.completedSteps ?? []
const allManualDone = goal.steps
.filter((s) => s.action !== 'install')
.every((s) => done.includes(s.id))
if (allRunning && allManualDone) return 'completed'
const anyInstalled = goal.requiredApps.some((appId) => const anyInstalled = goal.requiredApps.some((appId) =>
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)), Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
) )
if (allRunning || anyInstalled || progress.value[goalId]) return 'in-progress' if (anyInstalled || progress.value[goalId]) return 'in-progress'
return 'not-started' return 'not-started'
} }
+15 -3
View File
@@ -79,9 +79,7 @@ select:focus-visible {
border-color: rgba(251, 146, 60, 0.4); border-color: rgba(251, 146, 60, 0.4);
} }
/* Card action placement: actions always live at the bottom of the card as /* Card action placement: keep compact header buttons for genuinely wide layouts. */
full-width buttons same layout on every screen size. The compact header
variants are permanently retired for consistency (desktop == mobile). */
.responsive-card-actions-top, .responsive-card-actions-top,
.web5-card-actions-top { .web5-card-actions-top {
display: none; display: none;
@@ -111,6 +109,20 @@ select:focus-visible {
white-space: nowrap; white-space: nowrap;
} }
@media (min-width: 1800px) {
.responsive-card-actions-top,
.web5-card-actions-top {
display: flex;
}
.responsive-card-actions-bottom,
.responsive-card-actions-bottom-grid,
.web5-card-actions-bottom,
.web5-card-actions-bottom-grid {
display: none;
}
}
/* Mobile touch targets — ensure tappable elements meet 44px minimum */ /* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) { @media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) { button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
+1 -9
View File
@@ -17,16 +17,8 @@ export interface GoalStep {
title: string title: string
description: string description: string
appId?: string appId?: string
/** action: 'install' | 'configure' | 'verify' | 'info'
* 'fund' renders the bitcoin-wallet funding UI: gated on IBD completion
* (with a live sync timer), then a "Fund Wallet" receive flow.
*/
action: 'install' | 'configure' | 'verify' | 'info' | 'fund'
isAutomatic: boolean isAutomatic: boolean
/** Custom step icon (e.g. the Zeus logo) — overrides the appId-derived icon */
icon?: string
/** Custom label for the step's CTA button (configure steps) */
ctaLabel?: string
} }
export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error' export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error'
@@ -38,50 +38,4 @@ describe('shouldShowIntroSplash', () => {
replayRequested: true, replayRequested: true,
})).toBe(true) })).toBe(true)
}) })
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/login',
fromBoot: true,
onboardingComplete: false,
})).toBe(true)
})
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
it('fresh node on a deep route without boot handoff stays suppressed', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/onboarding/seed',
fromBoot: false,
onboardingComplete: false,
})).toBe(false)
})
it('boot dev mode never root-boots into the intro', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
devMode: 'boot',
onboardingComplete: false,
})).toBe(false)
})
}) })
+1 -10
View File
@@ -10,19 +10,10 @@ export interface IntroSplashDecisionInput {
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true if (input.replayRequested) return true
const isDirectRoute = input.routePath !== '/'
// A node the backend CONFIRMS has never completed onboarding always gets
// the full intro on a root boot. `seenIntro` is per-origin browser state —
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
// previous node's flag at the same origin, which silently muted the intro
// on genuinely fresh installs.
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
return true
}
if (input.seenIntro) return false if (input.seenIntro) return false
if (input.onboardingComplete === true) return false if (input.onboardingComplete === true) return false
const isDirectRoute = input.routePath !== '/'
if (input.fromBoot) return true if (input.fromBoot) return true
if (input.devMode === 'boot') return false if (input.devMode === 'boot') return false
return !isDirectRoute return !isDirectRoute
+2 -12
View File
@@ -52,11 +52,9 @@
aria-hidden="true" aria-hidden="true"
/> />
<!-- Background overlay. The web5/server backdrop is a much lighter image <!-- Background overlay - uniform 0.2 opacity -->
than the others, so it gets a heavier scrim for text contrast. -->
<div <div
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500" class="fixed inset-0 pointer-events-none bg-black/20"
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
style="z-index: -5;" style="z-index: -5;"
/> />
@@ -157,12 +155,8 @@ import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue' import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue' import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions' import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
import '@/views/dashboard/dashboard-styles.css' import '@/views/dashboard/dashboard-styles.css'
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
useIbdFinishWatcher()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAppStore() const store = useAppStore()
@@ -196,10 +190,6 @@ const backgroundImage = computed(() => {
return 'bg-home.webp' return 'bg-home.webp'
}) })
// bg-web5.jpg (web5 + server sections) is bright the scrim overlay deepens
// while it's showing so light text keeps its contrast.
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
const isDarkRoute = computed(() => { const isDarkRoute = computed(() => {
const p = route.path const p = route.path
return p.includes('/dashboard/web5') || return p.includes('/dashboard/web5') ||
+10 -173
View File
@@ -98,59 +98,12 @@
> >
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }} {{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
</button> </button>
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
<div v-else-if="step.action === 'fund'" class="space-y-3">
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<div class="flex items-center justify-between gap-3 mb-1.5">
<span class="text-xs text-white/75">Bitcoin is syncing funding unlocks when it finishes</span>
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
</div>
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
</div>
<p class="text-xs text-white/50">
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
<span v-else>Estimating time remaining</span>
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
</p>
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
</div>
<template v-else>
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-white/60">On-chain wallet balance</span>
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
{{ walletOnchainSats.toLocaleString() }} sats
</span>
</div>
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,0001,500,000 sats.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
@click="showFundModal = true"
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
Fund Wallet
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
</div>
<button <button
v-else-if="step.action === 'configure'" v-else-if="step.action === 'configure'"
@click="openConfigureStep(step)" @click="openConfigureStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
> >
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }} {{ t('goalDetail.openAndConfigure') }}
</button> </button>
<button <button
v-else-if="step.action === 'verify'" v-else-if="step.action === 'verify'"
@@ -189,27 +142,12 @@
</div> </div>
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2> <h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p> <p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
<button <RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
v-if="completionCta"
@click="openCompletionTarget"
class="glass-button rounded-lg px-6 py-3 font-medium"
>
{{ completionCta.label }}
</button>
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
{{ t('goalDetail.viewMyServices') }} {{ t('goalDetail.viewMyServices') }}
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
<ReceiveBitcoinModal
:show="showFundModal"
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
auto-generate
@close="showFundModal = false"
/>
<!-- Action error toast --> <!-- Action error toast -->
<Transition name="fade"> <Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive"> <div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
@@ -223,26 +161,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router' import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useGoalStore } from '@/stores/goals' import { useGoalStore } from '@/stores/goals'
import { useAppLauncherStore } from '@/stores/appLauncher' import { getGoalById } from '@/data/goals'
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
import type { GoalStep } from '@/types/goals' import type { GoalStep } from '@/types/goals'
import { goalStepRouteOverride } from './goals/goalStepActions' import { goalStepTargetPath } from './goals/goalStepActions'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
import {
acquireBitcoinSync,
bitcoinSynced,
bitcoinSyncLoaded,
bitcoinSyncPercent,
bitcoinBlockHeight,
bitcoinSyncEtaText,
} from '@/composables/useBitcoinSync'
/** Map appId to its icon file path under /assets/img/app-icons/ */ /** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = { const APP_ICON_MAP: Record<string, string> = {
@@ -258,27 +185,10 @@ const APP_ICON_MAP: Record<string, string> = {
} }
function stepIconUrl(step: GoalStep): string | undefined { function stepIconUrl(step: GoalStep): string | undefined {
if (step.icon) return step.icon
if (!step.appId) return undefined if (!step.appId) return undefined
return APP_ICON_MAP[step.appId] return APP_ICON_MAP[step.appId]
} }
/**
* Where the completion card sends the user: the app they just set up, not the
* generic services list. `launchAppId` opens via the app launcher (iframe apps
* overlay on top of the current screen; X-Frame-Options apps open a tab).
*/
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
}
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -304,9 +214,7 @@ const completedSteps = computed(() => {
if (!goal.value) return new Set<string>() if (!goal.value) return new Set<string>()
const completed = new Set<string>() const completed = new Set<string>()
for (const step of goal.value.steps) { for (const step of goal.value.steps) {
// Only install steps auto-tick from package state manual steps (fund the if (step.appId && isAppInstalled(step.appId)) {
// wallet, open a channel, configure) must be walked through.
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
completed.add(step.id) completed.add(step.id)
} }
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) { if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
@@ -398,16 +306,9 @@ async function installApp(step: GoalStep) {
function openConfigureStep(step: GoalStep) { function openConfigureStep(step: GoalStep) {
ensureGoalStarted() ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id) goalStore.completeStep(goalId.value, step.id)
const override = goalStepRouteOverride(step) const targetPath = goalStepTargetPath(step)
if (override) { if (targetPath) {
// Internal screens (channels, web5, settings) tag where we came from so router.push(targetPath)
// their back button returns to this wizard.
router.push({ path: override, query: { from: 'goal', goal: goalId.value } })
} else if (step.appId) {
// Launch the app itself: iframe apps overlay on top of the wizard,
// tab-only apps open a tab (mobile: the in-app browser) the app
// launcher handles every case.
useAppLauncherStore().openSession(step.appId)
} }
} }
@@ -428,71 +329,7 @@ function ensureGoalStarted() {
} }
function goBack() { function goBack() {
// The goal cards live on Home's Setup tab return there, not the dashboard. router.push('/dashboard')
router.push({ path: '/dashboard', query: { tab: 'setup' } })
}
// Fund-wallet step: live sync status + on-chain balance
const showFundModal = ref(false)
const walletOnchainSats = ref(0)
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
const fundStepActive = computed(() => {
if (!goal.value || !hasFundStep.value) return false
const active = goal.value.steps[activeStepIndex.value]
return active?.action === 'fund' && overallStatus.value !== 'completed'
})
let releaseSync: (() => void) | null = null
let balanceTimer: ReturnType<typeof setInterval> | null = null
async function refreshWalletBalance() {
try {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
walletOnchainSats.value = res.balance_sats || 0
} catch { /* LND not up yet — balance stays at last known value */ }
}
watch(fundStepActive, (active) => {
if (active) {
if (!releaseSync) releaseSync = acquireBitcoinSync()
void refreshWalletBalance()
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
} else {
if (releaseSync) { releaseSync(); releaseSync = null }
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
}
}, { immediate: true })
// Refresh the balance right after the receive modal closes the user may
// have just sent funds.
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
onUnmounted(() => {
if (releaseSync) { releaseSync(); releaseSync = null }
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
})
function completeFundStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
// Completion CTA: go to the app you just set up
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
function openCompletionTarget() {
const cta = completionCta.value
if (!cta) return
if (cta.launchAppId) {
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
// (BTCPay, Nextcloud) open in a new tab.
useAppLauncherStore().openSession(cta.launchAppId)
} else if (cta.route) {
router.push(cta.route)
}
} }
</script> </script>
+2 -8
View File
@@ -287,7 +287,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue' import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue' import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue' import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
@@ -315,15 +315,9 @@ import type { WalletTransaction } from './home/HomeWalletCard.vue'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const route = useRoute()
const uiMode = useUIModeStore() const uiMode = useUIModeStore()
const isDev = import.meta.env.DEV const isDev = import.meta.env.DEV
// ?tab=setup lands on the Setup tab (e.g. "Back to Goals" from a goal wizard) const homeTab = ref<'dashboard' | 'setup'>('dashboard')
const homeTab = ref<'dashboard' | 'setup'>(route.query.tab === 'setup' ? 'setup' : 'dashboard')
watch(() => route.query.tab, (tab) => {
if (tab === 'setup') homeTab.value = 'setup'
else if (tab === 'dashboard') homeTab.value = 'dashboard'
})
const topGoals = GOALS.slice(0, 3) const topGoals = GOALS.slice(0, 3)
const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))] const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
-10
View File
@@ -64,7 +64,6 @@
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
data-form-type="other" data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors" class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordSetup')" :placeholder="t('login.enterPasswordSetup')"
@keydown.enter="confirmPasswordInputRef?.focus()" @keydown.enter="confirmPasswordInputRef?.focus()"
@@ -84,7 +83,6 @@
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
data-form-type="other" data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors" class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.confirmPasswordPlaceholder')" :placeholder="t('login.confirmPasswordPlaceholder')"
@keydown.enter="handleSetupWithSound" @keydown.enter="handleSetupWithSound"
@@ -129,7 +127,6 @@
pattern="[0-9]*" pattern="[0-9]*"
maxlength="8" maxlength="8"
autocomplete="one-time-code" autocomplete="one-time-code"
data-controller-no-submit
:aria-label="t('login.totpLabel')" :aria-label="t('login.totpLabel')"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors" class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'" :placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
@@ -168,12 +165,6 @@
🎮 Demo mode Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span> 🎮 Demo mode Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
</div> </div>
<!-- All auth inputs opt out of controller-nav's Enterclick-next-button
pattern (data-controller-no-submit): they submit via their own Enter
handlers, and while the submit button is still disabled the "next
focusable" is Replay Intro the companion's auto-login injects
Enter before Vue re-enables the button, which replayed the intro
in a loop on every app connect. -->
<div class="mb-6"> <div class="mb-6">
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2"> <label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
{{ t('login.password') }} {{ t('login.password') }}
@@ -184,7 +175,6 @@
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
data-form-type="other" data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors" class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordPlaceholder')" :placeholder="t('login.enterPasswordPlaceholder')"
@keydown.enter="handleLoginWithSound" @keydown.enter="handleLoginWithSound"
+6 -10
View File
@@ -691,24 +691,20 @@ video.bg-layer {
why the kiosk login/onboarding background still went black. Keep 2D why the kiosk login/onboarding background still went black. Keep 2D
opacity crossfades; drop 3D transforms, blur filters, and blend-mode opacity crossfades; drop 3D transforms, blur filters, and blend-mode
glitch overlays. */ glitch overlays. */
/* The full selector must live inside :global() with `:global(html.kiosk-mode) :global(html.kiosk-mode) .bg-perspective-container,
.bg-layer` the SFC compiler drops the descendant part, emitting bare :global(html.kiosk-mode) .perspective-container {
`html.kiosk-mode { display: none !important }` rules that blank the whole
document on kiosk (the v1.7.104 white-screen). */
:global(html.kiosk-mode .bg-perspective-container),
:global(html.kiosk-mode .perspective-container) {
perspective: none !important; perspective: none !important;
} }
:global(html.kiosk-mode .bg-layer), :global(html.kiosk-mode) .bg-layer,
:global(html.kiosk-mode .view-wrapper) { :global(html.kiosk-mode) .view-wrapper {
transform: none !important; transform: none !important;
transform-style: flat !important; transform-style: flat !important;
backface-visibility: visible !important; backface-visibility: visible !important;
will-change: auto !important; will-change: auto !important;
filter: none !important; filter: none !important;
} }
:global(html.kiosk-mode .login-glitch-layer), :global(html.kiosk-mode) .login-glitch-layer,
:global(html.kiosk-mode .login-glitch-scan) { :global(html.kiosk-mode) .login-glitch-scan {
display: none !important; display: none !important;
} }
</style> </style>
+10 -15
View File
@@ -207,13 +207,9 @@
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div> <div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
</div> </div>
<!-- mt-auto pins the action to the card bottom so buttons align across <button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
equal-height grid cards --> Add Device
<div class="responsive-card-actions-bottom mt-auto pt-4"> </button>
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
Add Device
</button>
</div>
</div> </div>
<!-- Network Interfaces (second column on desktop) --> <!-- Network Interfaces (second column on desktop) -->
@@ -277,14 +273,13 @@
</div> </div>
</template> </template>
<div v-if="wifiAvailable" class="responsive-card-actions-bottom mt-auto pt-4"> <button
<button v-if="wifiAvailable"
@click="showWifiModal = true" @click="showWifiModal = true"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
> >
Scan WiFi Scan WiFi
</button> </button>
</div>
</div> </div>
</div><!-- close VPN+Network 2-col grid --> </div><!-- close VPN+Network 2-col grid -->
+8 -19
View File
@@ -1,6 +1,12 @@
<template> <template>
<div class="pb-16 md:pb-4"> <div class="pb-16 md:pb-4">
<BackButton :label="backLabel" desktop-margin="mb-6" @click="goBack" /> <!-- Back Button -->
<button @click="router.replace('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to LND
</button>
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1> <h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
@@ -9,25 +15,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue' import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
const route = useRoute()
const router = useRouter() const router = useRouter()
// When a setup wizard sent us here (?from=goal&goal=<id>), back returns to it.
const fromGoalId = computed(() =>
route.query.from === 'goal' && typeof route.query.goal === 'string' ? route.query.goal : null,
)
const backLabel = computed(() => (fromGoalId.value ? 'Back to Setup' : 'Back to LND'))
function goBack() {
if (fromGoalId.value) {
router.push(`/dashboard/goals/${fromGoalId.value}`)
} else {
router.replace('/dashboard/apps/lnd')
}
}
</script> </script>
@@ -7,7 +7,7 @@
@click="openCompanionIntro()" @click="openCompanionIntro()"
> >
<img <img
src="/assets/img/companion-banner-bg.webp" src="/assets/img/bg-intro-4.webp"
alt="" alt=""
class="featured-banner-img" class="featured-banner-img"
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')" @error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
@@ -1,37 +1,29 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { GOALS } from '@/data/goals' import { GOALS } from '@/data/goals'
import { goalStepRouteOverride } from '../goalStepActions' import { goalStepTargetPath } from '../goalStepActions'
import type { GoalStep } from '@/types/goals' import type { GoalStep } from '@/types/goals'
describe('goalStepActions', () => { describe('goalStepActions', () => {
it('app-backed configure steps have no route override — they launch the app itself', () => { it('routes app-backed steps to their app details page', () => {
expect(goalStepRouteOverride(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBeNull() expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
expect(goalStepRouteOverride(step({ id: 'configure-store', appId: 'btcpay-server' }))).toBeNull()
}) })
it('routes built-in identity and backup steps to their owning screens', () => { it('routes built-in identity and backup steps to their owning screens', () => {
expect(goalStepRouteOverride(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5') expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
expect(goalStepRouteOverride(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials') expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
expect(goalStepRouteOverride(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings') expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
})
it('routes channel steps to the Lightning channels screen', () => {
expect(goalStepRouteOverride(step({ id: 'open-channel' }))).toBe('/dashboard/apps/lnd/channels')
expect(goalStepRouteOverride(step({ id: 'open-channels' }))).toBe('/dashboard/apps/lnd/channels')
expect(goalStepRouteOverride(step({ id: 'open-zeus-channel' }))).toBe('/dashboard/apps/lnd/channels')
}) })
it('keeps passive info steps without a target route', () => { it('keeps passive info steps without a target route', () => {
expect(goalStepRouteOverride(step({ id: 'sync-setup' }))).toBeNull() expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
}) })
it('gives every shipped configure step a destination — a route override or an app to launch', () => { it('gives every configure step in the shipped goals a destination', () => {
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure')) const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
for (const candidate of configureSteps) { expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
const destination = goalStepRouteOverride(candidate) ?? candidate.appId ?? null configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
expect(destination, `configure step ${candidate.id} has no destination`).not.toBeNull() )
}
}) })
}) })
+2 -12
View File
@@ -1,24 +1,14 @@
import type { GoalStep } from '@/types/goals' import type { GoalStep } from '@/types/goals'
// Steps that land on an internal screen rather than launching an app UI.
const STEP_ROUTE_OVERRIDES: Record<string, string> = { const STEP_ROUTE_OVERRIDES: Record<string, string> = {
'setup-nostr': '/dashboard/web5', 'setup-nostr': '/dashboard/web5',
'export-identity': '/dashboard/web5/credentials', 'export-identity': '/dashboard/web5/credentials',
'create-passphrase': '/dashboard/settings', 'create-passphrase': '/dashboard/settings',
'create-backup': '/dashboard/settings', 'create-backup': '/dashboard/settings',
'save-backup': '/dashboard/settings', 'save-backup': '/dashboard/settings',
// Channel steps land directly on the Lightning channels screen (which
// carries the "open a channel with Zeus" suggestion).
'open-channel': '/dashboard/apps/lnd/channels',
'open-channels': '/dashboard/apps/lnd/channels',
'open-zeus-channel': '/dashboard/apps/lnd/channels',
} }
/** export function goalStepTargetPath(step: GoalStep): string | null {
* Internal route a step navigates to, or null when the step should launch its if (step.appId) return `/dashboard/apps/${step.appId}`
* app instead (via the app launcher iframe apps overlay on top, tab-only
* apps open a tab / the mobile in-app browser).
*/
export function goalStepRouteOverride(step: GoalStep): string | null {
return STEP_ROUTE_OVERRIDES[step.id] ?? null return STEP_ROUTE_OVERRIDES[step.id] ?? null
} }
+1 -3
View File
@@ -368,10 +368,8 @@ onMounted(() => load())
<template> <template>
<div class="pb-6"> <div class="pb-6">
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
title's centerline in this inline header row. -->
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<BackButton desktop-margin="mb-0" @click="goBack" /> <BackButton @click="goBack" />
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1> <h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
</div> </div>
+26 -56
View File
@@ -30,74 +30,44 @@
</svg> </svg>
Refreshing Tor services... Refreshing Tor services...
</div> </div>
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3"> <div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex items-center justify-between gap-3"> <div class="flex-1 min-w-0">
<div class="flex-1 min-w-0"> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <p class="text-white text-sm font-medium">{{ svc.name }}</p>
<p class="text-white text-sm font-medium">{{ svc.name }}</p> <span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span> <span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span> <span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span> <span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div> </div>
<!-- Desktop: compact inline actions next to the toggle --> <p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<div class="hidden md:flex items-center gap-2 shrink-0"> <p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<button <p v-else class="text-white/30 text-xs">Disabled</p>
v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div>
<ToggleSwitch class="shrink-0 md:hidden" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div> </div>
<!-- Mobile: actions in their own 50/50 row Delete on the LEFT, far <div class="flex items-center gap-2 shrink-0">
from the toggle above, so a rushed thumb can't hit the wrong control. -->
<div
v-if="svc.name !== 'archipelago' || (svc.onion_address && svc.enabled)"
class="grid md:hidden grid-cols-2 gap-2 mt-3"
>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
:title="'Delete ' + svc.name + ' hidden service'"
>
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
</button>
<button <button
v-if="svc.onion_address && svc.enabled" v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)" @click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name" :disabled="torRotating === svc.name"
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50" class="glass-button px-3 py-1.5 rounded-lg text-xs"
:class="{ 'col-span-2': svc.name === 'archipelago' }"
> >
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }} {{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button> </button>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div> </div>
</div> </div>
</div> </div>
<div class="responsive-card-actions-bottom-grid mt-auto pt-4 grid-cols-2 gap-3"> <div class="responsive-card-actions-bottom-grid mt-4 grid-cols-2 gap-3">
<button @click="$emit('restartTor')" :disabled="torRestarting" class="mobile-card-action glass-button rounded-lg text-sm font-medium disabled:opacity-50"> <button @click="$emit('restartTor')" :disabled="torRestarting" class="mobile-card-action glass-button rounded-lg text-sm font-medium disabled:opacity-50">
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }} {{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
</button> </button>
@@ -362,24 +362,6 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.102-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.102-alpha</span>
<span class="text-xs text-white/40">July 17, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too renaming no longer breaks the kiosk display.</p>
<p>Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end goals now complete when you've actually done the steps, not just when apps happen to be running.</p>
<p>Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.</p>
<p>First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps file cloud and ecash wallet even with no internet connection.</p>
<p>The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.</p>
<p>The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh updates now politely wait for the cinematic to finish and dark backgrounds stay dark instead of flashing black or white.</p>
<p>Your backups now include your secrets including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.</p>
<p>Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.</p>
<p>Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.</p>
</div>
</div>
<!-- v1.7.101-alpha --> <!-- v1.7.101-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
+66 -12
View File
@@ -11,6 +11,21 @@
<div class="flex-1"> <div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2> <h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
</div> </div>
<div class="web5-card-actions-top gap-2 shrink-0">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
</div>
</div> </div>
<!-- Mobile: stacked layout --> <!-- Mobile: stacked layout -->
<div class="md:hidden mb-4"> <div class="md:hidden mb-4">
@@ -159,9 +174,7 @@
</div> </div>
<div class="mt-auto pt-4 space-y-3"> <div class="mt-auto pt-4 space-y-3">
<!-- Always the bottom 50/50 pair, every screen size consistent with <div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
the other web5 cards' full-width bottom actions. -->
<div class="grid grid-cols-2 gap-3">
<button <button
@click="router.push('/dashboard/server/federation')" @click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
@@ -169,13 +182,37 @@
{{ t('web5.findNodes') }} {{ t('web5.findNodes') }}
</button> </button>
<button <button
@click="refreshActiveTab" @click="loadPeers"
:disabled="loadingPeers || loadingRequests" :disabled="loadingPeers"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
> >
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }} {{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button> </button>
</div> </div>
<button
v-if="nodesContainerTab === 'trusted'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'observers'"
@click="loadPeers"
:disabled="loadingPeers"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
<button
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div> </div>
</div> </div>
@@ -291,6 +328,7 @@ const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false) const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {}) const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value })) const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
// Send message modal // Send message modal
const showSendMessageModal = ref(false) const showSendMessageModal = ref(false)
@@ -340,12 +378,6 @@ function switchToRequestsTab() {
} }
} }
// The single bottom Refresh acts on whichever tab is open.
function refreshActiveTab() {
if (nodesContainerTab.value === 'requests') void loadConnectionRequests()
else void loadPeers()
}
async function loadPeers() { async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0 const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true loadingPeers.value = true
@@ -424,6 +456,28 @@ async function sendMessage() {
} }
} }
async function discoverAndAddPeers() {
discovering.value = true
try {
const res = await rpcClient.discoverNodes()
const nodes = res.nodes || []
for (const n of nodes) {
if (n.onion && n.pubkey) {
try {
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
} catch (e) {
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
}
}
}
await loadPeers()
} catch (e) {
if (import.meta.env.DEV) console.error('Discover failed:', e)
} finally {
discovering.value = false
}
}
async function loadConnectionRequests() { async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0 const hadRequests = connectionRequests.value.length > 0
loadingRequests.value = true loadingRequests.value = true
+2 -108
View File
@@ -61,91 +61,9 @@
<div <div
v-for="(identity, idx) in managedIdentities" v-for="(identity, idx) in managedIdentities"
:key="identity.id" :key="identity.id"
:class="{ 'card-stagger': showStagger }" :class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:style="{ '--stagger-index': idx }" :style="{ '--stagger-index': idx }"
> >
<!-- Narrow containers (phones): profile-card layout with banner +
overlapping avatar. Hidden on wide containers where the classic
row below renders instead. -->
<div class="identity-card-m rounded-xl overflow-hidden bg-white/[0.08]">
<div class="relative h-16" :class="bannerFallback(identity)">
<img
v-if="identity.profile?.banner"
:src="displayableUrl(identity.profile.banner)"
class="absolute inset-0 w-full h-full object-cover"
/>
</div>
<div class="px-4 pb-4">
<div class="flex items-end justify-between -mt-7">
<button @click="openProfileEditor(identity)" class="relative w-14 h-14 rounded-full overflow-hidden ring-4 ring-black/60 group shrink-0" title="Edit profile">
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-lg font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<div class="flex items-center gap-1 pt-8">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2 flex-wrap min-w-0">
<span class="text-white font-semibold text-sm truncate">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<p v-if="identity.profile?.about" class="text-white/60 text-xs mt-1 leading-relaxed">{{ identity.profile.about }}</p>
<div class="mt-2 space-y-0.5">
<div class="flex items-center gap-1 min-w-0">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 min-w-0">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Wide containers: the original row desktop appearance unchanged -->
<div class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg">
<div class="identity-row-main flex items-center gap-4 min-w-0"> <div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar --> <!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile"> <button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
@@ -216,7 +134,6 @@
</svg> </svg>
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
@@ -548,16 +465,6 @@ watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability // Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data // query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched. // URLs and plain external URLs pass through untouched.
// Gradient banner backdrop for the narrow profile card when the identity has
// no banner image, tinted by purpose to match the avatar/chip colors.
function bannerFallback(identity: ManagedIdentity): string {
switch (identity.purpose) {
case 'business': return 'bg-gradient-to-br from-orange-500/40 to-amber-500/15'
case 'anonymous': return 'bg-gradient-to-br from-purple-500/40 to-fuchsia-500/15'
default: return 'bg-gradient-to-br from-blue-500/40 to-indigo-500/15'
}
}
function displayableUrl(url: string | null | undefined): string { function displayableUrl(url: string | null | undefined): string {
if (!url) return '' if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url if (url.startsWith('data:') || url.startsWith('/')) return url
@@ -803,21 +710,8 @@ defineExpose({ loadIdentities, managedIdentities })
container-name: identities-card; container-name: identities-card;
} }
/* Narrow containers (default): the profile card renders and the classic row
is hidden. Wide containers: the original desktop row untouched and the
profile card hides. Doubled selectors out-rank the Tailwind utilities on
the same elements regardless of stylesheet order. */
.identities-card .identity-row {
display: none;
}
@container identities-card (min-width: 560px) { @container identities-card (min-width: 560px) {
.identities-card .identity-card-m { .identity-row {
display: none;
}
.identities-card .identity-row {
display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue' import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
const router = useRouter() const router = useRouter()
@@ -25,10 +23,6 @@ interface ServicePricing {
accepted_mints: string[] accepted_mints: string[]
} }
// Tabs
const tab = ref<'dashboard' | 'configure'>('dashboard')
// Configure state
const services = ref<ServicePricing[]>([]) const services = ref<ServicePricing[]>([])
const loading = ref(true) const loading = ref(true)
const loadError = ref('') const loadError = ref('')
@@ -38,7 +32,7 @@ const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner // "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in. // reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled) const allFree = computed(() => services.value.every((s) => !s.enabled))
function showStatus(msg: string, isError: boolean) { function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg statusMsg.value = msg
@@ -119,432 +113,86 @@ async function saveService(svc: ServicePricing) {
} }
} }
// TollGate (paid WiFi on the OpenWrt gateway) onMounted(load)
interface TollGateStatus {
installed: boolean
enabled?: boolean
step_size_ms?: number
price_per_step?: number
min_steps?: number
mint_url?: string
}
const tollgate = ref<TollGateStatus | null>(null)
const tollgateChecked = ref(false)
const tollgateSaving = ref(false)
const tollgatePrice = ref(10)
const tollgateEnabled = ref(false)
const tollgateUnit = computed(() => {
const ms = tollgate.value?.step_size_ms || 60_000
if (ms === 3_600_000) return 'hour'
if (ms === 60_000) return 'minute'
if (ms === 1000) return 'second'
return `${ms.toLocaleString()} ms`
})
async function loadTollgate() {
try {
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
tollgate.value = res.tollgate || null
tollgateEnabled.value = !!res.tollgate?.enabled
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
} catch {
// No gateway configured (or unreachable) the card shows the setup hint.
tollgate.value = null
} finally {
tollgateChecked.value = true
}
}
async function saveTollgate() {
if (tollgatePrice.value < 1) tollgatePrice.value = 1
tollgateSaving.value = true
try {
await rpcClient.call({
method: 'openwrt.provision-tollgate',
params: {
enabled: tollgateEnabled.value,
// Real backend reads price_sats; the mock reads price_per_step
// send both so the same call works against either.
price_sats: tollgatePrice.value,
price_per_step: tollgatePrice.value,
step_size_ms: tollgate.value?.step_size_ms || 60_000,
min_steps: tollgate.value?.min_steps || 1,
},
})
showStatus(
tollgateEnabled.value
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
: 'TollGate WiFi is now free.',
false,
)
void loadTollgate()
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
} finally {
tollgateSaving.value = false
}
}
// Dashboard state
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
interface ProfitEntry {
source: ProfitSource
amount_sats: number
timestamp: string
description?: string
}
interface ProfitsSummary {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
streaming_revenue_sats?: number
recent?: ProfitEntry[]
}
interface SessionsSummary {
total_active: number
total_revenue_sats: number
revenue_by_service: Record<string, number>
}
const profits = ref<ProfitsSummary | null>(null)
const sessionsSummary = ref<SessionsSummary | null>(null)
const dashboardLoading = ref(true)
async function loadDashboard() {
dashboardLoading.value = true
try {
const [p, s] = await Promise.all([
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
])
profits.value = p
sessionsSummary.value = s
} finally {
dashboardLoading.value = false
}
}
function formatSats(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
}
// 7 daily buckets, oldest newest, one series per earning source. A week of
// daily totals is the sweet spot here: earnings are sparse events (unlike the
// second-by-second system metrics on Monitoring), so finer buckets would just
// draw noise, and a longer window would flatten a new node's first sats.
const DAY_MS = 86_400_000
const WINDOW_DAYS = 7
const dayLabels = computed(() => {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const labels: string[] = []
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
}
return labels
})
function bucketize(source: ProfitSource): number[] {
const buckets = new Array(WINDOW_DAYS).fill(0)
const now = Date.now()
for (const e of profits.value?.recent || []) {
if (e.source !== source) continue
const t = Date.parse(e.timestamp)
if (Number.isNaN(t)) continue
const age = Math.floor((now - t) / DAY_MS)
if (age < 0 || age >= WINDOW_DAYS) continue
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
}
return buckets
}
const earningsDatasets = computed<ChartDataset[]>(() => [
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
])
const hasRecentEarnings = computed(() =>
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
)
// Revenue-by-service bars, largest first, scaled to the biggest earner.
const serviceBars = computed(() => {
const by = sessionsSummary.value?.revenue_by_service || {}
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
const max = entries[0]?.[1] || 1
return entries.map(([id, sats]) => ({
id,
name: services.value.find((s) => s.service_id === id)?.name || id,
sats,
pct: Math.max(4, Math.round((sats / max) * 100)),
}))
})
// Chart sizing (same measure-the-card approach as Monitoring)
const chartCard = ref<HTMLElement | null>(null)
const chartWidth = ref(600)
function updateChartWidth() {
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
}
onMounted(() => {
void load()
void loadTollgate()
void loadDashboard()
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateChartWidth)
})
</script> </script>
<template> <template>
<div class="pb-6"> <div class="pb-6">
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" /> <BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-4"> <div class="mb-6">
<h1 class="text-3xl font-bold text-white">Networking Profits</h1> <h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div> </div>
<!-- Tabs: Dashboard | Configure --> <!-- Status message -->
<div class="flex gap-1 mb-6 border-b border-white/10"> <div
<button v-if="statusMsg"
@click="tab = 'dashboard'" role="status"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors" aria-live="polite"
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'" class="mb-4 p-3 rounded-lg text-sm"
> :class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
Dashboard >
</button> {{ statusMsg }}
<button
@click="tab = 'configure'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Configure
</button>
</div> </div>
<!-- ============ DASHBOARD ============ --> <!-- Everything-free reassurance banner -->
<div v-show="tab === 'dashboard'"> <div
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings</div> v-if="!loading && allFree"
<template v-else> class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
<!-- Stat tiles --> >
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <span class="text-xl"></span>
<div class="glass-card p-5"> <p class="text-sm text-green-200">
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p> Everything is free. Your node isn't charging for anything enable a service below to
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p> start earning.
<p class="text-xs text-white/40">sats, all time</p> </p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
<p class="text-xs text-white/40">sats from paid services</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
<p class="text-xs text-white/40">sats from ecash sales</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
<p class="text-xs text-white/40">sats from Lightning</p>
</div>
</div>
<!-- Earnings chart -->
<div ref="chartCard" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white/80">Earnings last 7 days</h3>
<div class="flex items-center gap-4">
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
{{ d.label }}
</span>
</div>
</div>
<LineChart
v-if="hasRecentEarnings"
:datasets="earningsDatasets"
:labels="dayLabels"
:width="chartWidth"
:height="200"
/>
<div v-else class="py-10 text-center text-white/40 text-sm">
No earnings in the last 7 days enable a paid service under Configure to start earning.
</div>
</div>
<!-- Sessions + revenue by service -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
<p class="text-xs text-white/40">peers currently paying for services</p>
<p class="text-sm text-white/70 mt-4">
Session revenue:
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
</p>
</div>
<div class="glass-card p-5 lg:col-span-2">
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
No service revenue yet.
</div>
<div v-else class="space-y-3">
<div v-for="bar in serviceBars" :key="bar.id">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/80">{{ bar.name }}</span>
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div> </div>
<!-- ============ CONFIGURE ============ --> <div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-show="tab === 'configure'"> <div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
<div class="glass-card p-4 mb-6">
<p class="text-sm text-white/70 leading-relaxed">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message --> <div v-else class="space-y-4">
<div <div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
v-if="statusMsg" <div class="flex items-start justify-between gap-4 mb-3">
role="status" <div class="min-w-0">
aria-live="polite" <h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
class="mb-4 p-3 rounded-lg text-sm" <p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div> </div>
<div class="flex items-center gap-2 shrink-0">
<div class="flex flex-col sm:flex-row sm:items-end gap-3"> <span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }"> {{ svc.enabled ? 'Paid' : 'Free' }}
<label class="text-xs text-white/50 block mb-1">Price</label> </span>
<div class="flex items-center gap-2"> <ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div> </div>
</div> </div>
<!-- TollGate: paid WiFi on the OpenWrt gateway --> <div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div v-if="tollgateChecked" class="glass-card p-6"> <div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<div class="flex items-start justify-between gap-4 mb-3"> <label class="text-xs text-white/50 block mb-1">Price</label>
<div class="min-w-0"> <div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2> <input
<p class="text-sm text-white/60 mt-0.5"> v-model.number="svc.price_per_step"
Sell WiFi access on your OpenWrt gateway visitors pay per {{ tollgateUnit }} in ecash. type="number"
</p> min="1"
</div> step="1"
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0"> :disabled="!svc.enabled"
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'"> class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
{{ tollgateEnabled ? 'Paid' : 'Free' }} />
</span> <span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
</div> </div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div> </div>
<button
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3"> @click="saveService(svc)"
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }"> :disabled="savingId === svc.service_id"
<label class="text-xs text-white/50 block mb-1">Price</label> class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
<div class="flex items-center gap-2"> >
<input {{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
v-model.number="tollgatePrice" </button>
type="number"
min="1"
step="1"
:disabled="!tollgateEnabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
</div>
</div>
<button
@click="saveTollgate"
:disabled="tollgateSaving"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ tollgateSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
<p class="text-sm text-white/50 flex-1">
TollGate isn't set up yet it needs an OpenWrt gateway paired with this node.
</p>
<button
@click="router.push('/dashboard/server/openwrt')"
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
>
Set up gateway
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+34 -39
View File
@@ -1,36 +1,27 @@
<template> <template>
<!-- Node Visibility --> <!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3"> <div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
<div class="mb-4 shrink-0"> <div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex items-center sm:items-start gap-4"> <div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> </svg>
</svg> </div>
</div> <div class="flex-1 min-w-0">
<div class="flex-1 min-w-0"> <h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
<h2 class="text-xl font-semibold text-white sm:mb-2">{{ t('web5.nodeVisibility') }}</h2> <p class="text-white/70 text-sm">
<!-- Desktop: description beside the icon, as before --> Make your node publicly discoverable. When enabled, anyone on the Nostr
<p class="hidden sm:block text-white/70 text-sm"> network can find your node and request a connection requests always
Make your node publicly discoverable. When enabled, anyone on the Nostr wait for your approval and join as a Peer, never trusted.
network can find your node and request a connection requests always </p>
wait for your approval and join as a Peer, never trusted. </div>
</p> <div v-if="visibilityLoading" class="shrink-0">
</div> <svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<div v-if="visibilityLoading" class="shrink-0"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24"> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> </svg>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div> </div>
<!-- Mobile: description stacked under the icon + title -->
<p class="sm:hidden mt-3 text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div> </div>
<!-- Enable switch --> <!-- Enable switch -->
@@ -66,7 +57,16 @@
<!-- Discoverable nodes --> <!-- Discoverable nodes -->
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0"> <div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
<p class="text-sm font-medium text-white mb-2">Discoverable nodes</p> <div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-white">Discoverable nodes</p>
<button
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
</div>
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs"> <div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
Searching relays Searching relays
</div> </div>
@@ -95,15 +95,10 @@
</div> </div>
</div> </div>
<!-- Refresh full-width card action on every screen size --> <!-- Warning -->
<button <p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
v-if="discoverEnabled" {{ t('web5.discoverableWarning') }}
class="mt-4 w-full glass-button rounded-lg py-2.5 text-sm font-medium text-white/90 hover:text-white disabled:opacity-50" </p>
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
<PeerRequestModal <PeerRequestModal
:show="requestModalTarget !== null" :show="requestModalTarget !== null"
+24 -23
View File
@@ -1,35 +1,36 @@
{ {
"changelog": [ "changelog": [
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.", "The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.",
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.", "Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual \"Add Service\" step.",
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.", "\"Add Service\" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with \"see server logs\".",
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.", "Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.",
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.", "The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.",
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refreshupdates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.", "Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.",
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.", "The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — \"Replay Intro\" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.",
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.", "Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.",
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring." "The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.",
"Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport)."
], ],
"components": [ "components": [
{ {
"current_version": "1.7.102-alpha", "current_version": "1.7.101-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.7.102-alpha", "new_version": "1.7.101-alpha",
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a", "sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
"size_bytes": 49951520 "size_bytes": 50100808
}, },
{ {
"current_version": "1.7.102-alpha", "current_version": "1.7.101-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago-frontend-1.7.101-alpha.tar.gz",
"name": "archipelago-frontend-1.7.102-alpha.tar.gz", "name": "archipelago-frontend-1.7.101-alpha.tar.gz",
"new_version": "1.7.102-alpha", "new_version": "1.7.101-alpha",
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94", "sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
"size_bytes": 174594796 "size_bytes": 164830686
} }
], ],
"release_date": "2026-07-17", "release_date": "2026-07-15",
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04", "signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.102-alpha" "version": "1.7.101-alpha"
} }
+24 -23
View File
@@ -1,35 +1,36 @@
{ {
"changelog": [ "changelog": [
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.", "The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.",
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.", "Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual \"Add Service\" step.",
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.", "\"Add Service\" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with \"see server logs\".",
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.", "Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.",
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.", "The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.",
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refreshupdates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.", "Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.",
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.", "The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — \"Replay Intro\" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.",
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.", "Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.",
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring." "The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.",
"Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport)."
], ],
"components": [ "components": [
{ {
"current_version": "1.7.102-alpha", "current_version": "1.7.101-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.7.102-alpha", "new_version": "1.7.101-alpha",
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a", "sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
"size_bytes": 49951520 "size_bytes": 50100808
}, },
{ {
"current_version": "1.7.102-alpha", "current_version": "1.7.101-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago-frontend-1.7.101-alpha.tar.gz",
"name": "archipelago-frontend-1.7.102-alpha.tar.gz", "name": "archipelago-frontend-1.7.101-alpha.tar.gz",
"new_version": "1.7.102-alpha", "new_version": "1.7.101-alpha",
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94", "sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
"size_bytes": 174594796 "size_bytes": 164830686
} }
], ],
"release_date": "2026-07-17", "release_date": "2026-07-15",
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04", "signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.102-alpha" "version": "1.7.101-alpha"
} }
-17
View File
@@ -103,23 +103,6 @@ for f in /usr/local/bin/archipelago \
fi fi
done done
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
# crash-loop forever) and archipelago-diag.service without its script
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
[ -f "$unit" ] || continue
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
case "$exec_bin" in
/*) if [ -e "$exec_bin" ]; then
pass "Unit payload exists: $(basename "$unit")$exec_bin"
else
fail "Unit payload missing" "$(basename "$unit")$exec_bin"
fi ;;
esac
done
# 1.2 — Critical services active # 1.2 — Critical services active
for svc in archipelago nginx; do for svc in archipelago nginx; do
if systemctl is-active "$svc" >/dev/null 2>&1; then if systemctl is-active "$svc" >/dev/null 2>&1; then