Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573b469191 | ||
|
|
401f92a24f | ||
|
|
dc0adbef70 | ||
|
|
537c9fa70b | ||
|
|
b6468ebf3c | ||
|
|
70587210fb | ||
|
|
a27c7bafbf | ||
|
|
95b9d8f0fe | ||
|
|
918aba1de3 | ||
|
|
49b366fbe4 | ||
|
|
7eaf99873e | ||
|
|
a76a92cff8 | ||
|
|
a177ef3b38 | ||
|
|
ea0cd87b3b | ||
|
|
1ee1b56f70 | ||
|
|
73d181abea | ||
|
|
55d7f19545 | ||
|
|
9e264611e2 | ||
|
|
f32c4db7e2 | ||
|
|
8e13f981d0 | ||
|
|
3aebbcbbb8 |
@@ -741,7 +741,16 @@ private fun buildAutoLoginScript(password: String): String {
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
// Let Vue re-render before submitting: a synchronous Enter arrives
|
||||
// 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);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.103-alpha (2026-07-18)
|
||||
|
||||
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
|
||||
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
|
||||
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
|
||||
|
||||
## 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)
|
||||
|
||||
- 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.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.103-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.103-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -207,7 +207,11 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
|
||||
let file = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
|
||||
@@ -147,6 +147,15 @@ impl RpcHandler {
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
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
|
||||
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||
|
||||
@@ -95,33 +95,54 @@ impl RpcHandler {
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.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"));
|
||||
}
|
||||
// send_all sweeps the entire confirmed on-chain balance (LND computes
|
||||
// the amount after fees); amount is required otherwise.
|
||||
let send_all = params
|
||||
.get("send_all")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let amount = if send_all {
|
||||
None
|
||||
} else {
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.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)
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
});
|
||||
let send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"addr": addr,
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
|
||||
@@ -394,9 +394,9 @@ where
|
||||
// under any name variant AND no install in flight — waiting cannot
|
||||
// satisfy it.
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers.iter().any(|c| {
|
||||
existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)
|
||||
})
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c))
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
|
||||
@@ -1140,8 +1140,7 @@ impl RpcHandler {
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
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,9 +700,7 @@ async fn install_stack_via_orchestrator(
|
||||
// Truthful end-of-install signal, mirroring the legacy stack installers:
|
||||
// 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.
|
||||
handler
|
||||
.set_install_progress(stack_name, total, total)
|
||||
.await;
|
||||
handler.set_install_progress(stack_name, total, total).await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PostInstall)
|
||||
.await;
|
||||
|
||||
@@ -402,6 +402,16 @@ async fn sync_hostname_side_effects(hostname: &str) {
|
||||
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")
|
||||
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
|
||||
.output()
|
||||
|
||||
@@ -360,7 +360,7 @@ fn validate_password_strength(password: &str) -> Result<()> {
|
||||
/// Change the archipelago user's SSH/login password.
|
||||
/// 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.
|
||||
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
let ssh_user =
|
||||
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
|
||||
@@ -763,7 +763,9 @@ mod tests {
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
@@ -784,7 +786,9 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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, 2–5 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.
|
||||
@@ -1353,9 +1353,11 @@ if [ "$UNBUNDLED" = "1" ]; then
|
||||
# unbundled mode — their images must ride on the ISO so a fresh install
|
||||
# works with no internet: FileBrowser (Cloud file manager) and fmcd
|
||||
# (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="
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar
|
||||
${FMCD_IMAGE} fmcd.tar
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar.zst
|
||||
${FMCD_IMAGE} fmcd.tar.zst
|
||||
"
|
||||
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
|
||||
[ -n "$CORE_IMAGE" ] || continue
|
||||
@@ -1364,9 +1366,14 @@ ${FMCD_IMAGE} fmcd.tar
|
||||
else
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
|
||||
RAW_TAR="$IMAGES_DIR/${CORE_FILE%.zst}"
|
||||
if $CONTAINER_CMD save "$CORE_IMAGE" -o "$RAW_TAR" 2>/dev/null && \
|
||||
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"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline"
|
||||
fi
|
||||
@@ -1509,7 +1516,7 @@ done
|
||||
PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman"
|
||||
$PODMAN system migrate >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
for tarfile in "$IMAGES_DIR"/*.tar; do
|
||||
for tarfile in "$IMAGES_DIR"/*.tar "$IMAGES_DIR"/*.tar.zst; do
|
||||
if [ -f "$tarfile" ]; then
|
||||
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
|
||||
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
@@ -2520,7 +2527,7 @@ fi
|
||||
if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
|
||||
echo " Copying container images (this may take a moment)..."
|
||||
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
|
||||
mkdir -p /mnt/target/opt/archipelago/scripts
|
||||
|
||||
@@ -106,6 +106,12 @@ fi
|
||||
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -103,27 +103,10 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
||||
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
||||
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
||||
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/';
|
||||
}
|
||||
# IndeeHub is no longer proxied same-origin — the sub_filter rewrite
|
||||
# approach broke the SPA's runtime-built asset URLs. The demo now opens
|
||||
# the real site (https://indee.tx1138.com/) externally instead, via
|
||||
# DEMO_EXTERNAL_URLS in useDemoIntro.ts.
|
||||
|
||||
# Mempool is NOT proxied upstream anymore — the mock backend serves a
|
||||
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
|
||||
|
||||
+32
-10
@@ -3411,14 +3411,19 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
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({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
|
||||
{ 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' },
|
||||
],
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3462,7 +3467,10 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'lnd.sendcoins': {
|
||||
const amt = params?.amount || params?.amt || 50000
|
||||
// send_all sweeps the entire on-chain balance (minus a mock fee)
|
||||
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)
|
||||
const txid = randomHex(32)
|
||||
walletState.transactions.unshift({
|
||||
@@ -3643,15 +3651,29 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
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 0–1
|
||||
// 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({
|
||||
result: {
|
||||
chain: 'signet',
|
||||
blocks: 892451,
|
||||
headers: 892451,
|
||||
block_height: height,
|
||||
sync_progress: syncProgress,
|
||||
blocks: height,
|
||||
headers: tipHeight,
|
||||
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
|
||||
difficulty: 0.001126515290698186,
|
||||
mediantime: Math.floor(Date.now() / 1000) - 300,
|
||||
verificationprogress: 1.0,
|
||||
verificationprogress: syncProgress,
|
||||
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
|
||||
size_on_disk: 210_000_000,
|
||||
pruned: false,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.103-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.103-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.103-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -16,6 +16,39 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -74,15 +107,15 @@
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-400': ch.status === 'active',
|
||||
'bg-yellow-400': ch.status === 'pending_open',
|
||||
'bg-red-400': ch.status === 'inactive',
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="ch.status !== 'pending_open'"
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@@ -270,8 +303,13 @@ interface Channel {
|
||||
local_balance: number
|
||||
remote_balance: number
|
||||
active: boolean
|
||||
status: string
|
||||
channel_point: string
|
||||
status?: 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'
|
||||
@@ -288,6 +326,11 @@ const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
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 defaultOpenForm = () => ({
|
||||
peerUri: '',
|
||||
@@ -297,6 +340,19 @@ const defaultOpenForm = () => ({
|
||||
customConfTarget: 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 openingChannel = ref(false)
|
||||
const openError = ref<string | null>(null)
|
||||
@@ -313,7 +369,7 @@ function formatSats(sats: number): 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 : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
@@ -53,6 +54,12 @@ function reloadAfterCinematic() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The public demo has no version to update to — the prompt is noise, and
|
||||
// both accept-paths end in a reload that replays the demo intro ("the site
|
||||
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
|
||||
// waiting worker is safe: this page keeps its complete old cache, and the
|
||||
// new build activates on the next visit.
|
||||
if (IS_DEMO) return
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On the very first visit the page loads with no controlling SW; the
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
|
||||
<!-- On-chain -->
|
||||
<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">
|
||||
<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>
|
||||
@@ -77,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -85,9 +88,21 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const props = defineProps<{
|
||||
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: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
||||
const invoiceAmount = ref<number>(0)
|
||||
const invoiceMemo = ref('')
|
||||
|
||||
@@ -16,8 +16,30 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<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 v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
@@ -47,7 +69,7 @@
|
||||
|
||||
<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="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">
|
||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : t('common.send') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -55,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -75,6 +97,23 @@ const resultHash = ref('')
|
||||
const resultArk = 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(() => {
|
||||
if (sendMethod.value !== 'auto') return sendMethod.value
|
||||
const amt = amount.value || 0
|
||||
@@ -98,7 +137,8 @@ function copyText(text: string) {
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!amount.value || processing.value) return
|
||||
if (processing.value) return
|
||||
if (!amount.value && !isSweep.value) return
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
@@ -134,7 +174,9 @@ async function send() {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr: dest.value.trim(), amount: amount.value },
|
||||
params: isSweep.value
|
||||
? { addr: dest.value.trim(), send_all: true }
|
||||
: { addr: dest.value.trim(), amount: amount.value },
|
||||
})
|
||||
resultTxid.value = res.txid
|
||||
}
|
||||
|
||||
@@ -23,7 +23,14 @@
|
||||
<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>
|
||||
</div>
|
||||
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -32,10 +39,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ToastVariant } from '@/composables/useToast'
|
||||
import type { ToastItem, ToastVariant } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function runAction(toast: ToastItem | Readonly<ToastItem>) {
|
||||
toast.action?.onClick()
|
||||
dismiss(toast.id)
|
||||
}
|
||||
|
||||
function variantClass(variant: ToastVariant): string {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,17 @@ export function clearDemoIntroSeen(): void {
|
||||
// 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
|
||||
// and is not launchable.
|
||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {}
|
||||
// IndeeHub's real site sends X-Frame-Options: SAMEORIGIN, and the old
|
||||
// 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
|
||||
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
|
||||
// rewritten) so the frame-busting real sites can be embedded.
|
||||
const DEMO_MOCK_UI: Record<string, string> = {
|
||||
indeedhub: '/app/indeedhub/',
|
||||
mempool: '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'bitcoin-knots': '/app/bitcoin-knots/',
|
||||
@@ -61,11 +65,11 @@ const DEMO_MOCK_UI: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a demo app opens in a new tab. Nothing does — IndeeHub and Mempool
|
||||
* both load their real site directly in the in-app iframe.
|
||||
* Whether a demo app opens externally (new tab / in-app browser) because its
|
||||
* real site blocks iframing (X-Frame-Options).
|
||||
*/
|
||||
export function isDemoExternal(_appId: string): boolean {
|
||||
return false
|
||||
export function isDemoExternal(appId: string): boolean {
|
||||
return appId in DEMO_EXTERNAL_URLS
|
||||
}
|
||||
|
||||
/** Can this app be launched/installed in the demo? */
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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,19 +2,25 @@ import { ref, readonly } from 'vue'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
dismissing: boolean
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, variant, dismissing: false })
|
||||
toasts.value.push({ id, message, variant, dismissing: false, action })
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
@@ -42,6 +48,9 @@ export function useToast() {
|
||||
success: (msg: string) => addToast(msg, 'success'),
|
||||
error: (msg: string) => addToast(msg, 'error'),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { GoalDefinition } from '@/types/goals'
|
||||
import type { GoalDefinition, GoalStep } 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[] = [
|
||||
{
|
||||
@@ -25,6 +46,17 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
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,000–1,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',
|
||||
title: 'Install BTCPay Server',
|
||||
@@ -69,13 +101,16 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{ ...FUND_WALLET_STEP },
|
||||
{
|
||||
id: 'open-channel',
|
||||
title: 'Open a Lightning Channel',
|
||||
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
||||
appId: 'lnd',
|
||||
title: 'Open a Channel with Zeus',
|
||||
description:
|
||||
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,000–1,500,000 sats). You can then send and receive Lightning payments from your phone.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
ctaLabel: 'Open a channel',
|
||||
},
|
||||
],
|
||||
estimatedTime: '~30 min + sync time',
|
||||
@@ -168,13 +203,16 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{ ...FUND_WALLET_STEP },
|
||||
{
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||
appId: 'lnd',
|
||||
description:
|
||||
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,000–1,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
ctaLabel: 'Open a channel',
|
||||
},
|
||||
{
|
||||
id: 'verify-routing',
|
||||
|
||||
@@ -169,11 +169,16 @@ describe('useGoalStore', () => {
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns completed when all required apps are running', () => {
|
||||
it('returns completed when all required apps run AND manual steps are done', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -198,6 +203,8 @@ describe('useGoalStore', () => {
|
||||
mockPackages['immich-server'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('store-photos')
|
||||
store.completeStep('store-photos', 'configure-immich')
|
||||
expect(store.getGoalStatus('store-photos')).toBe('completed')
|
||||
})
|
||||
|
||||
@@ -218,6 +225,8 @@ describe('useGoalStore', () => {
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
const statuses = store.goalStatuses
|
||||
|
||||
expect(statuses['accept-payments']).toBe('completed')
|
||||
|
||||
@@ -88,12 +88,19 @@ export const useGoalStore = defineStore('goals', () => {
|
||||
([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) =>
|
||||
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
|
||||
)
|
||||
if (anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
if (allRunning || anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
|
||||
return 'not-started'
|
||||
}
|
||||
|
||||
@@ -17,8 +17,16 @@ export interface GoalStep {
|
||||
title: string
|
||||
description: 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
|
||||
/** 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'
|
||||
|
||||
@@ -157,8 +157,12 @@ import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
|
||||
useIbdFinishWatcher()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -98,12 +98,59 @@
|
||||
>
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</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,000–1,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
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@@ -142,12 +189,27 @@
|
||||
</div>
|
||||
<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>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
<button
|
||||
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') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -161,15 +223,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
import { goalStepRouteOverride } from './goals/goalStepActions'
|
||||
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/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
@@ -185,10 +258,27 @@ const APP_ICON_MAP: Record<string, string> = {
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
if (step.icon) return step.icon
|
||||
if (!step.appId) return undefined
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -214,7 +304,9 @@ const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
if (step.appId && isAppInstalled(step.appId)) {
|
||||
// Only install steps auto-tick from package state — manual steps (fund the
|
||||
// wallet, open a channel, configure) must be walked through.
|
||||
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
@@ -306,9 +398,16 @@ async function installApp(step: GoalStep) {
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
const targetPath = goalStepTargetPath(step)
|
||||
if (targetPath) {
|
||||
router.push(targetPath)
|
||||
const override = goalStepRouteOverride(step)
|
||||
if (override) {
|
||||
// Internal screens (channels, web5, settings) — tag where we came from so
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +428,71 @@ function ensureGoalStarted() {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard')
|
||||
// The goal cards live on Home's Setup tab — return there, not the 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>
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
@@ -315,9 +315,15 @@ import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const uiMode = useUIModeStore()
|
||||
const isDev = import.meta.env.DEV
|
||||
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
|
||||
// ?tab=setup lands on the Setup tab (e.g. "Back to Goals" from a goal wizard)
|
||||
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 QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
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"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||
@@ -83,6 +84,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
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"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keydown.enter="handleSetupWithSound"
|
||||
@@ -127,6 +129,7 @@
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
data-controller-no-submit
|
||||
: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"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@@ -165,6 +168,12 @@
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All auth inputs opt out of controller-nav's Enter→click-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">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
@@ -175,6 +184,7 @@
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
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"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keydown.enter="handleLoginWithSound"
|
||||
|
||||
@@ -207,9 +207,13 @@
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||
</div>
|
||||
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
<!-- mt-auto pins the action to the card bottom so buttons align across
|
||||
equal-height grid cards -->
|
||||
<div class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces (second column on desktop) -->
|
||||
@@ -273,13 +277,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
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
|
||||
</button>
|
||||
<div v-if="wifiAvailable" class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button
|
||||
@click="showWifiModal = true"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- close VPN+Network 2-col grid -->
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<!-- 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>
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
|
||||
|
||||
@@ -15,8 +9,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/bg-intro-4.webp"
|
||||
src="/assets/img/companion-banner-bg.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { goalStepTargetPath } from '../goalStepActions'
|
||||
import { goalStepRouteOverride } from '../goalStepActions'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
describe('goalStepActions', () => {
|
||||
it('routes app-backed steps to their app details page', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
|
||||
it('app-backed configure steps have no route override — they launch the app itself', () => {
|
||||
expect(goalStepRouteOverride(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBeNull()
|
||||
expect(goalStepRouteOverride(step({ id: 'configure-store', appId: 'btcpay-server' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('routes built-in identity and backup steps to their owning screens', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||
expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||
expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
|
||||
expect(goalStepRouteOverride(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||
expect(goalStepRouteOverride(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||
expect(goalStepRouteOverride(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', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
|
||||
expect(goalStepRouteOverride(step({ id: 'sync-setup' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('gives every configure step in the shipped goals a destination', () => {
|
||||
it('gives every shipped configure step a destination — a route override or an app to launch', () => {
|
||||
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
|
||||
|
||||
expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
|
||||
configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
|
||||
)
|
||||
for (const candidate of configureSteps) {
|
||||
const destination = goalStepRouteOverride(candidate) ?? candidate.appId ?? null
|
||||
expect(destination, `configure step ${candidate.id} has no destination`).not.toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
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> = {
|
||||
'setup-nostr': '/dashboard/web5',
|
||||
'export-identity': '/dashboard/web5/credentials',
|
||||
'create-passphrase': '/dashboard/settings',
|
||||
'create-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 {
|
||||
if (step.appId) return `/dashboard/apps/${step.appId}`
|
||||
/**
|
||||
* Internal route a step navigates to, or null when the step should launch its
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -44,13 +44,36 @@
|
||||
<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>
|
||||
<ToggleSwitch class="shrink-0" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
<!-- Desktop: compact inline actions next to the toggle -->
|
||||
<div class="hidden md:flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
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>
|
||||
<!-- Actions in their own 50/50 row: Delete on the LEFT, far from the
|
||||
toggle above, so a rushed thumb can't hit the wrong control. -->
|
||||
<!-- Mobile: actions in their own 50/50 row — Delete on the LEFT, far
|
||||
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 grid-cols-2 gap-2 mt-3"
|
||||
class="grid md:hidden grid-cols-2 gap-2 mt-3"
|
||||
>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@@ -74,7 +97,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="responsive-card-actions-bottom-grid mt-4 grid-cols-2 gap-3">
|
||||
<div class="responsive-card-actions-bottom-grid mt-auto pt-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">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
|
||||
@@ -362,6 +362,36 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.103-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.103-alpha</span>
|
||||
<span class="text-xs text-white/40">July 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.</p>
|
||||
<p>Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.</p>
|
||||
<p>The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+17
-24
@@ -1,36 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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.",
|
||||
"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.",
|
||||
"\"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\".",
|
||||
"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 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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)."
|
||||
"Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger \"Replay Intro\" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.",
|
||||
"Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer \"click\" a nearby button by mistake when using a controller or the companion app.",
|
||||
"The public demo no longer interrupts you with an \"Update Available\" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
|
||||
"current_version": "1.7.103-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.103-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
|
||||
"size_bytes": 50100808
|
||||
"new_version": "1.7.103-alpha",
|
||||
"sha256": "b1a30287c1a694186e092d1746ed7cd647c4d2615edc59132cdc323ea5958ac4",
|
||||
"size_bytes": 49949048
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"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.101-alpha.tar.gz",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
|
||||
"size_bytes": 164830686
|
||||
"current_version": "1.7.103-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.103-alpha/archipelago-frontend-1.7.103-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.103-alpha.tar.gz",
|
||||
"new_version": "1.7.103-alpha",
|
||||
"sha256": "0544897a1d365fda8f7113eb80d76c5edcf50d539588af2886764546f02f52bf",
|
||||
"size_bytes": 174592877
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-15",
|
||||
"signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
|
||||
"release_date": "2026-07-18",
|
||||
"signature": "39d3f161437a03d44a71cf87c0622d6c00cec2ec587e9e1533128f279c9b0e09c34c51465f2ee79983a3f76f080e909b836a0a74e9d16009cdcf528a7dc0830c",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.101-alpha"
|
||||
"version": "1.7.103-alpha"
|
||||
}
|
||||
|
||||
+17
-24
@@ -1,36 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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.",
|
||||
"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.",
|
||||
"\"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\".",
|
||||
"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 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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)."
|
||||
"Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger \"Replay Intro\" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.",
|
||||
"Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer \"click\" a nearby button by mistake when using a controller or the companion app.",
|
||||
"The public demo no longer interrupts you with an \"Update Available\" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
|
||||
"current_version": "1.7.103-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.103-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
|
||||
"size_bytes": 50100808
|
||||
"new_version": "1.7.103-alpha",
|
||||
"sha256": "b1a30287c1a694186e092d1746ed7cd647c4d2615edc59132cdc323ea5958ac4",
|
||||
"size_bytes": 49949048
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"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.101-alpha.tar.gz",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
|
||||
"size_bytes": 164830686
|
||||
"current_version": "1.7.103-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.103-alpha/archipelago-frontend-1.7.103-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.103-alpha.tar.gz",
|
||||
"new_version": "1.7.103-alpha",
|
||||
"sha256": "0544897a1d365fda8f7113eb80d76c5edcf50d539588af2886764546f02f52bf",
|
||||
"size_bytes": 174592877
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-15",
|
||||
"signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
|
||||
"release_date": "2026-07-18",
|
||||
"signature": "39d3f161437a03d44a71cf87c0622d6c00cec2ec587e9e1533128f279c9b0e09c34c51465f2ee79983a3f76f080e909b836a0a74e9d16009cdcf528a7dc0830c",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.101-alpha"
|
||||
"version": "1.7.103-alpha"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user