Compare commits

..
Author SHA1 Message Date
archipelagoandClaude Fable 5 9eadec6936 chore: sign v1.7.104-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:27:55 -04:00
archipelago 6b0a84e710 chore: release v1.7.104-alpha 2026-07-19 02:17:45 -04:00
archipelagoandClaude Fable 5 fd361fb35e test(update): serialize the apply_update regression tests
Both tests take the global single-flight UPDATE_OP_LOCK; run concurrently
by the test harness, one saw the other's lock and failed with 'another
update operation is already running' instead of its expected refusal.
A shared test mutex makes them mutually exclusive deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:01:58 -04:00
archipelagoandClaude Fable 5 8bb61a51e2 style: cargo fmt update.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:32:57 -04:00
archipelagoandClaude Fable 5 17d225190a docs: v1.7.104-alpha changelog + What's New sync
Demo images / Build & push demo images (push) Successful in 2m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:31:12 -04:00
archipelagoandClaude Fable 5 d08c0d29c7 fix(ui): ElectrumX sync screen shows the app's icon instead of a generic glyph
Demo images / Build & push demo images (push) Has been cancelled
The pre-UI sync overlay used a hardcoded orange box-glyph SVG; it now renders
the same resolved app icon the loading screen uses (with the shared image-error
fallback), so the wait page reads as ElectrumX at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:29:46 -04:00
archipelagoandClaude Fable 5 a93bd70c5a fix(electrumx): probe bitcoin-knots/bitcoin-core for the daemon host instead of hardcoding knots
The manifest baked bitcoin-knots:8332 into DAEMON_URL. Nodes whose
backend runs under the bitcoin-core name (and any future variant
without a knots DNS alias) left electrumx permanently disconnected —
'connection problem' forever and a block index stuck at 0. The
startup script now picks the first backend name that resolves on
archy-net and falls back to bitcoin-knots.

Catalog regenerated (catalog manifests override disk ones fleet-wide);
this regen also embeds the recently-merged barkd/Ark manifest for the
first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:55:06 -04:00
archipelagoandClaude Fable 5 25162ee846 feat(update): auto-rollback OTA binaries that crash-loop before they can self-verify
.198 crash-looped 236x on a truncated OTA binary with a good backup
sitting in update-backup/ — verify_pending_update() can't help when
the new binary never runs. New scripts/ota-crash-guard.sh runs as
root from ExecStartPre: while the post-OTA pending-verify marker
exists it counts start attempts, and after 5 failures restores the
backup binary (atomic rename), replaces the marker with an
update-rolled-back.json tombstone, and lets the service come back
on the previous version.

Wiring: fresh ISOs get the ExecStartPre line in the unit file;
existing nodes get a drop-in installed by apply_update's runtime
component step on their next OTA. '+-' prefix so a missing or
failing guard can never block the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:42:54 -04:00
archipelagoandClaude Fable 5 837cfdfd1f fix(update): never apply unverified staging — close the OTA truncated-binary race
Root cause of .198 bricking on the 1.7.103 OTA: two concurrent
update.download RPCs shared one staging file, cancel_download wiped
staging mid-flight, a third download began re-filling it, and
apply_update mv'd the 3-second-old 17MB partial of the 49MB binary
into /usr/local/bin -> SEGV boot loop (236 restarts, no rollback).

- Single-flight UPDATE_OP_LOCK across download/apply; concurrent
  callers get an explicit 'already running' error.
- apply_update now requires the .download-complete marker AND
  re-verifies every staged component (size + SHA-256 + BLAKE3)
  against the manifest before touching the system.
- cancel_download only wipes staging when no operation holds the
  lock; otherwise it just flags the in-flight loop to bail.
- Fixed the 'file already complete' path in
  download_component_resumable, which skipped verification and fell
  through to the bogus 'download failed without a captured error'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:52 -04:00
archipelagoandClaude Fable 5 573b469191 chore: sign v1.7.103-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m53s
Also folds the Cargo.lock version bump that create-release.sh missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:48:56 -04:00
archipelago 401f92a24f chore: release v1.7.103-alpha 2026-07-18 07:30:25 -04:00
archipelagoandClaude Fable 5 dc0adbef70 docs: v1.7.103-alpha changelog + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
archipelagoandClaude Fable 5 537c9fa70b fix(demo): suppress the PWA update prompt + reload on the public demo
The SW-update modal is noise on the demo (nothing to update to) and both
accept-paths end in a reload that replays the intro from scratch. With
skipWaiting/clientsClaim off, ignoring the waiting worker is safe — the
new build activates on the visitor's next session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
DorianandClaude Fable 5 b6468ebf3c fix(android): let Vue re-render before auto-login submits the password
Dispatch the Enter keydown two frames after the input event so the login
button is enabled when it arrives. Keeps auto-login working against nodes
running older web builds where controller-nav's Enter-in-input pattern
would otherwise click Replay Intro (see the matching web-ui fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
DorianandClaude Fable 5 70587210fb fix(ui): stop controller-nav Enter from clicking Replay Intro on auth inputs
The companion's auto-login fills the password and dispatches Enter in the
same tick, before Vue re-enables the disabled submit button. Controller-nav's
'Enter in input clicks the next enabled button' pattern then hit the Replay
Intro button — clearing neode_intro_seen and hard-navigating to /, so every
app connect replayed the intro cinematic in a loop. The auth inputs all have
their own Enter handlers, so they opt out via data-controller-no-submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
archipelagoandClaude Fable 5 a27c7bafbf chore: sign v1.7.102-alpha OTA manifest
Demo images / Build & push demo images (push) Failing after 2m53s
Also folds the Cargo.lock version bump that create-release.sh missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:26:46 -04:00
archipelago 95b9d8f0fe chore: release v1.7.102-alpha 2026-07-17 12:36:44 -04:00
archipelagoandClaude Fable 5 918aba1de3 docs: v1.7.102-alpha changelog + What's New sync
Demo images / Build & push demo images (push) Successful in 2m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:22:41 -04:00
archipelagoandClaude Fable 5 49b366fbe4 test(goals): align goal-status tests with manual-step completion semantics
Demo images / Build & push demo images (push) Successful in 2m47s
3aebbcbb made goal completion require walking the manual steps (running
apps alone no longer finish a goal) but left the store tests asserting
the old auto-complete behavior. Tests now walk the manual step and also
pin the new running-but-not-walked => in-progress case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:46:38 -04:00
archipelagoandClaude Fable 5 7eaf99873e chore: cargo fmt — settle formatting drift blocking the release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:41:31 -04:00
archipelagoandClaude Fable 5 a76a92cff8 fix(iso): zstd-compress bundled core container images (-160MB on the ISO)
Bundling fmcd for offline installs (6dcdada3) shipped the raw
uncompressed podman save tar and grew the ISO ~220MB (2.3G -> 2.5G in
RC6-RC9). Save the core bundle as .tar.zst instead — podman load
auto-detects compression, verified locally against the RC9 fmcd.tar
(228M -> 65M, loads cleanly into rootless storage). The first-boot
loader and installer copy globs now pick up .tar.zst too.

Also trims the installer's USB->disk image copy and first-boot load
time on fresh installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:31:43 -04:00
archipelagoandClaude Fable 5 a177ef3b38 fix(kiosk): clear stale Chromium profile lock so rename doesn't kill the kiosk (#98)
Chromium's SingletonLock is a symlink encoding <hostname>-<pid>. After a
node rename changes the OS hostname, the lock left from the previous
boot reads as "another computer" holding the profile; Chromium refuses
to start, --noerrdialogs hides the only dialog, and the kiosk
black-screens forever.

Two layers:
- kiosk launcher removes Singleton{Lock,Cookie,Socket} before every
  Chromium start (any prior owner is dead at that point — pkill at
  session start / loop respawn). Ships via ISO splice AND the binary's
  include_str! self-heal, so existing nodes get it with the next OTA.
- the rename handler clears the lock immediately as a hostname
  side-effect, covering nodes still running a pre-fix launcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:31:43 -04:00
archipelagoandClaude Fable 5 ea0cd87b3b fix(auth): sync OS login password when the UI password is set at install (#97)
auth.setup only wrote the web password hash to user.json, so the
archipelago system user kept the image default password ("archipelago")
on console/SSH even after the user picked a real password during
onboarding. Reuse the existing usermod-based sync (already used by
auth.changePassword's alsoChangeSsh path) at setup time, best-effort so
a failure can never break onboarding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:31:27 -04:00
archipelagoandClaude Fable 5 1ee1b56f70 fix(ui): desktop Tor rows get their inline actions back + card actions bottom-align across grid cards
Demo images / Build & push demo images (push) Failing after 1m55s
The card-action sweep put the mobile 50/50 Delete|Rotate row under every
Tor service on desktop too — desktop reverts to the original compact
inline Rotate / trash / toggle beside each row, while mobile keeps the
thumb-safe stacked layout. The VPN and Network Interfaces cards' bottom
buttons now pin to the card bottom (mt-auto) so they stay vertically
aligned when the grid stretches one card taller than the other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:15:44 -04:00
archipelagoandClaude Fable 5 73d181abea fix(setup): wizard navigation round-trips — back to Setup tab, goal-aware channels back button, configure launches the app
Demo images / Build & push demo images (push) Failing after 1m56s
- "Back to Goals" now returns to Home's Setup tab (?tab=setup) instead of
  landing on the dashboard tab.
- The channels screen remembers when a setup wizard sent you there
  (?from=goal) — its back button reads "Back to Setup" and returns to the
  wizard; it also now uses the shared BackButton pill instead of a bare
  link.
- "Open & Configure" steps launch the actual app via the app launcher —
  iframe apps overlay on top of the wizard, tab-only apps (BTCPay,
  Nextcloud) open a tab, mobile uses the in-app browser — instead of
  routing to the app-details page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:58 -04:00
lfg2025 55d7f19545 Merge pull request 'feat(ui): Networking Profits dashboard + card-action consistency sweep' (#96) from networking-profits-dashboard into main
Demo images / Build & push demo images (push) Failing after 2m46s
2026-07-17 02:08:02 +00:00
DorianandClaude Fable 5 46dd853614 fix(ui): heavier scrim over the bright web5/server backdrop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 977b8d06b8 fix(ui): Connect to Mesh routes to /dashboard/mesh, not the 404 /mesh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 f08f34ca10 fix(ui): card actions live at the bottom on every screen size
Retire the >=1800px compact-header variant globally (Monitoring Details,
Federation Find Nodes/Fleet, Identities Create, VPN Add Device, Network
Interfaces Scan WiFi, Tor Restart/Add Service). Tor service rows get a
50/50 Delete|Rotate row underneath — Delete on the left, away from the
enable toggle, so no missclicks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 aaa3477d5e feat(ui): Networking Profits becomes a dashboard
Dashboard tab (default): stat tiles per earning source, 7-day earnings
LineChart (one line per source, daily buckets — earnings are sparse
events so finer buckets would draw noise), active-session count and
revenue-by-service bars from streaming.list-sessions. Configure tab
keeps the old settings screen with the intro copy boxed in a card, plus
a new TollGate WiFi entry (price per minute via openwrt.provision-
tollgate, with a set-up-gateway hint when no router is paired). Demo
mocks gain profit history entries and streaming sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
archipelagoandClaude Fable 5 9e264611e2 docs: Nostr signer-login research + identity-import UX plan
Research report on the most frictionless "sign in with your Nostr signer"
flow for node login (NIP-07 extension + NIP-46 QR scan with Amber, password
always kept as fallback), and a plan for adding existing Nostr identities
(nsec import / npub watch-only / browser extension) to the Nostr Identities
screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:59:54 -04:00
archipelagoandClaude Fable 5 f32c4db7e2 style(ui): companion banner uses the Setup-tab hero image
Demo images / Build & push demo images (push) Failing after 3m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:51:12 -04:00
archipelagoandClaude Fable 5 8e13f981d0 fix(demo): IndeeHub opens its real site — the same-origin proxy broke its assets
The nginx sub_filter rewrite couldn't touch the SPA's runtime-built asset
URLs, so the iframed IndeeHub rendered with broken links and images. The
real site refuses iframing (X-Frame-Options), so the demo now opens
https://indee.tx1138.com/ directly via the demo-external mechanism, whose
isDemoExternal() check was previously hardcoded off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:51:12 -04:00
archipelagoandClaude Fable 5 3aebbcbbb8 feat(setup): Zeus lightning journey — fund-wallet step, IBD timer + finish-setup toast, channel suggestions
Every Lightning setup (Open a Shop, Accept Payments, Run a Lightning Node)
gains a guided path to a working channel:

- New "Fund Your Bitcoin Wallet" step, gated on the blockchain finishing
  its initial sync: while syncing it shows a live progress bar with a
  counting-down time-remaining estimate; once synced it shows the on-chain
  balance and a "Fund Wallet" button that opens the receive modal with a
  fresh address, QR, and the Zeus channel limits (min 150,000 / max
  1,500,000 sats) noted.
- When the sync finishes while a Lightning setup is mid-flight, a toast
  pops with a "Finish setup" link straight back to the wizard (toasts now
  support action links). If several setups are in flight, one is chosen —
  the shared steps complete the lightning part of any of them.
- Channel steps are Zeus-branded (logo + copy) and land on the Lightning
  Channels screen, which now carries an "Open a channel with Zeus" card
  that prefills the open-channel modal with the Olympus peer URI, 150k
  sats, and private-channel checked. "Get Zeus" links to zeusln.com.
- Setup completion now actually requires walking the manual steps (fund,
  open channel, configure) — previously any step whose app was installed
  was silently auto-ticked and running apps marked the whole goal done.
- Completion CTAs now go to the app you just set up ("Go to my shop
  (BTCPay)" etc.) instead of the generic services list; iframe apps open
  in the on-top app overlay.
- On-chain send modal gains a "Send all funds" sweep toggle, backed by
  LND's send_all on the backend (amount no longer required when sweeping).
- Demo/mock: bitcoin.getinfo now returns the block_height/sync_progress
  contract the UI reads and simulates a ~90s IBD ramp per visitor so the
  timer, toast, and fund flow can all be demoed live; channel list data
  fixed (status/channel_point/liquidity totals — the panel previously
  crashed on the missing status field); sendcoins supports send_all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:51:12 -04:00
lfg2025 90bedc2a25 Merge pull request 'feat(ui): WireGuard remote-access onboarding + Connected Nodes cleanup' (#95) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m38s
2026-07-17 01:39:02 +00:00
DorianandClaude Fable 5 a66e4bac6d fix(ui): Connected Nodes — consistent bottom actions
Drop the 'Discover Nodes on Nostr' button and the >=1800px header
buttons; Find Nodes + Refresh are now a 50/50 bottom pair on every
screen size, with Refresh acting on the active tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:38:07 +01:00
DorianandClaude Fable 5 af2dfd0bd6 chore(ui): mirror official WireGuard Android APK for the pairing flow
com.wireguard.android 1.0.20260315 (versionCode 519) from
download.wireguard.com, signer WireGuard LLC (cert sha256 84a13fa2…),
file sha256 f92971bc804f4c448e8845ae97e426095eab06ec09a2473a3fa2cfe7288e3298.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
DorianandClaude Fable 5 68c25534d4 feat(ui): remote-access steps in the companion pairing flow
After 'I've installed it' the modal now walks WireGuard setup before
pairing: install WireGuard (hosted APK + QR), scan the node's tunnel
config (auto-provisioned 'companion-phone' peer via the existing
vpn.create-peer/peer-config RPCs, rendered client-side), switch it on,
then pair. Real nodes pair against http://10.44.0.1 — the tunnel only
routes AllowedIPs 10.44.0.0/16, so the LAN origin is unreachable
remotely. The demo fakes the tunnel via mock RPCs and keeps its public
pairing URL. REMOTE_ACCESS_STEPS=false restores the old two-screen flow.
Phones get the config as a downloadable .conf (can't scan own screen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
lfg2025 45c9def94a Merge pull request 'fix(ui): web5 mobile polish — identity profile cards, visibility card, demo avatars, title alignment' (#94) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-17 01:13:56 +00:00
DorianandClaude Fable 5 299f7d8f39 fix(ui): center OpenWrt Gateway title with its back button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
DorianandClaude Fable 5 bb231e82b4 fix(ui): web5 cards mobile polish + demo identity avatars
- Identities render as profile cards (banner, overlapping avatar, bio)
  in narrow containers via container query; wide/desktop row untouched
- Demo identities get purpose-tinted SVG avatars (public/demo-avatars)
  in both mock identity.list/get branches
- Node Visibility: description stacks under icon+title on mobile,
  Refresh is a full-width bottom action on all sizes, discoverable
  warning line removed, and the demo no longer exposes an onion address

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
lfg2025 e2309cc2ac Merge pull request 'fix(android): pairing QR now actually scannable (v0.4.14)' (#93) from companion-qr-scan-fix into main
Demo images / Build & push demo images (push) Successful in 2m31s
2026-07-17 01:04:08 +00:00
Dorian a48499daeb chore(android): update companion apk download 2026-07-17 02:03:27 +01:00
DorianandClaude Fable 5 6df9afe684 fix(android): pairing QR now actually scannable
Camera decode failed because every lever was at its minimum: the web
modal generated the QR at 112px (~2.5px per module before upscaling),
the analysis stream was CameraX's 640x480 default, and ZXing ran
without TRY_HARDER. Generate the QR at 512px (displayed 192px with a
real quiet zone), analyze at 1280x720, decode with TRY_HARDER plus an
inverted-luminance fallback. v0.4.14 (versionCode 18).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:03:11 +01:00
lfg2025 9d21dd5111 Merge pull request 'feat(android): companion QR pairing — scan, deep link, auto-login (v0.4.13)' (#92) from companion-qr-pairing into main
Demo images / Build & push demo images (push) Successful in 2m34s
2026-07-17 00:46:47 +00:00
Dorian ddbfaf1d00 chore(android): update companion apk download 2026-07-17 01:46:24 +01:00
archipelagoandClaude Fable 5 2e5f67a59a feat(ui): companion app banner in the App Store + APK download unblocked
Demo images / Build & push demo images (push) Has been cancelled
The App Store gains a "Your Node. In Your Pocket." hero banner for the
mobile companion app, in the same format as the featured-app banner, with
the app running on a phone mockup rising out of the right edge. Install
(or clicking anywhere on it) opens the Remote Companion download/pairing
modal — now openable on demand via a shared trigger instead of only
auto-showing once.

Also fixes the companion APK download on PWA installs: /packages/ now
bypasses the service worker's SPA fallback, which was answering the
download click with index.html instead of the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:46:08 -04:00
DorianandClaude Fable 5 51d1c89137 fix(android): center connect-screen content vertically
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:46:07 +01:00
Dorian 785fb3d2be chore(android): update companion apk download 2026-07-17 01:41:59 +01:00
DorianandClaude Fable 5 2b3a18f189 feat(android): QR pairing — scan, deep link, one-step auto-login
Companion side of docs/companion-pairing-qr.md (v1 contract):
- Connect screen lands on Scan Node's QR / Enter Manually; NESMenu gains
  an add-server-by-QR icon button
- QrScannerOverlay: CameraX + ZXing core (pinned, on-device, no ML Kit)
- ServerQrParser: archipelago://pair?v=1&url=..[&pw=..]; unknown major v
  -> 'update the app'; upsert by origin so re-scans never duplicate
- archipelago:// deep link registered (singleTask + onNewIntent) for the
  modal's 'Open in companion app' button
- WebView auto-fills and submits Login.vue's password form when a stored
  password exists — demo QR is one step to a logged-in session
- Web modal + handoff doc copy synced to the real label: Scan Node's QR
- v0.4.13 (versionCode 17)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:41:35 +01:00
archipelagoandClaude Fable 5 3028685e6b feat(ui): companion modal pairing screen — scan a QR to auto-connect the app
Demo images / Build & push demo images (push) Successful in 2m45s
The Remote Companion intro modal gains a second screen: an "I've installed
it" button next to the download button slides over to a pairing QR the
companion app scans to load the server details automatically, with a Back
button to return. The QR is an archipelago://pair deep link carrying the
server URL — the demo embeds https://demo.archipelago-foundation.org plus
the shared demo password so a scan lands in a logged-in demo session; real
nodes send only the address (kiosk advertises the mDNS .local name since
its own origin is localhost). Phones also get a tap-to-open deep-link
button since you can't scan your own screen.

App-side contract + requirements handed off in docs/companion-pairing-qr.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:07:56 -04:00
archipelagoandClaude Fable 5 1a3170f1c3 fix(ui): companion popup waits for sustained calm; connection banner stops crying wolf
Demo images / Build & push demo images (push) Successful in 2m36s
Two "unpolished" moments, worst on the demo:

- CompanionIntroOverlay gated on a point-in-time check of the reveal
  cinematic flag. On a cold cache the entrance video can start buffering
  after the 5s base delay, so the flag was still false when sampled and
  the popup cut into the cinematic anyway. It now shows only after the
  scene has been continuously calm for the full grace window.

- ConnectionBanner's flat 2.5s debounce fired on every tab-return and on
  first dashboard paint: a dead WebSocket is the NORMAL state right then
  (browsers kill background-tab sockets; first paint races the initial
  connect), and reconnects routinely exceed 2.5s over real links. Those
  moments now get a 10s runway (15s window after load/resume); genuine
  mid-session drops keep the 2.5s response. On the demo the blip banner
  is suppressed entirely — it runs against a local mock, so "Connection
  lost" there is pure noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:42:57 -04:00
archipelagoandClaude Fable 5 547f674ac8 fix(pwa): defer update reloads until the intro cinematic finishes
Demo images / Build & push demo images (push) Successful in 3m23s
A genuine service-worker update still reloaded the page the instant the
new worker claimed it. The kiosk and the public demo replay the intro on
every boot/visit, so an update landing mid-sequence restarted the whole
cinematic — the "typing intro resets to the start after several seconds"
report. Hold the reload until the splash is complete and the
dashboard-reveal cinematic is over, then apply it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:48:56 -04:00
archipelagoandClaude Fable 5 92d221bbf1 fix(ui): show the controller focus ring only during actual arrow/gamepad navigation
Demo images / Build & push demo images (push) Failing after 1m42s
The orange "gamepad selection" lift+glow was plain CSS :focus styling on
[data-controller-container] — any tap on a tabindex card lit it up (worst
on mobile), and useControllerNav's autoFocusMain programmatically focused
the first card on mount and every route change, painting the ring with no
controller input at all.

Gate it behind html.controller-nav, toggled by actual input modality:
arrow-key navigation and gamepad connection turn it on, any pointerdown
turns it off. autoFocusMain now no-ops outside directional-nav mode (a
first arrow press focuses the first container instead of spatially
navigating from <body>), so pointer users never get surprise rings while
keyboard/gamepad users keep the exact same navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:38:46 -04:00
archipelagoandClaude Fable 5 e9cfc234cd fix(kiosk): flatten the onboarding/login background too + roomier renderer heap
The 454c4bb2 kiosk flattening only covered the Dashboard's background
stack, but the kiosk boots into the login/onboarding screen —
OnboardingWrapper.vue — whose scoped 3D stack (perspective +
preserve-3d bg layers + mix-blend-mode glitch overlays) has the exact
compositing pattern that fails to repaint under the kiosk's software
compositor, leaving the black body fill visible. Extend the same
html.kiosk-mode flattening to this screen via :global() rules: 2D
opacity crossfades stay, 3D transforms / blur filters / blend-mode
glitch layers go.

Also raise the kiosk chromium JS heap cap 128→256MB: the intro
cinematic (video + audio + app) inside a 128MB V8 old-space risks
renderer OOM, and a crashed kiosk tab auto-reloads — indistinguishable
from the reported "flickers and refreshes".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:38:22 -04:00
archipelagoandClaude Fable 5 06186ab30c fix(intro): restore the typing intro on fresh kiosk installs + seamless splash-to-onboarding handoff
Three regressions of the fresh-install cinematic, all in App.vue:

1. The kiosk chromium opens /kiosk (a marker route that stamps
   localStorage.kiosk and redirects to /). The deep-route splash
   suppression added in PR #91 reads window.location.pathname, so the
   kiosk boot was classified as a deep route and the typing intro →
   "Welcome Noderunner" → onboarding sequence never played. Normalize
   /kiosk to / for the splash decision.

2. The pre-splash onboarding-status check runs a ~30s retry ladder;
   against a still-booting backend it held the black !isReady screen the
   whole time. Bound it with a 2.5s race — unknown means "play the
   intro" (a fresh install IS the slow-backend case; onboarded nodes
   answer in milliseconds so their suppression is unaffected).

3. handleSplashComplete revealed RouterView before routing, flashing
   RootRedirect's spinner at '/' between the intro's final frame and the
   onboarding screen. Commit the destination route first, then reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:35:47 -04:00
archipelagoandClaude Fable 5 e5c7210663 fix(install): stall-aware, retried image pulls in the orchestrator runtime
PodmanClient::pull_image had a single attempt with a hard 600s wall
clock — on a fresh node, any image that legitimately takes >10 min to
download got killed mid-pull and failed the install. Podman keeps the
completed layers, so the user's retry started half-done and succeeded:
the exact "first install fails, second works" pattern reported for
btcpay and indeedhub (and earlier for LND, whose fix in 72d7fa07 only
covered the legacy install path — this is the same cure ported to the
orchestrator path both fresh installs and upgrades use).

A pull is now only killed when nothing is observably happening — no
stderr output AND no staged-byte growth in TMPDIR for 180s — or at a
1800s absolute ceiling, with 3 attempts and backoff on top. Dead
registries still fail fast (no bytes ever land). Failures now surface
podman's actual stderr tail instead of a bare exit status, and the
pull stages via the user's containers tmp like every other pull path
(rootless /var/tmp is read-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:02:53 -04:00
archipelagoandClaude Fable 5 500472944c fix(install): stream per-member progress for orchestrator stack installs
Orchestrator-installed stacks (indeedhub, btcpay, netbird, immich,
mempool) sat at "Preparing… 5%" for the entire install — the
install_stack_via_orchestrator path never called set_install_phase /
set_install_progress after the initial Preparing, so a 7-image,
multi-gigabyte indeedhub pull gave the user zero feedback ("just says
preparing for ages"). The legacy installers already report phases and
the frontend already interpolates X-of-N across the PullingImage band;
the new path simply never fed it.

Now: PullingImage phase up front, an X-of-N counter plus a per-member
"Installing postgres (2 of 7)…" message as each member installs, and
the legacy installers' truthful PostInstall→Done settle at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:02:05 -04:00
archipelagoandClaude Fable 5 a687df9bd9 feat(backup): include secrets (LND seed key) in backups + Download button
Demo images / Build & push demo images (push) Failing after 1m32s
The full backup carried identity/lnd_aezeed.enc but NOT the per-node
wallet secret that encrypts it (secrets/lnd-wallet-password), so a
restored node could never decrypt its Lightning seed. The secrets dir
now rides the backup (archive v3; the .bak itself is
passphrase-encrypted). Restore gains a staging-presence guard so
restoring an older archive without a secrets dir can no longer displace
and delete the node's live secrets — covered by two new tests.

Backups can now also be downloaded through the browser: a session-gated
GET /api/blob/backup/<id> endpoint (under the existing nginx /api/blob
prefix, so no fleet nginx changes) plus a Download button next to
Verify / USB / Restore / Delete in Settings → Backup & Restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:49:37 -04:00
archipelagoandClaude Fable 5 0aa9463b36 feat(iso): append auto-incrementing _RCn suffix to ISO filenames
Rebuilds of the same version overwrote the previous ISO under an
identical filename, making a flashed stick indistinguishable from a
newer build. The builder now keeps a per-version RC counter (next to
the build counter) and names outputs like
archipelago-installer-1.7.101-alpha-unbundled-x86_64_RC2.iso.
Override with RC=n env when re-cutting a specific candidate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:17:11 -04:00
archipelagoandClaude Fable 5 5cb53ade66 fix(health): stop raw-probing ports of containers with own healthchecks
Demo images / Build & push demo images (push) Failing after 1m34s
The health monitor opened a bare TCP connection to every published host
port each cycle. Against TLS listeners that connect+close logs noise on
the app side — LND printed 'http: TLS handshake error … EOF' endlessly.
Containers that define a podman healthcheck (LND uses lncli getinfo)
now rely on it alone; bare containers keep the port probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:06:03 -04:00
archipelagoandClaude Fable 5 47dea8cd55 fix(lnd): make the seed-backup notification actually open the backup flow
Clicking 'Back up now' on the Lightning seed banner only navigated to
the LND app page — and the backup card there hid itself permanently if
its single status fetch failed (common right after a fresh install),
so the click appeared to do nothing.

The banner now deep-links with ?seed-backup=1, which the LND page uses
to open the reveal flow directly, and the card retries its status fetch
with backoff instead of giving up after one failed RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:06:03 -04:00
archipelagoandClaude Fable 5 72d7fa07ff fix(install): never fail a first-time LND install over transient conditions
Two confirmed fresh-node failure modes made 'install LND' fail on the
first try and succeed on the second:

- The dependency gate failed fast when Bitcoin was still mid-install
  (image pulling, container not created yet): DepProbe.existing only
  covered 'podman ps -a', so an in-flight dependency looked identical to
  'not installed at all'. The probe now also reports packages in
  Installing/Updating state, and the gate waits up to 30 minutes for an
  in-flight dependency install to finish (surfacing 'Waiting for X to
  finish installing…' on the card) before its normal 3-minute
  started-but-not-running window.

- Image pulls had a hard 300s per-URL wall clock, which killed
  slow-but-progressing pulls of large images (LND on fresh-node WiFi);
  the retry then succeeded off the warm layer cache. Pulls are now
  stall-aware: killed only after 3 minutes with no podman output AND no
  byte movement in the staging dir, with a 30-minute absolute ceiling.
  Dead registries still fail in ~3 minutes.

Adds dep-gate unit tests for the in-flight-install wait and the
neither-installed-nor-installing fail-fast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 08:42:06 -04:00
archipelagoandClaude Fable 5 6dcdada371 fix(iso): make baseline apps work on a fresh install with no internet
Demo images / Build & push demo images (push) Failing after 1m36s
Fresh offline installs came up without fedimint-clientd and filebrowser
(they only appeared after connecting internet). Three root causes:

- archipelago-load-images.service ran 'podman load' as root, but every
  container runs rootless as archipelago — bundled images landed in
  root's storage where the rootless runtime can't see them, so all
  container creation silently depended on registry pulls. The loader now
  loads into the archipelago user's storage (with linger + runtime-dir
  wait + system migrate).
- The unbundled ISO bundled only filebrowser.tar; fmcd (fedimint-clientd)
  is a baseline first-boot app too and is now part of the unbundled core
  bundle.
- first-boot's pull_with_fallback always hit the network; it now uses an
  already-loaded local image first and skips the pull entirely.

Also: fedimint-clientd added to the UI's hardcoded curated-app fallback
list (it was missing when all catalog fetches fail offline), plus its
INSTALLED_ALIASES entry, and the stale fmcd bundling comment in
image-versions.sh corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:31:28 -04:00
archipelagoandClaude Fable 5 454c4bb25c fix(kiosk): flatten background compositing so backgrounds don't go black
Demo images / Build & push demo images (push) Failing after 1m40s
On kiosk, chromium runs software-composited (--in-process-gpu or
--disable-gpu, one raster thread). The dashboard's 3D-transformed
will-change background layers and the infinite mix-blend-mode:multiply
fixed overlays routinely fail to repaint there after the first
interaction/route change, leaving the container's black fill (or a stuck
multiply layer) covering the screen.

A kiosk-mode class is now set on <html> (localStorage kiosk flag, ?kiosk
param, or the /kiosk boot path) and gates CSS overrides: background
layers become plain 2D opacity crossfades (no preserve-3d/translateZ/
will-change) and the animated multiply overlays are disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:06:12 -04:00
archipelagoandClaude Fable 5 66fd121748 fix(kiosk): no reload on first service-worker claim + patient boot health check
Two causes of the fresh-install 'loads, refreshes, loads again' jank:

- The PWA autoUpdate service worker claims the page right after the very
  first load, firing controllerchange, which unconditionally reloaded the
  page. Now only a *replaced* controller (a real update) triggers reload.
- A single 2s RPC echo decided server-down on first boot, flashing the
  BootScreen and then hard-reloading seconds later. A second, 6s attempt
  now runs before concluding the backend is down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:05:56 -04:00
archipelagoandClaude Fable 5 fa91faa67c fix(intro): hold companion popup until dashboard reveal cinematic ends
Demo images / Build & push demo images (push) Failing after 1m42s
The remote-companion popup fired on a blind 5s timer while the
first-visit dashboard entrance cinematic runs for 8s, so on a fresh
install it appeared mid-reveal and broke the intro. The cinematic now
publishes an introCinematicPlaying flag via the loginTransition store,
and the popup waits for it to clear (plus a 2s grace) before showing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 06:57:42 -04:00
archipelagoandClaude Fable 5 e9c3311eff chore: sign v1.7.101-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m37s
Also folds the Cargo.lock version bump the release commit missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:18:46 -04:00
archipelago 338ae9a6de chore: release v1.7.101-alpha 2026-07-15 11:11:08 -04:00
archipelagoandClaude Fable 5 8f397b03f9 docs(release): fold PR #91 (audible entrance, no splash hijack on deep routes) into v1.7.101-alpha notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:07:57 -04:00
lfg2025 48d5fd0045 Merge pull request 'fix(intro): reliable first-visit cinematic — audible oomph, no splash hijack on deep routes, e2e suite' (#91) from intro-reliability-video-perf into main
Demo images / Build & push demo images (push) Successful in 2m34s
2026-07-15 14:29:23 +00:00
DorianandClaude Fable 5 a44cbe478a fix(intro): audible entrance oomph, splash off deep routes, finale on intro shortcuts — with e2e cinematic suite
Three first-visit-experience bugs, all caught by the new e2e suite
(e2e/intro-experience.spec.ts — 5 tests against the demo stack on real
Chrome, instrumenting HTMLMediaElement/WebAudio/MutationObserver since
headless can't hear or see the cinematic):

- playDashboardLoadOomph was always silent: the login→dashboard route
  change closes the AudioContext (stopAllAudio), and the oomph only
  fetched the dead context. It now recreates one, riding the login
  click's sticky user activation.
- A direct /login load in the demo hijacked into the splash: App.vue
  read route.path before the router resolved the initial navigation, so
  every deep-route boot looked like a root boot. The splash decision now
  reads window.location.pathname.
- Replayed intros ran mute (enableCinematicSounds override) and the
  intro's shortcut exits skipped the dashboard finale (flag now armed on
  both demo and login exits).

Verified: 5/5 e2e, 673/673 unit, vue-tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:13:39 +01:00
archipelagoandClaude Fable 5 519b4b209a docs(release): fold PR #90 (splash reliability + lighter intro video) into v1.7.101-alpha notes
Demo images / Build & push demo images (push) Successful in 2m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:58:55 -04:00
ai 2c82b498f0 Merge pull request 'fix(intro): splash reliability on fresh visits + 3x lighter video' (#90) from intro-reliability-video-perf into main
Demo images / Build & push demo images (push) Successful in 2m33s
2026-07-15 10:54:21 +00:00
DorianandClaude Fable 5 efd1ae41de fix(intro): splash always honors App's decision + video streams 3x lighter
- SplashScreen no longer re-checks neode_intro_seen itself: it silently
  skipped the whole logo-tap/typing sequence for any browser that had
  seen the intro before, even when App.vue explicitly requested it (demo
  fresh visits, Replay Intro). App.vue is the single decision point.
- Intro video re-encoded 12.7MB/12.6Mbps → 4.3MB/4.2Mbps (1920x1080 CRF23
  faststart, muted track dropped) — visually identical on this
  illustrated content; cache-buster bumped to v=8.
- Video is <link rel=preload>ed the moment the splash is scheduled (the
  <video> element only mounts ~20s in, so cold caches used to start the
  download mid-sequence and stutter).
- nginx demo cache regex now covers mp4/webm/mp3/webp/woff2 so repeat
  visits play from browser cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:53:14 +01:00
archipelagoandClaude Fable 5 0c591a997e docs(release): fold PRs #87-#89 into the v1.7.101-alpha notes
Demo images / Build & push demo images (push) Successful in 2m34s
Login video continuity, cloud bottom-bar music player, demo intro on
every visit — regenerated the What's New block to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:37:17 -04:00
ai 91e1059f56 Merge pull request 'fix(ui): login video/background continuity — video until first login, rotation after' (#89) from login-bg-continuity into main
Demo images / Build & push demo images (push) Successful in 2m31s
2026-07-15 10:34:40 +00:00
104 changed files with 4962 additions and 676 deletions
+8 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 16
versionName = "0.4.12"
versionCode = 18
versionName = "0.4.14"
vectorDrawables {
useSupportLibrary = true
@@ -111,6 +111,12 @@ dependencies {
// OkHttp for WebSocket (remote input)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+12
View File
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".ArchipelagoApp"
@@ -19,6 +22,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
@@ -26,6 +30,14 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Pairing deep link from the web UI's Companion popup:
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
</activity>
</application>
@@ -1,22 +1,41 @@
package com.archipelago.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
setContent {
ArchipelagoTheme {
AppNavHost()
val pairUri by pendingPairUri.collectAsState()
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
}
@@ -143,6 +143,39 @@ class ServerPreferences(private val context: Context) {
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) — used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
return merged
}
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
@@ -0,0 +1,67 @@
package com.archipelago.app.data
import android.net.Uri
/**
* Result of parsing a pairing QR / deep link.
*
* UnsupportedVersion means the payload is structurally a pairing URI but its
* major version is newer than this app understands — the UI should tell the
* user to update the app rather than call the code invalid.
*/
sealed class PairResult {
data class Success(val server: ServerEntry) : PairResult()
object UnsupportedVersion : PairResult()
object Invalid : PairResult()
}
/**
* Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root).
*
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
*
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away.
* - `pw` is only ever present for the public demo.
* - Unknown extra query params are tolerated (forward compat under v=1 —
* `name` is already honored if present).
*/
object ServerQrParser {
private const val SUPPORTED_MAJOR = 1
fun parse(raw: String): PairResult {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return PairResult.Invalid
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
val major = uri.getQueryParameter("v")
?.trim()
?.takeWhile { it.isDigit() }
?.toIntOrNull()
?: return PairResult.Invalid
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
val server = Uri.parse(serverUrl)
val scheme = server.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return PairResult.Invalid
val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid
return PairResult.Success(
ServerEntry(
address = host,
useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "",
password = uri.getQueryParameter("pw") ?: "",
name = uri.getQueryParameter("name") ?: "",
)
)
}
}
@@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
@@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
@@ -75,6 +80,7 @@ fun NESMenu(
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
@@ -88,7 +94,7 @@ fun NESMenu(
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
}
}
@@ -103,6 +109,7 @@ private fun MenuPanel(
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
@@ -235,7 +242,30 @@ private fun MenuPanel(
}
}
} else {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
Spacer(Modifier.height(2.dp))
@@ -0,0 +1,316 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Full-screen camera overlay that scans the node pairing QR
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
* Handles the camera permission itself; foreign/invalid codes show a hint
* and scanning continues.
*/
@Composable
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (ServerEntry) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Foreign-code hint fades after a moment so scanning feels live again.
LaunchedEffect(hintRes) {
if (hintRes != null) {
delay(2500)
hintRes = null
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result.server)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
// Aim frame
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
} else {
Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = TextPrimary,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
hintRes?.let { res ->
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
}
if (hasPermission) {
Text(
text = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Composable
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
}
DisposableEffect(Unit) {
val analysisExecutor = Executors.newSingleThreadExecutor()
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
providerFuture.addListener({
val p = providerFuture.get()
provider = p
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
analysisExecutor,
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
)
}
try {
p.unbindAll()
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
}
override fun analyze(image: ImageProxy) {
try {
val plane = image.planes[0]
val buffer = plane.buffer
// Copy into a rowStride-wide array; the last row of the plane buffer
// may be short of the full stride, so the tail stays zero-padded.
val data = ByteArray(plane.rowStride * image.height)
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val source = PlanarYUVLuminanceSource(
data, plane.rowStride, image.height,
0, 0, image.width, image.height,
false,
)
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
reader.reset()
image.close()
}
}
}
@@ -1,15 +1,21 @@
package com.archipelago.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
@@ -24,7 +30,10 @@ object Routes {
}
@Composable
fun AppNavHost() {
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
@@ -33,8 +42,41 @@ fun AppNavHost() {
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
// Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
if (introSeen == null) return
// Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here.
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
val startDestination = when {
introSeen == false -> Routes.INTRO
activeServer != null -> Routes.WEB_VIEW
@@ -65,6 +107,7 @@ fun AppNavHost() {
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
}
},
initialServer = pairPrefill,
)
}
@@ -81,6 +124,7 @@ fun AppNavHost() {
} else {
WebViewScreen(
serverUrl = server.toUrl(),
serverPassword = server.password,
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
@@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
@@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
@@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onScanQr = { showQrScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
@@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) {
},
onBackToWebView = { showModal = false; onBack() },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
// open behind the scanner so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showQrScanner,
onDismiss = { showQrScanner = false },
onServerScanned = { server ->
showQrScanner = false
scope.launch {
val merged = prefs.upsertServer(server)
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
@@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager
fun ServerConnectScreen(
onConnected: (String) -> Unit,
onRemoteInput: () -> Unit = {},
// Prefill from a pairing deep link (archipelago://pair) that carried no
// password — opens the manual form on the password prompt for that server.
initialServer: ServerEntry? = null,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
@@ -109,6 +114,9 @@ fun ServerConnectScreen(
var errorMessage by remember { mutableStateOf<String?>(null) }
// The saved server currently being edited, or null when adding/connecting.
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
@@ -173,6 +181,37 @@ fun ServerConnectScreen(
}
}
fun prefill(server: ServerEntry) {
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
}
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a password — the demo flow) or land on the password
// prompt with everything else filled in (real nodes never embed one).
fun onQrScanned(scanned: ServerEntry) {
showScanner = false
scope.launch {
val merged = prefs.upsertServer(scanned)
prefill(merged)
if (merged.password.isNotBlank()) {
connect(merged)
} else {
manualMode = true
}
}
}
LaunchedEffect(initialServer) {
if (initialServer != null) {
prefill(prefs.upsertServer(initialServer))
manualMode = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -208,7 +247,10 @@ fun ServerConnectScreen(
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
// Center the content vertically — the landing (logo + two buttons) is
// short and looks stranded at the top otherwise. Taller content (the
// manual form, saved servers) still scrolls from the top as normal.
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
// Circular badge logo
Image(
@@ -226,8 +268,10 @@ fun ServerConnectScreen(
textAlign = TextAlign.Center,
)
val showForm = manualMode || editingServer != null
Text(
text = stringResource(R.string.server_address_hint),
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
@@ -235,8 +279,25 @@ fun ServerConnectScreen(
Spacer(modifier = Modifier.height(4.dp))
if (!showForm) {
// Landing: scan the pairing QR, or fall back to manual entry
GlassButton(
text = stringResource(R.string.scan_node_qr),
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
GlassButton(
text = stringResource(R.string.enter_manually),
onClick = {
errorMessage = null
manualMode = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
// Glass card with form
Box(
if (showForm) Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
@@ -458,16 +519,30 @@ fun ServerConnectScreen(
modifier = Modifier.weight(1f).height(56.dp),
)
}
} else {
// Connect button — glass style
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else if (manualMode) {
// Back to the Scan/Manual landing + Connect
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.back),
onClick = {
keyboard?.hide()
manualMode = false
clearForm()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.weight(2f).height(56.dp),
)
}
}
if (isConnecting) {
@@ -499,6 +574,12 @@ fun ServerConnectScreen(
}
}
}
QrScannerOverlay(
visible = showScanner,
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
}
}
@@ -136,6 +136,10 @@ fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
serverPassword: String = "",
) {
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
@@ -310,6 +314,12 @@ fun WebViewScreen(
""".trimIndent(),
null,
)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages.
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
}
}
override fun onReceivedError(
@@ -700,3 +710,48 @@ private fun InAppBrowser(
}
}
}
/**
* JS that fills the web UI login form (Login.vue's #login-password) with the
* stored password and submits it once the form is interactive — the one-step
* pairing flow. No-ops when the login step never appears (already
* authenticated, first-boot setup, TOTP). At most two attempts per page load,
* then it stops for good so a wrong stored password can't spam the node.
*/
private fun buildAutoLoginScript(password: String): String {
val quoted = org.json.JSONObject.quote(password)
return """
(function () {
if (window.__archyAutoLogin) return;
window.__archyAutoLogin = true;
var pw = $quoted;
var attempts = 0;
var started = Date.now();
var timer = setInterval(function () {
if (Date.now() - started > 45000) { clearInterval(timer); return; }
var el = document.getElementById('login-password');
if (!el) {
// Field gone after we submitted = success or step change - stop.
if (attempts > 0) clearInterval(timer);
return;
}
if (el.disabled) return; // form waits on serverReady
if (attempts >= 2) { clearInterval(timer); return; }
attempts++;
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw);
el.dispatchEvent(new Event('input', { 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()
}
@@ -29,6 +29,15 @@
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
<string name="edit_server">Edit</string>
<string name="scan_node_qr">Scan Node\'s QR</string>
<string name="enter_manually">Enter Manually</string>
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
<string name="grant_camera_access">Grant Camera Access</string>
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
<string name="add_server_qr">Add server by QR</string>
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
+28 -3
View File
@@ -1,5 +1,30 @@
# Changelog
## v1.7.104-alpha (2026-07-19)
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
## 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.
@@ -7,10 +32,10 @@
- "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, and a search that also finds files shared by your federated peer nodes.
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
- 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: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
- 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).
## v1.7.100-alpha (2026-07-14)
+8 -1
View File
@@ -10,9 +10,16 @@ app:
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
# on which version the node runs (multi-version switch) — probe which name
# resolves on archy-net instead of hardcoding knots, which left electrumx
# permanently disconnected (block index 0) on core nodes.
custom_args:
- >-
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
for h in bitcoin-knots bitcoin-core; do
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
done;
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.100-alpha"
version = "1.7.104-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.100-alpha"
version = "1.7.104-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+47
View File
@@ -194,6 +194,43 @@ impl ApiHandler {
))
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
/// browser download. The archive is passphrase-encrypted at rest; the
/// session gate at the route controls who can fetch it.
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
// Backup ids are UUIDs — reject anything that could traverse paths.
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
));
}
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)
.header("Content-Type", "application/octet-stream")
.header(
"Content-Disposition",
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
)
.header("Content-Length", bytes.len())
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"backup not found"}"#),
)),
}
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
@@ -434,6 +471,16 @@ impl ApiHandler {
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
}
// Backup archive download — session-gated. Lives under /api/blob/
// so the existing nginx `location /api/blob` prefix proxies it on
// every fleet node without a config change.
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_backup_download(p).await
}
// Blob upload — local/session use only. Session-authenticated so
// only the node owner can push attachments into the blob store.
(Method::POST, "/api/blob") => {
+9
View File
@@ -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.
+39 -18
View File
@@ -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"))
@@ -246,6 +246,11 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
/// 36 × 5s = 3 minutes of bounded waiting.
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
/// Separate, much larger budget while a dependency is still INSTALLING
/// (image pulling, container not created yet): 360 × 5s = 30 minutes. A
/// fresh-node Bitcoin pull routinely takes >3 minutes, and rejecting LND
/// mid-pull was the confirmed "first install fails, second works" failure.
pub(super) const DEP_INSTALLING_WAIT_MAX_ATTEMPTS: u32 = 360;
/// Marker error: the install was rejected by the dependency gate BEFORE any
/// resource (container, image, data dir) was created for the package. The
@@ -317,8 +322,12 @@ pub(super) struct DepProbe {
/// Which dependency services are currently Running.
pub running: RunningDeps,
/// Container/package names that EXIST in any state — installed, but
/// possibly not running yet (`podman ps -a` package-state entries).
/// possibly not running yet (`podman ps -a`).
pub existing: Vec<String>,
/// Package ids currently mid-install (state Installing/Updating) —
/// no container exists yet, but one is on the way, so the gate must
/// wait for the install to finish instead of failing fast.
pub installing: Vec<String>,
}
/// All container names known to podman in any state (`podman ps -a`).
@@ -366,8 +375,13 @@ where
LF: std::future::Future<Output = ()>,
{
let mut waited_attempts = 0u32;
let mut installing_attempts = 0u32;
loop {
let DepProbe { running, existing } = probe().await?;
let DepProbe {
running,
existing,
installing,
} = probe().await?;
let missing = missing_install_deps(package_id, &running);
if missing.is_empty() {
// Keep behavior in lockstep with the canonical gate (covers any
@@ -377,11 +391,12 @@ where
}
// Fail fast if any missing dependency has no installed container
// under any name variant — waiting cannot satisfy it.
// 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))
.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) {
@@ -391,8 +406,38 @@ where
return Err(anyhow::Error::new(DependencyGateError(msg)));
}
// A dependency still mid-install (no container yet) gets its own,
// much larger budget: a fresh-node image pull can take far longer
// than the started-but-not-running window.
let some_dep_installing = missing.iter().any(|dep| {
dep.containers
.iter()
.any(|c| installing.iter().any(|i| i == c))
});
let labels = join_dep_labels(&missing);
if some_dep_installing {
if installing_attempts >= DEP_INSTALLING_WAIT_MAX_ATTEMPTS {
return Err(anyhow::Error::new(DependencyGateError(format!(
"{labels} is still installing after {} seconds. Wait for it \
to finish, then install {package_id} again.",
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
))));
}
installing_attempts += 1;
if installing_attempts == 1 {
info!(
"Install {package_id}: dependency {labels} is still installing — \
waiting up to {}s for it to finish",
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
);
}
on_waiting(format!("Waiting for {labels} to finish installing…")).await;
tokio::time::sleep(interval).await;
continue;
}
if waited_attempts >= max_attempts {
let labels = join_dep_labels(&missing);
return Err(anyhow::Error::new(DependencyGateError(format!(
"{labels} is installed but did not reach the running state within \
{} seconds. Start {labels}, then install {package_id} again.",
@@ -401,7 +446,6 @@ where
}
waited_attempts += 1;
let labels = join_dep_labels(&missing);
if waited_attempts == 1 {
info!(
"Install {package_id}: dependency {labels} installed but not running yet — \
@@ -874,9 +918,19 @@ mod tests {
}
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
probe_with_installing(has_bitcoin, has_electrumx, existing, &[])
}
fn probe_with_installing(
has_bitcoin: bool,
has_electrumx: bool,
existing: &[&str],
installing: &[&str],
) -> DepProbe {
DepProbe {
running: deps(has_bitcoin, has_electrumx),
existing: existing.iter().map(|s| s.to_string()).collect(),
installing: installing.iter().map(|s| s.to_string()).collect(),
}
}
@@ -965,6 +1019,71 @@ mod tests {
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
}
#[tokio::test]
async fn waits_while_dependency_is_still_installing_then_passes() {
// Bitcoin has NO container yet (image still pulling) but its
// package state is Installing. Old behavior failed fast here —
// the confirmed fresh-node LND "first install fails" bug. The
// gate must wait past the normal started-but-not-running budget
// (max_attempts=1 below) while the install is in flight.
let calls = Arc::new(AtomicU32::new(0));
let (labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
let result = wait_for_install_deps(
"lnd",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
async move {
Ok(match n {
// pulling: no container, package Installing
0 | 1 => probe_with_installing(false, false, &[], &["bitcoin-knots"]),
// container created, starting
2 => probe(false, false, &["bitcoin-knots"]),
// running
_ => probe(true, false, &["bitcoin-knots"]),
})
}
},
sink,
1,
Duration::ZERO,
)
.await;
assert!(result.is_ok(), "{result:?}");
assert_eq!(calls.load(Ordering::SeqCst), 4);
let labels = labels.lock().unwrap();
assert_eq!(
labels.as_slice(),
[
"Waiting for Bitcoin to finish installing…",
"Waiting for Bitcoin to finish installing…",
"Waiting for Bitcoin to start…",
]
);
}
#[tokio::test]
async fn fails_fast_when_dependency_neither_installed_nor_installing() {
// installing list has unrelated packages only — no reason to wait.
let calls = AtomicU32::new(0);
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"lnd",
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok(probe_with_installing(false, false, &[], &["grafana"])) }
},
sink,
36,
Duration::ZERO,
)
.await
.unwrap_err();
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(labels.lock().unwrap().is_empty());
assert!(err.downcast_ref::<DependencyGateError>().is_some());
}
#[tokio::test]
async fn times_out_when_installed_dependency_never_runs() {
let (labels, sink) = label_sink();
+124 -30
View File
@@ -984,6 +984,24 @@ impl RpcHandler {
}))
}
/// Package ids currently mid-install/update per the state manager — used
/// by the dependency gate so an app whose dependency is still pulling its
/// image (no container yet) waits instead of failing fast.
async fn packages_currently_installing(&self) -> Vec<String> {
let (data, _) = self.state_manager.get_snapshot().await;
data.package_data
.iter()
.filter(|(_, entry)| {
matches!(
entry.state,
crate::data_model::PackageState::Installing
| crate::data_model::PackageState::Updating
)
})
.map(|(id, _)| id.clone())
.collect()
}
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
@@ -1006,6 +1024,7 @@ impl RpcHandler {
Ok(DepProbe {
running: self.running_deps_for_install(package_id).await?,
existing: detect_existing_containers().await,
installing: self.packages_currently_installing().await,
})
},
|msg| async move { self.set_install_message(package_id, &msg).await },
@@ -1089,46 +1108,96 @@ impl RpcHandler {
.spawn()
.context("Failed to start image pull")?;
// 5-minute per-URL budget. A full install tries each configured mirror
// once, so a two-registry setup fails visibly in roughly 10 minutes
// instead of staying in Installing for up to an hour.
const PULL_URL_TIMEOUT_SECS: u64 = 300;
let pull_result = tokio::time::timeout(
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
async {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
// Stall-aware budget instead of a hard wall clock. The old fixed
// 300s cap killed slow-but-progressing pulls (LND on fresh-node
// WiFi routinely needs longer), which surfaced as "first install
// fails, second succeeds" once the layer cache was warm. A pull is
// only killed when NOTHING is observably happening — no stderr
// output and no byte growth in podman's staging dir — for
// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling.
// Dead registries still fail fast: no bytes ever land, so the
// stall window (3 min) is the effective bound.
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
const PULL_URL_MAX_SECS: u64 = 1800;
const PULL_POLL_INTERVAL_SECS: u64 = 5;
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
.await;
}
let started = std::time::Instant::now();
// Seconds-since-start of the last stderr line, updated by the
// reader task. u64::MAX sentinel = no line seen yet (treated as
// activity at t=0 so the stall window starts at spawn).
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
let line_clock = std::sync::Arc::clone(&last_line_at);
let started_reader = started;
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
line_clock.store(
started_reader.elapsed().as_secs(),
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;
}
}
child.wait().await
},
)
.await;
});
}
match pull_result {
Ok(Ok(status)) => Ok(status.success()),
Ok(Err(e)) => {
tracing::warn!("Image pull process error on {}: {}", url, e);
Ok(false)
let mut last_staged_bytes = dir_size_bytes(user_tmp);
let mut last_staged_change = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status.success()),
Ok(None) => {}
Err(e) => {
tracing::warn!("Image pull process error on {}: {}", url, e);
let _ = child.kill().await;
let _ = child.wait().await;
return Ok(false);
}
}
Err(_) => {
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
if started.elapsed() > std::time::Duration::from_secs(PULL_URL_MAX_SECS) {
tracing::warn!(
"Image pull timed out after {}s: {}",
PULL_URL_TIMEOUT_SECS,
"Image pull exceeded absolute {}s ceiling: {}",
PULL_URL_MAX_SECS,
url
);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
Ok(false)
return Ok(false);
}
// Activity signal 1: stderr output from podman.
let line_age = started
.elapsed()
.as_secs()
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
// Activity signal 2: podman stages layer downloads in TMPDIR
// (user_tmp) — any size change there means bytes are moving.
let staged = dir_size_bytes(user_tmp);
if staged != last_staged_bytes {
last_staged_bytes = staged;
last_staged_change = std::time::Instant::now();
}
let stalled = line_age > PULL_STALL_TIMEOUT_SECS
&& last_staged_change.elapsed()
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS);
if stalled {
tracing::warn!(
"Image pull stalled ({}s with no output and no staged bytes): {}",
PULL_STALL_TIMEOUT_SECS,
url
);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
return Ok(false);
}
}
}
@@ -2690,6 +2759,31 @@ async fn persist_install_version_selection(app_id: &str, version: &str) {
}
}
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
/// "bytes are moving" signal for pull stall detection — exact size doesn't
/// matter, only whether it CHANGES between polls. Errors count as 0.
fn dir_size_bytes(path: &str) -> u64 {
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
if depth > 8 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total = total.saturating_add(walk(&entry.path(), depth + 1));
} else {
total = total.saturating_add(meta.len());
}
}
total
}
walk(std::path::Path::new(path), 0)
}
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
+38 -1
View File
@@ -621,8 +621,33 @@ async fn install_stack_via_orchestrator(
))
.await;
// Phase: PullingImage — each member's orchestrator.install covers its
// whole pipeline (pull + create + start + health wait), and on a fresh
// node the pull dominates wall-clock. The X-of-N counter below is what
// the UI interpolates across the PullingImage band (20→70%); without
// these updates an orchestrator-installed stack sat at "Preparing… 5%"
// for the entire multi-gigabyte pull (indeedhub: 7 images).
let total = app_ids.len() as u64;
handler
.set_install_phase(stack_name, InstallPhase::PullingImage)
.await;
let mut installed = 0usize;
for app_id in app_ids {
for (idx, app_id) in app_ids.iter().enumerate() {
handler
.set_install_progress(stack_name, idx as u64, total)
.await;
// Message after progress: set_install_progress resets the label,
// set_install_message preserves the phase + counters just written.
let component = app_id
.strip_prefix(&format!("{stack_name}-"))
.unwrap_or(app_id);
handler
.set_install_message(
stack_name,
&format!("Installing {} ({} of {})…", component, idx + 1, total),
)
.await;
match orchestrator.install(app_id).await {
Ok(container_name) => {
installed += 1;
@@ -672,6 +697,18 @@ 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_phase(stack_name, InstallPhase::PostInstall)
.await;
handler
.set_install_phase(stack_name, InstallPhase::Done)
.await;
handler.clear_install_progress(stack_name).await;
install_log(&format!("INSTALL ORCH OK: {} stack", stack_name)).await;
Ok(Some(serde_json::json!({
"success": true,
@@ -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()
+1 -1
View File
@@ -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());
+63 -1
View File
@@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[
"credentials",
"tor-config",
"content",
// Per-node service secrets. Without these a restored node can't
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
// encrypted with secrets/lnd-wallet-password) or reuse its service
// credentials. The archive itself is passphrase-encrypted.
"secrets",
];
/// Files within data_dir to include in a full backup.
@@ -101,7 +106,9 @@ pub async fn create_full_backup(
// Step 4: Write metadata
let metadata = BackupMetadata {
id: backup_id,
version: 2,
// v3: archive additionally carries the `secrets` dir (needed to
// decrypt identity/lnd_aezeed.enc on a restored node).
version: 3,
created_at: timestamp,
encrypted: true,
size_bytes: encrypted.len() as u64,
@@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
.context("Failed to create rollback directory")?;
for dir_name in BACKUP_DIRS {
// Only displace live data the backup will actually replace —
// older archives don't contain every current BACKUP_DIRS entry
// (e.g. `secrets` was added later), and moving a live dir to
// rollback with nothing staged to take its place would DELETE it
// at cleanup time.
if !staging_dir.join(dir_name).exists() {
continue;
}
let src = data_dir.join(dir_name);
if src.exists() {
let dst = rollback_dir.join(dir_name);
@@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
}
}
for file_name in BACKUP_FILES {
if !staging_dir.join(file_name).exists() {
continue;
}
let src = data_dir.join(file_name);
if src.exists() {
let dst = rollback_dir.join(file_name);
@@ -732,6 +750,50 @@ mod tests {
assert!(!bad_result.valid);
}
#[tokio::test]
async fn secrets_dir_rides_backup_and_restore() {
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
// with secrets/lnd-wallet-password — a backup that omits it can't
// recover the Lightning wallet on restore.
let dir = TempDir::new().unwrap();
setup_data_dir(dir.path());
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
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();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "s3cret");
}
#[tokio::test]
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
// Archives created before `secrets` joined BACKUP_DIRS don't stage
// one — the restore must NOT displace (and then delete) the node's
// live secrets in that case.
let dir = TempDir::new().unwrap();
setup_data_dir(dir.path());
// Backup taken while no secrets dir existed (mimics an old archive).
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
// Live secrets appear afterwards.
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();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "keep-me");
}
#[tokio::test]
async fn backup_and_restore() {
let dir = TempDir::new().unwrap();
+6 -1
View File
@@ -475,7 +475,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
let podman_health = parse_podman_health(c, &state);
let host_ports = host_tcp_ports_from_container(c);
let host_port_ready = if host_ports.is_empty() {
// Only raw-probe published ports for containers WITHOUT their own
// podman healthcheck. The healthcheck is the better signal, and the
// bare TCP connect+close is noisy against TLS listeners — LND logged
// "http: TLS handshake error … EOF" on every monitor cycle because
// this probe hit its REST/gRPC ports and hung up mid-handshake.
let host_port_ready = if host_ports.is_empty() || podman_health.is_some() {
None
} else {
Some(host_ports_ready(&host_ports).await)
+226 -38
View File
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
/// confidence than "looks stuck at 0%".
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
/// Serializes the mutating update operations (download, apply, and the
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
/// exactly this race: two concurrent `update.download` RPCs shared one
/// staging file, a cancel wiped staging mid-flight, a third download began
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
/// via `try_lock` so a concurrent caller gets an explicit "already running"
/// error instead of silently interleaving.
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn now_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let mut state = load_state(data_dir).await?;
if state.available_update.is_none() {
state = check_for_updates(data_dir).await?;
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
dest: &Path,
prior_total: u64,
) -> Result<()> {
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
const MAX_ATTEMPTS: u32 = 6;
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
Err(_) => 0,
};
if existing_len >= component.size_bytes {
// File is already complete — break out and go verify.
break;
// File is already complete (a resumed run finished it, or a
// leftover from an earlier attempt) — verify it instead of
// trusting it. The old code `break`d here, which skipped
// verification entirely AND landed on the error return below
// ("download failed without a captured error").
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
continue;
}
}
}
if attempt > 1 {
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
continue;
}
// Full file — verify hash.
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash == component.sha256 {
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
// match too. SHA-256 stays the mandatory gate during migration;
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
// — treat it like a SHA mismatch and re-download.
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
));
continue;
}
// Full file — verify hashes. On mismatch the file on disk is
// garbage: nuke it and start over from scratch on the next attempt.
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
}
return Ok(());
}
// SHA mismatch — the file on disk is garbage. Nuke it and
// start over from scratch on the next attempt.
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
}
/// Verify a fully-downloaded component file on disk: SHA-256 is the
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
/// decides whether to remove the file and retry.
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
use sha2::{Digest, Sha256};
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash != component.sha256 {
anyhow::bail!(
"SHA256 mismatch for {}: expected {}, got {}",
component.name,
component.sha256,
hash
));
);
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
);
}
}
Ok(())
}
/// Re-verify every manifest component against the bytes actually sitting
/// in staging, immediately before install. The download path verifies as
/// it goes, but staging can change between download and apply — on .198
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
/// installed. This apply-time gate is the one that must never be skipped.
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
for component in &manifest.components {
let dest = staging_dir.join(&component.name);
let len = tokio::fs::metadata(&dest)
.await
.map(|m| m.len())
.unwrap_or(0);
if len != component.size_bytes {
anyhow::bail!(
"staged component {} is {} bytes but the manifest says {} — \
refusing to apply (incomplete or concurrently-rewritten download)",
component.name,
len,
component.size_bytes
);
}
verify_component_on_disk(component, &dest)
.await
.with_context(|| {
format!(
"staged component {} failed verification — refusing to apply",
component.name
)
})?;
}
Ok(())
}
/// Cancel an in-flight download. Sets the cancellation flag so the
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
// Only wipe staging when no download/apply holds the op lock. Wiping
// under a live operation is how .198 ended up applying a re-filling
// staging dir; with the lock held elsewhere we just set the cancel
// flag and let the in-flight loop bail at its next chunk boundary
// (partials are size+hash revalidated on the next resume anyway).
let staging = data_dir.join("update-staging");
let wiped = if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
let wiped = match UPDATE_OP_LOCK.try_lock() {
Ok(_op) => {
if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
}
}
Err(_) => false,
};
// Clear the "downloaded, ready to apply" marker too — a canceled
// download is not a staged update.
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let staging_dir = data_dir.join("update-staging");
if !staging_dir.exists() {
anyhow::bail!("No staged update found. Download first.");
}
// Gate 1: the completion marker is written only after EVERY component
// downloaded and hash-verified. A staging dir without it is a partial
// or in-flight download — exactly what got installed on .198.
if !has_staged_update(data_dir).await {
anyhow::bail!(
"Staged update is incomplete (no completion marker) — download the update again before applying"
);
}
// Gate 2: re-verify the actual staged bytes against the manifest.
let manifest = load_state(data_dir)
.await?
.available_update
.ok_or_else(|| {
anyhow::anyhow!(
"no update manifest in state to verify staged files against — re-download the update"
)
})?;
verify_staged_components(&staging_dir, &manifest).await?;
let backup_dir = data_dir.join("update-backup");
fs::create_dir_all(&backup_dir)
.await
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await;
}
// Install the OTA crash-loop guard as a drop-in on existing
// nodes (fresh ISOs carry it in the unit file itself). The
// guard restores the update-backup binary when a freshly
// applied binary SEGVs before it can run its own post-OTA
// verification — the .198 v1.7.103 truncated-binary loop.
// Best-effort: `+-` in the drop-in means a missing script can
// never block the service, and a failed install here must not
// abort the apply.
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
let _ = host_sudo(&[
"bash",
"-c",
&format!(
"printf '%s\\n' '[Service]' \
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
> {}/ota-crash-guard.conf",
dropin_dir
),
])
.await;
}
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
let _ =
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
@@ -2443,6 +2565,72 @@ mod tests {
assert!(!persisted.update_in_progress);
}
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
/// that call it must not run concurrently — one would see the other's
/// lock and fail with "another update operation is already running".
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn test_apply_refuses_unmarked_staging() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Regression: .198 v1.7.103 bricking — apply ran against a staging
// dir that a concurrent download was still filling. Without the
// .download-complete marker, apply must refuse before touching
// anything.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"partial")
.await
.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("completion marker"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Marker present (a complete download once existed) but the staged
// bytes no longer match the manifest — apply must re-verify and
// refuse rather than install whatever is on disk.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
.await
.unwrap();
let state = UpdateState {
available_update: Some(UpdateManifest {
version: "999.0.0".to_string(),
release_date: "2026-07-18".to_string(),
changelog: vec![],
components: vec![ComponentUpdate {
name: "archipelago".to_string(),
current_version: "1.0.0".to_string(),
new_version: "999.0.0".to_string(),
download_url: "http://example.invalid/archipelago".to_string(),
sha256: "0".repeat(64),
size_bytes: 49_949_048,
blake3: None,
}],
}),
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("refusing to apply"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
+180 -21
View File
@@ -263,28 +263,38 @@ impl PodmanClient {
)
.await?;
// Image pull uses CLI — it's a streaming operation that the API handles differently
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
// Stall-aware + retried, mirroring the legacy installer's
// pull_one_url_with_progress. The old single-attempt hard 600s wall
// clock killed slow-but-progressing pulls, which every orchestrator
// stack (btcpay, indeedhub, …) surfaced as "first install fails,
// second succeeds" once the layer cache was warm. Podman keeps
// completed layers between attempts, so retries resume cheaply.
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 2] = [5, 15];
let mut last_err = anyhow::anyhow!("podman pull {image}: no attempt ran");
for attempt in 1..=MAX_ATTEMPTS {
match pull_image_stall_aware(image).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {:#}",
image,
attempt,
MAX_ATTEMPTS,
e
);
last_err = e;
if attempt < MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_secs(
BACKOFF_SECS[(attempt - 1) as usize],
))
.await;
}
}
}
}
cmd.arg(image);
let output = tokio::time::timeout(
std::time::Duration::from_secs(600), // 10 min for large images
cmd.output(),
)
.await
.map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))?
.context("Failed to execute podman pull")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
}
Ok(())
Err(last_err)
}
pub async fn create_container(&self, manifest: &AppManifest, name: &str) -> Result<String> {
@@ -710,6 +720,155 @@ fn podman_network_settings(
// ─── Helpers ─────────────────────────────────────────────────────
/// One `podman pull` attempt with a stall-aware budget instead of a hard
/// wall clock. A pull is only killed when NOTHING is observably happening —
/// no stderr output AND no byte growth in podman's TMPDIR staging dir — for
/// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling. Dead
/// registries still fail fast (no bytes ever land, so the 3-minute stall
/// window is the effective bound), while a slow-but-moving multi-GB pull is
/// left alone. Same design as the legacy installer's
/// pull_one_url_with_progress (LND "first install fails" fix).
async fn pull_image_stall_aware(image: &str) -> Result<()> {
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
const PULL_MAX_SECS: u64 = 1800;
const PULL_POLL_INTERVAL_SECS: u64 = 5;
// Rootless podman's user namespace makes /var/tmp read-only; stage into
// the user's containers tmp (same dir every other pull path uses) — it
// doubles as the "bytes are moving" signal for stall detection.
let user_tmp = format!(
"{}/.local/share/containers/tmp",
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
);
let _ = std::fs::create_dir_all(&user_tmp);
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.arg(image);
cmd.env("TMPDIR", &user_tmp);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn().context("Failed to start podman pull")?;
let started = std::time::Instant::now();
// Seconds-since-start of the last stderr line, updated by the reader
// task. Starts at 0 so the stall window opens at spawn.
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Ring of recent stderr lines so a failed pull reports podman's actual
// error, not just "exit status 125".
let recent_stderr = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
String,
>::with_capacity(8)));
if let Some(stderr) = child.stderr.take() {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(stderr).lines();
let line_clock = std::sync::Arc::clone(&last_line_at);
let stderr_ring = std::sync::Arc::clone(&recent_stderr);
let started_reader = started;
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
line_clock.store(
started_reader.elapsed().as_secs(),
std::sync::atomic::Ordering::Relaxed,
);
if let Ok(mut ring) = stderr_ring.lock() {
if ring.len() >= 8 {
ring.pop_front();
}
ring.push_back(line);
}
}
});
}
let stderr_tail = |ring: &std::sync::Mutex<std::collections::VecDeque<String>>| {
ring.lock()
.map(|r| r.iter().cloned().collect::<Vec<_>>().join(" | "))
.unwrap_or_default()
};
let mut last_staged_bytes = dir_size_bytes(&user_tmp);
let mut last_staged_change = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
anyhow::bail!(
"podman pull {image} failed ({status}): {}",
stderr_tail(&recent_stderr)
);
}
Ok(None) => {}
Err(e) => {
let _ = child.kill().await;
let _ = child.wait().await;
return Err(anyhow::anyhow!("podman pull {image} process error: {e}"));
}
}
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
if started.elapsed() > std::time::Duration::from_secs(PULL_MAX_SECS) {
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
anyhow::bail!("podman pull {image} exceeded absolute {PULL_MAX_SECS}s ceiling");
}
// Activity signal 1: stderr output from podman.
let line_age = started
.elapsed()
.as_secs()
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
// Activity signal 2: staged layer bytes growing in TMPDIR.
let staged = dir_size_bytes(&user_tmp);
if staged != last_staged_bytes {
last_staged_bytes = staged;
last_staged_change = std::time::Instant::now();
}
if line_age > PULL_STALL_TIMEOUT_SECS
&& last_staged_change.elapsed()
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS)
{
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
anyhow::bail!(
"podman pull {image} stalled ({PULL_STALL_TIMEOUT_SECS}s with no output and no staged bytes): {}",
stderr_tail(&recent_stderr)
);
}
}
}
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
/// "bytes are moving" signal for pull stall detection — exact size doesn't
/// matter, only whether it CHANGES between polls. Errors count as 0.
fn dir_size_bytes(path: &str) -> u64 {
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
if depth > 8 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total = total.saturating_add(walk(&entry.path(), depth + 1));
} else {
total = total.saturating_add(meta.len());
}
}
total
}
walk(std::path::Path::new(path), 0)
}
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
let mut ports = Vec::new();
if let Some(obj) = bindings.as_object() {
+81
View File
@@ -0,0 +1,81 @@
# Companion app pairing QR — integration handoff
**Status:** web-UI side SHIPPED (CompanionIntroOverlay.vue, 2026-07-16). This doc is
the contract + requirements for the companion-app side (worked on separately, on
the Mac).
## What the web UI now does
The "Remote Companion" intro modal (shown once after first dashboard login, and in
the public demo) gained a second screen:
1. **Screen 1 (existing):** APK download QR (desktop) / download button, plus a new
**"I've installed it"** button to the right of the download button.
2. **Screen 2 (new, slide transition):** a **pairing QR** the companion app scans to
auto-fill the server entry, with a **Back** button returning to screen 1. On
small screens (where you can't scan your own display) an
**"Open in companion app"** deep-link button is shown above Back, using the same
URI as the QR.
## The QR payload / deep link (the contract)
A single URI, also usable as an OS deep link:
```
archipelago://pair?v=1&url=<percent-encoded server URL>[&pw=<percent-encoded password>]
```
Query parameters:
| param | required | meaning |
|-------|----------|---------|
| `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". |
| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. |
| `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. |
Examples the web UI actually emits:
- Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit`
- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228`
- Real node kiosk (UI runs on localhost, so it advertises the mDNS name from
`system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local`
## Companion app requirements
1. **Scan entry point:** the app's action is labeled **"Scan Node's QR"**
(implemented 2026-07-17; the modal copy in CompanionIntroOverlay.vue was
updated to match).
2. **Parse the URI** (from camera scan AND from an OS deep-link intent —
register the `archipelago://` scheme so the "Open in companion app" button on
phones works).
3. On success, **create/update a saved server entry**:
- Server address = `url` exactly as given (respect the scheme — the demo is
https, LAN nodes are typically http, `.local` mDNS names must work).
- If `pw` present, prefill the password and attempt auto-login; otherwise land
on the password prompt for that server.
- If an entry with the same origin already exists, update it rather than
duplicating.
4. **Demo flow (the showcase):** scanning the demo QR should take a fresh install
to a logged-in demo session in one step — url `https://demo.archipelago-foundation.org`,
password `entertoexit`, no manual typing.
5. **Robustness:**
- Tolerate unknown extra query params (forward compat — we may add `name`,
`cert` fingerprint, etc. under `v=1`).
- Self-signed HTTPS on `.local`/LAN addresses may appear later; don't hard-fail
the parse on scheme.
- Bad/foreign QR → clear error, stay on the scan screen.
## Notes / future extensions (not in v1)
- A real-node pairing **token** instead of a password (backend mints a one-time
token, QR carries it, app exchanges it for a session) — needs a backend RPC;
the `v` param exists so we can bump when this lands.
- Optional `name` param (node display name) so the app labels the entry nicely.
## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
- [ ] Scan a real node's QR (LAN IP origin) → entry created, password prompt shown.
- [ ] Scan a kiosk node's QR (`http://<name>.local`) → mDNS resolution works on the phone.
- [ ] Tap "Open in companion app" on a phone browser → deep link opens the app with the same behavior.
- [ ] Re-scan same node → no duplicate entry.
+82
View File
@@ -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).
+95
View File
@@ -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, 25 min TTL, `created_at` ±60 s, deleted on
first verify attempt; pin an origin/host tag. Rate-limit like password attempts.
- The `secret` in the nostrconnect URI is a bearer token — one QR per attempt, expires
with the challenge.
- Policy call: signer approval should count as the second factor for TOTP accounts
(possession of phone/extension key), so nostr login doesn't silently bypass TOTP.
## Open questions
1. Amber + `ws://` LAN relay — needs a 10-minute on-device test before committing.
2. Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host.
3. NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles
both; verify against current Amber.
4. Track draft **NIP-97 "Login with Nostr"** (matches this UX exactly, unmerged) —
align, don't depend.
**Prior art:** no mainstream self-hosted node OS (Umbrel, Start9, Alby Hub) ships Nostr
QR login for its own UI — this would be genuinely differentiating, and every building
block is already in the tree.
@@ -1349,21 +1349,36 @@ if [ "$UNBUNDLED" = "1" ]; then
# Clean stale images from previous builds (e.g. bundled build tars leaking into unbundled)
rm -rf "$IMAGES_DIR"
mkdir -p "$IMAGES_DIR"
# FileBrowser is a core dependency (powers the Cloud file manager) — always bundle it
CORE_IMAGE="${FILEBROWSER_IMAGE}"
CORE_FILE="filebrowser.tar"
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
echo " ✅ Using cached: $CORE_FILE"
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))" || \
echo " ⚠️ Failed to save $CORE_IMAGE"
# Core baseline apps created by first-boot-containers.sh even in
# 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.zst
${FMCD_IMAGE} fmcd.tar.zst
"
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
[ -n "$CORE_IMAGE" ] || continue
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
echo " ✅ Using cached: $CORE_FILE"
else
echo " ⚠️ Failed to pull $CORE_IMAGE — Cloud will not work until installed"
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
if container_pull "$CORE_IMAGE"; then
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
fi
fi
done
else
echo "📦 Step 3b: Bundling container images for offline use..."
@@ -1472,6 +1487,13 @@ LOADSERVICE
cat > "$WORK_DIR/load-container-images.sh" <<'LOADSCRIPT'
#!/bin/bash
# Load pre-bundled container images into Podman
#
# CRITICAL: all Archipelago containers run ROOTLESS as the archipelago user.
# This script runs as root (systemd oneshot), so a plain `podman load` here
# puts the images into root's storage where the rootless runtime can never
# see them — containers then silently depend on registry pulls, and a fresh
# install without internet gets no apps at all. Always load into the
# archipelago user's storage.
IMAGES_DIR="/opt/archipelago/container-images"
LOG_FILE="/var/log/archipelago-images.log"
@@ -1483,21 +1505,32 @@ if [ ! -d "$IMAGES_DIR" ]; then
exit 0
fi
for tarfile in "$IMAGES_DIR"/*.tar; do
ARCH_UID=$(id -u archipelago)
# Linger gives the archipelago user a runtime dir (/run/user/UID) at boot,
# before any login — required for rootless podman.
loginctl enable-linger archipelago 2>/dev/null || true
for _ in $(seq 1 30); do
[ -d "/run/user/$ARCH_UID" ] && break
sleep 1
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 "$IMAGES_DIR"/*.tar.zst; do
if [ -f "$tarfile" ]; then
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
podman load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
echo "$(date): Successfully loaded $(basename "$tarfile")" >> "$LOG_FILE" || \
echo "$(date): Failed to load $(basename "$tarfile")" >> "$LOG_FILE"
fi
done
# Ensure archy-net exists for mempool stack (db, api, frontend)
podman network create archy-net 2>/dev/null || true
$PODMAN network create archy-net 2>/dev/null || true
echo "$(date): Container image load complete" >> "$LOG_FILE"
echo "$(date): Available images:" >> "$LOG_FILE"
podman images >> "$LOG_FILE" 2>&1
$PODMAN images >> "$LOG_FILE" 2>&1
LOADSCRIPT
chmod +x "$WORK_DIR/load-container-images.sh"
@@ -2494,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
@@ -3587,10 +3620,27 @@ echo " Step 5 complete (GRUB + ISOLINUX configured)"
echo ""
echo "Step 6: Creating bootable ISO..."
if [ "$UNBUNDLED" = "1" ]; then
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}.iso"
# Release-candidate suffix: rebuilds of the SAME version used to overwrite
# the previous ISO under an identical filename, so a flashed stick was
# indistinguishable from a newer build. Auto-increment an RC counter per
# version (persists next to the build counter); override with RC=n env.
RC_COUNTER_FILE="/opt/archipelago/rc-counter-${BUILD_VERSION}"
if [ -n "${RC:-}" ]; then
RC_NUM="$RC"
else
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}.iso"
if [ -f "$RC_COUNTER_FILE" ]; then
RC_NUM=$(( $(cat "$RC_COUNTER_FILE") + 1 ))
else
RC_NUM=1
fi
fi
echo "$RC_NUM" | sudo tee "$RC_COUNTER_FILE" > /dev/null 2>/dev/null || true
echo " Release candidate: RC${RC_NUM}"
if [ "$UNBUNDLED" = "1" ]; then
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}_RC${RC_NUM}.iso"
else
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}_RC${RC_NUM}.iso"
fi
# Use the proven MBR code for hybrid USB boot
@@ -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
@@ -135,7 +141,7 @@ while true; do
--disable-metrics \
--disable-metrics-reporting \
--disable-domain-reliability \
--js-flags="--max-old-space-size=128" \
--js-flags="--max-old-space-size=256" \
--user-data-dir=/var/lib/archipelago/chromium-kiosk
sleep 3
done
+5
View File
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
# in-binary post-OTA probe never runs — this restores the update-backup binary
# after 5 failed start attempts while the pending-verify marker exists.
# "-" so a missing/failed guard can never block the service itself.
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
+7 -23
View File
@@ -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),
@@ -187,8 +170,9 @@ http {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# Cache static assets (media too — the intro video/audio are versioned
# with ?v=N query busters, so immutable is safe)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
+261
View File
@@ -0,0 +1,261 @@
/**
* End-to-end verification of the FIRST-VISIT cinematic:
* splash (tap logo alien typing Welcome Noderunner + speech + synthwave
* Archipelago logo) onboarding intro login (video background, finale
* armed) dashboard full reveal (background zoom + interface assembly +
* oomph) welcome typing and that a SECOND login stays deliberately
* low-key.
*
* Run against the local demo stack:
* DEMO=1 node mock-backend.js (port 5959)
* VITE_DEMO=1 npx vite --port 8100
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
*
* Audio can't be heard headless, so every HTMLMediaElement.play() and
* WebAudio oscillator start is recorded into window.__audioLog via an init
* script the assertions check the right cues fired at the right phases.
*/
import { test, expect, type Page } from '@playwright/test'
// Real Chrome (new headless): the bundled headless-shell has no working video
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
// meaningless there. Requires Google Chrome installed.
test.use({ channel: 'chrome' })
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
declare global {
interface Window {
__audioLog: string[]
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
__revealSeen: { zoom?: boolean; glass?: boolean }
}
}
async function instrumentAudioAndVideo(page: Page) {
await page.addInitScript(() => {
window.__audioLog = []
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
// The dashboard reveal classes live only ~8s; a slow run can burn that
// window between two sequential expect() polls. Record their appearance
// the moment it happens instead, and let the test assert on the record.
window.__revealSeen = {}
new MutationObserver(() => {
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
const origPlay = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function (...args) {
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
if (this.tagName === 'VIDEO') {
const v = this as HTMLVideoElement
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
}
return origPlay.apply(this, args)
}
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
const OrigOsc = OscillatorNode.prototype.start
OscillatorNode.prototype.start = function (...args) {
window.__audioLog.push('osc-start')
return OrigOsc.apply(this, args)
}
})
}
async function freshVisit(page: Page) {
await page.goto(BASE + '/')
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
await page.goto(BASE + '/')
}
/** Walk the full splash from tap-to-start through completion (real time). */
async function runSplash(page: Page, { skip }: { skip: boolean }) {
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
// on click, so don't wait for post-click actionability)
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
// Phase 2: alien typing begins (first line types out)
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
.toBeVisible({ timeout: 10_000 })
if (skip) {
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
} else {
// Let all four lines type out for real (~20s)
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
}
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
// text is only on screen ~6s, so verify the phases via durable signals:
// the video's live health here, and the audio log (speech/song) after.
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
// Wait for actual smooth playback (cold-cache buffering right after mount
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
await page.waitForFunction(() => {
const v = document.querySelector('video')
return !!v && v.readyState >= 3 && v.currentTime > 0.3
}, { timeout: 20_000 })
const s1 = await page.evaluate(() => {
const v = document.querySelector('video')!
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
await page.waitForTimeout(2_000)
const s2 = await page.evaluate(() => {
const v = document.querySelector('video')
if (!v) return null
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
expect(s2).not.toBeNull()
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
// Steady-state frame drops over the sample window (startup catch-up excluded).
const dTotal = s2!.total - s1.total
const dDropped = s2!.dropped - s1.dropped
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
// Splash completes → demo routes to the onboarding intro
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
}
async function enterDemoAndLogin(page: Page) {
// The CTA unmounts mid-click when the router transitions away — dispatch
// once, swallow the detach retry, and trust the URL change instead.
const cta = page.getByRole('button', { name: /Enter the demo/ })
await cta.waitFor({ timeout: 15_000 })
await Promise.all([
page.waitForURL('**/login', { timeout: 15_000 }),
cta.click({ noWaitAfter: true }).catch(() => {}),
])
// Demo prefills the password; the finale flag must be armed at this point.
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
const loginBtn = page.getByRole('button', { name: /log ?in/i })
await loginBtn.waitFor({ timeout: 10_000 })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
}
test.describe('first-visit cinematic', () => {
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
test.setTimeout(240_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: false })
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
// FULL first-entry reveal: big background zoom + glass assembly classes
// (recorded by the init-script observer the instant they appear — the
// classes only live ~8s and sequential polling can miss the window).
await expect
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
.toMatchObject({ zoom: true, glass: true })
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
// The reveal runs 8s, then the zoom layer class clears.
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
// The dashboard oomph is WebAudio oscillators — at least the login pop +
// oomph layers must have started after the splash's own sounds.
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
expect(oscCount).toBeGreaterThan(0)
})
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
})
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
// End the session the way logout does (auth token only — the intro/login
// flags survive) and revisit login directly. An authenticated /login visit
// would just bounce back to the dashboard via the router guard.
await page.evaluate(() => localStorage.removeItem('neode-auth'))
// Direct /login navigation (no splash — not a root boot) and re-login.
await page.goto(BASE + '/login')
await page.locator('#login-password').waitFor({ timeout: 15_000 })
// First login happened → static rotated background, not the video.
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
// reset with the /login reload, so it would have caught even a transient
// reveal since then.
await page.waitForTimeout(1500)
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
})
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
test.setTimeout(120_000)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
// Reload at root = a fresh boot → the splash must return even though
// neode_intro_seen is now set.
await page.goto(BASE + '/')
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
})
test('video is warmed before the splash needs it', async ({ page }) => {
await freshVisit(page)
// The warm-up is a detached <video preload=auto> kept on window (Chromium
// has no <link rel=preload as=video>). Give it a moment to buffer.
await expect
.poll(async () => page.evaluate(() => {
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
return w ? { src: w.src, readyState: w.readyState } : null
}), { timeout: 15_000 })
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
const ready = await page.evaluate(() =>
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
})
})
+106 -17
View File
@@ -2032,6 +2032,7 @@ app.post('/rpc/v1', (req, res) => {
about: 'Self-sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
},
},
{
@@ -2044,7 +2045,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false,
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
profile: { display_name: 'Anon' },
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' },
},
{
id: 'id-merchant',
@@ -2056,7 +2057,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false,
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' },
},
],
},
@@ -2079,6 +2080,7 @@ app.post('/rpc/v1', (req, res) => {
profile: {
display_name: 'Archipelago Node',
about: 'Self-sovereign Bitcoin node',
picture: '/demo-avatars/satoshi.svg',
},
},
})
@@ -2121,6 +2123,7 @@ app.post('/rpc/v1', (req, res) => {
about: 'Running a sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
},
},
{
@@ -2136,6 +2139,7 @@ app.post('/rpc/v1', (req, res) => {
profile: {
display_name: 'Archy Consulting',
about: 'Bitcoin infrastructure services',
picture: '/demo-avatars/business.svg',
},
},
{
@@ -2146,7 +2150,7 @@ app.post('/rpc/v1', (req, res) => {
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
created_at: '2026-03-01T18:00:00Z',
is_default: false,
profile: {},
profile: { picture: '/demo-avatars/anon.svg' },
},
],
},
@@ -2163,7 +2167,7 @@ app.post('/rpc/v1', (req, res) => {
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
is_default: true, created_at: '2026-01-10T08:00:00Z',
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' },
},
}
return res.json({ result: identities[id] || identities['id-primary'] })
@@ -2235,12 +2239,40 @@ app.post('/rpc/v1', (req, res) => {
bytes_out: 642_889_310,
} })
}
// Fake tunnel provisioning so the companion remote-access onboarding
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
// are not real; importing this config yields a harmless dead tunnel.
case 'vpn.list-peers': {
return res.json({ result: { peers: mockState.vpnPeers || [] } })
}
case 'vpn.create-peer':
case 'vpn.peer-config': {
const peerName = params?.name || 'device'
if (!mockState.vpnPeers) mockState.vpnPeers = []
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
}
const config = [
'[Interface]',
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
'Address = 10.44.0.7/32',
'DNS = 1.1.1.1',
'',
'[Peer]',
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
'Endpoint = demo.archipelago-foundation.org:51820',
'AllowedIPs = 10.44.0.0/16',
'PersistentKeepalive = 25',
].join('\n')
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
}
// Node visibility — interactive (persisted per demo session)
case 'network.get-visibility': {
// No onion_address in the demo: the public showcase shouldn't display
// a Tor address (even a fake one) on the Node Visibility card.
return res.json({ result: {
visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} })
}
case 'network.set-visibility': {
@@ -2248,7 +2280,6 @@ app.post('/rpc/v1', (req, res) => {
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
return res.json({ result: {
visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} })
}
@@ -2351,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
case 'streaming.list-services': {
return res.json({ result: { services: mockState.streamingServices || [] } })
}
case 'streaming.list-sessions': {
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
return res.json({ result: {
sessions: [
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
],
total_active: 3,
total_revenue_sats: 770_000,
revenue_by_service: {
'content-download': 512_400,
'nostr-relay': 201_600,
'api-access': 56_000,
},
} })
}
case 'streaming.configure-service': {
const p = params || {}
const list = mockState.streamingServices || []
@@ -3363,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),
},
})
}
@@ -3414,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({
@@ -3535,13 +3591,32 @@ app.post('/rpc/v1', (req, res) => {
}
case 'wallet.networking-profits': {
// Deterministic-but-varied week of profit events for the dashboard
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
const recent = []
const nowMs = Date.now()
for (let i = 0; i < 42; i++) {
const daysAgo = (i * 3) % 7
const hour = (i * 5) % 24
recent.push({
source: profitSources[i % 3],
amount_sats: 800 + ((i * 7919) % 14000),
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
description: profitNotes[i % 3],
})
}
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
return res.json({
result: {
total_sats: 5_231_978,
content_sales_sats: 3_180_000,
routing_fees_sats: 1_281_978,
relay_sats: 770_000,
streaming_revenue_sats: 770_000,
recent,
// legacy aliases kept for older UI builds
relay_sats: 770_000,
total_earned_sats: 5_231_978,
total_forwarded_sats: 1_281_978,
forward_count: 1284,
@@ -3576,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 01
// fraction — which is what the frontend reads; the bitcoin-core-style
// fields are kept for any legacy consumers.)
if (!walletState.ibd_started_at) walletState.ibd_started_at = Date.now()
const IBD_RAMP_MS = 90_000
const elapsed = Date.now() - walletState.ibd_started_at
const syncProgress = Math.min(1, 0.982 + 0.018 * (elapsed / IBD_RAMP_MS))
const tipHeight = 892451
const height = Math.round(tipHeight * syncProgress)
return res.json({
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,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.100-alpha",
"version": "1.7.104-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.100-alpha",
"version": "1.7.104-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.100-alpha",
"version": "1.7.104-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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

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

After

Width:  |  Height:  |  Size: 1000 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.
Binary file not shown.
+88 -23
View File
@@ -124,6 +124,13 @@ function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
// Very first kiosk boot: the launcher opens /kiosk before the route
// guard has had a chance to persist localStorage.kiosk.
|| window.location.pathname === '/kiosk'
// Global styling hook: kiosk chromium runs software-composited
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
// (3D bg layers, animated mix-blend overlays) fail and paint black.
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
@@ -400,19 +407,40 @@ onMounted(async () => {
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// The route object still holds the router's START_LOCATION ('/') here
// the initial navigation resolves asynchronously so a deep-route load
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
// bar is the only truthful source this early.
//
// The kiosk chromium opens /kiosk a marker route whose beforeEnter
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
// root boot, not a deep route: without normalizing it, the fresh-install
// kiosk never plays the typing intro (deep-route suppression regression).
const rawBootPath = window.location.pathname
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && route.path === '/') replayRequested = true
if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
if (splashCandidate && onboardingComplete !== true) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
onboardingComplete = await checkOnboardingStatus()
// Bound the pre-splash status check: its retry ladder can spend ~30s
// against a still-booting backend, and this await holds the black
// "!isReady" screen the whole time exactly where the typing intro
// should be playing on a fresh kiosk boot. Unknown within 2.5s let
// the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro.
onboardingComplete = await Promise.race([
checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
])
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
@@ -427,12 +455,30 @@ onMounted(async () => {
if (shouldShowIntroSplash({
seenIntro,
routePath: route.path,
routePath: bootPath,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// The intro is definitely playing unmute the whole cinematic (speech,
// synthwave, pops) even on browsers whose localStorage says onboarding is
// complete; the sound gate otherwise silences replays.
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
enableCinematicSounds()
// Kick off the intro video download NOW: the splash's <video> element only
// mounts ~20s in (after the typing sequence), and on a cold cache the file
// would otherwise start fetching mid-sequence and stutter. A detached
// <video preload=auto> is used because Chromium does not support
// <link rel=preload as=video> the media cache then serves the splash's
// element, which uses the identical URL.
try {
const warm = document.createElement('video')
warm.muted = true
warm.preload = 'auto'
warm.src = '/assets/video/video-intro.mp4?v=8'
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
} catch { /* ignore */ }
// Coming from boot screen show the full splash intro (Enter to Exit typing logo)
showSplash.value = true
} else {
@@ -494,42 +540,61 @@ function onShareToMeshMessage(ev: MessageEvent) {
* Routes user directly to appropriate screen based on onboarding status (from backend)
*/
async function handleSplashComplete() {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
sessionStorage.setItem('archipelago_from_splash', '1')
// Commit the destination route BEFORE revealing RouterView. Revealing
// first rendered whatever route was current '/' RootRedirect's
// spinner for a beat between the intro's final frame and the
// onboarding/login screen actually mounting: a visible flash on every
// fresh install. Await the push so the first RouterView render is
// already the destination.
const reveal = () => {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
}
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.push('/login').catch(() => {})
await router.push('/login').catch(() => {})
reveal()
return
}
// Demo: the cinematic always continues into the onboarding intro page
// the mock backend reports "onboarded", which would otherwise route
// straight to /login and cut the sequence short.
{
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO) {
await router.push('/onboarding/intro').catch(() => {})
reveal()
return
}
}
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
router.push('/login').catch(() => {})
return
}
if (seenOnboarding === false) {
router.push('/onboarding/intro').catch(() => {})
return
}
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it)
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
if (localStorage.getItem('neode_onboarding_complete') === '1') {
router.push('/login').catch(() => {})
await router.push('/login').catch(() => {})
} else if (seenOnboarding === false) {
await router.push('/onboarding/intro').catch(() => {})
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it)
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
await router.push('/login').catch(() => {})
} else {
router.push('/').catch(() => {})
await router.push('/').catch(() => {})
}
} catch {
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
router.push('/').catch(() => {})
await router.push('/').catch(() => {})
}
reveal()
}
</script>
+463 -52
View File
@@ -8,66 +8,240 @@
>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0 overflow-hidden"
@click.stop
>
<button
type="button"
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors z-10"
aria-label="Close companion modal"
@click="dismiss"
>
&times;
</button>
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
<p class="text-sm text-white/60 leading-relaxed">
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
</p>
</div>
</div>
<Transition :name="slideName" mode="out-in">
<!-- Screen 1: get the app -->
<div v-if="step === 'download'" key="download">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
<p class="text-sm text-white/60 leading-relaxed">
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
</p>
</div>
</div>
<div class="mb-4">
<a
:href="companionDownloadUrl"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
<div class="hidden md:flex justify-center">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="Companion app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="Companion app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="companionDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download app
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="advanceFromDownload"
>
I've installed it
</button>
</div>
</div>
</div>
<a
:href="companionDownloadUrl"
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
<!-- Screen 2 (remote access): install WireGuard -->
<div v-else-if="step === 'wireguard'" key="wireguard">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
<p class="text-sm text-white/60 leading-relaxed">
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
</p>
</div>
</div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgApkQrDataUrl"
:src="wgApkQrDataUrl"
alt="WireGuard app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="wireguardDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download WireGuard
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showWgQrScreen"
>
I've installed it
</button>
</div>
<button
type="button"
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
>
Back
</button>
</div>
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
<div v-else-if="step === 'wgqr'" key="wgqr">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
<p class="text-sm text-white/60 leading-relaxed">
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
</p>
</div>
</div>
<div class="flex justify-center mb-3">
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgQrDataUrl"
:src="wgQrDataUrl"
alt="WireGuard tunnel config QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
</div>
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
<button
v-if="wgConfig"
type="button"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
@click="downloadWgConfig"
>
Download tunnel config
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
@click="showPairScreen"
>
I'm connected
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showWireguardScreen('back')"
>
Back
</button>
</div>
<!-- Screen 2: pair the app with this node -->
<div v-else key="pair">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke-width="1.5" />
<path d="M13 13h3v3h-3zM17 17h3v3h-3z" fill="currentColor" stroke="none" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
<p class="text-sm text-white/60 leading-relaxed">
In the companion app, choose Scan Node's QR and point your phone here the server details fill in automatically.
</p>
</div>
</div>
<div class="flex justify-center mb-4">
<!-- Bigger than the download QR: this one is scanned by the
companion app camera, and physical size drives decode. -->
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="pairQrDataUrl"
:src="pairQrDataUrl"
alt="Companion app pairing QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<!-- Same-device path: a QR on the phone's own screen can't be
scanned, so offer the deep link directly on small screens. -->
<a
v-if="pairingUrl"
:href="pairingUrl"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors mb-2"
>
Open in companion app
</a>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="backFromPair"
>
Back
</button>
</div>
</Transition>
</div>
</div>
</Transition>
@@ -75,9 +249,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO } from '@/composables/useDemoIntro'
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { rpcClient } from '@/api/rpc-client'
const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no
@@ -89,36 +266,260 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
// Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract).
const PAIR_SCHEME = 'archipelago://pair'
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
// between "I've installed it" and the pairing QR. Real nodes pair against the
// node's VPN address instead of the LAN origin; the demo walks the same steps
// with a mock tunnel config (its pairing QR still carries the public demo
// URL). Set to false to restore the original two-screen flow (instant revert).
const REMOTE_ACCESS_STEPS = true
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
// 10.44.0.0/16 core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
// the node is reachable exclusively at its tunnel address. The pairing QR
// must therefore advertise this, not the browser's LAN origin. The flow has
// the user switch the tunnel on right before pairing, so it validates.
const VPN_NODE_URL = 'http://10.44.0.1'
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
// sha256 f92971bc, mirrored from download.wireguard.com), served like the
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/wireguard.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
// Name of the auto-created VPN peer for the phone running the companion.
const WG_PEER_NAME = 'companion-phone'
const visible = ref(false)
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
const slideName = ref('slide-forward')
const qrDataUrl = ref('')
const pairQrDataUrl = ref('')
const pairingUrl = ref('')
const wgApkQrDataUrl = ref('')
const wgQrDataUrl = ref('')
const wgConfig = ref('')
const wgLoading = ref(false)
const wgError = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
const loginTransition = useLoginTransitionStore()
// Base delay before the popup may appear, and extra breathing room after the
// dashboard entrance cinematic ends so the popup never cuts into the reveal.
const BASE_DELAY_MS = 5000
const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
// Delay slightly so it doesn't compete with login animation
setTimeout(() => { visible.value = true }, 5000)
setTimeout(maybeShow, BASE_DELAY_MS)
}
} catch {
// localStorage unavailable
}
})
onUnmounted(() => {
if (calmTicker) clearInterval(calmTicker)
})
function maybeShow() {
// Show only after the scene has been CONTINUOUSLY calm (no reveal
// cinematic) for the full grace window. The previous point-in-time check
// raced a slow-starting reveal on a cold cache the entrance video can
// begin buffering after the 5s base delay, so the flag was still false
// when sampled and the popup cut straight into the cinematic.
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
calmTicker = setInterval(() => {
if (loginTransition.introCinematicPlaying) {
calmSince = null
return
}
if (calmSince === null) calmSince = Date.now()
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
if (calmTicker) clearInterval(calmTicker)
calmTicker = null
visible.value = true
}
}, 250)
}
// Manual open (App Store banner etc.) ignores the once-per-browser gate.
watch(companionIntroRequested, (requested) => {
if (!requested) return
companionIntroRequested.value = false
if (calmTicker) {
clearInterval(calmTicker)
calmTicker = null
}
step.value = 'download'
visible.value = true
})
watch(visible, async (isVisible) => {
if (!isVisible) return
// Generate large and let CSS scale down at 112px source a ~45-module QR
// is 2.5px/module, which camera decoders (the companion app included)
// routinely fail on. 512px keeps every module crisp.
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 112,
margin: 1,
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
}, { immediate: true, flush: 'post' })
/**
* The server URL the companion app should connect to. The demo advertises its
* public https origin; a real node advertises whatever address the browser is
* already using except on the kiosk, where that is localhost and useless to
* a phone, so fall back to the node's mDNS .local name.
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
// Remote-access flow: the app must connect through the tunnel the user just
// switched on the LAN origin is unreachable from outside AllowedIPs.
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
} catch {
// RPC unavailable fall through to the (localhost) origin
}
return origin
}
async function buildPairingUrl(): Promise<string> {
const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() })
// Only the shared demo password ever rides in the QR; real node passwords
// are never available to the frontend and stay typed-by-hand in the app.
if (IS_DEMO) params.set('pw', DEMO_PASSWORD)
return `${PAIR_SCHEME}?${params.toString()}`
}
async function showPairScreen() {
slideName.value = 'slide-forward'
step.value = 'pair'
if (!pairQrDataUrl.value) {
pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above).
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
}
function showDownloadScreen() {
slideName.value = 'slide-back'
step.value = 'download'
}
function advanceFromDownload() {
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
else void showPairScreen()
}
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
step.value = 'wireguard'
}
function showWgQrScreen() {
slideName.value = 'slide-forward'
step.value = 'wgqr'
void loadWgPeer()
}
function backFromPair() {
if (REMOTE_ACCESS_STEPS) {
slideName.value = 'slide-back'
step.value = 'wgqr'
} else {
showDownloadScreen()
}
}
// Create (or fetch) the phone's VPN peer and render its config as a QR.
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
// looked up first so reopening the modal never duplicates it.
async function loadWgPeer() {
if (wgQrDataUrl.value || wgLoading.value) return
wgLoading.value = true
wgError.value = ''
try {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => ({ peers: [] as { name: string }[] }))
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
params: { name: WG_PEER_NAME },
})
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} catch (e) {
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
} finally {
wgLoading.value = false
}
}
// Same-device path: hand the config to the WireGuard app as an importable
// .conf file (a phone can't scan its own screen).
function downloadWgConfig() {
if (!wgConfig.value) return
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'archipelago.conf'
a.click()
URL.revokeObjectURL(url)
}
function dismiss() {
visible.value = false
step.value = 'download'
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
@@ -134,4 +535,14 @@ function dismiss() {
.overlay-fade-leave-to { opacity: 0; }
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
/* Horizontal slide between the download and pairing screens */
.slide-forward-enter-active,
.slide-forward-leave-active,
.slide-back-enter-active,
.slide-back-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
.slide-forward-enter-from { transform: translateX(40px); opacity: 0; }
.slide-forward-leave-to { transform: translateX(-40px); opacity: 0; }
.slide-back-enter-from { transform: translateX(-40px); opacity: 0; }
.slide-back-leave-to { transform: translateX(40px); opacity: 0; }
</style>
@@ -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 : ''
}
@@ -101,7 +101,10 @@ const show = computed(() =>
)
function backUpNow() {
router.push(LND_DETAILS_PATH).catch(() => {})
// seed-backup=1 makes the LND page open the reveal flow immediately
// landing on the app page with the card below the fold read as
// "clicking the notification did nothing".
router.push({ path: LND_DETAILS_PATH, query: { 'seed-backup': '1' } }).catch(() => {})
}
function snooze() {
+42 -2
View File
@@ -25,16 +25,56 @@
<script setup lang="ts">
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
// Reload for a genuine SW update but never mid-cinematic. The splash
// typing intro and the dashboard-reveal cinematic are the two moments a
// surprise reload reads as "the app just reset itself" (kiosk and demo
// replay the intro on every boot/visit, so an update landing during it
// restarted the whole sequence). Wait until both are over, then reload.
function reloadAfterCinematic() {
const loginTransition = useLoginTransitionStore()
const calm = () =>
document.body.classList.contains('splash-complete') &&
!loginTransition.introCinematicPlaying
if (calm()) {
window.location.reload()
return
}
const poll = setInterval(() => {
if (calm()) {
clearInterval(poll)
window.location.reload()
}
}, 1000)
}
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
// freshly-installed worker then activates and claims the page
// (autoUpdate clientsClaim), firing controllerchange. Reloading on
// that first claim caused the fresh-install "loads, then reloads"
// jank (worst on kiosk). Only reload when an existing controller is
// REPLACED i.e. a genuine update.
let hadController = !!navigator.serviceWorker.controller
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Service worker updated, reload the page
window.location.reload()
if (!hadController) {
hadController = true
return
}
reloadAfterCinematic()
})
// Check for updates periodically
@@ -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('')
+48 -6
View File
@@ -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
}
+8 -18
View File
@@ -14,7 +14,7 @@
preload="auto"
poster="/assets/img/bg-intro.jpg"
>
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
<!-- Fallback to image if video fails -->
<div
class="absolute inset-0"
@@ -266,16 +266,12 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
}
})
// Check if user has seen intro
// Also detect returning users who cleared cache: if we're on a /dashboard route,
// the backend session is still active so the user has been here before.
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
const seenIntro = storedSeenIntro || isOnDashboard
// Persist the flag so subsequent loads don't re-check
if (!storedSeenIntro && isOnDashboard) {
localStorage.setItem('neode_intro_seen', '1')
}
// NOTE: whether the intro should play is decided ONCE by App.vue
// (shouldShowIntroSplash) this component is only mounted when the answer was
// yes, so it must not re-check neode_intro_seen itself. The old internal
// re-check silently skipped the whole sequence for any browser that had seen
// the intro before, even when App.vue explicitly asked for a replay (demo
// fresh visits, the Replay Intro link).
function handleEnterKey(e: KeyboardEvent) {
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
@@ -468,13 +464,7 @@ function startAlienIntro() {
}
onMounted(() => {
if (seenIntro) {
showSplash.value = false
document.body.classList.add('splash-complete')
emit('complete')
} else {
window.addEventListener('keydown', handleEnterKey)
}
window.addEventListener('keydown', handleEnterKey)
})
onBeforeUnmount(() => {
+14 -2
View File
@@ -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'
@@ -216,7 +216,8 @@ async function connect() {
device_kind: form.value.deviceKind,
})
mesh.dismissDetectedDevice(path)
void router.push('/mesh')
// The Mesh view lives under the dashboard shell a bare /mesh 404s.
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
+109
View File
@@ -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
}
}
}
@@ -0,0 +1,13 @@
import { ref } from 'vue'
/**
* Cross-view trigger for the Remote Companion intro/pairing modal
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
* App Store banner call openCompanionIntro() to pop it on demand this
* bypasses the once-per-browser auto-show gate.
*/
export const companionIntroRequested = ref(false)
export function openCompanionIntro(): void {
companionIntroRequested.value = true
}
@@ -199,6 +199,22 @@ function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
// ─── Nav-Ring Modality ──────────────────────────────────────────
// The orange focus ring must only render while the user is actually
// navigating directionally (arrows / gamepad). Ring visibility is gated in
// CSS behind html.controller-nav; directional input turns it on, any
// pointer input (tap, click) turns it off. Without this gate the ring
// painted on plain :focus — every tap on a tabindex card, and every
// programmatic route-change autofocus, lit it up with no controller in
// sight (worst on mobile).
function setNavRing(on: boolean) {
document.documentElement.classList.toggle('controller-nav', on)
}
function navRingActive(): boolean {
return document.documentElement.classList.contains('controller-nav')
}
// ─── Main Composable ────────────────────────────────────────────
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
@@ -396,9 +412,18 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// Mark controller as active
isControllerActive.value = true
setNavRing(true)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
// Nothing focused yet (fresh page, or focus was lost) — enter nav mode
// on the first arrow press by focusing the first container, instead of
// spatially navigating from <body>.
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
autoFocusMain()
return
}
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
@@ -611,6 +636,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true
setNavRing(true)
}
function handleGamepadDisconnected() {
@@ -644,6 +670,10 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
// Only when the user is actually in directional-nav mode. For pointer
// and touch users this programmatic focus painted the first card's
// focus ring on every route change with zero controller input.
if (!navRingActive()) return
const active = document.activeElement as HTMLElement | null
// Don't steal focus from inputs, modals, or sidebar
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
@@ -668,9 +698,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ─── Lifecycle ─────────────────────────────────────────────
// Any pointer interaction ends directional-nav mode — the ring should
// never linger once the user reaches for mouse or touch.
function handlePointerDown() {
setNavRing(false)
}
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true })
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
@@ -680,11 +717,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions)
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('gamepadconnected', handleGamepadConnected)
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
if (pollIntervalId) clearInterval(pollIntervalId)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
setNavRing(false)
})
return { isControllerActive, gamepadCount }
+10 -6
View File
@@ -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
}
})
}
+23 -7
View File
@@ -8,14 +8,27 @@
* exempt and continue to play regardless.
*/
/** One-way, per-page-load override: when App.vue decides to run the intro
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
* cinematic speech, synthwave, pops must be audible even though
* localStorage already says onboarding is complete. Without this the
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
let cinematicMode = false
export function enableCinematicSounds() {
cinematicMode = true
}
/** True when the node has not yet completed onboarding i.e. we're
* still in the first-install cinematic. Reads the localStorage cache
* set by useOnboarding (which is re-seeded from the backend on each
* successful check), so this stays correct after a browser clear
* once the onboarding-complete probe runs. Sound calls that fire
* before that probe completes will fall through silent on an already-
* onboarded node which is exactly what we want. */
* still in the first-install cinematic or when the intro is being
* deliberately replayed this page load (see enableCinematicSounds).
* Reads the localStorage cache set by useOnboarding (which is
* re-seeded from the backend on each successful check), so this stays
* correct after a browser clear once the onboarding-complete probe
* runs. Sound calls that fire before that probe completes will fall
* through silent on an already-onboarded node which is exactly what
* we want: ordinary re-logins stay quiet. */
function isFirstInstallPhase(): boolean {
if (cinematicMode) return true
try {
return localStorage.getItem('neode_onboarding_complete') !== '1'
} catch {
@@ -260,7 +273,10 @@ export function playKeyboardTypingSound() {
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
const ctx = getContext()
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
// AudioContext — so the context must be recreated here, not just fetched.
// The login click's sticky user activation lets the fresh context run.
const ctx = ensureContext()
if (!ctx) return
try {
+11 -2
View File
@@ -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,
}
}
+44 -6
View File
@@ -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,0001,500,000 sats and your shop can accept instant Lightning payments.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
},
{
id: 'install-btcpay',
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,0001,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,0001,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',
+10 -1
View File
@@ -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')
+9 -2
View File
@@ -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'
}
+12
View File
@@ -15,6 +15,12 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
const pendingWelcomeTyping = ref(false)
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
const startWelcomeTyping = ref(false)
/**
* True while the dashboard entrance cinematic (zoom/glitch reveal) is
* playing. Overlays that would visually compete with it (e.g. the
* companion intro popup) hold off until this flips back to false.
*/
const introCinematicPlaying = ref(false)
function setJustLoggedIn(value: boolean) {
justLoggedIn.value = value
@@ -32,6 +38,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
startWelcomeTyping.value = value
}
function setIntroCinematicPlaying(value: boolean) {
introCinematicPlaying.value = value
}
return {
justLoggedIn,
setJustLoggedIn,
@@ -41,5 +51,7 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
setPendingWelcomeTyping,
startWelcomeTyping,
setStartWelcomeTyping,
introCinematicPlaying,
setIntroCinematicPlaying,
}
})
+22 -18
View File
@@ -79,7 +79,9 @@ select:focus-visible {
border-color: rgba(251, 146, 60, 0.4);
}
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
/* Card action placement: actions always live at the bottom of the card as
full-width buttons same layout on every screen size. The compact header
variants are permanently retired for consistency (desktop == mobile). */
.responsive-card-actions-top,
.web5-card-actions-top {
display: none;
@@ -109,20 +111,6 @@ select:focus-visible {
white-space: nowrap;
}
@media (min-width: 1800px) {
.responsive-card-actions-top,
.web5-card-actions-top {
display: flex;
}
.responsive-card-actions-bottom,
.responsive-card-actions-bottom-grid,
.web5-card-actions-bottom,
.web5-card-actions-bottom-grid {
display: none;
}
}
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
@@ -270,9 +258,13 @@ input[type="radio"]:active + * {
/* Containers: console-style focus lift + ambient orange glow.
Pure glow approach no border-color or outline changes, avoids
Chromium compositor bugs with border-radius on translateZ(0) layers. */
[data-controller-container]:focus-visible,
[data-controller-container]:focus {
Chromium compositor bugs with border-radius on translateZ(0) layers.
Gated behind html.controller-nav (set by useControllerNav only while
the user navigates with arrows/gamepad; cleared on any pointer input).
Ungated, the plain :focus selector painted the ring on every tap of a
tabindex card the "gamepad selection with no gamepad" bug on mobile. */
html.controller-nav [data-controller-container]:focus-visible,
html.controller-nav [data-controller-container]:focus {
outline: none;
transform: translateY(-4px) scale(1.01);
box-shadow:
@@ -1615,6 +1607,18 @@ html.no-backdrop *::after {
-webkit-backdrop-filter: none !important;
}
/* Kiosk: the infinite mix-blend-mode:multiply glitch overlays with
background-attachment:fixed are a known black-screen vector under the
kiosk's software compositor a missed repaint leaves a dark multiply
layer stuck over the whole viewport. Their base opacity is 0, so
disabling the animations hides them entirely on kiosk. */
html.kiosk-mode body::before,
html.kiosk-mode body::after,
html.kiosk-mode::before {
animation: none !important;
will-change: auto !important;
}
/* Dashboard: full viewport width, no letterboxing, no body scroll */
body.dashboard-active {
overflow: hidden;
+9 -1
View File
@@ -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'
+20 -3
View File
@@ -52,9 +52,11 @@
aria-hidden="true"
/>
<!-- Background overlay - uniform 0.2 opacity -->
<!-- Background overlay. The web5/server backdrop is a much lighter image
than the others, so it gets a heavier scrim for text contrast. -->
<div
class="fixed inset-0 pointer-events-none bg-black/20"
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
style="z-index: -5;"
/>
@@ -155,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()
@@ -190,6 +196,10 @@ const backgroundImage = computed(() => {
return 'bg-home.webp'
})
// bg-web5.jpg (web5 + server sections) is bright the scrim overlay deepens
// while it's showing so light text keeps its contrast.
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
const isDarkRoute = computed(() => {
const p = route.path
return p.includes('/dashboard/web5') ||
@@ -373,6 +383,7 @@ onMounted(() => {
// un-forced call would gate itself silent.
playDashboardLoadOomph(true)
showZoomIn.value = true
loginTransition.setIntroCinematicPlaying(true)
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false)
loginTransition.setJustLoggedIn(false)
@@ -383,7 +394,10 @@ onMounted(() => {
scheduledTimeout(triggerRevealGlitch, 500)
scheduledTimeout(triggerRevealGlitch, 1200)
scheduledTimeout(triggerRevealGlitch, 2000)
scheduledTimeout(() => { showZoomIn.value = false }, 8000)
scheduledTimeout(() => {
showZoomIn.value = false
loginTransition.setIntroCinematicPlaying(false)
}, 8000)
scheduledTimeout(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
@@ -405,6 +419,9 @@ onMounted(() => {
onBeforeUnmount(() => {
document.body.classList.remove('dashboard-active')
// Timers are cleared below; make sure overlays waiting on the cinematic
// aren't left blocked forever if we unmount mid-reveal.
loginTransition.setIntroCinematicPlaying(false)
window.removeEventListener('keydown', handleKioskShortcuts)
for (const id of pendingTimers) clearTimeout(id)
pendingTimers.length = 0
+4
View File
@@ -150,6 +150,9 @@
</div>
</div>
<!-- Mobile companion app banner opens the download/pairing modal -->
<CompanionBanner />
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
+173 -10
View File
@@ -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,0001,500,000 sats.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
@click="showFundModal = true"
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
Fund Wallet
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
</div>
<button
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>
+8 -2
View File
@@ -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))]
+10
View File
@@ -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 Enterclick-next-button
pattern (data-controller-no-submit): they submit via their own Enter
handlers, and while the submit button is still disabled the "next
focusable" is Replay Intro the companion's auto-login injects
Enter before Vue re-enables the button, which replayed the intro
in a loop on every app connect. -->
<div class="mb-6">
<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"
+11
View File
@@ -69,12 +69,22 @@ onMounted(() => {
}, 2100)
})
/** Any exit from the intro INTO login is the end of the cinematic hand the
* login the one-shot finale flag so the dashboard plays its full first-entry
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
* full wizard; these are the shortcut exits that used to skip it (which is
* why the demo never got the big entrance). */
function armOnboardingFinale() {
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
}
function goToOptions() {
playNavSound('action')
// Demo: skip the onboarding wizard (seed/identity setup) entirely go straight
// to login, which is prefilled with the demo password.
if (isDemo) {
localStorage.setItem('neode_onboarding_complete', '1')
armOnboardingFinale()
router.push('/login').catch(() => {})
return
}
@@ -89,6 +99,7 @@ function goToRestore() {
function goToLogin() {
playNavSound('action')
localStorage.setItem('neode_onboarding_complete', '1')
armOnboardingFinale()
router.push('/login').catch(() => {})
}
</script>
+26 -1
View File
@@ -17,7 +17,7 @@
@pause.prevent="handleVideoPause"
@ended="handleVideoEnded"
>
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
</video>
<!-- Login: static background + archipelago-style glitch (no zoom) -->
@@ -682,5 +682,30 @@ video.bg-layer {
transform: translateX(1px);
}
}
/* Kiosk (software compositor): flatten THIS screen's background stack.
Same failure mode the dashboard kiosk fix addressed perspective +
preserve-3d layers and mix-blend-mode overlays fail to repaint under
Chromium software compositing, so the black body fill shows through.
The dashboard-only overrides never reached this scoped stack, which is
why the kiosk login/onboarding background still went black. Keep 2D
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
glitch overlays. */
:global(html.kiosk-mode) .bg-perspective-container,
:global(html.kiosk-mode) .perspective-container {
perspective: none !important;
}
:global(html.kiosk-mode) .bg-layer,
:global(html.kiosk-mode) .view-wrapper {
transform: none !important;
transform-style: flat !important;
backface-visibility: visible !important;
will-change: auto !important;
filter: none !important;
}
:global(html.kiosk-mode) .login-glitch-layer,
:global(html.kiosk-mode) .login-glitch-scan {
display: none !important;
}
</style>
+12 -3
View File
@@ -40,10 +40,10 @@ function log(msg: string, data?: unknown) {
sessionStorage.setItem('archipelago_boot_log', prev + entry + '\n')
}
async function quickHealthCheck(): Promise<boolean> {
async function quickHealthCheck(timeoutMs = 2000): Promise<boolean> {
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 2000)
const t = setTimeout(() => ac.abort(), timeoutMs)
const res = await fetch('/rpc/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -132,7 +132,16 @@ onMounted(async () => {
return
}
const isUp = await quickHealthCheck()
// First-boot backends can be up but slow (image loads, first-run podman
// work), so a single 2s echo timing out used to flash the BootScreen and
// then hard-reload seconds later ("loads, refreshes, loads again" on
// kiosk). Give it a second, more patient attempt before concluding the
// server is down.
let isUp = await quickHealthCheck()
if (!isUp) {
log('healthCheck retry with longer timeout')
isUp = await quickHealthCheck(6000)
}
log('production flow', { isUp })
if (isUp) {
+15 -10
View File
@@ -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,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
@@ -7,11 +8,14 @@ import { rpcClient } from '@/api/rpc-client'
// confirms writing it down (`acknowledged`), the card renders as a
// prominent first-launch prompt.
const route = useRoute()
const router = useRouter()
const available = ref(false)
const acknowledged = ref(true)
const statusLoaded = ref(false)
async function loadStatus() {
async function loadStatus(): Promise<boolean> {
try {
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
method: 'lnd.seed-backup-status',
@@ -20,12 +24,28 @@ async function loadStatus() {
available.value = !!res.available
acknowledged.value = !!res.acknowledged
statusLoaded.value = true
return true
} catch {
statusLoaded.value = false
// Leave statusLoaded as-is; a one-off RPC blip must not permanently
// hide the card (the global banner may have just promised it's here).
return false
}
}
onMounted(loadStatus)
onMounted(async () => {
// Retry a few times on fresh installs the backend can still be warming
// up when the user lands here straight from the backup banner.
for (let attempt = 0; attempt < 4; attempt++) {
if (await loadStatus()) break
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
}
// Deep link from the "Back up your Lightning seed" banner: open the
// reveal flow directly so the click visibly does something.
if (route.query['seed-backup'] === '1') {
router.replace({ query: { ...route.query, 'seed-backup': undefined } }).catch(() => {})
if (statusLoaded.value && available.value) openReveal()
}
})
// Reveal modal re-auth gated (password + 2FA when enabled), same UX as
// the recovery-phrase reveal in Settings Backup.
@@ -18,10 +18,8 @@
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
</svg>
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
@@ -119,6 +117,7 @@
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = defineProps<{
appUrl: string
+19 -8
View File
@@ -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>
@@ -40,6 +40,7 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { IS_DEMO } from '@/composables/useDemoIntro'
const store = useAppStore()
@@ -62,8 +63,23 @@ const hasConnIssue = computed(
)
const SHOW_DELAY_MS = 2500
// Right after the page loads or the tab returns to the foreground, a dead
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
// first paint races the initial connect). Reconnecting takes longer than
// the steady-state grace on real links radio wake-up, TLS, proxies so
// the 2.5s window made every tab-return flash "Connection lost" on a
// perfectly healthy node. Give those moments a much longer runway; keep
// the short window for genuine mid-session drops.
const RESUME_GRACE_WINDOW_MS = 15000
const RESUME_SHOW_DELAY_MS = 10000
const showConnIssue = ref(false)
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
function onVisibilityResume() {
if (!document.hidden) lastResumeAt = Date.now()
}
document.addEventListener('visibilitychange', onVisibilityResume)
function clearTimer() {
if (pendingTimer) {
@@ -76,11 +92,17 @@ watch(
hasConnIssue,
(issue) => {
clearTimer()
// The demo runs against a local mock a connection banner there is
// meaningless noise on what should be a flawless showcase.
if (IS_DEMO) return
if (issue) {
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
? RESUME_SHOW_DELAY_MS
: SHOW_DELAY_MS
pendingTimer = setTimeout(() => {
showConnIssue.value = true
pendingTimer = null
}, SHOW_DELAY_MS)
}, delay)
} else {
// Recovered before the grace window elapsed hide at once.
showConnIssue.value = false
@@ -89,7 +111,10 @@ watch(
{ immediate: true }
)
onUnmounted(clearTimer)
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibilityResume)
})
// Debounced visual states the template renders.
const showReconnecting = computed(
@@ -741,6 +741,21 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
transform: translateZ(0) scale(1.05) rotateY(0deg) !important;
}
/* Kiosk: chromium runs software-composited (--in-process-gpu or
--disable-gpu, single raster thread). 3D-transformed will-change layers
routinely fail to repaint there after the first background swap, leaving
the container's black fill on screen. Flatten the stack to plain 2D
opacity crossfades in kiosk mode. */
html.kiosk-mode .dashboard-view .bg-perspective-container {
perspective: none;
}
html.kiosk-mode .dashboard-view .bg-layer {
transform: none !important;
transform-style: flat;
will-change: auto;
transition: opacity 0.45s ease;
}
/* Background glitch effect layers - World Fair style */
.bg-glitch-layer-1,
.bg-glitch-layer-2,
@@ -0,0 +1,119 @@
<template>
<!-- Companion app banner same format as the featured app banner, with a
phone mockup rising out of the right edge. Clicking anywhere (or the
Install button) opens the Remote Companion download/pairing modal. -->
<div
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="openCompanionIntro()"
>
<img
src="/assets/img/companion-banner-bg.webp"
alt=""
class="featured-banner-img"
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
/>
<div class="featured-banner-overlay companion-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">companion</span>
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
The Archipelago Companion puts your node on your phone remote control and typing,
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
</p>
<div class="flex items-center gap-3">
<button
@click.stop="openCompanionIntro()"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>Install</button>
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
</div>
</div>
<div class="companion-phone-wrap" aria-hidden="true">
<div class="companion-phone">
<span class="companion-phone-lens"></span>
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { openCompanionIntro } from '@/composables/useCompanionIntro'
</script>
<style scoped>
.companion-banner {
border-color: rgba(251, 146, 60, 0.25);
}
/* Keep the copy clear of the phone mockup on wide screens */
@media (min-width: 768px) {
.companion-banner-overlay {
padding-right: 320px;
}
}
.companion-phone-wrap {
display: none;
}
@media (min-width: 768px) {
.companion-phone-wrap {
display: block;
position: absolute;
top: 2rem;
right: 3.5rem;
z-index: 2;
transform: rotate(6deg);
transition: transform 0.4s ease;
}
.companion-banner:hover .companion-phone-wrap {
transform: rotate(4deg) translateY(-8px);
}
}
.companion-phone {
position: relative;
width: 200px;
padding: 9px;
border-radius: 30px;
background: #0b0b12;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.55),
0 0 46px rgba(251, 146, 60, 0.28),
inset 0 0 2px rgba(255, 255, 255, 0.18);
}
/* Punch-hole camera */
.companion-phone-lens {
position: absolute;
top: 17px;
left: 50%;
transform: translateX(-50%);
width: 9px;
height: 9px;
border-radius: 9999px;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.14);
z-index: 2;
}
.companion-phone-screen {
display: block;
width: 100%;
border-radius: 22px;
}
/* Screen glare */
.companion-phone::after {
content: '';
position: absolute;
inset: 9px;
border-radius: 22px;
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
pointer-events: none;
}
</style>
@@ -105,6 +105,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint Guardian', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'fedimint-clientd', title: 'Fedimint Client', version: '0.8.0', description: 'Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fmcd:0.8.1`, repoUrl: 'https://github.com/minmoto/fmcd' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
@@ -122,6 +123,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
'fedimint-clientd': ['fedimint-clientd'],
electrumx: ['electrumx'],
grafana: ['grafana'],
jellyfin: ['jellyfin'],
@@ -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()
}
})
})
+12 -2
View File
@@ -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
}
+3 -1
View File
@@ -368,8 +368,10 @@ onMounted(() => load())
<template>
<div class="pb-6">
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
title's centerline in this inline header row. -->
<div class="flex items-center gap-3 mb-6">
<BackButton @click="goBack" />
<BackButton desktop-margin="mb-0" @click="goBack" />
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
</div>
+56 -26
View File
@@ -30,44 +30,74 @@
</svg>
Refreshing Tor services...
</div>
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
<!-- 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>
<div class="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>
<!-- 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 md:hidden grid-cols-2 gap-2 mt-3"
>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
:title="'Delete ' + svc.name + ' hidden service'"
>
<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>
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
</button>
<button
v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name"
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50"
:class="{ 'col-span-2': svc.name === 'archipelago' }"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</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,49 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.104-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.104-alpha</span>
<span class="text-xs text-white/40">July 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.</p>
<p>If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself no manual rescue needed.</p>
<p>The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.</p>
<p>While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.</p>
</div>
</div>
<!-- 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">
@@ -374,10 +417,10 @@ init()
<p>"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".</p>
<p>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.</p>
<p>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.</p>
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.</p>
<p>The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
<p>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.</p>
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
</div>
</div>
@@ -126,6 +126,31 @@ async function deleteBackup(id: string) {
}
}
// Download the encrypted backup archive through the browser the only
// path off the node for remote/companion users with no USB access.
const downloadingId = ref<string | null>(null)
async function downloadBackup(id: string) {
downloadingId.value = id
try {
const res = await fetch(`/api/blob/backup/${encodeURIComponent(id)}`, { credentials: 'include' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `archipelago-backup-${id}.bak`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
showBackupStatus('Backup download started', 'success')
} catch {
showBackupStatus('Backup download failed', 'error')
} finally {
downloadingId.value = null
}
}
// Recovery phrase reveal re-auth gated (password + 2FA when enabled).
const showRevealModal = ref(false)
const revealPassword = ref('')
@@ -364,6 +389,9 @@ defineExpose({ loadBackups })
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
{{ usbCopyingId === b.id ? '...' : 'USB' }}
</button>
<button @click="downloadBackup(b.id)" :disabled="downloadingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-green-400 disabled:opacity-50" title="Download backup file">
{{ downloadingId === b.id ? '...' : 'Download' }}
</button>
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
{{ t('common.restore') }}
</button>
+12 -66
View File
@@ -11,21 +11,6 @@
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
</div>
<div class="web5-card-actions-top gap-2 shrink-0">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
</div>
</div>
<!-- Mobile: stacked layout -->
<div class="md:hidden mb-4">
@@ -174,7 +159,9 @@
</div>
<div class="mt-auto pt-4 space-y-3">
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
<!-- Always the bottom 50/50 pair, every screen size consistent with
the other web5 cards' full-width bottom actions. -->
<div class="grid grid-cols-2 gap-3">
<button
@click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
@@ -182,37 +169,13 @@
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
</button>
</div>
<button
v-if="nodesContainerTab === 'trusted'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'observers'"
@click="loadPeers"
:disabled="loadingPeers"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
<button
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
@@ -328,7 +291,6 @@ const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
// Send message modal
const showSendMessageModal = ref(false)
@@ -378,6 +340,12 @@ function switchToRequestsTab() {
}
}
// The single bottom Refresh acts on whichever tab is open.
function refreshActiveTab() {
if (nodesContainerTab.value === 'requests') void loadConnectionRequests()
else void loadPeers()
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
@@ -456,28 +424,6 @@ async function sendMessage() {
}
}
async function discoverAndAddPeers() {
discovering.value = true
try {
const res = await rpcClient.discoverNodes()
const nodes = res.nodes || []
for (const n of nodes) {
if (n.onion && n.pubkey) {
try {
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
} catch (e) {
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
}
}
}
await loadPeers()
} catch (e) {
if (import.meta.env.DEV) console.error('Discover failed:', e)
} finally {
discovering.value = false
}
}
async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0
loadingRequests.value = true
+108 -2
View File
@@ -61,9 +61,91 @@
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': idx }"
>
<!-- Narrow containers (phones): profile-card layout with banner +
overlapping avatar. Hidden on wide containers where the classic
row below renders instead. -->
<div class="identity-card-m rounded-xl overflow-hidden bg-white/[0.08]">
<div class="relative h-16" :class="bannerFallback(identity)">
<img
v-if="identity.profile?.banner"
:src="displayableUrl(identity.profile.banner)"
class="absolute inset-0 w-full h-full object-cover"
/>
</div>
<div class="px-4 pb-4">
<div class="flex items-end justify-between -mt-7">
<button @click="openProfileEditor(identity)" class="relative w-14 h-14 rounded-full overflow-hidden ring-4 ring-black/60 group shrink-0" title="Edit profile">
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-lg font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<div class="flex items-center gap-1 pt-8">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2 flex-wrap min-w-0">
<span class="text-white font-semibold text-sm truncate">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<p v-if="identity.profile?.about" class="text-white/60 text-xs mt-1 leading-relaxed">{{ identity.profile.about }}</p>
<div class="mt-2 space-y-0.5">
<div class="flex items-center gap-1 min-w-0">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 min-w-0">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Wide containers: the original row desktop appearance unchanged -->
<div class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg">
<div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
@@ -134,6 +216,7 @@
</svg>
</button>
</div>
</div>
</div>
</div>
@@ -465,6 +548,16 @@ watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched.
// Gradient banner backdrop for the narrow profile card when the identity has
// no banner image, tinted by purpose to match the avatar/chip colors.
function bannerFallback(identity: ManagedIdentity): string {
switch (identity.purpose) {
case 'business': return 'bg-gradient-to-br from-orange-500/40 to-amber-500/15'
case 'anonymous': return 'bg-gradient-to-br from-purple-500/40 to-fuchsia-500/15'
default: return 'bg-gradient-to-br from-blue-500/40 to-indigo-500/15'
}
}
function displayableUrl(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url
@@ -710,8 +803,21 @@ defineExpose({ loadIdentities, managedIdentities })
container-name: identities-card;
}
/* Narrow containers (default): the profile card renders and the classic row
is hidden. Wide containers: the original desktop row untouched and the
profile card hides. Doubled selectors out-rank the Tailwind utilities on
the same elements regardless of stylesheet order. */
.identities-card .identity-row {
display: none;
}
@container identities-card (min-width: 560px) {
.identity-row {
.identities-card .identity-card-m {
display: none;
}
.identities-card .identity-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
const router = useRouter()
@@ -23,6 +25,10 @@ interface ServicePricing {
accepted_mints: string[]
}
// Tabs
const tab = ref<'dashboard' | 'configure'>('dashboard')
// Configure state
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
@@ -32,7 +38,7 @@ const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled)
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
@@ -113,86 +119,432 @@ async function saveService(svc: ServicePricing) {
}
}
onMounted(load)
// TollGate (paid WiFi on the OpenWrt gateway)
interface TollGateStatus {
installed: boolean
enabled?: boolean
step_size_ms?: number
price_per_step?: number
min_steps?: number
mint_url?: string
}
const tollgate = ref<TollGateStatus | null>(null)
const tollgateChecked = ref(false)
const tollgateSaving = ref(false)
const tollgatePrice = ref(10)
const tollgateEnabled = ref(false)
const tollgateUnit = computed(() => {
const ms = tollgate.value?.step_size_ms || 60_000
if (ms === 3_600_000) return 'hour'
if (ms === 60_000) return 'minute'
if (ms === 1000) return 'second'
return `${ms.toLocaleString()} ms`
})
async function loadTollgate() {
try {
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
tollgate.value = res.tollgate || null
tollgateEnabled.value = !!res.tollgate?.enabled
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
} catch {
// No gateway configured (or unreachable) the card shows the setup hint.
tollgate.value = null
} finally {
tollgateChecked.value = true
}
}
async function saveTollgate() {
if (tollgatePrice.value < 1) tollgatePrice.value = 1
tollgateSaving.value = true
try {
await rpcClient.call({
method: 'openwrt.provision-tollgate',
params: {
enabled: tollgateEnabled.value,
// Real backend reads price_sats; the mock reads price_per_step
// send both so the same call works against either.
price_sats: tollgatePrice.value,
price_per_step: tollgatePrice.value,
step_size_ms: tollgate.value?.step_size_ms || 60_000,
min_steps: tollgate.value?.min_steps || 1,
},
})
showStatus(
tollgateEnabled.value
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
: 'TollGate WiFi is now free.',
false,
)
void loadTollgate()
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
} finally {
tollgateSaving.value = false
}
}
// Dashboard state
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
interface ProfitEntry {
source: ProfitSource
amount_sats: number
timestamp: string
description?: string
}
interface ProfitsSummary {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
streaming_revenue_sats?: number
recent?: ProfitEntry[]
}
interface SessionsSummary {
total_active: number
total_revenue_sats: number
revenue_by_service: Record<string, number>
}
const profits = ref<ProfitsSummary | null>(null)
const sessionsSummary = ref<SessionsSummary | null>(null)
const dashboardLoading = ref(true)
async function loadDashboard() {
dashboardLoading.value = true
try {
const [p, s] = await Promise.all([
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
])
profits.value = p
sessionsSummary.value = s
} finally {
dashboardLoading.value = false
}
}
function formatSats(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
}
// 7 daily buckets, oldest newest, one series per earning source. A week of
// daily totals is the sweet spot here: earnings are sparse events (unlike the
// second-by-second system metrics on Monitoring), so finer buckets would just
// draw noise, and a longer window would flatten a new node's first sats.
const DAY_MS = 86_400_000
const WINDOW_DAYS = 7
const dayLabels = computed(() => {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const labels: string[] = []
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
}
return labels
})
function bucketize(source: ProfitSource): number[] {
const buckets = new Array(WINDOW_DAYS).fill(0)
const now = Date.now()
for (const e of profits.value?.recent || []) {
if (e.source !== source) continue
const t = Date.parse(e.timestamp)
if (Number.isNaN(t)) continue
const age = Math.floor((now - t) / DAY_MS)
if (age < 0 || age >= WINDOW_DAYS) continue
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
}
return buckets
}
const earningsDatasets = computed<ChartDataset[]>(() => [
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
])
const hasRecentEarnings = computed(() =>
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
)
// Revenue-by-service bars, largest first, scaled to the biggest earner.
const serviceBars = computed(() => {
const by = sessionsSummary.value?.revenue_by_service || {}
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
const max = entries[0]?.[1] || 1
return entries.map(([id, sats]) => ({
id,
name: services.value.find((s) => s.service_id === id)?.name || id,
sats,
pct: Math.max(4, Math.round((sats / max) * 100)),
}))
})
// Chart sizing (same measure-the-card approach as Monitoring)
const chartCard = ref<HTMLElement | null>(null)
const chartWidth = ref(600)
function updateChartWidth() {
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
}
onMounted(() => {
void load()
void loadTollgate()
void loadDashboard()
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateChartWidth)
})
</script>
<template>
<div class="pb-6">
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
<div class="mb-4">
<h1 class="text-3xl font-bold text-white">Networking Profits</h1>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
<!-- Tabs: Dashboard | Configure -->
<div class="flex gap-1 mb-6 border-b border-white/10">
<button
@click="tab = 'dashboard'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Dashboard
</button>
<button
@click="tab = 'configure'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Configure
</button>
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
<!-- ============ DASHBOARD ============ -->
<div v-show="tab === 'dashboard'">
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings</div>
<template v-else>
<!-- Stat tiles -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
<p class="text-xs text-white/40">sats, all time</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
<p class="text-xs text-white/40">sats from paid services</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
<p class="text-xs text-white/40">sats from ecash sales</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
<p class="text-xs text-white/40">sats from Lightning</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
<!-- Earnings chart -->
<div ref="chartCard" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white/80">Earnings last 7 days</h3>
<div class="flex items-center gap-4">
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
{{ d.label }}
</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
<LineChart
v-if="hasRecentEarnings"
:datasets="earningsDatasets"
:labels="dayLabels"
:width="chartWidth"
:height="200"
/>
<div v-else class="py-10 text-center text-white/40 text-sm">
No earnings in the last 7 days enable a paid service under Configure to start earning.
</div>
</div>
<!-- Sessions + revenue by service -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
<p class="text-xs text-white/40">peers currently paying for services</p>
<p class="text-sm text-white/70 mt-4">
Session revenue:
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
</p>
</div>
<div class="glass-card p-5 lg:col-span-2">
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
No service revenue yet.
</div>
<div v-else class="space-y-3">
<div v-for="bar in serviceBars" :key="bar.id">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/80">{{ bar.name }}</span>
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- ============ CONFIGURE ============ -->
<div v-show="tab === 'configure'">
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
<div class="glass-card p-4 mb-6">
<p class="text-sm text-white/70 leading-relaxed">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
<!-- TollGate: paid WiFi on the OpenWrt gateway -->
<div v-if="tollgateChecked" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2>
<p class="text-sm text-white/60 mt-0.5">
Sell WiFi access on your OpenWrt gateway visitors pay per {{ tollgateUnit }} in ecash.
</p>
</div>
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'">
{{ tollgateEnabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
</div>
</div>
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="tollgatePrice"
type="number"
min="1"
step="1"
:disabled="!tollgateEnabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
</div>
</div>
<button
@click="saveTollgate"
:disabled="tollgateSaving"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ tollgateSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
<p class="text-sm text-white/50 flex-1">
TollGate isn't set up yet it needs an OpenWrt gateway paired with this node.
</p>
<button
@click="router.push('/dashboard/server/openwrt')"
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
>
Set up gateway
</button>
</div>
</div>
</div>
</div>
+39 -34
View File
@@ -1,27 +1,36 @@
<template>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
<p class="text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="mb-4 shrink-0">
<div class="flex items-center sm:items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white sm:mb-2">{{ t('web5.nodeVisibility') }}</h2>
<!-- Desktop: description beside the icon, as before -->
<p class="hidden sm:block text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Mobile: description stacked under the icon + title -->
<p class="sm:hidden mt-3 text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<!-- Enable switch -->
@@ -57,16 +66,7 @@
<!-- Discoverable nodes -->
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-white">Discoverable nodes</p>
<button
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
</div>
<p class="text-sm font-medium text-white mb-2">Discoverable nodes</p>
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
Searching relays
</div>
@@ -95,10 +95,15 @@
</div>
</div>
<!-- Warning -->
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
{{ t('web5.discoverableWarning') }}
</p>
<!-- Refresh full-width card action on every screen size -->
<button
v-if="discoverEnabled"
class="mt-4 w-full glass-button rounded-lg py-2.5 text-sm font-medium text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
<PeerRequestModal
:show="requestModalTarget !== null"
+3 -1
View File
@@ -37,7 +37,9 @@ export default defineConfig({
]
},
workbox: {
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
// /packages/ must bypass the SPA fallback — otherwise clicking the
// companion APK download link gets index.html instead of the file.
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [
+18 -24
View File
@@ -1,36 +1,30 @@
{
"changelog": [
"Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.",
"Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.",
"Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.",
"The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.",
"Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.",
"Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).",
"Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.",
"Peering is now trust-aware: \"Invite a Peer\" grants view-only Observer access while \"Link Your Nodes\" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.",
"Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.",
"Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even \"running\" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar."
"Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.",
"If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.",
"The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.",
"While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress."
],
"components": [
{
"current_version": "1.7.100-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago",
"current_version": "1.7.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.100-alpha",
"sha256": "9d317e8f308557af7f4f5928ccb39d452f61ff7eeff2024ef61067f72afc469a",
"size_bytes": 49792496
"new_version": "1.7.104-alpha",
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
"size_bytes": 49976648
},
{
"current_version": "1.7.100-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago-frontend-1.7.100-alpha.tar.gz",
"name": "archipelago-frontend-1.7.100-alpha.tar.gz",
"new_version": "1.7.100-alpha",
"sha256": "b5962bc779f9e238b842398b13ef7727570afb181fcecbc14a1645cb96bdfb77",
"size_bytes": 173254632
"current_version": "1.7.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
"new_version": "1.7.104-alpha",
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
"size_bytes": 174592628
}
],
"release_date": "2026-07-14",
"signature": "646e24ffddfb7f3769ca81e9f8c788d021636b9715a3d285b97a561116ce9224fe9b61a441f9d379feca549862e89e0cd31cb8eed6fd14ad177c97781cf4d10f",
"release_date": "2026-07-19",
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.100-alpha"
"version": "1.7.104-alpha"
}
+74 -2
View File
@@ -1,6 +1,6 @@
{
"schema": 1,
"updated": "2026-07-10",
"updated": "2026-07-18",
"apps": {
"adguardhome": {
"version": "v0.107.55",
@@ -333,6 +333,78 @@
}
}
},
"barkd": {
"version": "0.3.0",
"manifest": {
"app": {
"id": "barkd",
"name": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"container": {
"image": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"pull_policy": "if-not-present",
"network": "archy-net",
"generated_secrets": [
{
"name": "barkd-secret",
"kind": "hex32"
}
],
"secret_env": [
{
"key": "BARKD_SECRET",
"secret_file": "barkd-secret"
}
],
"data_uid": "1000:1000"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"resources": {
"cpu_limit": 1,
"memory_limit": "512Mi",
"disk_limit": "1Gi"
},
"security": {
"readonly_root": true,
"network_policy": "bridge"
},
"ports": [
{
"host": 3535,
"container": 3535,
"protocol": "tcp"
}
],
"volumes": [
{
"type": "bind",
"source": "/var/lib/archipelago/barkd",
"target": "/data",
"options": [
"rw"
]
}
],
"environment": [
"BARKD_DATADIR=/data",
"BARKD_BIND_HOST=0.0.0.0",
"BARKD_BIND_PORT=3535"
],
"health_check": {
"type": "tcp",
"endpoint": "localhost:3535",
"interval": "30s",
"timeout": "5s",
"retries": 3
}
}
}
},
"bitcoin-core": {
"version": "latest",
"manifest": {
@@ -1085,7 +1157,7 @@
"-lc"
],
"custom_args": [
"export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/\"; exec electrumx_server"
"for h in bitcoin-knots bitcoin-core; do if getent hosts \"$h\" >/dev/null 2>&1; then BTC_HOST=\"$h\"; break; fi; done; export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/\"; exec electrumx_server"
],
"secret_env": [
{

Some files were not shown because too many files have changed in this diff Show More