Compare commits

..

93 Commits

Author SHA1 Message Date
Dorian
e65b039914 chore: unbundled ISO builds on main, full Debian ISO manual-only
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 57m38s
- build-iso-dev.yml now triggers on both main and dev-iso
- build-iso.yml (full Debian) is workflow_dispatch only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:57:40 +01:00
Dorian
5bd3caf141 fix: auth, container resilience, ISO build, gamepad polish
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Successful in 57m41s
Build Archipelago ISO / build-iso (push) Has been cancelled
Container Orchestration Tests / unit-tests (push) Failing after 7m14s
Container Orchestration Tests / smoke-tests (push) Has been skipped
- fix: login disconnect — verify session before WebSocket connect
- fix: 403 on app install — distinguish CSRF vs RBAC errors, only retry CSRF
- fix: health monitor now watches ALL containers (removed skip list for
  backend services like nbxplorer, databases, UI containers)
- fix: server.get-state added to CSRF-exempt list (read-only)
- fix: ISO build includes container-specs.sh and lib/common.sh in rootfs
  so reconcile actually works on fresh installs
- fix: gamepad nav — improved Server tab zone nav, focus styles, autofocus
- chore: move L484 web-only apps to Services tab
- chore: install store for cross-view install tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:35:02 +01:00
Dorian
377195f7e0 feat: gamepad navigation for Mesh tab — zone-based panel nav
- Peer rows: tabindex + role=button + Enter handler for D-pad selection
- Zone attributes: mesh-left, mesh-chat, mesh-tools for cross-panel nav
- Actions row: data-controller-container for Up from peers
- Right from peers → chat input, Right from chat → tools tabs (wide)
- Down from tabs → panel fields/buttons in grid fashion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:24:48 +01:00
Dorian
9ea8877d20 fix: onboarding gamepad — autofocus, click sounds, focus styles
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 30m19s
All screens:
- playNavSound('action') on every button click
- path-action-button orange focus glow (removed from suppression list)

Per-screen autofocus:
- Intro: CTA button (after animation)
- Path: Continue button
- Identity: name input
- Backup: passphrase input, Continue after download
- Verify: Sign Challenge, then Finish after verification
- Done: Set Password button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:09 +01:00
Dorian
1c82b8285e fix: vertical nav prefers closest element over widest overlap
Some checks are pending
Build Archipelago ISO (dev) / build-iso (push) Has started running
Down from Identity name input now lands on Personal button (closest)
instead of Continue (wider overlap but further away).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:50:50 +01:00
Dorian
b773ba610f fix: backup screen — autofocus passphrase, rename button, focus Continue after download
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 5m7s
- Passphrase input autofocused on mount
- "Download Backup" renamed to "Backup to Continue"
- Continue button autofocused after successful backup download

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:42:26 +01:00
Dorian
ff85754aa2 fix: onboarding autofocus — Continue button + Identity name input
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Path screen: Continue autofocused after 500ms (was 400ms, missed transition)
- Identity screen: name input autofocused on mount
- path-action-button now shows orange focus glow (removed from suppression list)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:38:47 +01:00
Dorian
ccad4737de fix: Continue button focus visible on onboarding Path screen
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Remove path-action-button from focus-visible suppression list
- Orange glow now shows on Continue when autofocused
- Bump autofocus delay to 500ms to clear slide transition

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:38:06 +01:00
Dorian
b214b2f52f fix: onboarding gamepad focus styles + sounds
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- glass-button gets orange glow on focus-visible (was suppressed)
- Input fields get orange border on focus-visible
- Restore link made focusable (tabindex, role, keydown.enter)
- Gamepad nav sounds play via existing fallback handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:36:18 +01:00
Dorian
c85534357e fix: poll for containers after route transition animation
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Sidebar Right now polls every 100ms (up to 1s) for containers to
appear, instead of a single 200ms retry that missed animations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:35:06 +01:00
Dorian
70254b1bb7 fix: sidebar Right arrow reliably focuses first app container
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Only recall container elements (not nav bar buttons) from focus memory
- Retry after 200ms when containers aren't rendered yet (async route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:18:41 +01:00
Dorian
a69aef53b5 fix: gamepad input field navigation — exit at cursor edges
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Up/Down from input: try containers as fallback when spatial nav fails
- Left/Right from input: exit field when cursor is at start/end
  (e.g. Left from search bar at position 0 → category buttons)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:17:39 +01:00
Dorian
9dd7539edc fix: orange glass on nav bar tabs, revert sidebar to original style
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
mode-switcher-btn-active gets orange glass (bg, border, glow).
mode-switcher-btn:focus-visible gets orange ring on gamepad focus.
Sidebar nav-tab-active reverted to original white/black glass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:16:19 +01:00
Dorian
11f7434866 fix: gamepad nav dead ends on Apps page, orange glass active sidebar style
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 57m48s
- Nav-tab-active now uses orange glass (bg, border, glow, gradient)
- Sidebar focus-visible uses matching orange tint
- Enter on containers skips uninstall button, finds primary action
- Down/Right from grid edges falls back to all focusable elements
- Global fallback for standalone buttons in empty/error states
- Full gamepad nav map for all onboarding screens + login modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:04:58 +01:00
Dorian
9d437ea476 fix: password setup, CSRF 403, reboot after install
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Successful in 48m51s
Container Orchestration Tests / unit-tests (push) Failing after 5m22s
Container Orchestration Tests / smoke-tests (push) Has been skipped
Critical fixes:
- Remove ensure_default_user() — no more auto-creating user with
  password123. Login page now shows "Create Password" form on first
  boot. User sets their own password during onboarding flow.
- CSRF 403: increased retry delay from 300ms to 500ms for stale
  cookie recovery after remember-me session restore.
- Reboot: multiple fallback methods (/sbin/reboot, sysrq, kill init)
  when USB is pulled and /usr/sbin isn't available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:44:46 +01:00
Dorian
89a9f69a9b fix: CSRF 403 blocking all operations + reboot after install
Some checks failed
Container Orchestration Tests / unit-tests (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
CSRF fix (THE BLOCKER):
- After remember-me session restore, the browser has a stale CSRF
  cookie but a new session token. ALL subsequent RPC calls return 403.
- Fix: exempt read-only polling methods (node-messages-received,
  server.echo, system.stats, tor.status, etc.) from CSRF validation.
  CSRF still protects state-changing operations (install, uninstall,
  start, stop, restart, settings changes).

Reboot fix:
- The separate /tmp/archipelago-reboot.sh approach failed because
  /bin/bash is on the squashfs which gets unmounted when USB is pulled.
- Fix: do everything inline in the installer script — show message,
  unmount USB, wait for Enter, then reboot. Use sysrq-trigger first
  (kernel-level, doesn't need userspace binaries).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:42:09 +01:00
Dorian
37f32f4e54 fix: version display, FileBrowser auto-login, nostr relay, UID mappings
Some checks failed
Container Orchestration Tests / unit-tests (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Version per build:
- Health endpoint returns "1.2.0-alpha-{git_hash}" using GIT_HASH env
- CI passes git hash to cargo build

FileBrowser auto-login:
- filebrowser-client.ts: include CSRF token + credentials:include
- First-boot: generate random password, store at secrets/filebrowser/
- Set FileBrowser admin password to match after container creation

Nostr relay:
- Use docker.io/scsibug/nostr-rs-relay:0.9.0 (not in our registry)

UID mappings:
- Added electrumx (UID 1000), mysql-mempool, archy-btcpay-db, nextcloud-db

522 tests pass, Rust compiles clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:56:38 +01:00
Dorian
2c0d4a7393 fix: login tests — mock health check for server startup progress
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 48m45s
Login.vue now shows "Starting server..." until health check passes.
Tests need to mock server.echo and auth.isSetup RPCs and flush
promises before asserting on the rendered form.

522 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:04:44 +01:00
Dorian
5b186da770 fix: container orchestration overhaul — names, errors, Tor, restart
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 18m5s
Container Orchestration Tests / unit-tests (push) Failing after 6m2s
Container Orchestration Tests / smoke-tests (push) Has been skipped
Container name resolution:
- New all_container_names() — single source of truth for every app's
  container name variants (bitcoin-knots/bitcoin/bitcoin-core, etc.)
- Covers all historical naming patterns and multi-container stacks

Start/Stop/Restart:
- No more silent failures (let _ = podman...). Every operation logs
  the command, checks exit status, and returns real errors to the UI.
- Restart uses stop+start fallback when podman restart fails
  (handles rootless podman loopback adapter errors)
- "No containers found" error when app doesn't exist

Tor helper:
- Install archipelago-tor-helper.path + .service in rootfs
- Enable the path unit so backend can manage Tor as non-root
- Copy tor-helper.sh to /opt/archipelago/scripts/

Verified: container with proper caps can stop/start/restart cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:26:21 +01:00
Dorian
08ddc73c75 fix: auto-build UI containers for Bitcoin, LND, Electrumx
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 23m8s
Container Orchestration Tests / unit-tests (push) Failing after 6m27s
Container Orchestration Tests / smoke-tests (push) Has been skipped
Critical: headless services (Bitcoin, LND, Electrumx) need companion
UI containers that serve web dashboards. These were only built for
Bitcoin, and only on bundled ISO builds.

Changes:
- install.rs: auto-build UI containers for LND (port 8081) and
  Electrumx (port 50002) in addition to Bitcoin (port 8334)
- build-auto-installer-iso.sh: always bundle docker UI source files
  (was skipping for unbundled builds — they're tiny HTML, not images)
- Dockerfiles: fix nginx base image tag 1.29.6→1.27.4 (matches registry)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:48:13 +01:00
Dorian
0b5fb4c90b fix: fedimint --bitcoind-url CLI arg + data-dir
Some checks failed
Container Orchestration Tests / unit-tests (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
fedimintd v0.10.0 requires --data-dir and --bitcoind-url as CLI args,
not just env vars. Container was exiting with usage error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:28:33 +01:00
Dorian
e8735b39ec feat: TASK-49 container reliability — tests, orchestration, MASTER_PLAN
Some checks failed
Container Orchestration Tests / unit-tests (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Add orchestration_tests.rs + mock_podman.rs (container unit tests)
- Add container-tests.yml CI workflow
- Add dev-container-test.sh for local testing
- MASTER_PLAN.md: add TASK-49 (P0) with 6-phase plan
- Login.vue: minor fixes from user testing
- AppCard.vue: enter key handler fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:15:56 +01:00
Dorian
25b789bd3f fix: Home Assistant NET_RAW cap, container storage on LUKS, NET_BIND for all
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- Home Assistant: add NET_RAW for DHCP discovery (fixes dhcp permission error)
- Nextcloud/BTCPay/Jellyfin/etc: add NET_BIND_SERVICE (was missing)
- Container storage: redirect graphroot to /var/lib/archipelago/containers/storage
  (prevents root partition filling up — was 100% after 6 images on 29GB root)

Tested on .198: 10 containers running simultaneously:
  Bitcoin Knots (syncing), LND (wallet ready), FileBrowser (healthy),
  Grafana, Vaultwarden, SearXNG, Home Assistant, Electrumx,
  Uptime Kuma, Jellyfin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:34:57 +01:00
Dorian
9b49ab6d99 feat: TUI updates — ASCII block logo, install demo script
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- archipelago-menu.sh: replace box-drawing banner with ASCII block
  letter logo (ARCHIPELAGO in chunky block chars)
- scripts/install-tui-demo.sh: standalone TUI demo with all animations
  (boot scan, decrypt reveal, progress bars, bouncing BTC symbol,
  CRT transitions, celebration effects)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:08:41 +01:00
Dorian
cba87e2c28 fix: disk usage shows encrypted data partition, not root
Dashboard System card now reports disk usage for /var/lib/archipelago
(the LUKS encrypted partition) instead of / (small root partition).
This shows the actual usable storage (428GB) rather than the 29GB root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:04:35 +01:00
Dorian
48e87d0cfb fix: redirect container storage to LUKS encrypted partition
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Container image pulls were filling the 29GB root partition (100% full
after 6 images). Now podman graphroot points to /var/lib/archipelago/
containers/storage on the 400GB+ LUKS encrypted data partition.

Added storage.conf with graphroot redirect + symlink for compat.
Also create containers/storage dir on encrypted partition during install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:43:57 +01:00
Dorian
09a9dbc6ca fix: LND mainnet config, SearXNG settings seed, default caps
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- LND: add --bitcoin.active --bitcoin.mainnet and all bitcoind
  connection args as container CMD args (was only env var before)
- SearXNG: add volume mount + auto-create settings.yml on install
  (container exits immediately without it)
- Default caps: all containers get full rootless podman baseline

Tested on .198:
- Bitcoin Knots: running, syncing (942803 blocks)
- Grafana: running, migration complete
- Vaultwarden: running, keys created
- SearXNG: running, listening on 8080
- LND: needs bitcoin container named 'bitcoin-knots' on archy-net

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:29:24 +01:00
Dorian
9085a7e79f fix: default container caps for rootless podman reliability
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
All containers now get CHOWN+FOWNER+SETUID+SETGID+DAC_OVERRIDE+NET_BIND_SERVICE
as the default cap set. Rootless podman needs these for:
- CHOWN/FOWNER/DAC_OVERRIDE: file ownership in mapped UID namespace
- SETUID/SETGID: internal user switching (entrypoint scripts)
- NET_BIND_SERVICE: port binding in network namespaces

Tested on .198: Grafana, Vaultwarden, Bitcoin Knots all start successfully.
Previously failed with "Permission denied" or "loopback adapter" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:24:28 +01:00
Dorian
d989535a9a fix: NET_BIND_SERVICE cap for Bitcoin/LND + default for all apps
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Bitcoin Knots failed to start with "failed to set loopback adapter up"
because cap-drop=ALL removed NET_BIND_SERVICE, which rootless podman
needs for network namespace setup.

- Add NET_BIND_SERVICE to Bitcoin/LND/Fedimint capabilities
- Add NET_BIND_SERVICE as default for ALL apps (rootless podman needs it)
- UID mapping fix from previous commit also included

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:12:40 +01:00
Dorian
20289c5bec fix: rootless podman UID mapping for container data dirs
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
create_data_dirs now chowns data directories to the correct mapped
UID for rootless podman (host_uid = 100000 + container_uid).

Previously only Grafana (UID 472) was handled. Now all containers
get the correct ownership:
- Bitcoin Knots: 100101 (container UID 101)
- Grafana: 100472 (UID 472)
- LND: 101000 (UID 1000)
- MariaDB: 100999 (UID 999)
- Postgres: 100070 (UID 70)
- All others: 100000 (UID 0, root)

Without this, containers fail with "Operation not permitted" on
chown during startup because rootless podman restricts UID operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:48:37 +01:00
Dorian
d25969e2e5 fix: align image-versions.sh with registry, PATH for reboot
- image-versions.sh: fix 15+ tag mismatches against actual registry
  (bitcoin-knots:28.1→latest, lnd:v0.18.5→v0.18.4, grafana:11.4→10.2,
  vaultwarden:1.32.5→1.30.0-alpine, nextcloud:29→28, etc.)
- .bashrc: add /sbin:/usr/sbin to PATH so reboot/shutdown work
- Tailscale: add Arch Atob node (100.113.33.31)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:25:13 +01:00
Dorian
cb1f252e4d fix: UEFI ESP partition type, WebSocket cookie, password UX
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 38m21s
UEFI boot:
- xorriso now uses -append_partition with ESP type GUID
  (C12A7328-F81F-11D2-BA4B-00A0C93EC93B) instead of -isohybrid-gpt-basdat
  which only creates "basic data" partitions. Strict UEFI firmware
  requires the correct ESP type to find BOOTX64.EFI.
- Uses Arch Linux ISO approach: -append_partition + appended_part_as_gpt

WebSocket/login from LAN browser:
- HTTPS nginx /ws block was missing proxy_set_header Cookie $http_cookie
  Session cookie wasn't forwarded → backend returned 401 → WS failed

Password UX:
- Renamed "Change Password" → "Set Password" with description explaining
  default password is password123

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:44:13 +01:00
Dorian
39d7bd07b9 fix: suppress verbose command output in installer TUI
All mkfs, cryptsetup, grub-install, tar, update-initramfs output now
goes to log file only via run() wrapper. Console shows only clean TUI
status messages (step/ok/warn/fail/spinner).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:06:19 +01:00
Dorian
2e29a41627 feat: persistent app install state across navigation (#9)
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 1h50m5s
Move installingApps from local refs in Marketplace/Discover to the
global server store. Install progress now persists when navigating
between views. My Apps shows installing overlay with progress bar
for apps being installed from the Marketplace.

Changes:
- server.ts: add installingApps Map + helpers to store
- Marketplace.vue: use store's installingApps instead of local ref
- Discover.vue: same
- Apps.vue: pass isInstalling + installProgress to AppCard
- AppCard.vue: add amber installing overlay with progress bar

522 tests pass, vue-tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:13:39 +00:00
Dorian
840ecfaa5f fix: UEFI boot fallback — search by file when label fails
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
The embedded GRUB EFI config only searched by volume label ARCHIPELAGO.
Some UEFI firmware presents USB devices differently, causing the search
to fail and GRUB to stall.

Added fallbacks:
1. search --file /archipelago/auto-install.sh (known ISO file)
2. Fall back to $cmdpath (EFI partition itself)
3. Use configfile before normal for explicit config loading
4. Added search_fs_file module to grub-mkstandalone

Also added same fallback to the main ISO grub.cfg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:58:42 +00:00
Dorian
b47fec7fba fix: batch beta fixes — 13 issues from 2026-03-28 testing
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Frontend (neode-ui):
- Login double-enter: change @keyup.enter to @keydown.enter (#10)
- Login loop on LAN: post-login session verify before navigation (#12)
- Splash flash: reorder isReady/showSplash, add black fallback div (#7)
- Skip button text: remove "skip this step" from onboarding (#8)
- Password UI: import existing ChangePasswordSection in Settings (#11)
- Arrow key focus trap: add tab-order fallback when spatial nav fails (#13)

ISO/Boot (image-recipe):
- Step counter: TOTAL_STEPS=7 → 8 to match actual step count
- GRUB theme: add desktop-image-scale-method stretch, widen menu
- Boot noise: add loglevel=0, rd.systemd.show_status=false to kernel
- USB removal: copy reboot script to tmpfs, exec from there
- Tor setup: rewrite python3 JSON generation as bash heredoc
- Doctor/reconcile: copy scripts into rootfs, fix missing file errors
- zstd: add to rootfs packages for initramfs compression

Docs:
- BETA-ISSUES-20260328.md: full issue tracker
- INSTALL-SCREENS-DESIGN.md: editable TUI mockups

522 tests pass, vue-tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:41:40 +00:00
Dorian
6be30b99fa fix: root podman D-Bus cgroup issue in ISO build
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 37m2s
When running as sudo, root podman can't reach the systemd D-Bus
session, causing "Transport endpoint is not connected" errors.
Auto-detect and fall back to cgroupfs cgroup manager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:01:10 +00:00
Dorian
4f90cf39cf fix: remove clean:false from CI checkout (stale workspace failures)
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 38m10s
The clean:false setting causes checkout to fail when previous runs
leave corrupted workspaces. Default clean behavior ensures fresh
checkout each run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:11:34 +00:00
Dorian
53e62ea25b fix: skip missing orchestration_tests in dev CI
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 25m11s
The orchestration_tests integration test file is not yet committed,
causing CI to fail with "no test target named orchestration_tests".
Gracefully skip if not present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:19:46 +00:00
Dorian
aff9e5111b chore: retrigger CI (clean workspace)
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
2026-03-28 19:18:49 +00:00
Dorian
cfe4a03ffb fix: heredoc quoting in installer profile.d (boot media not found)
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 22m46s
The profile.d script used <<'PROFILE' (single-quoted heredoc) inside
a bash -c '...' single-quoted block. The inner quotes broke the outer
quoting, causing all $ variables to expand to empty at build time.
The for loop checked if [ -f "/archipelago/auto-install.sh" ] instead
of if [ -f "$dev/archipelago/auto-install.sh" ] — never matching.

Fix: use <<PROFILE with \$ escaping (matching .228's working version).
Also adds fallback device scanning if standard mount points are empty,
and fixes same quoting issue in grub-embed.cfg ($root variable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:44:36 +00:00
Dorian
aada19754d feat: gamepad navigation rewrite, focus styling, container grid system
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 34m52s
- Rewrite useControllerNav.ts with clean console-style navigation:
  Sidebar (up/down wrap, right→containers, left→nothing),
  Container tile grid (spatial nav, no wrap at edges),
  Nav bar support (up from containers, down to grid),
  Inner controls (enter drills in, escape exits, trapped arrows)
- Add data-controller-container to Mesh, Fleet, Settings pages
- Fix Home.vue fragment (modals outside root div) causing Vue warnings
- Remove skip-to-content link (handled by controller nav)
- Orange ambient glow focus styling matching glass aesthetic
- Disable PWA service worker in dev mode (fixes HMR caching)
- Add gamepad-nav skill and GAMEPAD-NAV-MAP.md spec document
- 39 tests covering all navigation patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:01:17 +00:00
Dorian
1444bcb0c4 fix: QEMU test script name in dev CI (headless→qemu)
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 22m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:04:19 +00:00
Dorian
2c03dce947 fix: heredoc escaping in installer profile.d (build failure)
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 36m26s
The z99-archipelago-installer.sh heredoc used $'\033[...]' ANSI-C
quoting inside an unquoted <<PROFILE heredoc. Bash misparses this
during expansion, treating multi-line content as a single ANSI-C
quoted string.

Fix: switch to <<'PROFILE' (quoted, no expansion) and use raw
\033 escape codes in echo -e instead of $'...' variables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:15:42 +00:00
Dorian
7f03e39f58 feat: onboarding polish, splash screen, controller nav, dev script
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Failing after 45m15s
Onboarding flow:
- Intro: improved layout and transitions
- DID: better card styling and responsiveness
- Path: added visual enhancements
- Backup/Identity/Verify: streamlined markup
- SplashScreen component added

UI:
- Controller navigation improvements (useControllerNav)
- Style.css refinements

Backend:
- Runtime package fix

Dev:
- dev-start.sh improvements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:41:52 +00:00
Dorian
82eeb915a3 fix: UEFI boot, TUI installer steps, clean progress output
UEFI boot fix:
- Write proper EFI grub.cfg with root UUID after update-grub
  (was missing — GRUB dropped to grub> prompt because it couldn't
  find its config on the EFI FAT partition)

Installer TUI (Claude Code-inspired):
- Step counter [1/7] through [7/7] with clean progress display
- Helper functions: step(), ok(), warn(), fail(), spinner()
- Centered output with cc() helper
- Clean status messages instead of emoji + raw echo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:39:10 +00:00
Dorian
e28de77596 fix: onboarding "Set Password" label, reboot sequence, initramfs noise
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- OnboardingDone: "Go to Login" → "Set Password" with context text
- Reboot: lazy-unmount live FS before USB removal prompt, suppress
  kernel SquashFS messages, auto-reboot after 10s countdown
- Initramfs: filter "Possible missing firmware" warnings (cosmetic)
- ISOLINUX: menu centered at bottom (VSHIFT 18, HSHIFT 32, WIDTH 18)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:14:33 +00:00
Dorian
2021de5cda fix: auto-create default user, force reboot, i915 firmware, first boot info
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Critical fixes from ISO testing on .198:
- Backend auto-creates default user (password123) on first start
  so login works immediately after onboarding
- Force reboot (reboot -f) after install to avoid SquashFS errors
  when live USB is removed
- Eject USB before prompting user, not after
- Add firmware-misc-nonfree for Intel i915 GPU (suppresses dozens
  of "Possible missing firmware" warnings during initramfs update)
- First boot screen: wait up to 10s for DHCP before showing IP
- First boot screen: compact layout fits 80-col terminals
- ISOLINUX menu resolution dropped to 640x480 for universal
  VESA compatibility (was 1024x768, caused scaling on some hardware)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:06:34 +00:00
Dorian
9db55b0b34 feat: container orchestration, branding overhaul, onboarding logging
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 34m59s
Container orchestration:
- Health monitor with crash recovery and auto-restart
- Doctor service (periodic health checks via systemd timer)
- Reconcile service (desired-state convergence)
- Stack-aware install/uninstall with dependency tracking

Branding:
- Custom GRUB background (designer artwork, 1024x768)
- ISOLINUX boot menu: centered, orange accents, clean labels
- Terminal banners: adaptive width, basic ANSI colors, fits 80-col
- Removed auto-generated splash scripts (designer provides assets)
- GRUB theme: lowercase branding

Frontend:
- 401 handler clears localStorage immediately (prevents cascade)

Backend:
- Onboarding/auth logging ([onboarding] tag in journalctl)
- Cookie Secure flag logging for debugging HTTP/HTTPS issues

ISO fixes:
- Install log saved before unmount (was silently failing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
9d38989048 feat: UEFI boot fix, graphical ISOLINUX menu, instant boot
UEFI (#5): grub-mkstandalone embedded config now insmod's all needed
modules (iso9660, search_label, normal, linux) and uses 'normal' to
load the full grub.cfg. Previous config couldn't find the ISO root.

ISOLINUX (#6, #7): Switch from menu.c32 to vesamenu.c32 for background
image support. Copies splash.png from branding. TIMEOUT 0 for instant
boot (no keyboard lag, no menu flicker). Dark theme with transparent
background over the splash image.

Also: added vesamenu.c32 and libcom32.c32 to build artifacts.
Removed console=ttyS0 from quiet boot (interferes with Plymouth).
Added splash to quiet boot kernel params.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
782a4a62d5 fix: cookies Secure flag based on X-Forwarded-Proto, not dev_mode
Secure flag on session cookies broke HTTP LAN access — browsers refuse
to send Secure cookies over plain HTTP, causing 401 redirect loop.

Fix: check X-Forwarded-Proto header. Only set Secure when request came
over HTTPS. HTTP on LAN works, HTTPS still gets Secure cookies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
24a5ed7601 fix: onboarding redirect, login Enter key, uidmap, Tor perms, QEMU CI
Frontend:
- Router guard checks isOnboardingComplete before redirecting to /login.
  Fresh installs now go to /onboarding/intro instead of stuck on login.
- Login.vue: autocomplete="off" — fixes Enter key focusing button
  instead of submitting the form.

ISO build:
- Added uidmap, slirp4netns, fuse-overlayfs to rootfs (required for
  rootless Podman, lost to --no-install-recommends)
- Tor setup: mkdir + chmod 700 for hidden service dirs before starting
  (Tor refuses 750/setgid permissions)

CI:
- QEMU headless boot test step after smoke test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
eecc7e0e71 fix: add uidmap/slirp4netns for rootless Podman, fix Tor permissions
Two critical issues found on fresh .198 install:

1. Podman broken — uidmap package missing from rootfs because
   --no-install-recommends dropped it. Without newuidmap, rootless
   Podman can't create user namespaces. Also add slirp4netns and
   fuse-overlayfs which are required for rootless networking and
   storage.

2. Tor hidden service dirs created with 750 permissions (setgid).
   Tor requires exactly 700. Added explicit mkdir + chmod 700 for
   all hidden service dirs before starting Tor.

Both issues fixed on .198 live. Build script updated for future installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
b94428a97b feat: QEMU headless boot test in CI, updated skills + references
CI now runs a headless QEMU boot test after the smoke test:
- Boots ISO with -nographic, captures serial output
- Watches for "Press Enter to start installation" (pass)
- Detects kernel panic or initramfs shell (fail)
- 120 second timeout, runs as continue-on-error

Also: updated iso-debug reference with embedded vs appended EFI
findings from real hardware testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
3bb91e90f3 fix: remove sudo from installer (already root), reduce ISOLINUX timeout
- sudo not installed in minbase squashfs — caused "command not found"
  when pressing Enter to install. We're already root via auto-login.
- ISOLINUX timeout from 5s to 1s — reduces menu flicker/duplication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
56be32e55b fix: installer auto-start via profile.d, revert to embedded EFI, dark ISOLINUX
Three fixes from real hardware testing:

1. Installer auto-start: replace systemd service with profile.d script.
   The service and getty raced on tty1 — service output was overwritten
   by the login prompt. Profile.d runs AFTER auto-login, same approach
   the working Debian Live build used.

2. xorriso: revert from -append_partition to embedded -e boot/grub/efi.img.
   The appended partition approach produces cyl-align-off with zero CHS
   geometry, which is why BIOS wouldn't recognize the USB. The embedded
   approach matches the working main ISO (cyl-align-on, proper CHS).

3. ISOLINUX: dark theme instead of ugly blue. Black background, orange
   title, dark selection highlight. No blue boxes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
34a476d0a1 fix: xorriso append_partition for real USB boot + grub-mkstandalone
Root cause of USB boot failure: our xorriso used -e boot/grub/efi.img
to embed the EFI image inside the ISO. This works for CD-ROM and QEMU
but NOT for USB on real UEFI hardware.

Fix: use the Will Haley / Debian live-build approach:
- -append_partition 2 (GPT type EFI) appends efi.img AFTER ISO data
- -e --interval:appended_partition_2:all:: references the appended partition
- --mbr-force-bootable forces MBR active flag
- grub-mkstandalone with embedded bootstrap config (searches for grub.cfg)
- grub.cfg placed in both /boot/grub/ AND /EFI/BOOT/ on ISO
- grub.cfg uses search --label ARCHIPELAGO to find the ISO root

This is the exact approach used by StartOS, TAILS, and every production
custom Debian live ISO that boots from USB.

Also: iso-debug, iso-branding skills + reference docs, dev-start.sh
option 0 for branding dev, improved dev-branding.sh and test-iso-qemu.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
013b724e02 feat: add boot branding dev option (0) to dev-start.sh
Option 0 in dev-start.sh launches the branding development workflow:
- Finds latest ISO on Desktop or results/
- Patches branding files into the ISO
- Boots in QEMU for immediate visual feedback
- Lists editable files if no ISO is available

Edit background.png, theme.txt, or Plymouth files, re-run option 0,
see changes in ~10 seconds without a full CI build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
f3f7b8b72f feat: custom boot branding, MBR fix, Plymouth theme, CI smoke tests
Boot fix:
- Ship proven Debian Live MBR (4552) as branding/isohdpfx.bin — the
  ISOLINUX package MBR (33ed) doesn't boot on all hardware. This was
  the root cause of "machine doesn't pick up the USB".

Branding:
- Custom GRUB background: pixel-art floating island (1024x574)
- Archipelago pixel-art logo for Plymouth boot splash
- GRUB theme: dark background, orange selected item, no broken font refs
- Plymouth theme: script-based with progress bar, LUKS prompt support
- Plymouth + splash added to target rootfs packages
- GRUB theme installed on both installer ISO and target system
- Serial console (ttyS0) added to kernel params for QEMU debugging

CI improvements:
- Smoke test step: mounts ISO, verifies all critical files, checks
  initrd has live-boot, confirms boot=live in grub.cfg. Fails build
  before copying to Builds if any check fails.

Dev workflow:
- dev-branding.sh: extract ISO, swap branding, repackage, boot in QEMU
  (~10 seconds vs 20 min full rebuild)
- generate-grub-background.py: procedural cyberpunk background generator
- generate-plymouth-logo.py: procedural logo generator
- Improved test-iso-qemu.sh: --bios/--nographic flags, serial logging

Build:
- Simplified live-boot install (clean chroot, no complex fallbacks)
- Static branding images preferred, generators as fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
e8c80263f3 ci: retrigger dev-iso build 2026-03-28 11:34:29 +00:00
Dorian
9e3c0b85ea fix: GRUB theme font refs, improve QEMU test script
Theme: remove explicit font name references that don't match
grub-mkfont output names, remove select_*.png pixmap reference
(files don't exist). GRUB falls back to default when theme fails
to load — this was causing the Debian helmet to show.

QEMU test script: add --bios/--nographic flags, serial console
logging to /tmp/archipelago-qemu-serial.log, auto-detect latest
ISO, use -drive for OVMF firmware.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
93b2af203a fix: restore -partition_offset 16 to xorriso for USB boot compatibility
The old Debian Live ISO used -partition_offset 16 which reserves space
for a GPT partition table in the hybrid MBR layout. UEFI firmware on
some machines requires this to recognize the USB as bootable. We
removed it thinking it was Debian Live-specific but it's actually an
xorriso hybrid boot requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
0212bfdc1d fix: live-boot check — scripts/live is a file not a directory
The verification used [ -d ] but live-boot-initramfs-tools installs
scripts/live as a regular file, not a directory. Changed to [ -e ].
The chroot install was actually succeeding — only the check was wrong.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
c1ff912cb1 fix: live-boot install — avoid chroot, use dpkg extraction fallback
The chroot /installer command fails inside the CI container because
the container exits after debootstrap completes (set -e + container
boundary). The chroot then runs on the host where /installer doesn't
exist.

Fix: use apt-get with Dir overrides first, fall back to dpkg-deb -x
extraction of live-boot .deb files directly into the installer root.
This bypasses chroot entirely and is more robust in container-in-
container environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
71b93548c3 fix: install live-boot via apt after debootstrap, remove partition_offset
Two boot fixes:
- live-boot package must be installed via chroot apt-get, not debootstrap
  --include (minbase resolver can't handle its deps). Verified initrd was
  missing scripts/live* entirely.
- Remove -partition_offset 16 from xorriso — it was designed for the
  original Debian Live MBR, not the standard ISOLINUX isohdpfx.bin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
69c62eb47a fix: boot chain — add live-boot, mount proc/sys/dev, fix kernel params
The first ISO build didn't boot. Three root causes:

1. No squashfs-as-root mechanism — the custom initramfs hook mounted
   boot media but had no way to use the squashfs as the root filesystem.
   Fix: add live-boot + live-boot-initramfs-tools to debootstrap includes.
   This is ~100KB and provides proven squashfs-as-root with overlayfs.

2. Broken initramfs — update-initramfs needs /proc, /sys, /dev mounted
   in the chroot to detect modules and generate a working initrd.
   Fix: bind-mount virtual filesystems before update-initramfs.

3. Missing kernel parameters — GRUB and ISOLINUX configs lacked
   boot=live components, so live-boot never activated.
   Fix: add boot=live components to all kernel command lines.

Also: add all_video/efi_gop/efi_uga modules to GRUB EFI image for
display output on real hardware, and update installer wrapper to
check /run/live/medium first (where live-boot mounts the ISO).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
7183ebfa2b feat: replace Debian Live with custom debootstrap ISO base + branding
Major ISO build overhaul on dev-iso branch:

- Replace ~800MB Debian Live download with debootstrap --variant=minbase
  (~150MB installer squashfs built from scratch)
- Custom initramfs with archipelago-mount hook for boot media detection
- Systemd service auto-starts installer (replaces profile.d hack)
- GRUB + ISOLINUX configs written from scratch (no Debian Live dependency)
- EFI boot image built with grub-mkimage (no more MBR extraction)
- Archipelago GRUB theme: dark background, Bitcoin orange accents
- Theme installed on both installer ISO and target system
- Rootfs optimizations: --no-install-recommends, strip docs/man/locales,
  remove firmware-misc-nonfree/wget/htop, add explicit font deps
- Separate CI workflow (build-iso-dev.yml) for dev-iso branch
- Includes pre-existing fixes from main (build-iso.yml, middleware, Login)

Target: sub-2GB unbundled ISO (down from 3.9GB)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00
Dorian
39857c775a fix: onboarding auth, stale CI build, autocomplete attrs
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- Add identity.create + server.echo to UNAUTHENTICATED_METHODS
- Clear web/dist before frontend build to prevent stale artifacts
- Add autocomplete attrs to login inputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:19:51 +00:00
Dorian
f940b4562a fix: filebrowser port bind, CSRF in tests, console-setup, auto-test scope
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 18m35s
FileBrowser crash fix:
- Add --cap-add=NET_BIND_SERVICE (port 80 needs it with --cap-drop=ALL)
- Add --cap-add=DAC_OVERRIDE for rootless volume access
- Both in first-boot script and backend config.rs

Test script fixes:
- Extract csrf_token cookie and send as X-CSRF-Token header on RPC calls
- Add --phase1-only flag for safe install-only checks (no side effects)
- Auto-test service uses --phase1-only so it doesn't steal onboarding

Install fixes:
- Pre-create ~/.local/share/containers (ReadWritePaths mount namespace error)
- Fix console-setup.service: add After=tmp.mount + ExecStartPre mkdir /tmp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:17:18 +00:00
Dorian
4325c15541 fix: run post-install tests automatically on first boot
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 19m19s
Adds archipelago-post-install-tests.service — runs once after all
services are up, outputs to console + journal + log file at
/var/log/archipelago-post-install-tests.log. Tests password setup,
onboarding, and container lifecycle. Runs with default password
(password123) for automated validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:19:33 +00:00
Dorian
127a36c5c8 fix: production onboarding, CI tests, container security, keyboard nav
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Install & Onboarding:
- Remove DEV_MODE=true from production ISO service file (auto-created
  users, skipped password setup)
- Auto-install no longer overwrites rootfs service file with bad template
- Login.vue always checks auth.isSetup — shows password creation form
  on fresh install without requiring dev build flag
- Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes
- First-boot-containers sources image-versions.sh, runs podman as
  archipelago user (rootless), enables linger + podman.socket
- Correct volume ownership (100000:100000 for rootless UID mapping)

Container Security:
- FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access
- FileBrowser: add --read-only, /data volume for database, proper cmd args
- First-boot script matches backend config (security hardening + health check)

CI Pipeline:
- Add vue-tsc type check + vitest run to build-iso.yml (runs every push)
- Add post-install-tests.yml workflow (workflow_dispatch, SSH to target)
- Build report: set +eo pipefail, fix rootfs path, add || true guards
- Bundle run-post-install-tests.sh into ISO

E2E Test Suite (scripts/run-post-install-tests.sh):
- Phase 1: Install verification (files, services, podman, linger, DEV_MODE check)
- Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete)
- Phase 3: Container lifecycle (install 3 apps via package.install RPC,
  verify running, stop, verify stopped, restart, verify running, health)
- Phase 4: Log verification (first-boot log, diagnostics, journal errors)
- Correct package.install params: {"id", "dockerImage"}

Frontend:
- Fix backdrop-filter tab-switch bug (keep animations paused during rebuild)
- Dashboard glitch animations paused during tab-hidden
- Gamepad nav: auto-focus first container on route change
- Tab roving: Left/Right on role="tab" cycles and activates sibling tabs
- ContainerApps: data-controller-launch on running app cards
- 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:57 +00:00
Dorian
b684c2972e fix: CI report step uses sudo for root-owned files, continue-on-error
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 18m43s
The Build report step was failing the entire job because `du -h` and
`tar tf` on root-owned rootfs.tar returned permission denied. Added
sudo and continue-on-error: true so the report never fails the build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:41:47 +00:00
Dorian
320c9f5b19 fix: container install flow, filebrowser auth, AppCard enrichment
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- Fix .198-style fresh installs: systemd service ExecStartPre creates
  /run/user/1000, enable podman.socket, chmod 644 /etc/hosts
- Filebrowser: add /data volume for database (fixes read-only crash),
  secure auth with random password via backend RPC (no more admin/admin)
- AppCard: enrich installing state with marketplace metadata (icon,
  title, description, tier badge, author, version)
- Registry: btcpayserver 1.13.5 → 1.13.7, images mirrored
- ReadWritePaths: add home container paths for rootless podman

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:32:54 +00:00
Dorian
bc5121b33f docs: trim CLAUDE.md — lean, updated for CI/CD and registry
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 27m22s
Removed duplication with rules/ files, updated infrastructure table
(git.tx1138.com, app registry, CI runner, ISO debugging), trimmed
from 404 lines to ~120. Security rules kept via reference to rules/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:03:04 +00:00
Dorian
0bef26badd fix: filebrowser registry, CI cleanup, autologin, auth debug logging
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 18m25s
- CI: configure root podman with insecure registry so FileBrowser
  image can be pulled during ISO build
- CI: chmod u+rwX on workspace and act cache to fix cleanup failure
- ISO: auto-login on tty1 (no password prompt on console)
- Frontend: add console.log debug output for onboarding routing,
  health checks, and 401 redirects to diagnose session issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:13:01 +00:00
Dorian
1ddf90ae50 fix: bundle FileBrowser, auto-login tty1, boot/auth debug logging
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- ISO build: configure insecure registry for root podman so FileBrowser
  image can be pulled during build (was failing with HTTPS error)
- Auto-login on tty1 so no password prompt on console
- RootRedirect: persistent debug logging to sessionStorage
  (view in DevTools > Application > Session Storage > archipelago_boot_log)
- Logs: health check, onboarding state, routing decisions, 401 handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:12:31 +00:00
Dorian
ab48266353 fix: CI chown act cache to prevent false build failure
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 21m21s
The checkout action post-cleanup fails on root-owned files in the
workspace, marking the build as failed even though the ISO was built.
Chown the entire act cache dir so cleanup succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:02:43 +00:00
Dorian
493a659ed4 fix: TS2532 undefined check in controller nav Enter handler
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 17m29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:29:14 +00:00
Dorian
e4bdc775e4 fix: kiosk cursor, Esc dead-end, PWA prompt, password overlay, gamepad Enter
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 11m2s
- Kiosk: show cursor when active (removed -nocursor from Xorg),
  unclutter hides after 3s idle. X11 on VT7 for Ctrl+Alt+F1/F7 switching.
- Kiosk: keep getty@tty1 running so MOTD is accessible via Ctrl+Alt+F1
- Kiosk: disable Chromium password save overlay (--password-store=basic)
- Esc: don't navigate back from top-level pages (dashboard, login, kiosk)
  to prevent dead-end at root redirect
- PWA: suppress install prompt in kiosk mode (/kiosk path)
- Gamepad: Enter in text fields moves focus to next element (submit button)
  instead of submitting the form

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:16:07 +00:00
Dorian
13b832fdd3 feat: add install log and first-boot diagnostics
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- Installer: tee all output to /var/log/archipelago-install.log
  on the target disk for post-install debugging
- First boot: oneshot service captures system state 30s after boot:
  services, nginx, LUKS, EFI, SSL, containers, journal errors
- On-demand: sudo archipelago-diagnostics to re-run anytime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:58:34 +00:00
Dorian
3db9ff9216 feat: add build report and first-boot diagnostics
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
CI build report: checks rootfs contents (nginx, SSL, keyboard, kiosk,
lid config, backend, frontend) and ISO contents after build. Reports
in the Actions log so build issues are immediately visible.

First-boot diagnostics: one-shot systemd service runs 30s after first
boot, logs service status, nginx test, SSL certs, LUKS, podman,
kiosk, console-setup, disk, network, and journal errors to
/var/log/archipelago-first-boot-diag.log. Only runs once (ConditionPathExists).

SSH in and cat the log to debug any fresh install issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:54:32 +00:00
Dorian
5b60d13693 fix: onboarding 401 redirect, glass card rendering bugs
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 17m16s
- rpc-client: don't redirect to /login on 401 during onboarding flow,
  which caused session expired kicks on fresh installs
- style.css: add translateZ(0) + isolation:isolate to glass-card,
  glass-strong, path-option-card to fix Chromium compositor bug where
  backdrop-filter + animated fixed overlays cause black rectangles
- App.vue: pause background animations when tab hidden, force
  compositor layer rebuild on tab return to prevent stale renders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:06:09 +00:00
Dorian
71d7d8c918 fix: preseed keyboard config, enable kiosk by default
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 12m46s
- Preseed keyboard-configuration and console-setup debconf values
  to prevent console-setup.service failure on boot
- Enable archipelago-kiosk.service by default on fresh installs
  so the system boots into the web UI display, not a login prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:50:59 +00:00
Dorian
fad79ff955 fix: nginx startup, kiosk fullscreen, reboot errors, kiosk toggle
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 18m5s
- Remove hardcoded Tailscale IP from nginx listen (broke fresh install)
- Generate SSL cert in installer if rootfs missed it (safety net)
- Kiosk: add --start-fullscreen --start-maximized --window-size flags
- Kiosk: remove --disable-gpu (can prevent fullscreen rendering)
- Kiosk: add toggle command and Ctrl+Alt+F1/F7 docs in MOTD
- Reboot: suppress stderr during cleanup to hide flashing errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:30:13 +00:00
Dorian
732b04c9df fix: purge shim-signed and clean EFI/BOOT to fix boot failure
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 18m36s
Shim-signed package hooks reinstall shimx64.efi and BOOTX64.CSV
which cause 'Failed to open \EFI\BOOT\' with garbled filenames.
Purge the package before grub-install, then nuke everything from
EFI/BOOT except BOOTX64.EFI and grub.cfg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:31:26 +00:00
Dorian
6063ac553c fix: load dm_mod/dm_crypt and mount /proc /sys for LUKS setup
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
The live installer environment doesn't have dm_mod loaded, causing
'Cannot initialize device-mapper' during LUKS2 encryption. Also
bind-mount /proc and /sys into chroot so cryptsetup can detect
hardware capabilities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:28:08 +00:00
Dorian
bda8b38a95 fix: CI pass absolute ARCHIPELAGO_BIN path through sudo
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 18m38s
sudo doesn't inherit env vars. Use absolute path and pass it
explicitly so the ISO build finds the freshly built binary
instead of falling through to podman build from source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:48:36 +00:00
Dorian
9354a27909 fix: CI fix 'local' outside function and root-owned file cleanup
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 20m1s
- Remove 'local' keyword in ISO build script (not in a function)
- Add workspace permission fix step so runner can clean up after sudo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:24:30 +00:00
Dorian
3a31c2aa95 fix: remove 'local' keyword outside function in ISO build script
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:23:19 +00:00
Dorian
1eea46542e fix: CI cache Debian Live ISO to avoid 1.4GB re-download
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 16m55s
Copy the Debian Live ISO from the server's existing build cache
into the CI workspace before running the ISO build. Saves ~10 min.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:03:49 +00:00
Dorian
1a64b14354 feat: ignore lid close on laptops so server keeps running
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Adds logind.conf.d drop-in to HandleLidSwitch=ignore for all
lid close scenarios (battery, external power, docked). Archipelago
nodes installed on laptops won't suspend when the lid is closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:58:16 +00:00
Dorian
f7a57b8f1f chore: remove dead core/parmanode crate
The parmanode compatibility layer was scaffolded but never wired up —
zero imports or calls from anywhere in the codebase. Closes gitea#1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:33:13 +00:00
Dorian
1d9fe06f97 fix: CI don't replace live binary, pass build path to ISO script
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Remove the cp to /usr/local/bin that caused 'Text file busy'.
The ISO build script now accepts ARCHIPELAGO_BIN env var to find
the freshly built binary instead of requiring it installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:28:43 +00:00
120 changed files with 9471 additions and 2891 deletions

View File

@@ -1,21 +1,25 @@
---
name: Tailscale node addresses
description: Complete list of all Tailscale node IPs and hostnames for SSH access
name: Node inventory and SSH access
description: Complete list of all Archipelago nodes — LAN and Tailscale IPs, SSH commands, build capabilities, deploy methods
type: reference
---
## Tailscale Nodes
| Name | Tailscale IP | Hostname | SSH |
|------|-------------|----------|-----|
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` |
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` |
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` |
Note: `archipelago-3.tail2b6225.ts.net` and `100.124.105.113` are the SAME machine.
## LAN Nodes
| Name | IP | SSH |
|------|-----|-----|
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` |
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` |
| Name | IP | SSH | Notes |
|------|-----|-----|-------|
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` | Full build env, CI runner, OAuth proxy |
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` | Full build env |
## Tailscale Nodes
| Name | Tailscale IP | Hostname | SSH | Build? |
|------|-------------|----------|-----|--------|
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` | Unknown |
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` | Yes (Node, Rust, Podman) |
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` | No (Podman only, copy pre-built artifacts) |
| Arch Atob | 100.113.33.31 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.113.33.31` | Unknown |
## Deploy Methods
- **LAN nodes (.228, .198):** `./scripts/deploy-to-target.sh --both`
- **Arch 2:** `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
- **Arch 3:** SCP pre-built binary + frontend tarball (no build tools). Do NOT relay through .228 — SSH directly from Mac.
- **All nodes:** Use `~/.ssh/archipelago-deploy` key

View File

@@ -0,0 +1,243 @@
# ISO Overhaul: Custom Minimal Base + Branding + Size Optimization
## Context
The Archipelago ISO is ~3.9GB — too large. The root cause is a ~800MB Debian Live ISO used as the boot base, plus a ~2.1GB rootfs with no `--no-install-recommends`. We're replacing the Debian Live dependency entirely with a custom debootstrap-built installer, adding full Archipelago branding to the boot chain, and stripping the rootfs. Target: sub-2GB ISO.
All work on `dev-iso` branch with its own CI workflow. Main branch stays untouched.
---
## Phase 0: Branch + CI Setup
**Create `dev-iso` branch and separate CI workflow.**
1. Branch from current `main`
2. Create `.gitea/workflows/build-iso-dev.yml`:
- Trigger: `push: branches: [dev-iso]` + `workflow_dispatch`
- Same structure as `build-iso.yml` (131 lines) but:
- Remove "Cache Debian Live ISO" step (no longer needed)
- Add `debootstrap`, `squashfs-tools`, `isolinux`, `syslinux-common`, `mtools`, `grub-efi-amd64-bin`, `grub-pc-bin` to tool dependencies
- Output naming: `archipelago-dev-unbundled-{date}.iso`
- Keep: backend build, frontend build, type check, tests, build report
3. Push and verify CI triggers on .228 runner
**Files:**
- New: `.gitea/workflows/build-iso-dev.yml`
---
## Phase 1: Rootfs Size Optimizations
**Shrink rootfs.tar from ~2.1GB to ~1.5GB. Only touches the Dockerfile heredoc in Step 1 (lines 210-335).**
### 1.1 Add `--no-install-recommends`
- Line 229: `apt-get install -y``apt-get install -y --no-install-recommends`
- Line 269: Same for Tailscale install
- Explicitly add packages that may be needed as recommends: `fonts-liberation`, `xfonts-base` (for Chromium kiosk)
- **Saves: ~150-300MB**
### 1.2 Remove `firmware-misc-nonfree`
- Line 257: Remove `firmware-misc-nonfree` from package list
- Keep: `firmware-realtek`, `firmware-iwlwifi`, `intel-microcode`, `amd64-microcode`
- **Saves: ~50-80MB**
### 1.3 Strip docs/man/locales
- Add after line 264 (after apt-get clean):
```dockerfile
RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null; \
find /usr/share/doc -empty -delete 2>/dev/null; \
rm -rf /usr/share/man /usr/share/info /usr/share/lintian /usr/share/linda; \
find /usr/share/locale -maxdepth 1 -mindepth 1 ! -name 'en_US' ! -name 'locale.alias' -exec rm -rf {} +
```
- **Saves: ~50-80MB**
### 1.4 Remove `wget` and `htop`
- Lines 244, 246: Remove `wget` (curl covers it) and `htop` (luxury tool)
- Keep `git` (used by self-update system)
- **Saves: ~5MB** (minor but removes unnecessary surface)
### Verification
- Build ISO, compare rootfs.tar size
- Boot in QEMU, verify: kiosk renders, SSH works, nginx serves UI, podman runs
**Files modified:**
- `image-recipe/build-auto-installer-iso.sh` (Step 1 Dockerfile heredoc, lines 210-335)
---
## Phase 2: Replace Debian Live with Custom Debootstrap Base
**The big one. Replaces Steps 2, 5, and parts of 4 and 6.**
### 2.1 New Step 2: Build Minimal Installer Environment
Replace lines 420-502 entirely. Run debootstrap inside a container to produce:
- `vmlinuz` — kernel (reused from linux-image-amd64)
- `initrd.img` — custom initramfs with ISO-mount hook
- `filesystem.squashfs` — minimal Debian root (~120-180MB)
The installer squashfs contains only what's needed to run the auto-install script:
- `debootstrap --variant=minbase --include=systemd,systemd-sysv,udev,bash,coreutils,mount,util-linux,cryptsetup,parted,dosfstools,e2fsprogs,kmod,procps,iproute2,ca-certificates,gdisk`
- Auto-login on tty1 via getty override
- systemd service that auto-starts the installer (replaces profile.d hack)
**Key: Custom initramfs hook** (`local-bottom/archipelago-mount`) that:
1. Scans `/dev/sr0`, `/dev/sd*` for a partition containing `archipelago/auto-install.sh`
2. Mounts it read-only at `/run/archiso`
3. This replaces Debian Live's `boot=live components` mechanism
### 2.2 New Step 5: Assemble ISO Directory
Replace lines 2236-2448 entirely. Much simpler — no squashfs overlay mechanism, no tools extraction (tools are in the squashfs), no profile.d manipulation.
New Step 5 just assembles the directory structure:
```
$INSTALLER_ISO/
live/
vmlinuz
initrd.img
filesystem.squashfs
boot/grub/
grub.cfg
themes/archipelago/ (Phase 3)
efi.img (built with grub-mkimage)
isolinux/
isolinux.bin
ldlinux.c32
isolinux.cfg
EFI/BOOT/
BOOTX64.EFI (built with grub-mkimage)
archipelago/
auto-install.sh
rootfs.tar
bin/archipelago
web-ui/
scripts/
container-images/ (if bundled)
```
Generate EFI boot image with `grub-mkimage` and ISOLINUX files from the `isolinux` package. No more extracting MBR from Debian Live.
### 2.3 Updated Step 6: ISO Creation
Replace lines 2461-2511 (MBR extraction + EFI image search). Use:
- MBR: `/usr/lib/ISOLINUX/isohdpfx.bin` (from `isolinux` package)
- EFI: `boot/grub/efi.img` (built in Step 5)
- xorriso command stays the same structure
### 2.4 Update Boot Media Paths in Step 4 (auto-install.sh)
Lines 1154-1155: Add `/run/archiso` as first search path:
```bash
for dev in /run/archiso /cdrom /media/cdrom /run/live/medium /lib/live/mount/medium; do
```
Also update lines 2326, 2377 (no longer needed — replaced by systemd service in installer squashfs).
### 2.5 Remove Debian Live cleanup from auto-install.sh
The installed system's auto-install script currently removes `live-boot`, `live-boot-initramfs-tools`, `live-config` (around line 1872). With the custom base, these packages won't exist in the rootfs, so this cleanup becomes a harmless no-op — but should be cleaned up for clarity.
### Verification
- Build ISO, verify size < 2GB
- Boot in QEMU (UEFI mode): verify GRUB menu → installer → full install → reboot
- Boot in QEMU (BIOS mode): verify ISOLINUX → installer → full install → reboot
- After install: SSH, web UI, kiosk, container loading all work
- Test `test-iso-qemu.sh` (may need minor path updates)
**Files modified:**
- `image-recipe/build-auto-installer-iso.sh` (Steps 2, 4, 5, 6 — major rewrite)
---
## Phase 3: Archipelago Boot Branding
**Custom GRUB theme, installer banner, installed system GRUB.**
### 3.1 Create GRUB Theme
New directory: `image-recipe/branding/grub-theme/`
- `theme.txt` — dark background (#0a0a0a), white text, Bitcoin orange (#f7931a) highlight
- `background.png` — 1920x1080 dark with subtle Archipelago logo watermark
- Font files (`.pf2`) — generated with `grub-mkfont` from DejaVu Sans during build
GRUB menu entries:
- "Install Archipelago" (default, quiet boot)
- "Install Archipelago (verbose)" (no `quiet`, for debugging)
- "Boot from local disk" (chainloader)
### 3.2 Create ISOLINUX Theme
New file: `image-recipe/branding/isolinux.cfg`
- Matching dark theme for legacy BIOS boot
- Same menu entries as GRUB
### 3.3 Branded Installer Banner
The systemd service's start script displays:
```
ARCHIPELAGO BITCOIN NODE OS
Automatic Installer v0.1.0
Press Enter to start installation...
```
### 3.4 Install GRUB Theme to Target System
In Step 4 (auto-install.sh), before `update-grub` (around line 1888):
- Copy GRUB theme from ISO to `/mnt/target/boot/grub/themes/archipelago/`
- Add `GRUB_THEME="/boot/grub/themes/archipelago/theme.txt"` to `/mnt/target/etc/default/grub`
- The installed system boots with Archipelago branding, not Debian default
### 3.5 Create Background Image
Render from existing SVG favicon (`neode-ui/public/assets/icon/favico-black-v2.svg`) to PNG at appropriate sizes. Dark background with subtle centered logo.
### Verification
- Boot ISO: GRUB shows Archipelago theme (dark + orange)
- No Debian branding visible anywhere
- After install: target system GRUB also shows Archipelago theme
**Files:**
- New: `image-recipe/branding/grub-theme/theme.txt`
- New: `image-recipe/branding/grub-theme/background.png`
- New: `image-recipe/branding/isolinux.cfg`
- Modified: `image-recipe/build-auto-installer-iso.sh` (Steps 5, 4)
---
## Risk Areas
| Risk | Severity | Mitigation |
|------|----------|------------|
| Custom initramfs fails to find USB media | High | Test multiple USB controller types in QEMU; add verbose fallback boot option |
| Missing packages in minbase break install | Medium | Trace auto-install.sh dependencies; test full install flow |
| GRUB EFI image missing modules | High | Include all common modules in grub-mkimage; test UEFI + BIOS |
| Kiosk breaks without recommends | Medium | Explicitly add Chromium/X11 font deps; test kiosk before merge |
| initramfs overlayfs mount fails | High | Follow well-established patterns from Arch/Ubuntu live ISOs |
---
## Implementation Order
1. **Phase 0** — branch + CI (~1 hour)
2. **Phase 1** — rootfs size opts (~2 hours, push + verify)
3. **Phase 2** — custom base (~8-10 hours, iterative QEMU testing)
4. **Phase 3** — branding (~3 hours)
Phases are sequential — each builds on the previous. Push after each phase, verify CI passes.
---
## Key Files
| File | Role |
|------|------|
| `image-recipe/build-auto-installer-iso.sh` | Main build script — most changes here |
| `.gitea/workflows/build-iso-dev.yml` | New CI workflow for dev-iso branch |
| `image-recipe/branding/grub-theme/*` | New GRUB theme assets |
| `image-recipe/branding/isolinux.cfg` | New ISOLINUX config |
| `image-recipe/test-iso-qemu.sh` | QEMU test script (minor updates) |
| `.gitea/workflows/build-iso.yml` | Reference for new CI workflow |
| `scripts/image-versions.sh` | Unchanged — container image versions |

View File

@@ -1,87 +1,121 @@
---
name: build-iso
description: Build a new Archipelago auto-installer ISO image (bundled or unbundled)
disable-model-invocation: true
allowed-tools: Bash, Read
description: Build Archipelago auto-installer ISOs. Custom debootstrap base (no Debian Live dependency), live-boot for squashfs root, hybrid BIOS+UEFI boot, Archipelago branding. Use when user says "build ISO", "build image", "create installer", or needs to work on the ISO build pipeline.
allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Agent
---
Build a new Archipelago auto-installer ISO.
# Build Archipelago ISO
## Pre-build checklist
## Architecture (dev-iso branch)
1. Latest code deployed to server (`/deploy` first)
2. System configs synced (`/sync-configs` first)
3. Everything tested and working on live server
4. Sync build scripts to server before building:
```bash
rsync -avz -e "ssh -i ~/.ssh/archipelago-deploy" \
/Users/dorian/Projects/archy/image-recipe/build-auto-installer-iso.sh \
/Users/dorian/Projects/archy/image-recipe/build-unbundled-iso.sh \
archipelago@192.168.1.228:~/archy/image-recipe/
```
Custom debootstrap-based installer. NO Debian Live ISO download.
## Build variants
| Component | Source | Size |
|-----------|--------|------|
| Installer squashfs | debootstrap --variant=minbase + live-boot | ~180MB |
| Target rootfs | Docker build (Debian bookworm, full stack) | ~1.5GB compressed |
| Kernel + initramfs | From debootstrap, with live-boot hooks | ~50MB |
| GRUB + ISOLINUX | Built from packages during Step 2 | ~1MB |
| **Total ISO** | **Unbundled** | **~2.2GB** |
### Unbundled ISO (recommended for distribution — ~3GB)
No pre-bundled container images. Apps install on-demand from Marketplace (requires internet).
## Build Pipeline (6 Steps)
**Step 1** (lines ~200-430): Build target rootfs via Docker
- Debian bookworm + all runtime packages (podman, nginx, tor, chromium, etc.)
- `--no-install-recommends` for size reduction
- Strips docs/man/locales
- Output: `archipelago-rootfs.tar` (~1.5GB)
**Step 2** (lines ~430-710): Build installer environment via debootstrap
- `debootstrap --variant=minbase` inside a container
- Installs live-boot via chroot (NOT --include — minbase can't resolve it)
- Custom initramfs with live-boot hooks
- Builds GRUB EFI image with grub-mkimage
- Creates ISOLINUX files, EFI boot image
- Installs GRUB theme + background
- Output: vmlinuz, initrd.img, filesystem.squashfs, BOOTX64.EFI, efi.img, isolinux.bin
**Step 3** (lines ~710-850): Add Archipelago components
- Backend binary, web UI, rootfs.tar, scripts, Plymouth theme
**Step 3b** (lines ~850-1230): Bundle container images (skipped if UNBUNDLED=1)
**Step 4** (lines ~1230-2380): Generate auto-install.sh
- Embedded installer script (~1100 lines)
- Disk detection, partitioning, LUKS encryption, GRUB install
- Installs GRUB + Plymouth theme on target
**Step 5** (lines ~2380-2460): Configure boot loaders
- Write GRUB config (boot=live components)
- Write ISOLINUX config
- Both reference kernel at /live/vmlinuz
**Step 6** (lines ~2460-2540): Create final ISO
- xorriso with hybrid BIOS+UEFI boot
- Uses proven MBR from `branding/isohdpfx.bin`
- `-partition_offset 16` for UEFI compatibility
## CI Workflow
**Branch**: `dev-iso``.gitea/workflows/build-iso-dev.yml`
**Branch**: `main``.gitea/workflows/build-iso.yml`
Dev CI includes a smoke test step that verifies:
- All critical files present in ISO
- Initrd contains live-boot scripts
- grub.cfg has boot=live
- Fails build before copying to Builds if any check fails
## Critical Rules
1. **MBR**: Always use `branding/isohdpfx.bin` (Debian Live MBR, starts with `4552`). The ISOLINUX generic MBR (`33ed`) doesn't boot on all hardware.
2. **live-boot**: Must be installed via `chroot /installer apt-get install` AFTER debootstrap completes. The `--include` flag silently fails for live-boot.
3. **Initramfs**: `update-initramfs` needs `/proc`, `/sys`, `/dev` bind-mounted in the chroot. Without them, the initramfs is broken.
4. **scripts/live is a FILE**: Verify with `[ -e ]` not `[ -d ]`.
5. **Kernel params**: Must include `boot=live components`. Without `boot=live`, live-boot hooks never activate.
6. **partition_offset 16**: Required in xorriso for UEFI firmware to recognize the USB.
7. **Never push during a running CI build**: The gitea-runner kills in-progress builds when a new commit arrives on the same branch.
## Quick Commands
```bash
# Build locally (on .228):
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228
cd ~/archy/image-recipe
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
# Check build status:
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'cd ~/archy/image-recipe && sudo ./build-unbundled-iso.sh'
"ps aux | grep build-auto | grep -v grep"
# Check latest ISO:
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
"ls -lt /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso | head -3"
# Verify ISO:
# See /iso-debug skill for the full verification checklist
# Iterate on branding without rebuilding:
./image-recipe/dev-branding.sh [path-to-iso]
# Or: ./scripts/dev-start.sh → option 0
```
Output: `results/archipelago-installer-unbundled-x86_64.iso`
## Key Files
### Full bundled ISO (~11GB)
All container images pre-bundled for offline install.
```bash
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh'
```
Output: `results/archipelago-installer-x86_64.iso`
## Post-build: ALWAYS publish to FileBrowser
After EVERY successful build, copy the ISO to the FileBrowser `Builds` folder so it's downloadable from the web UI. This is mandatory — do not skip.
**FileBrowser data root**: `/var/lib/archipelago/filebrowser/`
```bash
# For unbundled:
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
sudo cp ~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-unbundled-x86_64.iso'
# For bundled:
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
sudo cp ~/archy/image-recipe/results/archipelago-installer-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-x86_64.iso'
```
## Post-build: Download to Mac (optional)
```bash
# Unbundled:
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso ~/Downloads/
# Bundled:
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-x86_64.iso ~/Downloads/
```
## Key paths on server
- Build scripts: `~/archy/image-recipe/build-auto-installer-iso.sh`, `build-unbundled-iso.sh`
- Build output: `~/archy/image-recipe/results/`
- Build cache (rootfs, base ISO): `~/archy/image-recipe/build/auto-installer/`
- FileBrowser Builds: `/var/lib/archipelago/filebrowser/Builds/`
## Notes
- Use `--rebuild` flag to force rootfs rebuild (otherwise uses cached)
- FileBrowser container mounts `/var/lib/archipelago/filebrowser` → `/srv`
- Always `chown 1000:1000` files in FileBrowser so the app can serve them
- **IMPORTANT**: Use `build-auto-installer-iso.sh` (or `build-unbundled-iso.sh`) only. The deprecated `build-debian-iso.sh` causes boot-to-prompt issues.
| File | Role |
|------|------|
| `image-recipe/build-auto-installer-iso.sh` | Main build script (~2600 lines) |
| `image-recipe/build-unbundled-iso.sh` | Wrapper: sets UNBUNDLED=1 |
| `image-recipe/branding/isohdpfx.bin` | Proven MBR (432 bytes) |
| `image-recipe/branding/grub-theme/` | GRUB theme + background |
| `image-recipe/branding/plymouth-theme/` | Plymouth boot splash |
| `scripts/image-versions.sh` | Pinned container image versions |
| `.gitea/workflows/build-iso-dev.yml` | CI for dev-iso branch |
| `image-recipe/test-iso-qemu.sh` | QEMU test script |
| `image-recipe/dev-branding.sh` | Quick branding iteration |

View File

@@ -0,0 +1,107 @@
---
name: design-pixel-retro
description: >
Pixel Art Retro design system — ChonkyPixels font, neon glow CTAs, pixel
dot animations, and dark foundation theme. Use when building retro/pixel art
UIs, foundation sites, when user says "pixel art", "retro design", "8-bit
aesthetic", "neon glow buttons", "pixel font", or "retro foundation style".
metadata:
author: dorian
version: 1.0.0
category: design-system
tags: [pixel-art, retro, 8-bit, neon, dark-theme, foundation]
---
# Pixel Art Retro Design System
Extracted from Archipelago Foundation. Pixel-perfect aesthetics with modern
web technology, neon glow accents, and playful retro energy.
## Design Identity
**Name:** Pixel Art Retro
**Mood:** Playful retro, 8-bit nostalgia with modern polish
**Background:** Dark (#0A0A0A) with pixel texture overlays
**Accent:** Bitcoin orange (#F7931A) with radial neon glow
## Typography
```css
--font-pixel: 'ChonkyPixels', monospace; /* Display/headings — CRITICAL */
--font-body: 'Avenir Next', system-ui, sans-serif;
--font-mono: 'Courier New', monospace;
```
**Rule:** ChonkyPixels must be loaded with `font-synthesis: none` and
`!important` on headings to prevent browser synthesis of bold/italic.
## Color Palette
Same dark base as Glassmorphism, but with neon glow effects:
```css
--bg-primary: #0A0A0A;
--accent: #F7931A;
--accent-glow: radial-gradient(circle, rgba(247,147,26,0.4) 0%, transparent 70%);
--neon-green: #39ff14;
--neon-pink: #ff6ec7;
--neon-blue: #04d9ff;
```
## Key Components
### Neon Glow CTA
```css
.neon-cta {
background: linear-gradient(135deg, #f7931a, #e68a00);
border: 2px solid rgba(247, 147, 26, 0.5);
border-radius: 4px; /* Sharp corners — pixel aesthetic */
padding: 12px 32px;
font-family: var(--font-pixel);
text-transform: uppercase;
position: relative;
}
.neon-cta::after {
content: '';
position: absolute;
inset: -8px;
background: var(--accent-glow);
opacity: 0;
transition: opacity 0.3s;
z-index: -1;
}
.neon-cta:hover::after { opacity: 1; }
```
### Pixel Dot Animation
```css
@keyframes pixel-dot-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.pixel-dot { animation: pixel-dot-bounce 0.6s steps(2) infinite; }
```
### Intro Sequence
```css
.intro-container { animation: intro-container 0.6s ease-out; transform-origin: center; }
.intro-corners { animation: intro-corners 0.5s ease-out 0.35s both; }
.intro-logo { animation: fadeIn 0.5s ease-out 0.7s both; }
@keyframes intro-container { from { transform: scale(0.97); opacity: 0; } }
@keyframes intro-corners { from { transform: scale(0.8); opacity: 0; } }
```
## UI Approach
- Sharp corners (2-4px radius) — pixel aesthetic, not rounded
- Stepped animations (`steps(N)`) where possible for pixel feel
- Monospace alignment for data displays
- Donation modal: max-width 480px, QR code on white background
- Theme toggle: smooth dark/light with inverted logo filter
## Modular Architecture
- Pixel font loaded via `@font-face` with subset for performance
- Glow effects via CSS pseudo-elements (no extra DOM)
- Animation keyframes in global stylesheet
- Component-scoped overrides only

View File

@@ -0,0 +1,114 @@
---
name: gamepad-nav
description: Expert-level gamepad/controller navigation for Archipelago's console-style UI. Use when working on D-pad navigation, focus management, spatial navigation, controller support, or 10-foot UI design.
---
# Gamepad Navigation Expert
When working on gamepad/controller navigation in Archipelago, apply these console-derived patterns.
## Architecture
**File**: `neode-ui/src/composables/useControllerNav.ts`
**Styles**: `neode-ui/src/style.css` (focus-visible rules)
The system uses `data-` attributes for navigation zones:
- `data-controller-zone="sidebar"` / `"main"` — navigation zones
- `data-controller-container` — focusable card/group (Enter drills in, Escape exits)
- `data-controller-focusable` — marks element as focusable
- `data-controller-ignore` — excludes from navigation
- `data-controller-install` / `data-controller-launch` — app-specific actions
## Core Navigation Rules (Xbox/PS5/Switch consensus)
### D-pad Movement
- **4 directions only** — Up/Down/Left/Right, one element per press
- **Spatial navigation** — find nearest focusable in direction using bounding rect geometry
- **Distance formula**: `euclidean + displacement - alignment` with overlap scoring
- **Tiebreaker for up/down**: prefer leftmost element (visual consistency in grids)
### Wrapping
- **Linear lists (1D)**: WRAP (last to first, first to last) — sidebar menu, tab bars
- **Grids (2D)**: NO WRAP — stops at edges, prevents disorientation
### Zone Transitions
- **Right from sidebar** -> first focusable in main content (topmost)
- **Left from main's leftmost** -> sidebar's active tab (`.nav-tab-active`)
- **Focus memory**: remember last-focused element per zone, restore on re-entry
### Container Navigation
- **Enter/A**: drill into container (focus first inner element)
- **Escape/B**: exit container (focus the container itself)
- **D-pad inside container**: navigate among inner elements spatially
- **D-pad at container edge**: exit and navigate to adjacent container
### Text Input Handling
- **Up/Down arrows**: EXIT input, navigate to nearest element above/below
- **Left/Right arrows**: stay in input (cursor movement)
- **Enter**: if next focusable is a button, click it directly (submit)
- **Escape**: blur input, navigate out
### Button Mapping
| Action | Xbox | PlayStation | Switch | Keyboard |
|--------|------|------------|--------|----------|
| Confirm | A | Cross | A | Enter |
| Back | B | Circle | B | Escape |
| Navigate | D-pad | D-pad | D-pad | Arrow keys |
## Focus Visual Design
### Console standard (10-foot viewing distance)
- **Minimum 2px** border/outline (1px flickers on interlaced TVs)
- **3:1 contrast ratio** against adjacent colors (WCAG 2.4.7)
- **Smooth transitions**: 150-200ms ease-out
- **GPU compositing**: use `translateZ(0)` on animated elements
- **Never pure white** (#f1f1f1 prevents TV halo effects)
### Archipelago Focus Patterns
```css
/* Global — subtle outline that follows border-radius */
*:focus-visible {
outline: 2px solid rgba(251, 146, 60, 0.6);
outline-offset: 2px;
}
/* Containers — soft glow + slight scale */
[data-controller-container]:focus-visible {
outline: none;
transform: scale(1.01);
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5),
0 0 20px rgba(251, 146, 60, 0.15);
}
/* Sidebar items — background tint + thin ring */
.sidebar-nav-item:focus-visible {
outline: none;
background: rgba(251, 146, 60, 0.12);
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.45);
}
```
## Gamepad API Integration
### Polling
- Poll `navigator.getGamepads()` in `requestAnimationFrame` loop (cheap, returns snapshot)
- Apply deadzone: `Math.abs(axis) > 0.2` before registering input
- D-pad repeat: 400ms initial delay, 150ms interval (gamepads don't auto-repeat)
### Button indices (W3C Standard Mapping)
- 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 12=DUp, 13=DDown, 14=DLeft, 15=DRight
## When Investigating Issues
1. Check `useControllerNav.ts` for the `handleKeyDown` function
2. Check `data-controller-*` attributes in the view's template
3. Verify focusable elements are in the right `data-controller-zone`
4. Test with: arrow keys on keyboard (simulates D-pad)
5. Check `style.css` for `focus-visible` rules
## Key Sources
- [Xbox Accessibility Guideline 112](https://learn.microsoft.com/en-us/gaming/accessibility/xbox-accessibility-guidelines/112)
- [Microsoft: Gamepad and remote interactions](https://learn.microsoft.com/en-us/windows/apps/design/input/gamepad-and-remote-interactions)
- [W3C CSS Spatial Navigation](https://www.w3.org/TR/css-nav-1/)
- [W3C Gamepad Spec](https://w3c.github.io/gamepad/)
- [Norigin Spatial Navigation (React reference)](https://github.com/NoriginMedia/Norigin-Spatial-Navigation)

View File

@@ -0,0 +1,146 @@
---
name: iso-branding
description: Design and implement Archipelago boot visuals — GRUB theme, Plymouth splash, ISOLINUX menu, console banners. Handles pixel-art cyberpunk aesthetic with Bitcoin orange accents. Use when working on boot screen design, splash animations, GRUB backgrounds, or installer UI appearance.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob, Agent
---
# ISO Boot Branding — Archipelago
Design and build the visual boot experience from USB power-on to web UI.
## Brand Identity
**Archipelago** = self-sovereign Bitcoin node OS. Floating islands in the sky.
| Element | Value |
|---------|-------|
| Primary accent | `#fb923c` (Bitcoin orange) |
| Secondary accent | `#f7931a` (deeper orange) |
| Success | `#4ade80` (green) |
| Background | `#0a0a0a``#050505` (near-black) |
| Text | `#ffffff` (white), `#aaaaaa` (dim), `#555555` (subtle) |
| Glass | `rgba(255,255,255,0.06)` frost overlay |
| Style | Pixel art cyberpunk, dark glass morphism, CRT scanlines |
| Logo | Pixel-art lowercase "a" (from SVG favicon) |
## Boot Stages & What's Customizable
### 1. GRUB Menu (UEFI boot)
- **Background**: `branding/grub-theme/background.png` — any PNG, GRUB scales it
- **Theme**: `branding/grub-theme/theme.txt` — colors, layout, labels
- **Fonts**: Generated with `grub-mkfont` during build, .pf2 format
- **Config**: Written by build script in Step 5 (`grub.cfg` heredoc)
GRUB theme.txt properties that work:
```
desktop-color: "#rrggbb" # Fallback if no background
desktop-image: "background.png" # Background image
title-text: "" # Empty = no title
+ boot_menu {
left/top/width/height = N%
item_color = "#rrggbb"
selected_item_color = "#rrggbb"
item_height = N
item_spacing = N
scrollbar = false
}
+ label {
left/top/width = N%
text = "string"
color = "#rrggbb"
align = "center"
}
```
**IMPORTANT**: Do NOT reference font names in theme.txt unless you know the exact internal name from grub-mkfont output. GRUB falls back to default if a font reference fails, which causes the ENTIRE theme to not load.
### 2. ISOLINUX Menu (BIOS boot)
- **Config**: Written by build script in Step 5 (`isolinux.cfg` heredoc)
- **Colors**: ANSI-style color codes in `MENU COLOR` directives
- **Title**: `MENU TITLE` string
- Text-only — no background image (use `vesamenu.c32` for graphical, but `menu.c32` is more compatible)
### 3. Plymouth Splash (kernel boot → login)
- **Theme**: `branding/plymouth-theme/archipelago.script`
- **Logo**: `branding/plymouth-theme/logo.png` (PNG with transparency)
- **Config**: `branding/plymouth-theme/archipelago.plymouth`
- Supports: animated progress bar, logo sprites, LUKS password prompt
- Kernel param `splash` must be present (added to GRUB_CMDLINE_LINUX_DEFAULT)
Plymouth script language:
```javascript
Window.SetBackgroundTopColor(r, g, b); // 0.0-1.0
logo = Image("logo.png");
sprite = Sprite(logo);
sprite.SetX(x); sprite.SetY(y);
Plymouth.SetRefreshFunction(callback);
Plymouth.SetBootProgressFunction(callback);
Plymouth.SetDisplayPasswordFunction(callback);
```
### 4. Console Banner (TTY login)
- ASCII art + system info in `/etc/profile.d/archipelago.sh`
- Generated in auto-install.sh (Step 4, the INSTALLER_SCRIPT heredoc)
- Uses ANSI escape codes for color
### 5. Installer Prompt
- "ARCHIPELAGO BITCOIN NODE OS / Automatic Installer"
- In the systemd service wrapper: `/usr/local/bin/archipelago-start-installer`
- Built inside the debootstrap container in Step 2
## Dev Workflow
### Quick preview (no ISO needed)
```bash
# Edit background, see it instantly:
open image-recipe/branding/grub-theme/background.png
# Generate procedural background:
python3 image-recipe/branding/generate-grub-background.py /tmp/bg.png && open /tmp/bg.png
# Generate Plymouth logo:
python3 image-recipe/branding/generate-plymouth-logo.py /tmp/logo.png && open /tmp/logo.png
```
### Full boot test (needs base ISO)
```bash
./image-recipe/dev-branding.sh [path-to-iso]
# Or via dev-start.sh option 0
```
Extracts ISO → patches branding → repackages → boots QEMU. ~30 seconds.
### What to edit
| File | Affects |
|------|---------|
| `branding/grub-theme/background.png` | GRUB boot screen image |
| `branding/grub-theme/theme.txt` | GRUB menu colors, layout |
| `branding/plymouth-theme/logo.png` | Plymouth boot logo |
| `branding/plymouth-theme/archipelago.script` | Plymouth animation/progress |
| `branding/generate-grub-background.py` | Procedural background generator |
| `branding/generate-plymouth-logo.py` | Procedural logo generator |
## Image Specs
| Asset | Format | Size | Notes |
|-------|--------|------|-------|
| GRUB background | PNG | 1024x768 recommended | GRUB scales any size, but large images slow boot |
| Plymouth logo | PNG (RGBA) | 256x256 recommended | Transparent background |
| GRUB fonts | .pf2 | Generated | `grub-mkfont -s SIZE -o out.pf2 input.ttf` |
## Build Integration
GRUB theme is installed in Step 2 (after artifacts placed):
- Static `background.png` copied from `branding/grub-theme/`
- Falls back to Python generator if static file missing
- Fonts generated in debootstrap container with `grub-mkfont`
Plymouth theme installed in Step 3 (component copy) + Step 4 (auto-install.sh):
- Files copied to `$ARCH_DIR/plymouth-theme/` in ISO
- Auto-install.sh copies to target at `/usr/share/plymouth/themes/archipelago/`
- Sets as default via `plymouth-set-default-theme`
GRUB theme also installed on TARGET system (not just installer):
- Auto-install.sh copies theme to `/mnt/target/boot/grub/themes/archipelago/`
- Adds `GRUB_THEME=` to `/mnt/target/etc/default/grub`

View File

@@ -0,0 +1,175 @@
---
name: iso-debug
description: Diagnose and fix Archipelago ISO boot failures. Covers hybrid MBR/GPT, UEFI/BIOS boot chains, live-boot initramfs, GRUB/ISOLINUX configuration, xorriso packaging, and USB boot compatibility. Use when ISO doesn't boot, installer doesn't start, kernel panics, or USB isn't recognized by BIOS/UEFI.
allowed-tools: Bash, Read, Grep, Glob, Agent, Edit
---
# ISO Boot Debugging — Archipelago Custom Base
Systematic diagnosis of ISO boot failures for the Archipelago debootstrap-based installer.
## Architecture
The ISO boot chain has 5 stages. Failure at any stage has distinct symptoms:
| Stage | Component | Symptom if broken |
|-------|-----------|-------------------|
| 1. BIOS/UEFI recognition | Hybrid MBR + GPT | USB not in boot menu at all |
| 2. Bootloader | ISOLINUX (BIOS) or GRUB EFI (UEFI) | Black screen after selecting USB |
| 3. Kernel + initramfs | vmlinuz + initrd.img with live-boot | Kernel panic or initramfs shell |
| 4. Root filesystem | live-boot mounts filesystem.squashfs | "No root device" or blank screen |
| 5. Installer | systemd service + auto-install.sh | Boots to shell but no installer prompt |
## Stage 1: USB Not Recognized
**Most common cause**: Wrong MBR code in the ISO hybrid boot sector.
### Diagnosis
```bash
# Compare first 16 bytes of working vs broken ISO
xxd -l 16 working.iso
xxd -l 16 broken.iso
# Check for valid boot signature at offset 510
xxd -s 510 -l 2 broken.iso
# Must show: 55aa
```
### Known MBR codes
- `4552` — Debian Live MBR (extracted from Debian Live ISO). **Works on all tested hardware.**
- `33ed` — ISOLINUX package generic isohdpfx.bin. **Does NOT work on some UEFI hardware.**
### Fix
The project ships the proven MBR at `image-recipe/branding/isohdpfx.bin` (432 bytes, starts with `4552`).
Build script uses it via: `-isohybrid-mbr "$SCRIPT_DIR/branding/isohdpfx.bin"`
### xorriso flags that matter
- `-isohybrid-mbr <file>` — Embeds MBR code for USB hybrid boot
- `-isohybrid-gpt-basdat` — Adds GPT partition entry for EFI (REQUIRED for UEFI USB boot)
- `-partition_offset 16` — Reserves space for GPT table (REQUIRED — without this some UEFI firmware won't see the USB)
- `-eltorito-alt-boot -e boot/grub/efi.img -no-emul-boot` — EFI boot catalog entry
### Balena Etcher
Writes raw ISO to USB — no special formatting. If the ISO boots in QEMU but not on hardware, the MBR code is the issue, not Etcher.
## Stage 2: Bootloader Failure
### BIOS path: ISOLINUX
Required files in ISO: `isolinux/isolinux.bin`, `isolinux/ldlinux.c32`, `isolinux/boot.cat`
Config: `isolinux/isolinux.cfg`
### UEFI path: GRUB
Required files: `EFI/BOOT/BOOTX64.EFI`, `boot/grub/efi.img`, `boot/grub/grub.cfg`
The EFI image is a FAT32 filesystem containing the GRUB binary, built with:
```bash
grub-mkimage -O x86_64-efi -o BOOTX64.EFI -p /boot/grub \
part_gpt part_msdos fat iso9660 udf normal boot linux search \
search_fs_uuid search_fs_file search_label configfile echo cat \
ls test true loopback gfxterm gfxmenu font png all_video video \
video_bochs video_cirrus efi_gop efi_uga
```
**Critical**: `all_video`, `efi_gop`, `efi_uga` needed for display on real hardware.
### Diagnosis
```bash
# Mount ISO and verify files
sudo mount -o loop,ro broken.iso /mnt
ls -la /mnt/isolinux/
ls -la /mnt/EFI/BOOT/
cat /mnt/boot/grub/grub.cfg
cat /mnt/isolinux/isolinux.cfg
sudo umount /mnt
```
## Stage 3: Kernel / Initramfs
### live-boot
The initramfs must contain live-boot hooks. Without them, the kernel boots but can't find root.
**Kernel params required**: `boot=live components`
- `boot=live` — triggers live-boot's initramfs scripts
- `components` — tells live-boot to scan live/ for squashfs files
### Verify initramfs has live-boot
```bash
TMPDIR=$(mktemp -d)
unmkinitramfs /path/to/initrd.img $TMPDIR
# live-boot installs scripts/live as a FILE (not directory)
ls -la $TMPDIR/scripts/live # or $TMPDIR/main/scripts/live
file $TMPDIR/scripts/live # Should say "ASCII text"
```
### Common initramfs failures
1. **live-boot not installed**: debootstrap `--include` can't resolve its deps. Must install via `chroot apt-get` after debootstrap.
2. **Broken initramfs from container build**: `update-initramfs` needs `/proc`, `/sys`, `/dev` mounted in the chroot.
3. **scripts/live is a FILE not directory**: Verification code must use `[ -e ]` not `[ -d ]`.
## Stage 4: Root Filesystem
live-boot searches for squashfs files in `live/` on the boot media.
- Mounts boot media (USB/CDROM) at `/run/live/medium`
- Finds `live/filesystem.squashfs`
- Mounts it read-only, creates tmpfs overlay
- pivot_root into the combined root
### Diagnosis
If you get an initramfs shell prompt `(initramfs)`:
```bash
# Inside initramfs shell:
ls /run/live/medium/ # Is boot media mounted?
ls /run/live/medium/live/ # Is squashfs there?
cat /proc/cmdline # Does it have boot=live?
```
## Stage 5: Installer Not Starting
The installer auto-starts via:
1. Getty auto-login on tty1 (root, no password)
2. systemd service `archipelago-installer.service`
3. Wrapper script searches for boot media at: `/run/live/medium`, `/run/archiso`, `/cdrom`
### Diagnosis
If you get a shell but no installer prompt:
```bash
systemctl status archipelago-installer.service
cat /usr/local/bin/archipelago-start-installer
ls /run/live/medium/archipelago/auto-install.sh
```
## Quick Verification Checklist
Run against any ISO before flashing:
```bash
ISO=path/to/iso
MNT=$(mktemp -d)
sudo mount -o loop,ro $ISO $MNT
echo "=== MBR ===" && xxd -l 4 $ISO
echo "=== Boot sig ===" && xxd -s 510 -l 2 $ISO
echo "=== Files ===" && for f in live/vmlinuz live/initrd.img live/filesystem.squashfs isolinux/isolinux.bin EFI/BOOT/BOOTX64.EFI boot/grub/grub.cfg archipelago/auto-install.sh; do [ -e $MNT/$f ] && echo "OK: $f" || echo "MISSING: $f"; done
echo "=== Kernel params ===" && grep "boot=live" $MNT/boot/grub/grub.cfg && echo OK || echo MISSING
echo "=== live-boot ===" && INITRD=$(mktemp -d) && unmkinitramfs $MNT/live/initrd.img $INITRD 2>/dev/null && ([ -e $INITRD/scripts/live ] && echo "OK" || echo "MISSING")
sudo umount $MNT
```
## Key Files
| File | Purpose |
|------|---------|
| `image-recipe/build-auto-installer-iso.sh` | Main build script (~2600 lines) |
| `image-recipe/branding/isohdpfx.bin` | Proven MBR code (432 bytes) |
| `image-recipe/branding/grub-theme/` | GRUB theme (theme.txt + background.png) |
| `image-recipe/branding/plymouth-theme/` | Plymouth boot splash |
| `.gitea/workflows/build-iso-dev.yml` | CI workflow with smoke test |
| `image-recipe/test-iso-qemu.sh` | QEMU testing script |
| `image-recipe/dev-branding.sh` | Quick branding iteration (patch + repackage) |
## Infrastructure
| What | Where |
|------|-------|
| CI runner | gitea-runner.service on 192.168.1.228 |
| ISO builds | FileBrowser at http://192.168.1.228:8083 → Builds/ |
| Dev branch | dev-iso (separate CI: build-iso-dev.yml) |
| Main branch | main (CI: build-iso.yml) — DO NOT break |

View File

@@ -0,0 +1,383 @@
# Custom Debian ISO Boot Chain — Technical Reference
Expert reference for building and debugging custom bootable Debian-based ISOs.
Covers hybrid MBR/GPT, live-boot, debootstrap, GRUB, ISOLINUX, Plymouth, and xorriso.
---
## 1. Hybrid MBR/GPT for USB Boot
### What is isohdpfx.bin?
The first 432 bytes of a hybrid-bootable ISO. Contains the Master Boot Record code that BIOS firmware executes when booting from USB. Different sources produce different MBR code:
| Source | First bytes | Compatibility |
|--------|-------------|---------------|
| Debian Live ISO (`dd if=debian-live.iso bs=1 count=432`) | `45 52` | Best — works on all tested hardware |
| `/usr/lib/ISOLINUX/isohdpfx.bin` | `33 ed` | Generic — fails on some UEFI hardware |
| Manually built with `isohybrid` | Varies | Unpredictable |
**Rule**: Always extract MBR from a known-working ISO. Never rely on the generic ISOLINUX one.
### CRITICAL: Embedded vs Appended EFI — Real Hardware Impact
Two approaches for EFI boot in xorriso. They produce DIFFERENT hybrid structures:
| Approach | xorriso flag | cyl-align | CHS geometry | Real hardware |
|----------|-------------|-----------|--------------|---------------|
| **Embedded** | `-e boot/grub/efi.img` | `cyl-align-on` | Non-zero (e.g. 244/32) | **WORKS** |
| **Appended** | `-append_partition 2 ... -e --interval:appended_partition_2:all::` | `cyl-align-off` | `0/0` | **FAILS** |
The Will Haley guide recommends appended, but on our Dell hardware only embedded works.
Use `xorriso -indev image.iso -report_system_area plain` to check which mode an ISO uses.
### Common gotcha: installer minbase missing sudo
debootstrap --variant=minbase does NOT include sudo. If the installer runs as root
(via auto-login), do NOT use sudo in scripts. `bash: sudo: command not found` is the symptom.
### xorriso flags for hybrid boot
```bash
xorriso -as mkisofs -o output.iso \
-isohybrid-mbr isohdpfx.bin \ # Embeds MBR for BIOS USB boot
-c isolinux/boot.cat \ # El Torito boot catalog
-b isolinux/isolinux.bin \ # BIOS bootloader
-no-emul-boot -boot-load-size 4 -boot-info-table \
-eltorito-alt-boot \ # Second boot entry (EFI)
-e boot/grub/efi.img \ # EFI boot image
-no-emul-boot \
-isohybrid-gpt-basdat \ # Adds GPT partition for EFI
-partition_offset 16 \ # Space for GPT table — REQUIRED for UEFI
/path/to/iso/contents
```
**Critical flags**:
- `-isohybrid-gpt-basdat`: Without this, UEFI firmware won't see the EFI partition
- `-partition_offset 16`: Reserves 16 sectors for GPT. Without it, some UEFI firmware ignores the USB entirely
- `-isohybrid-mbr`: Without this, the ISO won't boot from USB at all (only CD-ROM)
### Balena Etcher
Writes the ISO byte-for-byte to USB — no reformatting, no special partition creation. If the ISO works with `dd`, it works with Etcher. If BIOS doesn't see the USB, the MBR code is wrong, not Etcher.
### Verifying hybrid structure
```bash
xxd -l 4 image.iso # MBR code (should be 45 52 for Debian Live)
xxd -s 510 -l 2 image.iso # Boot signature (must be 55 aa)
xxd -s 512 -l 8 image.iso # GPT signature at LBA 1 (should be "EFI PART")
file image.iso # Should say "DOS/MBR boot sector" and "bootable"
```
---
## 2. live-boot Package
### What it does
Provides initramfs hooks that mount a squashfs file as the root filesystem using overlayfs. This is how every Debian/Ubuntu live ISO works.
Boot flow: kernel → initramfs → live-boot scripts → find squashfs → mount overlayfs → pivot_root → systemd
### Package structure
- `live-boot` (~29KB): Main package, boot scripts
- `live-boot-initramfs-tools` (~6KB): Initramfs hooks that get baked into initrd.img
**Critical**: `scripts/live` is a **FILE**, not a directory. Verification must use `[ -e ]` not `[ -d ]`.
### Kernel parameters
| Parameter | Required | Effect |
|-----------|----------|--------|
| `boot=live` | YES | Activates live-boot's initramfs hooks |
| `components` | YES | Scans live/ for additional squashfs modules |
| `toram` | No | Copies squashfs to RAM (faster, allows USB removal) |
| `persistence` | No | Enables writable overlay on a partition labeled "persistence" |
| `quiet` | No | Suppresses boot messages |
| `splash` | No | Enables Plymouth splash screen |
| `console=ttyS0,115200` | No | Serial console for QEMU debugging |
### Where live-boot mounts things
- `/run/live/medium` — The boot media (USB/CDROM) mount point
- `/run/live/rootfs/filesystem.squashfs` — The mounted squashfs
- `/run/live/overlay` — The tmpfs overlay for writes
### Verifying live-boot in initramfs
```bash
TMPDIR=$(mktemp -d)
unmkinitramfs /path/to/initrd.img $TMPDIR
# Check for live-boot scripts
file $TMPDIR/scripts/live # Should be "ASCII text"
# OR (some initramfs have main/ prefix)
file $TMPDIR/main/scripts/live
```
### Common failures
1. **live-boot not in initrd**: Installed in rootfs but initramfs not regenerated after
2. **Missing kernel params**: `boot=live` not in GRUB/ISOLINUX config
3. **Broken initramfs**: Built without /proc /sys /dev mounted in chroot
4. **Wrong verification**: `[ -d scripts/live ]` fails because it's a file
---
## 3. debootstrap for Installer Environments
### Variants
- `--variant=minbase`: Absolute minimum (~150MB). Only essential + apt. Good for installer squashfs.
- Default (no variant): Full base system (~300MB). More packages, fewer missing deps.
### --include limitations
debootstrap's minbase resolver is simplified and **cannot resolve complex dependency chains**. Packages like `live-boot` that depend on `initramfs-tools` which depends on many other packages will silently fail or be skipped.
**Fix**: Install complex packages via `chroot apt-get` after debootstrap completes:
```bash
debootstrap --variant=minbase --include=basic,packages bookworm /installer http://deb.debian.org/debian
# Then:
mount --bind /proc /installer/proc
mount --bind /sys /installer/sys
mount --bind /dev /installer/dev
chroot /installer apt-get update
chroot /installer apt-get install -y live-boot live-boot-initramfs-tools
umount /installer/dev /installer/sys /installer/proc
```
### Initramfs generation inside containers
`update-initramfs` REQUIRES `/proc`, `/sys`, `/dev` to be mounted in the chroot. Without them:
- Module detection fails (can't read /proc/modules)
- Device nodes missing (can't detect hardware)
- The resulting initramfs boots but can't load kernel modules
### Container-in-container considerations
When running debootstrap inside a Podman/Docker container on a CI runner:
- `--privileged` flag needed for chroot to work
- The container runtime may kill the container after debootstrap exits if using `set -e`
- proc/sys/dev mounts inside the debootstrapped chroot work fine with `--privileged`
---
## 4. GRUB Theming
### theme.txt format
```
desktop-color: "#0a0a0a" # Fallback background color
desktop-image: "background.png" # Background image (any PNG, GRUB scales)
title-text: "" # Empty = hide title
+ boot_menu {
left = 25%
top = 40%
width = 50%
height = 30%
item_color = "#aaaaaa" # Normal menu item color
selected_item_color = "#fb923c" # Selected item color
item_height = 36
item_spacing = 8
scrollbar = false
}
+ label {
left = 25%
top = 20%
width = 50%
text = "Some Text"
color = "#f7931a"
align = "center"
}
```
**IMPORTANT**: Do NOT specify `font = "Name Size"` in theme elements unless you know the exact internal font name. If GRUB can't find the font, the ENTIRE theme fails to load and you get the ugly default.
### Font handling
```bash
# Generate .pf2 font file
grub-mkfont -s 16 -o dejavu_16.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
# In grub.cfg, load fonts BEFORE setting theme:
loadfont /boot/grub/font.pf2
loadfont /boot/grub/themes/archipelago/dejavu_16.pf2
set theme=/boot/grub/themes/archipelago/theme.txt
```
### Background images
- Any PNG works, GRUB scales to screen resolution
- Smaller images (1024x768) load faster
- Large images (3000x2000+) add seconds to boot and may fail on limited GRUB heap
### grub-mkimage — essential modules for ISO boot
```bash
grub-mkimage -O x86_64-efi -o BOOTX64.EFI -p /boot/grub \
part_gpt part_msdos fat iso9660 udf \ # Filesystem access
normal boot linux search search_fs_uuid search_fs_file search_label \
configfile echo cat ls test true \ # Basic commands
loopback \ # Loop device support
gfxterm gfxmenu font png \ # Graphical display
all_video video video_bochs video_cirrus \ # Video drivers
efi_gop efi_uga # EFI display protocols
```
Missing `all_video`/`efi_gop` = black screen on real hardware (works in QEMU).
### EFI boot image creation
```bash
dd if=/dev/zero of=efi.img bs=1M count=4
mkfs.vfat efi.img
mmd -i efi.img ::/EFI ::/EFI/BOOT
mcopy -i efi.img BOOTX64.EFI ::/EFI/BOOT/BOOTX64.EFI
```
---
## 5. Plymouth Boot Splash
### Theme types
- **script**: Most flexible. Lua-like scripting with sprites, animations, callbacks.
- **two-step**: Simple logo + spinner. Less customizable but easier.
- **fade-in**: Logo fades in. Minimal.
### Script theme structure
```
/usr/share/plymouth/themes/mytheme/
mytheme.plymouth # Theme metadata
mytheme.script # Animation script
logo.png # Logo image (PNG with alpha)
```
### mytheme.plymouth
```ini
[Plymouth Theme]
Name=MyTheme
Description=Custom boot splash
ModuleName=script
[script]
ImageDir=/usr/share/plymouth/themes/mytheme
ScriptFile=/usr/share/plymouth/themes/mytheme/mytheme.script
```
### Script language key functions
```javascript
Window.SetBackgroundTopColor(r, g, b); // 0.0-1.0 floats
Window.SetBackgroundBottomColor(r, g, b);
image = Image("logo.png");
sprite = Sprite(image);
sprite.SetX(x); sprite.SetY(y); sprite.SetOpacity(0.0-1.0);
Plymouth.SetRefreshFunction(fn); // Called every frame
Plymouth.SetBootProgressFunction(fn); // fn(duration, progress)
Plymouth.SetDisplayPasswordFunction(fn); // fn(prompt, bullets)
Plymouth.SetQuitFunction(fn);
screen_w = Window.GetWidth();
screen_h = Window.GetHeight();
```
### Setting default theme
```bash
plymouth-set-default-theme mytheme
# OR manually:
ln -sf /usr/share/plymouth/themes/mytheme/mytheme.plymouth /etc/alternatives/default.plymouth
```
### Kernel params
- `splash` in GRUB_CMDLINE_LINUX_DEFAULT enables Plymouth
- `quiet` suppresses text that would overlay Plymouth
---
## 6. ISOLINUX/SYSLINUX
### Required files
| File | Source | Purpose |
|------|--------|---------|
| `isolinux.bin` | `/usr/lib/ISOLINUX/isolinux.bin` | BIOS bootloader |
| `ldlinux.c32` | `/usr/lib/syslinux/modules/bios/ldlinux.c32` | Core library (REQUIRED) |
| `menu.c32` | `/usr/lib/syslinux/modules/bios/menu.c32` | Text menu UI |
| `libutil.c32` | `/usr/lib/syslinux/modules/bios/libutil.c32` | Utility library |
| `boot.cat` | Auto-generated by xorriso | El Torito boot catalog |
| `isohdpfx.bin` | Extracted from working ISO | Hybrid MBR code |
### Configuration (isolinux.cfg)
```
UI menu.c32
PROMPT 0
TIMEOUT 50 # 5 seconds (units of 1/10 second)
DEFAULT install
MENU TITLE MY INSTALLER
MENU COLOR border 30;44 #40ffffff #00000000 std
MENU COLOR title 1;36;44 #ff00b7ff #00000000 std
MENU COLOR sel 7;37;40 #ffffffff #ff333333 std
MENU COLOR unsel 37;44 #ffaaaaaa #00000000 std
LABEL install
MENU LABEL Install System
KERNEL /live/vmlinuz
APPEND initrd=/live/initrd.img boot=live components quiet
MENU DEFAULT
```
### menu.c32 vs vesamenu.c32
- `menu.c32`: Text-mode menu. More compatible, no background image.
- `vesamenu.c32`: VESA graphical menu. Supports background PNG, but some hardware/VMs don't support VESA.
---
## 7. Testing Without Real Hardware
### QEMU UEFI boot
```bash
qemu-system-x86_64 \
-machine q35 \
-drive if=pflash,format=raw,readonly=on,file=/path/to/OVMF_CODE.fd \
-m 4G -smp 2 \
-boot d -cdrom image.iso \
-drive if=virtio,format=qcow2,file=test-disk.qcow2 \
-vga virtio -display default
```
### QEMU BIOS boot (sees ISOLINUX)
```bash
qemu-system-x86_64 \
-machine pc \
-m 4G -smp 2 \
-boot d -cdrom image.iso \
-drive if=virtio,format=qcow2,file=test-disk.qcow2 \
-vga virtio -display default
```
### Serial console capture
Add to QEMU: `-serial file:/tmp/serial.log`
Add to kernel params: `console=ttyS0,115200 console=tty0`
### ISO structure verification (no boot required)
```bash
MNT=$(mktemp -d)
sudo mount -o loop,ro image.iso $MNT
# Check all critical files
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
isolinux/isolinux.bin EFI/BOOT/BOOTX64.EFI boot/grub/grub.cfg; do
[ -e $MNT/$f ] && echo "OK: $f" || echo "MISSING: $f"
done
# Check initramfs for live-boot
INITRD=$(mktemp -d)
unmkinitramfs $MNT/live/initrd.img $INITRD
[ -e $INITRD/scripts/live ] && echo "live-boot: OK" || echo "live-boot: MISSING"
# Check kernel params
grep "boot=live" $MNT/boot/grub/grub.cfg && echo "params: OK"
sudo umount $MNT
```
---
## 8. Security Considerations for Custom ISOs
### Supply chain
- Pin the Debian mirror URL (don't use redirectors in production)
- Verify package signatures (debootstrap does this by default)
- Pin kernel and GRUB package versions for reproducibility
### Installer security
- Auto-install.sh runs as root — validate all inputs before path construction
- LUKS key generation must use CSPRNG (`/dev/urandom`, never `/dev/random` which blocks)
- Drop the LUKS key file after writing to crypttab (or store in root-only location with 0400)
### Boot security
- Secure Boot requires signed GRUB EFI binary (shim-signed package)
- Without Secure Boot, the unsigned BOOTX64.EFI works but users must disable Secure Boot in BIOS
- The MBR code (isohdpfx.bin) is not signed — Secure Boot only validates EFI path

View File

@@ -0,0 +1,204 @@
name: Build Archipelago ISO (dev)
on:
push:
branches: [main, dev-iso]
workflow_dispatch:
jobs:
build-iso:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install ISO build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
- name: Build backend
run: |
source $HOME/.cargo/env 2>/dev/null || true
export GIT_HASH=$(git rev-parse --short HEAD)
cargo build --release --manifest-path core/Cargo.toml
- name: Build frontend
run: cd neode-ui && npm ci && npm run build
- name: Type check frontend
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run frontend tests
run: cd neode-ui && npx vitest run
- name: Run container orchestration unit tests
run: |
source $HOME/.cargo/env 2>/dev/null || true
echo "=== Container crate tests ==="
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
echo ""
echo "=== Orchestration integration tests ==="
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
- name: Configure root podman for insecure registry
run: |
sudo mkdir -p /etc/containers/registries.conf.d
echo '[[registry]]
location = "80.71.235.15:3000"
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
- name: Build unbundled ISO
run: |
cd image-recipe
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
./build-auto-installer-iso.sh
- name: Smoke test ISO
run: |
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
if [ -z "$ISO" ]; then
echo "FAIL: No ISO produced"
exit 1
fi
echo "ISO: $ISO ($(du -h "$ISO" | cut -f1))"
# Mount and verify structure
MNT=$(mktemp -d)
sudo mount -o loop,ro "$ISO" "$MNT"
FAIL=0
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
isolinux/isolinux.bin isolinux/isolinux.cfg \
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
archipelago/auto-install.sh archipelago/rootfs.tar; do
if [ -e "$MNT/$f" ]; then
echo " OK: $f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
else
echo " MISSING: $f"
FAIL=1
fi
done
# Verify initrd has live-boot
INITRD_DIR=$(mktemp -d)
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
echo " OK: initrd has live-boot scripts"
else
echo " MISSING: live-boot scripts in initrd!"
echo " initrd scripts/: $(ls "$INITRD_DIR/scripts/" 2>/dev/null || ls "$INITRD_DIR/main/scripts/" 2>/dev/null)"
FAIL=1
fi
# Check GRUB config has boot=live
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg"; then
echo " OK: grub.cfg has boot=live"
else
echo " MISSING: boot=live in grub.cfg"
FAIL=1
fi
sudo umount "$MNT" 2>/dev/null
rmdir "$MNT" 2>/dev/null
sudo rm -r "$INITRD_DIR" 2>/dev/null
if [ "$FAIL" = "1" ]; then
echo "SMOKE TEST FAILED"
exit 1
fi
echo "SMOKE TEST PASSED"
- name: QEMU boot test
timeout-minutes: 5
continue-on-error: true
run: |
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
if [ -n "$ISO" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo "Running headless QEMU boot test..."
bash image-recipe/test-iso-qemu.sh "$ISO" 120
else
echo "Skipping QEMU test (no ISO or QEMU not available)"
fi
- name: Copy to Builds
run: |
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
if [ -n "$ISO" ]; then
DATE=$(date +%Y%m%d-%H%M)
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-dev-unbundled-${DATE}.iso"
sudo cp "$ISO" "$DEST"
sudo chown 1000:1000 "$DEST"
echo "ISO: archipelago-dev-unbundled-${DATE}.iso"
echo "Size: $(du -h "$DEST" | cut -f1)"
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
fi
- name: Build report
if: always()
continue-on-error: true
run: |
set +eo pipefail
echo "══════════════════════════════════════════"
echo "DEV ISO BUILD REPORT"
echo "══════════════════════════════════════════"
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Runner: $(hostname)"
echo ""
echo "── Artifacts ──"
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso 2>/dev/null | tail -3
echo ""
echo "── Rootfs contents check ──"
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
if [ -n "$ROOTFS" ]; then
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
else
echo " rootfs.tar not found in workspace"
fi
echo ""
echo "── ISO contents check ──"
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
if [ -n "$ISO" ]; then
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
ISO_MOUNT=$(mktemp -d)
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
echo " vmlinuz: $([ -f "$ISO_MOUNT/live/vmlinuz" ] && echo 'PRESENT' || echo 'MISSING')"
echo " initrd: $([ -f "$ISO_MOUNT/live/initrd.img" ] && echo 'PRESENT' || echo 'MISSING')"
echo " squashfs: $([ -f "$ISO_MOUNT/live/filesystem.squashfs" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/live/filesystem.squashfs" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " grub theme: $([ -d "$ISO_MOUNT/boot/grub/themes/archipelago" ] && echo 'PRESENT' || echo 'MISSING')"
sudo umount "$ISO_MOUNT" 2>/dev/null || true
else
echo " Could not mount ISO for inspection"
fi
rmdir "$ISO_MOUNT" 2>/dev/null || true
fi
echo "══════════════════════════════════════════"
- name: Fix workspace permissions
if: always()
run: |
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
sudo chmod -R u+rwX . 2>/dev/null || true
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true

View File

@@ -1,8 +1,6 @@
name: Build Archipelago ISO
on:
push:
branches: [main]
workflow_dispatch:
jobs:
@@ -14,34 +12,119 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 1
clean: false
- name: Build backend
run: |
source $HOME/.cargo/env 2>/dev/null || true
cargo build --release --manifest-path core/Cargo.toml
sudo rm -f /usr/local/bin/archipelago
sudo cp core/target/release/archipelago /usr/local/bin/archipelago
sudo systemctl restart archipelago 2>/dev/null || true
- name: Build frontend
run: |
echo "PWD: $(pwd)"
ls -la neode-ui/package.json || echo "neode-ui/package.json NOT FOUND"
cd neode-ui
npm ci
npm run build
rm -rf web/dist/neode-ui
cd neode-ui && npm ci && npm run build
- name: Type check frontend
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run frontend tests
run: cd neode-ui && npx vitest run
- name: Cache Debian Live ISO
run: |
WORK_DIR="image-recipe/build/auto-installer"
mkdir -p "$WORK_DIR"
CACHED="/home/archipelago/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
fi
- name: Configure root podman for insecure registry
run: |
sudo mkdir -p /etc/containers/registries.conf.d
echo '[[registry]]
location = "80.71.235.15:3000"
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
- name: Build unbundled ISO
run: |
cd image-recipe
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
./build-auto-installer-iso.sh
- name: Copy to Builds
run: |
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
if [ -n "$ISO" ]; then
DATE=$(date +%Y%m%d-%H%M)
sudo cp "$ISO" "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
sudo chown 1000:1000 "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
sudo cp "$ISO" "$DEST"
sudo chown 1000:1000 "$DEST"
echo "ISO: archipelago-unbundled-${DATE}.iso"
echo "Size: $(du -h "$DEST" | cut -f1)"
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
fi
- name: Build report
if: always()
continue-on-error: true
run: |
set +eo pipefail
echo "══════════════════════════════════════════"
echo "BUILD REPORT"
echo "══════════════════════════════════════════"
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Runner: $(hostname)"
echo ""
echo "── Artifacts ──"
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
echo ""
echo "── Rootfs contents check ──"
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
if [ -n "$ROOTFS" ]; then
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
else
echo " rootfs.tar not found in workspace"
fi
echo ""
echo "── ISO contents check ──"
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
if [ -n "$ISO" ]; then
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
ISO_MOUNT=$(mktemp -d)
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
sudo umount "$ISO_MOUNT" 2>/dev/null || true
else
echo " Could not mount ISO for inspection"
fi
rmdir "$ISO_MOUNT" 2>/dev/null || true
fi
echo "══════════════════════════════════════════"
- name: Fix workspace permissions
if: always()
run: |
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
sudo chmod -R u+rwX . 2>/dev/null || true
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true

View File

@@ -0,0 +1,60 @@
name: Container Orchestration Tests
on:
push:
branches: [dev-iso, main]
paths:
- 'core/archipelago/src/**'
- 'core/container/src/**'
- 'scripts/container-*.sh'
- 'scripts/reconcile-*.sh'
- 'scripts/image-versions.sh'
workflow_dispatch:
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
core/target
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
- name: Run orchestration unit tests
working-directory: core
run: |
echo "=== Container crate tests ==="
cargo test -p archipelago-container --no-fail-fast 2>&1
echo ""
echo "=== Orchestration integration tests ==="
cargo test --test orchestration_tests --no-fail-fast 2>&1
- name: Verify cargo check (full crate)
working-directory: core
run: cargo check --release 2>&1
smoke-tests:
runs-on: ubuntu-latest
needs: unit-tests
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Run container smoke tests on .228
env:
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
run: |
# Only run if SSH key exists (CI runner has deploy access)
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
bash scripts/dev-container-test.sh --once
else
echo "⚠ SSH key not available — skipping live smoke tests"
echo " To enable: add archipelago-deploy key to CI runner"
fi

View File

@@ -0,0 +1,72 @@
name: Post-Install Tests
on:
workflow_dispatch:
inputs:
target:
description: 'Target node IP (e.g. 192.168.1.198)'
required: true
default: '192.168.1.198'
password:
description: 'Node password (or "auto" for fresh install)'
required: false
default: 'auto'
jobs:
post-install-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run post-install tests on target
run: |
TARGET="${{ github.event.inputs.target }}"
PASSWORD="${{ github.event.inputs.password }}"
if [ "$PASSWORD" = "auto" ]; then
PASSWORD="testpass123!"
fi
echo "══════════════════════════════════════════"
echo "Running post-install tests on $TARGET"
echo "══════════════════════════════════════════"
# Copy test script to target and run
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
# Run tests (with sudo for service checks)
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
frontend-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: cd neode-ui && npm ci
- name: Type check
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run tests
run: cd neode-ui && npx vitest run
- name: Audit dependencies
run: cd neode-ui && npm audit --omit=dev

453
CLAUDE.md
View File

@@ -1,403 +1,130 @@
# CLAUDE.md — Archipelago (Archy) Project Guide
# CLAUDE.md — Archipelago (Archy)
## Project Overview
## Overview
Archipelago is a **Bitcoin Node OS** a bootable, self-sovereign personal server you flash to USB, install on hardware, and manage via a web UI. Similar to Umbrel/Start9/RaspiBlitz but custom-built with production-grade security.
Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI.
**Stack**: Rust backend + Vue 3 (Composition API) + TypeScript (strict) + Vite 7 + Tailwind CSS + Pinia + Podman
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
**Current version**: 0.1.0
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 12
**Version**: 0.1.0 | **Target**: x86_64 and ARM64
---
## BETA FREEZE — ACTIVE (2026-03-18)
## Beta Freeze (2026-03-18)
**Goal: Ship a flawless beta that works perfectly on every machine we install it on.**
**Phase 1: Feature Testing (internal) — WE ARE HERE**
We are in **beta stabilization mode**. The current feature set is LOCKED. Every session must push toward this goal.
Feature set is LOCKED. Only: bug fixes, security hardening, ISO build fixes, UI polish, testing.
No new features, no new apps, no new deps, no scope creep.
### Pipeline
```
PHASE 1: Feature Testing (internal) ← WE ARE HERE
↓ Gate: every feature works, bugs fixed, security hardened, ISO verified
PHASE 2: User Testing (real users on real hardware we don't control)
↓ Gate: user-reported issues resolved, telemetry shows stable fleet
PHASE 3: Beta Live (public release)
```
### What IS allowed
- Bug fixes for existing features
- Security hardening and testing
- Beta telemetry / node reporting (TASK-12 — needed for user testing)
- UI/layout rearrangements (moving things around, improving flow)
- Boot screen completion (FEATURE-4 — already in progress)
- Testing all features end-to-end on fresh installs
- Performance and reliability improvements to existing code
- ISO build hardening
### What is NOT allowed
- New features (watch-only wallet, mesh balance check, etc. are POST-BETA)
- New app integrations
- New backend modules or RPC endpoints (unless fixing existing bugs or beta telemetry)
- New dependencies (unless required for beta infrastructure)
- Scope creep of any kind
### Status tracking
- **Progress tracker**: `docs/BETA-PROGRESS.md` — updated every session
- **Beta checklist**: `docs/BETA-RELEASE-CHECKLIST.md` — the acceptance criteria
- **Master plan**: `docs/MASTER_PLAN.md` — phased roadmap (Phase 1/2/3)
### Session protocol
1. Read `docs/BETA-PROGRESS.md` at start of every session
2. Report current phase and status before starting work
3. Work only on current-phase items
4. Update `docs/BETA-PROGRESS.md` at end of every session with what changed
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
---
## Quick Reference
```bash
# Frontend local dev (mock backend on :5959, Vite on :8100)
cd neode-ui && npm start
# Deploy to live server (frontend + backend + restart services)
./scripts/deploy-to-target.sh --live
# Deploy to both servers
./scripts/deploy-to-target.sh --both
# Frontend build (outputs to web/dist/neode-ui/)
cd neode-ui && npm run build
# Type-check frontend
cd neode-ui && npm run type-check
# Rust checks (run on dev server, NOT macOS)
cargo clippy --all-targets --all-features
cargo fmt --all
cargo test --all-features
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
```
Dev server: `http://192.168.1.228` | Local frontend: `http://localhost:8100` (password: `password123`)
## Infrastructure
| What | Where |
|------|-------|
| Dev server | `192.168.1.228` (SSH key: `~/.ssh/archipelago-deploy`) |
| Secondary | `192.168.1.198` |
| Git remote | `git.tx1138.com` (remote name: `tx1138`) |
| App registry | `80.71.235.15:3000/archipelago/` (HTTP, insecure) |
| CI runner | act_runner on .228, workflow: `.gitea/workflows/build-iso.yml` |
| ISO builds | FileBrowser at `http://192.168.1.228:8083` → Builds/ |
| SSH creds | Gitignored `scripts/deploy-config.sh` |
| Web password | `password123` |
## Architecture
```
Debian 12 (Bookworm)
├── Podman (rootless containers)
├── Nginx (port 80 → proxies /rpc/, /ws/, /health to backend)
├── Rust Backend (core/) — binary on port 5678
│ ├── core/archipelago/ — Main binary, RPC endpoints
── core/container/ — PodmanClient, manifest parser, dependency resolver, health monitor
│ ├── core/security/ — AppArmor profiles, secrets manager, Cosign image verifier
│ ├── core/performance/ — Resource manager
│ └── core/parmanode/ — Parmanode compatibility layer
Debian 12
├── Podman (rootless, user archipelago)
├── Nginx (80/443 → backend, app proxies)
├── Rust Backend (core/) on 127.0.0.1:5678
│ ├── core/archipelago/ — Binary, RPC, auth, sessions
── core/container/ — PodmanClient, manifests, health
└── Vue.js UI (neode-ui/)
├── src/api/ — RPC client (rpc-client.ts), WebSocket, container client
├── src/stores/ — Pinia stores
├── src/views/ — Page components
── src/components/ — Reusable components
├── src/router/ — Vue Router
├── src/types/ — TypeScript type definitions
└── src/style.css — Global styles + Tailwind utilities
├── src/api/rpc-client.ts — All backend communication
├── src/stores/ — Pinia state
├── src/views/ — Pages
── src/style.css — ALL styling (global classes only)
```
### Data Paths (Server)
**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary)
- App data: `/var/lib/archipelago/{app-id}/`
- Secrets: `/var/lib/archipelago/secrets/{app-id}/` (encrypted)
- Frontend: `/opt/archipelago/web-ui/`
- Backend binary: `/usr/local/bin/archipelago`
- Systemd service: `/etc/systemd/system/archipelago.service`
- Nginx config: `/etc/nginx/sites-available/archipelago`
## Critical Rules
## CRITICAL Workflow Rules
1. **Never build Rust on macOS** — deploy script handles cross-compilation via rsync + remote build
2. **Always deploy after changes**`./scripts/deploy-to-target.sh --live`
3. **Frontend builds to `web/dist/neode-ui/`** — not `neode-ui/dist/`
4. **Container images**: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, never hardcoded registry paths.
5. **Type-check before committing**`cd neode-ui && npx vue-tsc -b --noEmit`
### 1. NEVER Build Rust on macOS for Linux
## Frontend
Always rsync source to the Linux dev server and build there. Building on macOS and copying the binary causes Exec format errors.
- `<script setup lang="ts">` always — no Options API
- Global CSS in `style.css`**never inline Tailwind**
- `.glass-button` for ALL buttons — `.gradient-button` is BANNED
- `.glass-card` for containers, `.path-option-card` for interactive cards
- `translateZ(0)` + `isolation: isolate` on glass elements (Chromium compositor fix)
- Pinia for state, typed RPC client, handle loading/error/empty states
## Backend (Rust)
- No `unwrap()`/`expect()` — use `?` with `.context()`
- `tracing` for logging, never `println!` or log secrets
- Backend binds `127.0.0.1` only — nginx handles external access
- Validate all input before path construction — reject `..`, `/`, null bytes
- `tokio` runtime, timeouts on all external ops
## Security (Post-Pentest)
- RBAC: explicit method allowlists, never prefix matching
- Session cookies: `SameSite=Lax; HttpOnly; Path=/`
- Rate-limit auth endpoints, rotate tokens after privilege escalation
- Validate redirect URLs with `isLocalRedirect()`, never `v-html` with user input
- Container security: drop ALL caps, add only required, `no-new-privileges`, memory limits, health checks
- See `.claude/rules/` for detailed crypto, API, container, and Bitcoin rules
## ISO Build & CI
CI builds on every push to `main` via git.tx1138.com Actions.
```bash
# Deploy does this automatically:
./scripts/deploy-to-target.sh --live
# Manual build on .228:
ssh archipelago@192.168.1.228
cd ~/archy/image-recipe
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
```
### 2. Always Deploy After Changes
After editing code (frontend, backend, scripts, or configs), deploy to the live server. Do not leave deployment to the user.
### 3. Frontend Build Output Path
Frontend builds to `web/dist/neode-ui/` — NOT `neode-ui/dist/`.
### 4. Deploy-Test-Fix Loop
1. Make the change
2. Deploy with `./scripts/deploy-to-target.sh --live`
3. Test at http://192.168.1.228
4. If broken, fix and redeploy — repeat until working
5. End loop only when everything works
### 5. SSH Access
- **Primary**: `archipelago@192.168.1.228` — password: `EwPDR8q45l0Upx@`
- **Secondary**: `archipelago@192.168.1.198`
- Credentials stored in gitignored `scripts/deploy-config.sh`
**Debugging fresh installs** — SSH in and check:
```bash
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
cat /var/log/archipelago-install.log # Full installer output
cat /var/log/archipelago-first-boot-diagnostics.log # Service status, nginx, LUKS, etc.
sudo archipelago-diagnostics # Re-run diagnostics anytime
```
## Frontend Rules (Vue.js + TypeScript)
### Component Standards
- **Always** `<script setup lang="ts">` — never Options API, never plain JS
- **Pinia** for all state management — focused single-purpose stores
- **TypeScript strict mode** — no `any`, use `unknown` or proper types
- Export types from dedicated `.types.ts` files
- Use type guards for runtime type checking
### Styling — Global Classes Only
- **ALWAYS** create global utility classes in `neode-ui/src/style.css`
- **NEVER** use inline Tailwind classes directly in components
- Use semantic class names: `.glass-card`, `.glass-button`, `.gradient-button`, `.path-option-card`
### API Client Rules
- Use `@/api/rpc-client.ts` for RPC calls, `@/api/container-client.ts` for containers
- **NEVER** hardcode API endpoints — use environment variables
- Handle loading states, error states, retry logic for all async operations
### CSS Class Hierarchy
| Class | Use | Hover |
|-------|-----|-------|
| `.path-option-card` | Section containers, interactive cards (Settings-style) | Lifts -2px |
| `.glass-card` | Content containers, modals, panels | No |
| `.info-card` | Status badges, metric displays | No |
| `.info-card-button` | Action buttons inside info sections | Lifts, brightens |
| `bg-black/20 rounded-xl border border-white/10` | Info sub-cards inside sections | No |
| `bg-white/5` | Simple read-only info rows | No |
| `.glass-button` | ALL buttons (primary and secondary) | Subtle brighten |
| `.path-action-button` | Large action buttons (Logout, Continue) | Lifts -2px |
### BANNED Classes — Do NOT Use
- **`.gradient-button`** — REMOVED. Use `.glass-button` instead. The gradient style breaks the clean glass aesthetic.
- **`.gradient-card`** / **`.gradient-card-dark`** — REMOVED. Use `.glass-card` or `.path-option-card` instead.
### Design Tokens
- **Font**: Avenir Next (primary), Montserrat (`font-archipelago`)
- **Spacing**: 4px grid system, 16px default padding
- **Glassmorphism**: `background: rgba(0,0,0,0.60)`, `backdrop-filter: blur(24px)`, `inset 0 1px 0 rgba(255,255,255,0.22)`
- **Transitions**: `all 0.3s ease` standard, `translateY(-2px)` hover, `translateY(1px)` active
- **Accent orange** (Bitcoin): `#fb923c``#f59e0b`
- **Green** (success): `#4ade80` | **Red** (danger): `#ef4444` | **Blue** (info): `#3b82f6`
- **Text**: `rgba(255,255,255,0.9)` primary, `rgba(255,255,255,0.6-0.7)` muted
### Tailwind Custom Values
- Blur: `backdrop-blur-glass` (18px), `backdrop-blur-glass-strong` (24px)
- Colors: `glass-dark` (0,0,0,0.35), `glass-darker` (0,0,0,0.6), `glass-border` (255,255,255,0.18)
- Shadows: `shadow-glass`, `shadow-glass-inset`
## Backend Rules (Rust)
### Error Handling
- **No `unwrap()` or `expect()` in production code** — use `?` operator
- `thiserror` for library error types, `anyhow` for application errors
- Custom error types per module: `{module}::Error`
- Include context: `.context("What failed and why")`
### RPC Endpoints
- Use `rpc_toolkit::command` macro for all endpoints
- Use `#[context] ctx: RpcContext` for context
- Return `Result<T, Error>` — validate all inputs before processing
### Async & Runtime
- `tokio` runtime only — never mix with other async runtimes
- Set timeouts on all external operations
- Use `select!` for racing futures with timeouts
- Handle shutdown gracefully with cancellation tokens
### Code Organization
- New modules in `core/{module-name}/`, add to `core/Cargo.toml` members
- `snake_case` for all modules/files
- Run `cargo clippy --all-targets --all-features` and `cargo fmt --all` before commits
### Logging
- Use `tracing` for structured logging — never `println!`
- Never log secrets, passwords, keys, or tokens
- Include context: `tracing::info!(user_id = %id, "Action")`
## Container & Security
### App Manifests
- All manifests in `apps/{app-id}/manifest.yml`
- Follow spec in `docs/app-manifest-spec.md`
- Use `archipelago_container::PodmanClient`**NEVER** call Docker directly
### Security Requirements (Non-Negotiable)
- **ALWAYS** `readonly_root: true` unless explicitly needed
- **ALWAYS** drop all capabilities, add only required ones
- **ALWAYS** run as non-root user (UID > 1000)
- **ALWAYS** `no-new-privileges: true`
- **NEVER** use `latest` tag — pin specific image versions
- **NEVER** hardcode secrets — use `core/security/secrets_manager.rs`
### App Icons
Single source of truth: `neode-ui/public/assets/img/app-icons/`
Naming: `{app-id}.{png|webp|svg}` — do not duplicate elsewhere.
## Security Standards (Post-Pentest — Mandatory)
These rules come from a full penetration test (33 findings, all remediated). Follow them for ALL new code.
### Backend (Rust)
- **Backend binds to 127.0.0.1 ONLY** — never `0.0.0.0`. All external access goes through nginx.
- **Validate ALL user input before path construction** — reject `..`, `/`, `\`, null bytes. Use the existing `validate_app_id()` pattern in `tor.rs`.
- **Never pass user input to `sudo` commands** — if unavoidable, validate strictly against an allowlist of characters `[a-zA-Z0-9_-]`.
- **Every HTTP endpoint that returns sensitive data MUST check authentication** — use `self.is_authenticated(&headers).await` or be in `UNAUTHENTICATED_METHODS` with justification.
- **Rate-limit authentication endpoints** — `extract_client_ip()` must only trust `X-Real-IP` from the loopback interface (127.0.0.1).
- **Federation messages require ed25519 signatures** — never accept unsigned peer-joined messages.
- **RBAC: use explicit allowlists, not prefix matching** — `method.starts_with("node.")` is BANNED. List exact methods per role.
- **Session cookies: `SameSite=Lax; HttpOnly; Path=/`** — `Strict` breaks iframe app fetches. `Lax` still prevents CSRF on POST.
- **Destructive operations require password re-verification** — factory reset, onboarding reset, identity export.
- **Remember-me secrets: use `OsRng` random bytes** — never derive from `/etc/machine-id` or other public data.
- **Rotate session tokens after privilege escalation** — TOTP verification must issue a new token, invalidating the pending one.
- **Tar archive extraction: validate every entry path** — never use `archive.unpack()`. Iterate entries and verify no `..` components or paths escaping the target directory.
### Frontend (Vue/TypeScript)
- **Validate redirect URLs** — use `isLocalRedirect()` from `router/index.ts` before any `window.location.href` assignment. Reject `javascript:`, protocol-relative (`//`), and external URLs.
- **Never use `v-html` with user input** — if unavoidable, always sanitize with `DOMPurify.sanitize()`.
- **CSP: no `unsafe-inline` in `script-src`** — Vite builds don't need it. Keep `unsafe-inline` only in `style-src` for Tailwind.
### Nginx
- **Session validation: `$cookie_session` (not `$cookie_session_id`)** — cookie name must match the Rust backend's `session=` cookie.
- **Prefer `auth_request` over cookie-presence checks** — `if ($cookie_session = "")` only checks presence, not validity. For sensitive endpoints, use nginx `auth_request` to validate against the backend.
- **All `/app/*` proxies are unauthenticated at nginx level** — each app must handle its own auth. Never expose apps with default credentials (change Grafana `admin/admin` on first boot, etc.).
### SSRF Prevention
- **Validate all user-supplied URLs** — require `https://` scheme, reject private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7).
- **Disable redirect following** — use `redirect(Policy::none())` on reqwest clients that fetch user-supplied URLs.
- **Onion addresses: validate v3 format** — exactly 56 base32 `[a-z2-7]` chars + `.onion`.
- **Webhook URLs: parse with `Url::parse`** — don't split on `:` for host extraction (breaks IPv6).
### Container Security
- **Memory limits on every container** — use `--memory=$(mem_limit <name>)` pattern from `first-boot-containers.sh`. Prevents one container from OOM-killing the system.
- **Health checks on every container** — define via `--health-cmd` in `podman run`.
- **User-stopped tracking** — when a user stops a container via UI, record in `user-stopped.json` so crash recovery and health monitor don't auto-restart it.
## Code Quality
- Zero compiler warnings (Rust and TypeScript)
- Zero linter errors (clippy, eslint)
- Functions under 50 lines, single responsibility
- Comment WHY not WHAT — code should be self-documenting
- Remove dead code entirely — never comment it out
- No `TODO`/`FIXME` in commits — fix now or create issues
- Workspace-relative paths only — **NEVER** hardcode `/Users/dorian/...`
## Git Conventions
### Commit Format
```
type: description
```
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
### Rules
- Atomic commits — one logical change per commit
- `main` branch always production-ready
- Feature branches: `feature/description`, bug fixes: `fix/description`
- Never commit secrets, `.env` files, or credentials
- Tag releases: `v1.2.3` (SemVer)
**Kiosk**: X11 on VT7, console on VT1. `Ctrl+Alt+F1` for terminal, `Ctrl+Alt+F7` for kiosk.
Toggle: `sudo archipelago-kiosk enable|disable|toggle`
## App Integration Checklist
When adding or fixing apps, **every file below must be checked**. Missing any one causes failures on fresh installs.
When adding/fixing apps, check ALL of these:
- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps
- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry
- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS)
- `scripts/image-versions.sh` — pinned image version
- `scripts/first-boot-containers.sh` — first boot creation
- `scripts/deploy-to-target.sh` — deploy logic
### Backend (Rust)
## Git
- [ ] `core/archipelago/src/api/rpc/package.rs``get_app_config()`: ports, volumes, env vars, custom args
- [ ] `core/archipelago/src/api/rpc/package.rs``needs_archy_net`: add if app needs container DNS
- [ ] `core/archipelago/src/api/rpc/package.rs``get_app_capabilities()`: add required caps (CHOWN, etc.)
- [ ] `core/archipelago/src/api/rpc/package.rs` — dependency checks (e.g., electrs requires bitcoin)
- [ ] `core/archipelago/src/container/docker_packages.rs``get_app_metadata()`: title, description, icon, repo
- [ ] `core/archipelago/src/container/docker_packages.rs` — UI address mapping (e.g., `http://localhost:50002`)
### Frontend (Vue)
- [ ] `neode-ui/src/views/Marketplace.vue``getCuratedAppList()`: marketplace entry with dockerImage
- [ ] `neode-ui/src/stores/appLauncher.ts` — port-to-proxy mapping (if app has custom UI port)
- [ ] `neode-ui/src/views/AppDetails.vue` — route ID mapping (if app ID differs from container name)
### Nginx
- [ ] `image-recipe/configs/nginx-archipelago.conf``/app/{id}/` proxy in HTTP block
- [ ] `image-recipe/configs/snippets/archipelago-https-app-proxies.conf``/app/{id}/` proxy in HTTPS block
- [ ] Any custom status endpoints (e.g., `/electrs-status`) proxied before the SPA catch-all
### Deploy & First Boot
- [ ] `scripts/deploy-to-target.sh` — container creation/update logic
- [ ] `scripts/first-boot-containers.sh` — container created on fresh ISO install
- [ ] Custom UI containers (e.g., electrs-ui): built and started in both deploy and first-boot
### ISO Build
- [ ] `image-recipe/build-auto-installer-iso.sh``CAPTURE_PATTERNS`: image captured from live server
- [ ] `image-recipe/build-auto-installer-iso.sh``CONTAINER_IMAGES`: fallback image pulled from registry
- [ ] `image-recipe/build-auto-installer-iso.sh` — docker UI source files bundled for build fallback
- [ ] `image-recipe/build-auto-installer-iso.sh` — installer copies files to target disk
### Runtime Verification
- [ ] Test the app UI loads on its configured port
- [ ] Auto-connect dependencies (Bitcoin RPC, LND, etc.) — apps must work out of the box
- [ ] Most apps launch in iframe; BTCPay (23000) and Home Assistant (8123) open in new tab (X-Frame-Options)
## ISO Build
Build on the target server (has all dependencies):
```bash
ssh archipelago@192.168.1.228
cd ~/archy/image-recipe
sudo ./build-auto-installer-iso.sh
# Result: results/archipelago-auto-installer-*.iso
```
After testing on live server, always update ISO build to include changes. Sync system configs:
- `archipelago.service``image-recipe/configs/`
- `nginx-archipelago.conf``image-recipe/configs/`
## Key Documentation
- `docs/architecture.md` — System architecture
- `docs/current-state.md` — Current development phase
- `docs/development-setup.md` — Local dev setup
- `docs/app-manifest-spec.md` — YAML manifest spec
- `BUILD-GUIDE.md` — ISO build guide
- `DEPLOYMENT.md` — Deployment details
- `CHANGELOG.md` — Version history
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
Push to: `git push tx1138 main`

15
core/Cargo.lock generated
View File

@@ -84,7 +84,6 @@ version = "1.2.0-alpha"
dependencies = [
"anyhow",
"archipelago-container",
"archipelago-parmanode",
"archipelago-performance",
"archipelago-security",
"argon2",
@@ -160,20 +159,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "archipelago-parmanode"
version = "0.1.0"
dependencies = [
"anyhow",
"archipelago-container",
"log",
"serde",
"serde_yaml",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "archipelago-performance"
version = "0.1.0"

View File

@@ -4,7 +4,6 @@ resolver = "2"
members = [
"archipelago",
"container",
"parmanode",
"performance",
"security",
]

View File

@@ -34,7 +34,7 @@ futures-util = "0.3"
archipelago-container = { path = "../container" }
archipelago-security = { path = "../security" }
archipelago-performance = { path = "../performance" }
archipelago-parmanode = { path = "../parmanode" }
# Database (optional for now - can use SQLite or skip)
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }

View File

@@ -16,8 +16,10 @@ impl RpcHandler {
if !is_setup {
// Dev mode: allow default password so UI can log in without running setup
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
tracing::info!("[onboarding] login via dev default password");
return Ok(serde_json::Value::Null);
}
tracing::warn!("[onboarding] login attempt before setup complete");
return Err(anyhow::anyhow!(
"User not set up. Please complete setup first."
));
@@ -25,13 +27,16 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
tracing::warn!("[onboarding] login failed — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::info!("[onboarding] login successful");
Ok(serde_json::Value::Null)
}
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
tracing::info!("[onboarding] logout");
Ok(serde_json::Value::Null)
}
@@ -78,6 +83,7 @@ impl RpcHandler {
// Prevent re-setup if already set up
let is_setup = self.auth_manager.is_setup().await?;
if is_setup {
tracing::warn!("[onboarding] setup rejected — already set up");
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
}
@@ -88,20 +94,24 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
if password.len() < 8 {
tracing::warn!("[onboarding] setup rejected — password too short");
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
}
self.auth_manager.setup_user(password).await?;
tracing::info!("[onboarding] user setup complete");
Ok(serde_json::json!(true))
}
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
self.auth_manager.complete_onboarding().await?;
tracing::info!("[onboarding] onboarding marked complete");
Ok(serde_json::json!(true))
}
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
let complete = self.auth_manager.is_onboarding_complete().await?;
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
Ok(serde_json::json!(complete))
}
@@ -117,10 +127,12 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
tracing::warn!("[onboarding] reset rejected — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
self.auth_manager.reset_onboarding().await?;
tracing::info!("[onboarding] onboarding reset");
Ok(serde_json::json!(true))
}
}

View File

@@ -38,6 +38,7 @@ impl RpcHandler {
"package.stop" => self.handle_package_stop(params).await,
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.handle_package_uninstall(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
"bundled-app-start" => self.handle_bundled_app_start(params).await,
@@ -392,7 +393,7 @@ impl RpcHandler {
"status": status,
"crash_recovery_complete": recovery_complete,
"uptime_seconds": uptime,
"version": env!("CARGO_PKG_VERSION"),
"version": format!("{}-{}", env!("CARGO_PKG_VERSION"), option_env!("GIT_HASH").unwrap_or("dev")),
}))
}
}

View File

@@ -11,11 +11,14 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.setup",
"auth.onboardingComplete",
"health",
// Server readiness check (Login.vue polls this before showing form)
"server.echo",
// Onboarding flow (before user has a session — DID creation, signing, backup)
"node.did",
"node.signChallenge",
"node.nostr-pubkey",
"node.createBackup",
"identity.create",
"identity.verify",
"identity.resolve-did",
// Onboarding restore (before user account exists)

View File

@@ -144,8 +144,21 @@ impl RpcHandler {
Arc::clone(&self.mesh_service)
}
fn cookie_suffix(&self) -> &'static str {
if self.config.dev_mode { "" } else { "; Secure" }
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
// Only set Secure flag when the original request was over HTTPS.
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
// On LAN HTTP, Secure flag prevents browsers from sending cookies back.
if self.config.dev_mode {
return "";
}
if let Some(proto) = headers.get("x-forwarded-proto") {
if proto.as_bytes() == b"https" {
tracing::debug!("[onboarding] cookie: Secure (X-Forwarded-Proto: https)");
return "; Secure";
}
}
tracing::debug!("[onboarding] cookie: no Secure flag (HTTP or no X-Forwarded-Proto)");
""
}
pub async fn handle(
@@ -155,6 +168,7 @@ impl RpcHandler {
// Extract session cookie before consuming the request
let (parts, body) = req.into_parts();
let session_token = session::extract_session_cookie(&parts.headers);
let secure_suffix = self.cookie_suffix_for_request(&parts.headers);
let body_bytes = hyper::body::to_bytes(body).await
.context("Failed to read body")?;
@@ -203,8 +217,15 @@ impl RpcHandler {
}
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF check if session was just auto-restored from remember-me.
if !is_unauthenticated && new_session_cookies.is_none() {
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(rpc_req.method.as_str(),
"node-messages-received" | "server.echo" | "server.get-state"
| "system.stats" | "tor.status"
| "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings"
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts
.headers
.get("x-csrf-token")
@@ -231,12 +252,24 @@ impl RpcHandler {
};
if !csrf_valid {
tracing::warn!(
method = %rpc_req.method,
has_session = session_token.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF validation failed — rejecting RPC call"
);
// Debug: log expected vs received for diagnosis
if let (Some(token), Some(header)) = (&session_token, &csrf_header) {
let expected = derive_csrf_token(token).await;
tracing::warn!(
method = %rpc_req.method,
session_prefix = %&token[..8.min(token.len())],
csrf_prefix = %&header[..8.min(header.len())],
expected_prefix = %&expected[..8.min(expected.len())],
"403 CSRF mismatch — session/csrf/expected prefixes shown"
);
} else {
tracing::warn!(
method = %rpc_req.method,
has_session = session_token.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF validation failed — rejecting RPC call"
);
}
return Ok(self.error_response(403, "CSRF token missing or invalid", StatusCode::FORBIDDEN));
}
}
@@ -327,6 +360,7 @@ impl RpcHandler {
&login_params,
&new_session_cookies,
client_ip,
secure_suffix,
).await;
Ok(response)
@@ -372,6 +406,7 @@ impl RpcHandler {
login_params: &Option<serde_json::Value>,
new_session_cookies: &Option<(String, String)>,
client_ip: std::net::IpAddr,
secure_suffix: &str,
) {
// Track failed login attempts for rate limiting
if method == "auth.login" && rpc_resp.error.is_some() {
@@ -391,8 +426,8 @@ impl RpcHandler {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
let token = self.session_store.create_pending(secret).await;
let csrf_token = derive_csrf_token(&token).await;
self.set_session_cookie(response, &token);
self.set_csrf_cookie(response, &csrf_token);
self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
let totp_body = serde_json::json!({
"result": { "requires_totp": true },
"error": null
@@ -406,9 +441,9 @@ impl RpcHandler {
let token = self.session_store.create().await;
let csrf_token = derive_csrf_token(&token).await;
let remember_token = self.session_store.create_remember_token().await;
self.set_session_cookie(response, &token);
self.set_csrf_cookie(response, &csrf_token);
self.set_remember_cookie(response, &remember_token);
self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix);
}
}
@@ -426,9 +461,9 @@ impl RpcHandler {
if let Some(new_token) = new_token_opt {
let csrf_token = derive_csrf_token(&new_token).await;
let remember_token = self.session_store.create_remember_token().await;
self.set_session_cookie(response, &new_token);
self.set_csrf_cookie(response, &csrf_token);
self.set_remember_cookie(response, &remember_token);
self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix);
// Strip the token from the response body
if let Some(result) = rpc_resp.result.as_mut() {
if let Some(obj) = result.as_object_mut() {
@@ -445,8 +480,8 @@ impl RpcHandler {
if let Some(token) = session_token {
let new_token = self.session_store.rotate(token).await;
let csrf_token = derive_csrf_token(&new_token).await;
self.set_session_cookie(response, &new_token);
self.set_csrf_cookie(response, &csrf_token);
self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
}
}
@@ -455,7 +490,6 @@ impl RpcHandler {
if let Some(token) = session_token {
self.session_store.remove(token).await;
}
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
@@ -468,29 +502,29 @@ impl RpcHandler {
// If session was auto-restored from remember-me, set new cookies
if let Some((new_session, new_csrf)) = new_session_cookies {
self.set_session_cookie(response, new_session);
self.set_csrf_cookie(response, new_csrf);
self.set_session_cookie(response, new_session, secure_suffix);
self.set_csrf_cookie(response, new_csrf, secure_suffix);
}
}
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str) {
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str, secure_suffix: &str) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())),
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, secure_suffix)),
);
}
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str) {
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str, secure_suffix: &str) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())),
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, secure_suffix)),
);
}
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str) {
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str, secure_suffix: &str) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())),
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, secure_suffix)),
);
}
}

View File

@@ -48,12 +48,23 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
match app_id {
// Apps that need user switching and file ownership changes
"nextcloud" | "homeassistant" | "home-assistant" | "btcpay-server" | "btcpayserver"
// Home Assistant needs NET_RAW for DHCP discovery
"homeassistant" | "home-assistant" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
],
"nextcloud" | "btcpay-server" | "btcpayserver"
| "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Nginx Proxy Manager needs to bind low ports
"nginx-proxy-manager" => vec![
@@ -62,7 +73,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETGID".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Bitcoin and Lightning need file ownership ops + DAC_OVERRIDE for data dir access
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
| "fedimint-gateway" => vec![
"--cap-add=CHOWN".to_string(),
@@ -70,6 +81,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
"vaultwarden" => vec![
@@ -97,8 +109,21 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
],
// Minimal apps (searxng, filebrowser, etc.) need no extra caps
_ => vec![],
// FileBrowser needs DAC_OVERRIDE for volume access + NET_BIND_SERVICE to bind port 80
"filebrowser" => vec![
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Default: standard capabilities for rootless podman containers
// Most apps need file ownership + port binding to function correctly
_ => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
}
}
@@ -256,6 +281,66 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
}
/// Get all container names for an app (handles multi-container apps like mempool)
/// All known container name variants for a given app ID.
/// This is the single source of truth for container name resolution.
/// Every name that could appear in `podman ps` for this app must be listed here.
pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
let base = package_id.to_string();
let archy = format!("archy-{}", package_id);
match package_id {
// Bitcoin: multiple historical names
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
"bitcoin-knots".into(), "bitcoin".into(), "bitcoin-core".into(),
"archy-bitcoin-knots".into(), "archy-bitcoin".into(),
"bitcoin-ui".into(),
],
// LND + UI
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
// Electrumx: multiple aliases
"electrumx" | "electrs" | "mempool-electrs" => vec![
"electrumx".into(), "electrs".into(), "mempool-electrs".into(),
"archy-electrumx".into(), "archy-electrs-ui".into(),
],
// Mempool: multi-container stack
"mempool" | "mempool-web" => vec![
"mempool".into(), "mempool-web".into(), "mempool-api".into(),
"archy-mempool-web".into(), "archy-mempool-api".into(),
"archy-mempool-db".into(), "mysql-mempool".into(),
],
// BTCPay: multi-container + multiple aliases
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
"btcpay-server".into(), "btcpay".into(), "btcpayserver".into(),
"archy-btcpay".into(), "archy-btcpay-db".into(), "archy-nbxplorer".into(),
],
// Home Assistant: two naming conventions
"homeassistant" | "home-assistant" => vec![
"homeassistant".into(), "home-assistant".into(),
"archy-homeassistant".into(),
],
// Fedimint: multiple related containers
"fedimint" => vec![
"fedimint".into(), "fedimintd".into(),
"fedimint-ui".into(), "archy-fedimint".into(),
"fedimint-gateway".into(),
],
"fedimint-gateway" => vec!["fedimint-gateway".into()],
// Immich: multi-container
"immich" => vec![
"immich_postgres".into(), "immich_redis".into(), "immich_server".into(),
],
// Penpot: multi-container
"penpot" | "penpot-frontend" => vec![
"penpot-postgres".into(), "penpot-valkey".into(),
"penpot-backend".into(), "penpot-exporter".into(), "penpot-frontend".into(),
],
// Default: exact name + archy- prefix
_ => vec![base, archy],
}
}
/// Find all running/stopped containers that belong to a given app.
/// Uses the canonical name list from all_container_names().
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
validate_app_id(package_id)?;
let output = tokio::process::Command::new("podman")
@@ -266,48 +351,11 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
let patterns: Vec<String> = match package_id {
"mempool" | "mempool-web" => {
vec![
"electrumx".into(),
"mempool-electrs".into(),
"mempool-api".into(),
"archy-mempool-api".into(),
"archy-mempool-web".into(),
"mempool".into(),
"archy-mempool-db".into(),
"mysql-mempool".into(),
]
}
"fedimint" => vec![
"fedimint".into(),
"fedimint-ui".into(),
"archy-fedimint".into(),
"fedimint-gateway".into(),
],
"fedimint-gateway" => vec!["fedimint-gateway".into()],
"immich" => vec![
"immich_postgres".into(),
"immich_redis".into(),
"immich_server".into(),
],
"penpot" | "penpot-frontend" => vec![
"penpot-postgres".into(),
"penpot-valkey".into(),
"penpot-backend".into(),
"penpot-exporter".into(),
"penpot-frontend".into(),
],
_ => vec![package_id.to_string(), format!("archy-{}", package_id)],
};
let patterns = all_container_names(package_id);
let mut result = Vec::new();
for name in all {
for pat in &patterns {
if name == pat {
result.push(name.to_string());
break;
}
if patterns.iter().any(|p| p == name) {
result.push(name.to_string());
}
}
Ok(result)
@@ -378,9 +426,21 @@ pub(super) async fn get_app_config(
"8080:8080".to_string(),
],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec!["BITCOIN_ACTIVE=1".to_string()],
None,
vec![],
None,
Some(vec![
"--bitcoin.active".to_string(),
"--bitcoin.mainnet".to_string(),
"--bitcoin.node=bitcoind".to_string(),
format!("--bitcoind.rpcuser={}", rpc_user),
format!("--bitcoind.rpcpass={}", rpc_pass),
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
"--bitcoind.zmqpubrawblock=tcp://bitcoin-knots:28332".to_string(),
"--bitcoind.zmqpubrawtx=tcp://bitcoin-knots:28333".to_string(),
"--rpclisten=0.0.0.0:10009".to_string(),
"--restlisten=0.0.0.0:8080".to_string(),
"--listen=0.0.0.0:9735".to_string(),
]),
),
"btcpay-server" | "btcpayserver" => (
vec!["23000:49392".to_string()],
@@ -469,7 +529,7 @@ pub(super) async fn get_app_config(
),
"searxng" => (
vec!["8888:8080".to_string()],
vec![],
vec!["/var/lib/archipelago/searxng:/etc/searxng".to_string()],
vec![],
None,
None,
@@ -562,10 +622,18 @@ pub(super) async fn get_app_config(
.unwrap_or(8083);
(
vec![format!("{}:80", host_port)],
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
vec![
"/var/lib/archipelago/filebrowser:/srv".to_string(),
"/var/lib/archipelago/filebrowser-data:/data".to_string(),
],
vec![],
None,
None,
Some(vec![
"--database=/data/database.db".to_string(),
"--root=/srv".to_string(),
"--address=0.0.0.0".to_string(),
"--port=80".to_string(),
]),
)
}
"nginx-proxy-manager" => (
@@ -628,7 +696,11 @@ pub(super) async fn get_app_config(
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
],
None,
None,
Some(vec![
"--data-dir".to_string(),
"/data".to_string(),
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
]),
),
"fedimint-gateway" => (
vec!["8176:8176".to_string(), "9737:9737".to_string()],

View File

@@ -173,6 +173,22 @@ impl RpcHandler {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
}
// Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng";
let settings_path = format!("{}/settings.yml", searx_dir);
if !tokio::fs::try_exists(&settings_path).await.unwrap_or(false) {
let secret: [u8; 32] = rand::random();
let secret_hex = hex::encode(secret);
let settings = format!(
"use_default_settings: true\ngeneral:\n instance_name: Archipelago Search\nserver:\n secret_key: \"{}\"\n bind_address: \"0.0.0.0\"\n port: 8080\n limiter: false\nui:\n default_theme: simple\n",
secret_hex
);
let _ = tokio::fs::write(&settings_path, settings).await;
info!("Created SearXNG settings.yml");
}
}
// Port mappings (skip for host-network containers)
if !is_tailscale {
for port in &ports {
@@ -228,6 +244,11 @@ impl RpcHandler {
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
// Rollback: remove partially created container
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
@@ -235,6 +256,43 @@ impl RpcHandler {
.trim()
.to_string();
// Post-start health verification: wait up to 30s for container to be running
for i in 0..6u32 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "running" {
break;
}
if state == "exited" {
// Container crashed immediately — get logs for diagnosis
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "20", container_name])
.output()
.await;
let log_output = logs
.map(|o| String::from_utf8_lossy(&o.stderr).to_string())
.unwrap_or_default();
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
return Err(anyhow::anyhow!(
"Container {} exited immediately after start. Logs: {}",
container_name,
log_output.chars().take(500).collect::<String>()
));
}
}
if i == 5 {
debug!("Container {} health check timeout (30s) — continuing anyway", container_name);
}
}
// Post-install hooks
self.run_post_install_hooks(package_id).await;
@@ -301,11 +359,43 @@ impl RpcHandler {
Ok(has_local_fallback)
}
/// Stream `podman pull` while updating install progress state.
/// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s).
async fn pull_image_with_progress(
&self,
package_id: &str,
docker_image: &str,
) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
for attempt in 1..=MAX_ATTEMPTS {
match self.do_pull_image(package_id, docker_image).await {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
let delay = BACKOFF_SECS[(attempt - 1) as usize];
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
docker_image, attempt, MAX_ATTEMPTS, e, delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
Err(e) => {
self.clear_install_progress(package_id).await;
return Err(e.context(format!(
"Failed to pull {} after {} attempts",
docker_image, MAX_ATTEMPTS
)));
}
}
}
unreachable!()
}
/// Single image pull attempt with progress streaming.
async fn do_pull_image(
&self,
package_id: &str,
docker_image: &str,
) -> Result<()> {
debug!("Pulling image: {}", docker_image);
self.set_install_progress(package_id, 0, 0).await;
@@ -336,8 +426,20 @@ impl RpcHandler {
.await
.context("Failed to wait for image pull")?;
if !status.success() {
self.clear_install_progress(package_id).await;
return Err(anyhow::anyhow!("Failed to pull image"));
return Err(anyhow::anyhow!("podman pull exited with non-zero status"));
}
// Verify image exists locally after pull
let verify = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
.await
.context("Failed to verify pulled image")?;
if String::from_utf8_lossy(&verify.stdout).trim().is_empty() {
return Err(anyhow::anyhow!(
"Image {} not found locally after pull",
docker_image
));
}
self.set_install_progress(package_id, 100, 100).await;
@@ -345,11 +447,31 @@ impl RpcHandler {
}
/// Create data directories for volume mounts under /var/lib/archipelago/.
/// Get the mapped host UID for a container's internal UID.
/// Rootless podman maps container UIDs: host_uid = subuid_start + container_uid
/// Default subuid start for archipelago user is 100000.
fn mapped_uid(package_id: &str) -> u32 {
let container_uid = match package_id {
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => 101,
"grafana" => 472,
"lnd" => 1000,
"mariadb" | "mysql" | "mysql-mempool" | "archy-mempool-db" => 999,
"postgres" | "btcpay-postgres" | "immich-postgres" | "penpot-postgres"
| "archy-btcpay-db" | "nextcloud-db" => 70,
"electrumx" | "electrs" => 1000,
_ => 0, // Most containers run as root (UID 0)
};
100000 + container_uid
}
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
let uid = Self::mapped_uid(package_id);
let uid_str = format!("{}:{}", uid, uid);
for volume in volumes {
if let Some(host_path) = volume.split(':').next() {
if host_path.starts_with("/var/lib/archipelago/") {
debug!("Creating directory: {}", host_path);
debug!("Creating directory: {} (owner: {})", host_path, uid_str);
let create_dir = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", host_path])
.output()
@@ -357,13 +479,11 @@ impl RpcHandler {
if let Err(e) = create_dir {
debug!("Failed to create directory {}: {}", host_path, e);
}
// Grafana runs as UID 472 — fix permissions
if package_id == "grafana" && host_path.contains("grafana") {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "472:472", host_path])
.output()
.await;
}
// Set ownership to the mapped UID for rootless podman
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", &uid_str, host_path])
.output()
.await;
}
}
}
@@ -404,6 +524,67 @@ printtoconsole=1\n",
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
async fn run_post_install_hooks(&self, package_id: &str) {
if package_id == "filebrowser" {
tokio::spawn(async move {
// Wait for filebrowser to start and initialize its database
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// Generate a random password (32 bytes, hex-encoded)
let mut buf = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
let password = hex::encode(buf);
// Get a JWT token with default credentials
let login_res = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default()
.post("http://127.0.0.1:8083/api/login")
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
.send()
.await;
let token = match login_res {
Ok(resp) if resp.status().is_success() => {
resp.text().await.unwrap_or_default().trim_matches('"').to_string()
}
_ => {
tracing::warn!("FileBrowser not ready for password change — keeping default");
return;
}
};
// Change admin password via filebrowser API
let change_res = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default()
.put("http://127.0.0.1:8083/api/users/1")
.header("X-Auth", &token)
.json(&serde_json::json!({"password": password}))
.send()
.await;
match change_res {
Ok(resp) if resp.status().is_success() => {
let secret_dir = "/var/lib/archipelago/secrets/filebrowser";
let _ = tokio::fs::create_dir_all(secret_dir).await;
let _ = tokio::fs::write(
format!("{}/password", secret_dir),
&password,
).await;
info!("FileBrowser admin password secured (default credentials replaced)");
}
Ok(resp) => {
tracing::warn!("FileBrowser password change failed: {}", resp.status());
}
Err(e) => {
tracing::warn!("FileBrowser password change error: {}", e);
}
}
});
}
if package_id == "nextcloud" {
let host_ip = self.config.host_ip.clone();
tokio::spawn(async move {
@@ -436,32 +617,87 @@ printtoconsole=1\n",
});
}
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Build and start companion UI containers for headless services
let ui_builds: Vec<(&str, &str, &str, &str)> = match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![("bitcoin-ui", "/opt/archipelago/docker/bitcoin-ui", "localhost/bitcoin-ui", "8334:80")]
}
"lnd" => {
vec![("archy-lnd-ui", "/opt/archipelago/docker/lnd-ui", "localhost/lnd-ui", "8081:80")]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec![("archy-electrs-ui", "/opt/archipelago/docker/electrs-ui", "localhost/electrs-ui", "50002:80")]
}
_ => vec![],
};
for (name, ui_dir, image, port) in ui_builds {
let name = name.to_string();
let ui_dir = ui_dir.to_string();
let image = image.to_string();
let port = port.to_string();
tokio::spawn(async move {
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
if !std::path::Path::new(&ui_dir).exists() {
info!("UI source not found at {}, skipping", ui_dir);
return;
}
info!("Building UI container {} from {}", name, ui_dir);
let _ = tokio::process::Command::new("podman")
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
.args(["build", "-t", &image, &ui_dir])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", "bitcoin-ui"])
.args(["rm", "-f", &name])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"bitcoin-ui",
"run", "-d",
"--name", &name,
"--restart=unless-stopped",
"-p",
"8334:80",
"localhost/bitcoin-ui:latest",
"--network=archy-net",
"--cap-drop=ALL",
"--cap-add=NET_BIND_SERVICE",
"--memory=64m",
"-p", &port,
&format!("{}:latest", image),
])
.output()
.await;
info!("Bitcoin UI container started on port 8334");
info!("{} UI container started on port {}", name, port);
});
}
}
/// Get a fresh FileBrowser JWT token for the frontend.
/// Reads the stored random password and authenticates to filebrowser's API.
pub(in crate::api::rpc) async fn handle_filebrowser_token(
&self,
) -> Result<serde_json::Value> {
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password";
let password = tokio::fs::read_to_string(secret_path)
.await
.unwrap_or_else(|_| "admin".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
let resp = client
.post("http://127.0.0.1:8083/api/login")
.json(&serde_json::json!({"username": "admin", "password": password}))
.send()
.await
.context("Failed to connect to FileBrowser")?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("FileBrowser login failed ({})", resp.status()));
}
let token = resp.text().await.unwrap_or_default();
let token = token.trim_matches('"');
Ok(serde_json::json!({ "token": token }))
}
}

View File

@@ -4,6 +4,22 @@ use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
/// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
"lnd" => "330",
"electrumx" | "electrs" | "mempool-electrs" => "300",
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "nextcloud-db" | "endurain-db" => "120",
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
_ => "30",
}
}
impl RpcHandler {
/// Start a package: start all containers in dependency order.
pub(in crate::api::rpc) async fn handle_package_start(
@@ -18,6 +34,10 @@ impl RpcHandler {
validate_app_id(package_id)?;
let to_start = ordered_containers_for_start(package_id).await?;
if to_start.is_empty() {
tracing::warn!("package.start {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
// Clear user-stopped flag — user explicitly started this app
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
@@ -25,13 +45,24 @@ impl RpcHandler {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
for name in to_start {
let _ = tokio::process::Command::new("podman")
.args(["start", &name])
let mut errors = Vec::new();
for name in &to_start {
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await;
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to start {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")));
}
Ok(serde_json::Value::Null)
}
@@ -47,31 +78,36 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
.await;
let _ = tokio::process::Command::new("podman")
.args(["stop", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
tracing::warn!("package.stop {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["stop", &name])
let mut errors = Vec::new();
for name in &containers {
tracing::info!("Stopping container: {} (timeout: {}s)", name, stop_timeout_secs(name));
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
.await
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to stop {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")));
}
Ok(serde_json::Value::Null)
}
@@ -89,21 +125,47 @@ impl RpcHandler {
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
let _ = tokio::process::Command::new("podman")
.args(["restart", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
tracing::warn!("package.restart {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["restart", &name])
let mut errors = Vec::new();
for name in &containers {
tracing::info!("Restarting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
.await;
.await
.context(format!("Failed to exec podman restart {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::warn!("podman restart {} failed: {}, trying stop+start", name, stderr);
// Fallback: stop then start (handles rootless podman loopback issues)
let _ = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
let start_err = String::from_utf8_lossy(&start_out.stderr).trim().to_string();
tracing::error!("stop+start {} also failed: {}", name, start_err);
errors.push(format!("{}: {}", name, start_err));
} else {
tracing::info!("Restarted {} via stop+start fallback", name);
}
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
}
Ok(serde_json::Value::Null)
}
@@ -135,7 +197,7 @@ impl RpcHandler {
for name in &containers_to_remove {
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", "10", name])
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
match stop_out {
@@ -344,7 +406,7 @@ impl RpcHandler {
validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman")
.args(["stop", app_id])
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
.output()
.await
.context("Failed to stop container")?;

View File

@@ -7,6 +7,41 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::info;
/// Pull an image with retry and exponential backoff (3 attempts).
async fn pull_image_with_retry(image: &str) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
for attempt in 1..=MAX_ATTEMPTS {
let output = tokio::process::Command::new("podman")
.args(["pull", image])
.output()
.await
.context("Failed to execute podman pull")?;
if output.status.success() {
return Ok(());
}
if attempt < MAX_ATTEMPTS {
let delay = BACKOFF_SECS[(attempt - 1) as usize];
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
image, attempt, MAX_ATTEMPTS, stderr.trim(), delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Failed to pull {} after {} attempts: {}",
image, MAX_ATTEMPTS, stderr.trim()
));
}
}
unreachable!()
}
impl RpcHandler {
/// Install Immich stack (postgres + redis + server).
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
@@ -38,10 +73,7 @@ impl RpcHandler {
"80.71.235.15:3000/archipelago/immich-server:release",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
pull_image_with_retry(img).await?;
}
let _ = tokio::process::Command::new("sudo")
@@ -168,10 +200,7 @@ impl RpcHandler {
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
pull_image_with_retry(img).await?;
}
let _ = tokio::process::Command::new("sudo")

View File

@@ -95,6 +95,18 @@ impl AuthManager {
Self { data_dir }
}
/// Ensure a default user exists on first boot.
/// Called once at startup — creates user with default password if none exists.
pub async fn ensure_default_user(&self) -> Result<()> {
if self.is_setup().await? {
return Ok(());
}
tracing::info!("[onboarding] no user found — creating default user (password: password123)");
self.setup_user("password123").await?;
tracing::info!("[onboarding] default user created — user should change password after login");
Ok(())
}
pub async fn is_setup(&self) -> Result<bool> {
let user_file = self.data_dir.join("user.json");
Ok(user_file.exists())

View File

@@ -0,0 +1,265 @@
//! Mock container runtime for unit testing orchestration logic.
//!
//! Simulates podman behavior in-memory: container lifecycle, health checks,
//! image pulls (with configurable failures for retry testing).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}};
/// Container state matching podman's real states.
#[derive(Debug, Clone, PartialEq)]
pub enum MockContainerState {
Created,
Running,
Exited(i32), // exit code
Stopped,
}
impl MockContainerState {
pub fn as_str(&self) -> &str {
match self {
Self::Created => "created",
Self::Running => "running",
Self::Exited(_) => "exited",
Self::Stopped => "stopped",
}
}
}
/// A simulated container.
#[derive(Debug, Clone)]
pub struct MockContainer {
pub name: String,
pub image: String,
pub state: MockContainerState,
pub stop_timeout_used: Option<u64>,
}
/// Mock podman runtime for testing orchestration logic without real containers.
pub struct MockPodman {
containers: Arc<Mutex<HashMap<String, MockContainer>>>,
/// When true, `podman pull` will fail (simulates registry down).
pub fail_pull: Arc<AtomicBool>,
/// When true, containers exit immediately after start (simulates crash).
pub fail_start: Arc<AtomicBool>,
/// Count of pull attempts (for retry testing).
pub pull_attempt_count: Arc<AtomicU32>,
/// Count of start attempts.
pub start_attempt_count: Arc<AtomicU32>,
/// Images that have been "pulled" (exist locally).
images: Arc<Mutex<Vec<String>>>,
}
impl MockPodman {
pub fn new() -> Self {
Self {
containers: Arc::new(Mutex::new(HashMap::new())),
fail_pull: Arc::new(AtomicBool::new(false)),
fail_start: Arc::new(AtomicBool::new(false)),
pull_attempt_count: Arc::new(AtomicU32::new(0)),
start_attempt_count: Arc::new(AtomicU32::new(0)),
images: Arc::new(Mutex::new(Vec::new())),
}
}
/// Simulate `podman pull <image>`. Respects fail_pull flag.
pub fn pull_image(&self, image: &str) -> Result<(), String> {
self.pull_attempt_count.fetch_add(1, Ordering::SeqCst);
if self.fail_pull.load(Ordering::SeqCst) {
return Err(format!("Error: initializing source docker://{}: connection refused", image));
}
self.images.lock().unwrap().push(image.to_string());
Ok(())
}
/// Check if an image exists locally (was pulled).
pub fn image_exists(&self, image: &str) -> bool {
self.images.lock().unwrap().iter().any(|i| i == image)
}
/// Simulate `podman run -d --name <name> <image>`.
pub fn create_and_start(&self, name: &str, image: &str) -> Result<String, String> {
self.start_attempt_count.fetch_add(1, Ordering::SeqCst);
if !self.image_exists(image) {
return Err(format!("Error: {} not found", image));
}
let state = if self.fail_start.load(Ordering::SeqCst) {
MockContainerState::Exited(1)
} else {
MockContainerState::Running
};
let container = MockContainer {
name: name.to_string(),
image: image.to_string(),
state,
stop_timeout_used: None,
};
self.containers.lock().unwrap().insert(name.to_string(), container);
Ok(format!("abc123def456_{}", name))
}
/// Simulate `podman start <name>`.
pub fn start(&self, name: &str) -> Result<(), String> {
let mut containers = self.containers.lock().unwrap();
match containers.get_mut(name) {
Some(c) => {
if self.fail_start.load(Ordering::SeqCst) {
c.state = MockContainerState::Exited(1);
} else {
c.state = MockContainerState::Running;
}
Ok(())
}
None => Err(format!("Error: no such container {}", name)),
}
}
/// Simulate `podman stop -t <timeout> <name>`.
pub fn stop(&self, name: &str, timeout: u64) -> Result<(), String> {
let mut containers = self.containers.lock().unwrap();
match containers.get_mut(name) {
Some(c) => {
c.state = MockContainerState::Stopped;
c.stop_timeout_used = Some(timeout);
Ok(())
}
None => Err(format!("Error: no such container {}", name)),
}
}
/// Simulate `podman rm -f <name>`.
pub fn remove(&self, name: &str) -> Result<(), String> {
self.containers.lock().unwrap().remove(name);
Ok(())
}
/// Simulate `podman inspect <name> --format {{.State.Status}}`.
pub fn inspect_state(&self, name: &str) -> Option<String> {
self.containers.lock().unwrap()
.get(name)
.map(|c| c.state.as_str().to_string())
}
/// List all containers (like `podman ps -a`).
pub fn list_all(&self) -> Vec<MockContainer> {
self.containers.lock().unwrap().values().cloned().collect()
}
/// Get a specific container.
pub fn get(&self, name: &str) -> Option<MockContainer> {
self.containers.lock().unwrap().get(name).cloned()
}
/// Pre-load an image (as if it was already pulled or bundled).
pub fn preload_image(&self, image: &str) {
self.images.lock().unwrap().push(image.to_string());
}
/// Pre-load a container in a specific state.
pub fn preload_container(&self, name: &str, image: &str, state: MockContainerState) {
self.containers.lock().unwrap().insert(name.to_string(), MockContainer {
name: name.to_string(),
image: image.to_string(),
state,
stop_timeout_used: None,
});
}
/// Get the stop timeout that was used for a container.
pub fn get_stop_timeout(&self, name: &str) -> Option<u64> {
self.containers.lock().unwrap()
.get(name)
.and_then(|c| c.stop_timeout_used)
}
/// Reset all counters and state.
pub fn reset(&self) {
self.containers.lock().unwrap().clear();
self.images.lock().unwrap().clear();
self.fail_pull.store(false, Ordering::SeqCst);
self.fail_start.store(false, Ordering::SeqCst);
self.pull_attempt_count.store(0, Ordering::SeqCst);
self.start_attempt_count.store(0, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pull_and_start() {
let mock = MockPodman::new();
mock.pull_image("test:latest").unwrap();
assert!(mock.image_exists("test:latest"));
mock.create_and_start("test-container", "test:latest").unwrap();
assert_eq!(mock.inspect_state("test-container"), Some("running".to_string()));
}
#[test]
fn test_pull_failure() {
let mock = MockPodman::new();
mock.fail_pull.store(true, Ordering::SeqCst);
assert!(mock.pull_image("test:latest").is_err());
assert!(!mock.image_exists("test:latest"));
assert_eq!(mock.pull_attempt_count.load(Ordering::SeqCst), 1);
}
#[test]
fn test_start_failure() {
let mock = MockPodman::new();
mock.preload_image("test:latest");
mock.fail_start.store(true, Ordering::SeqCst);
mock.create_and_start("crasher", "test:latest").unwrap();
assert_eq!(mock.inspect_state("crasher"), Some("exited".to_string()));
}
#[test]
fn test_stop_records_timeout() {
let mock = MockPodman::new();
mock.preload_image("test:latest");
mock.create_and_start("test", "test:latest").unwrap();
mock.stop("test", 600).unwrap();
assert_eq!(mock.get_stop_timeout("test"), Some(600));
assert_eq!(mock.inspect_state("test"), Some("stopped".to_string()));
}
#[test]
fn test_remove() {
let mock = MockPodman::new();
mock.preload_image("test:latest");
mock.create_and_start("removeme", "test:latest").unwrap();
mock.remove("removeme").unwrap();
assert!(mock.inspect_state("removeme").is_none());
}
#[test]
fn test_start_without_image_fails() {
let mock = MockPodman::new();
assert!(mock.create_and_start("nope", "missing:latest").is_err());
}
#[test]
fn test_preload_container() {
let mock = MockPodman::new();
mock.preload_container("existing", "img:1.0", MockContainerState::Running);
assert_eq!(mock.inspect_state("existing"), Some("running".to_string()));
assert_eq!(mock.list_all().len(), 1);
}
#[test]
fn test_reset() {
let mock = MockPodman::new();
mock.preload_image("img:1");
mock.preload_container("c1", "img:1", MockContainerState::Running);
mock.fail_pull.store(true, Ordering::SeqCst);
mock.reset();
assert!(!mock.image_exists("img:1"));
assert!(mock.list_all().is_empty());
assert!(!mock.fail_pull.load(Ordering::SeqCst));
}
}

View File

@@ -384,6 +384,36 @@ fn container_boot_tier(name: &str) -> u8 {
}
}
/// Run the reconciliation script after boot to fix any config drift.
/// Ensures all containers match their canonical specs from container-specs.sh.
pub async fn run_boot_reconciliation() {
let script = "/home/archipelago/archy/scripts/reconcile-containers.sh";
if !std::path::Path::new(script).exists() {
info!("Reconciliation script not found (dev mode?) — skipping boot reconciliation");
return;
}
info!("Running boot reconciliation...");
let result = tokio::time::timeout(
std::time::Duration::from_secs(300),
tokio::process::Command::new(script).output(),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
info!("Boot reconciliation complete");
}
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Boot reconciliation had failures: {}",
stderr.chars().take(500).collect::<String>()
);
}
Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e),
Err(_) => warn!("Boot reconciliation timed out (300s)"),
}
}
/// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move {

View File

@@ -33,11 +33,18 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
Ok((used, total, percent))
}
/// Check disk usage percentage for the root filesystem.
/// Check disk usage percentage for the data partition.
/// Uses /var/lib/archipelago (encrypted LUKS partition) if available, falls back to /.
/// Returns (used_bytes, total_bytes, used_percent).
pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
// Prefer the encrypted data partition — this is where all user data lives
let data_path = if std::path::Path::new("/var/lib/archipelago").exists() {
"/var/lib/archipelago"
} else {
"/"
};
let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", "/"])
.args(["--block-size=1", "--output=used,size", data_path])
.output()
.await
.context("Failed to run df")?;

View File

@@ -6,8 +6,9 @@
use crate::data_model::{Notification, NotificationLevel};
use crate::state::StateManager;
use crate::webhooks::{self, WebhookEvent};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tracing::{debug, info, warn};
@@ -177,6 +178,69 @@ impl MemoryTracker {
}
// ── Persistent restart tracking ────────────────────────────────────────
// Survives process restarts so a container can't loop infinitely by
// crashing 3 times → triggering process restart → resetting counter → repeat.
const RESTART_HISTORY_FILE: &str = "restart-tracker.json";
#[derive(Serialize, Deserialize, Default)]
struct RestartHistory {
containers: HashMap<String, ContainerRestartRecord>,
}
#[derive(Serialize, Deserialize, Clone)]
struct ContainerRestartRecord {
attempts: u32,
last_failure_epoch: i64,
}
impl RestartHistory {
async fn load(data_dir: &Path) -> Self {
let path = data_dir.join(RESTART_HISTORY_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
async fn save(&self, data_dir: &Path) {
let path = data_dir.join(RESTART_HISTORY_FILE);
if let Ok(json) = serde_json::to_string(self) {
let _ = tokio::fs::write(&path, json).await;
}
}
/// Seed the in-memory RestartTracker from persisted history.
fn seed_tracker(&self, tracker: &mut RestartTracker) {
let now_epoch = chrono::Utc::now().timestamp();
for (name, record) in &self.containers {
// Only seed if last failure was within the stability window
let secs_since_failure = now_epoch - record.last_failure_epoch;
if secs_since_failure < STABILITY_RESET_SECS as i64 && record.attempts > 0 {
tracker.attempts.insert(name.clone(), record.attempts);
info!(
"Restored restart counter for {}: {} attempts ({}s ago)",
name, record.attempts, secs_since_failure
);
}
}
}
fn record_attempt(&mut self, name: &str) {
let entry = self.containers.entry(name.to_string()).or_insert(ContainerRestartRecord {
attempts: 0,
last_failure_epoch: 0,
});
entry.attempts += 1;
entry.last_failure_epoch = chrono::Utc::now().timestamp();
}
fn clear(&mut self, name: &str) {
self.containers.remove(name);
}
}
/// Query container memory stats from podman.
async fn check_container_memory() -> HashMap<String, u64> {
let output = match tokio::time::timeout(
@@ -262,13 +326,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
let containers: Vec<serde_json::Value> =
serde_json::from_str(&stdout).unwrap_or_default();
// Backend services and one-shot init containers to skip
let skip = [
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
"penpot-mailcatch", "immich_postgres", "immich_redis",
"endurain-db", "nextcloud-db",
];
// Monitor ALL long-running containers for health — backend services (databases,
// nbxplorer, mempool-api) and UI containers need auto-restart too.
// Only skip ephemeral containers (build infrastructure, init one-shots).
containers
.iter()
@@ -281,20 +341,16 @@ async fn check_containers() -> Vec<ContainerHealth> {
}
})?;
let app_id = name
.strip_prefix("archy-")
.unwrap_or(&name)
.to_string();
if skip.contains(&app_id.as_str()) || app_id.ends_with("-ui") {
return None;
}
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
return None;
}
let app_id = name
.strip_prefix("archy-")
.unwrap_or(&name)
.to_string();
let state = c.get("State")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
@@ -373,6 +429,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
let mut mem_check_counter: u32 = 0;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
// Load persistent restart history and seed the in-memory tracker
let mut restart_history = RestartHistory::load(&data_dir).await;
restart_history.seed_tracker(&mut tracker);
let mut history_dirty = false;
loop {
interval.tick().await;
mem_check_counter += 1;
@@ -406,6 +467,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
if tracker.attempt_count(&container.name) > 0 {
info!("Container {} is healthy again after restart", container.name);
tracker.clear(&container.name);
restart_history.clear(&container.name);
history_dirty = true;
}
continue;
}
@@ -430,6 +493,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
if tracker.should_reset_failed(&container.name) {
info!("Resetting restart counter for {} after {}s stability window", container.name, STABILITY_RESET_SECS);
tracker.clear(&container.name);
restart_history.clear(&container.name);
history_dirty = true;
}
if tracker.attempt_count(&container.name) >= MAX_RESTART_ATTEMPTS {
@@ -453,6 +518,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
prev_tier = Some(tier);
if tracker.record_attempt(&container.name) {
restart_history.record_attempt(&container.name);
history_dirty = true;
let attempt = tracker.attempt_count(&container.name);
info!("Restarting {} (tier {:?}, attempt {}/{}, backoff {}s)",
container.name, tier, attempt, MAX_RESTART_ATTEMPTS,
@@ -509,6 +576,12 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
state.update_data(data).await;
debug!("Health monitor: state updated with notifications");
}
// Persist restart history to disk (debounced: once per check cycle)
if history_dirty {
restart_history.save(&data_dir).await;
history_dirty = false;
}
}
});
}

View File

@@ -106,17 +106,18 @@ async fn main() -> Result<()> {
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
// Reconcile containers against canonical specs (fixes config drift)
crash_recovery::run_boot_reconciliation().await;
});
}
// In dev mode, ensure a default user exists so login works without manual setup
if config.dev_mode {
let auth = AuthManager::new(config.data_dir.clone());
if !auth.is_setup().await? {
auth.setup_user(DEV_DEFAULT_PASSWORD).await?;
info!("👤 Created default dev user (password: {})", DEV_DEFAULT_PASSWORD);
}
}
// Ensure a default user exists so login works after install/onboarding.
// In production, the default password is "password123" (shown during install).
// In dev mode, the dev default password is used.
// Don't auto-create default user — let onboarding flow handle password setup
// via auth.setup RPC. The Login page detects is_setup=false and shows
// "Create Password" form instead of login form.
// Create server
let server = Server::new(config.clone()).await?;

View File

@@ -0,0 +1,499 @@
//! Container orchestration tests.
//!
//! Tests the orchestration LOGIC without real containers:
//! - Stop grace periods per container type
//! - Image pull retry with exponential backoff
//! - Restart tracker persistence across process restarts
//! - Health monitor tier ordering and user-stopped filtering
//! - Crash recovery snapshot loading
//! - Failsafe install verification
//!
//! Self-contained: no imports from the archipelago binary crate.
//! Uses inline mock + duplicated logic functions to test correctness.
#[path = "../src/container/mock_podman.rs"]
mod mock_podman;
// ── Stop Grace Periods ─────────────────────────────────────────────────
mod stop_grace_periods {
/// Mirror of runtime.rs stop_timeout_secs — kept in sync.
/// Tests verify the logic; the real function lives in runtime.rs.
fn stop_timeout_secs(container_name: &str) -> &'static str {
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
"lnd" => "330",
"electrumx" | "electrs" | "mempool-electrs" => "300",
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "nextcloud-db" | "endurain-db" => "120",
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
_ => "30",
}
}
#[test]
fn bitcoin_core_gets_600s() {
assert_eq!(stop_timeout_secs("bitcoin-knots"), "600");
assert_eq!(stop_timeout_secs("bitcoin-core"), "600");
assert_eq!(stop_timeout_secs("bitcoin"), "600");
}
#[test]
fn bitcoin_with_archy_prefix() {
assert_eq!(stop_timeout_secs("archy-bitcoin-knots"), "600");
}
#[test]
fn lnd_gets_330s() {
assert_eq!(stop_timeout_secs("lnd"), "330");
assert_eq!(stop_timeout_secs("archy-lnd"), "330");
}
#[test]
fn indexers_get_300s() {
assert_eq!(stop_timeout_secs("electrumx"), "300");
assert_eq!(stop_timeout_secs("electrs"), "300");
assert_eq!(stop_timeout_secs("mempool-electrs"), "300");
}
#[test]
fn databases_get_120s() {
assert_eq!(stop_timeout_secs("btcpay-db"), "120");
assert_eq!(stop_timeout_secs("archy-mempool-db"), "120");
assert_eq!(stop_timeout_secs("penpot-postgres"), "120");
assert_eq!(stop_timeout_secs("immich_postgres"), "120");
}
#[test]
fn btcpay_services_get_60s() {
assert_eq!(stop_timeout_secs("btcpay-server"), "60");
assert_eq!(stop_timeout_secs("nbxplorer"), "60");
assert_eq!(stop_timeout_secs("fedimint"), "60");
}
#[test]
fn default_is_30s() {
assert_eq!(stop_timeout_secs("grafana"), "30");
assert_eq!(stop_timeout_secs("filebrowser"), "30");
assert_eq!(stop_timeout_secs("searxng"), "30");
assert_eq!(stop_timeout_secs("ollama"), "30");
assert_eq!(stop_timeout_secs("unknown-app"), "30");
}
#[test]
fn ui_containers_get_30s() {
assert_eq!(stop_timeout_secs("archy-bitcoin-ui"), "30");
assert_eq!(stop_timeout_secs("archy-lnd-ui"), "30");
assert_eq!(stop_timeout_secs("archy-electrs-ui"), "30");
}
}
// ── Image Pull Retry Logic ─────────────────────────────────────────────
mod pull_retry {
use crate::mock_podman::MockPodman;
use std::sync::atomic::Ordering;
/// Simulate the retry logic from install.rs: 3 attempts, backoff.
fn pull_with_retry(mock: &MockPodman, image: &str) -> Result<(), String> {
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
match mock.pull_image(image) {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
// In real code, we'd sleep here. In tests, just continue.
let _ = e;
}
Err(e) => return Err(format!("Failed after {} attempts: {}", MAX_ATTEMPTS, e)),
}
}
unreachable!()
}
#[test]
fn succeeds_first_try() {
let mock = MockPodman::new();
pull_with_retry(&mock, "test:1.0").unwrap();
assert_eq!(mock.pull_attempt_count.load(Ordering::SeqCst), 1);
assert!(mock.image_exists("test:1.0"));
}
#[test]
fn fails_then_succeeds() {
let mock = MockPodman::new();
// Simulate: fail attempt 1, succeed attempt 2
mock.fail_pull.store(true, Ordering::SeqCst);
// Attempt 1: fails
assert!(mock.pull_image("test:1.0").is_err());
assert_eq!(mock.pull_attempt_count.load(Ordering::SeqCst), 1);
// Registry comes back
mock.fail_pull.store(false, Ordering::SeqCst);
// Attempt 2: succeeds
assert!(mock.pull_image("test:1.0").is_ok());
assert_eq!(mock.pull_attempt_count.load(Ordering::SeqCst), 2);
assert!(mock.image_exists("test:1.0"));
}
#[test]
fn all_attempts_fail() {
let mock = MockPodman::new();
mock.fail_pull.store(true, Ordering::SeqCst);
let result = pull_with_retry(&mock, "test:1.0");
assert!(result.is_err());
assert_eq!(mock.pull_attempt_count.load(Ordering::SeqCst), 3);
assert!(!mock.image_exists("test:1.0"));
}
}
// ── Restart Tracker Persistence ────────────────────────────────────────
mod restart_tracker {
use tempfile::TempDir;
use std::collections::HashMap;
// Inline the serialization structs (same as health_monitor.rs)
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct RestartHistory {
containers: HashMap<String, ContainerRestartRecord>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct ContainerRestartRecord {
attempts: u32,
last_failure_epoch: i64,
}
#[test]
fn save_and_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("restart-tracker.json");
let mut history = RestartHistory::default();
history.containers.insert("bitcoin-knots".to_string(), ContainerRestartRecord {
attempts: 2,
last_failure_epoch: 1700000000,
});
history.containers.insert("lnd".to_string(), ContainerRestartRecord {
attempts: 1,
last_failure_epoch: 1700000100,
});
// Save
let json = serde_json::to_string(&history).unwrap();
std::fs::write(&path, &json).unwrap();
// Load
let loaded_json = std::fs::read_to_string(&path).unwrap();
let loaded: RestartHistory = serde_json::from_str(&loaded_json).unwrap();
assert_eq!(loaded.containers.len(), 2);
assert_eq!(loaded.containers["bitcoin-knots"].attempts, 2);
assert_eq!(loaded.containers["lnd"].attempts, 1);
}
#[test]
fn missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("restart-tracker.json");
let result = std::fs::read_to_string(&path);
assert!(result.is_err());
// Same behavior as health_monitor.rs: unwrap_or_default
let history: RestartHistory = result
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
assert!(history.containers.is_empty());
}
#[test]
fn corrupt_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("restart-tracker.json");
std::fs::write(&path, "not valid json {{{").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let history: RestartHistory = serde_json::from_str(&content).unwrap_or_default();
assert!(history.containers.is_empty());
}
#[test]
fn clear_removes_container() {
let mut history = RestartHistory::default();
history.containers.insert("test".to_string(), ContainerRestartRecord {
attempts: 3,
last_failure_epoch: 1700000000,
});
history.containers.remove("test");
assert!(history.containers.is_empty());
}
#[test]
fn stability_window_check() {
let now = chrono::Utc::now().timestamp();
let one_hour_ago = now - 3601;
let five_min_ago = now - 300;
// Old failure: should reset
let old_record = ContainerRestartRecord {
attempts: 3,
last_failure_epoch: one_hour_ago,
};
assert!(now - old_record.last_failure_epoch >= 3600);
// Recent failure: should NOT reset
let recent_record = ContainerRestartRecord {
attempts: 3,
last_failure_epoch: five_min_ago,
};
assert!(now - recent_record.last_failure_epoch < 3600);
}
}
// ── Failsafe Install ──────────────────────────────────────────────────
mod failsafe_install {
use crate::mock_podman::{MockPodman, MockContainerState};
use std::sync::atomic::Ordering;
#[test]
fn successful_install_flow() {
let mock = MockPodman::new();
// Pull succeeds
mock.pull_image("registry/app:1.0").unwrap();
// Image exists
assert!(mock.image_exists("registry/app:1.0"));
// Container starts
mock.create_and_start("test-app", "registry/app:1.0").unwrap();
// Running state
assert_eq!(mock.inspect_state("test-app"), Some("running".to_string()));
}
#[test]
fn rollback_on_immediate_exit() {
let mock = MockPodman::new();
mock.preload_image("registry/app:1.0");
mock.fail_start.store(true, Ordering::SeqCst);
// Container is created but exits immediately
mock.create_and_start("crasher", "registry/app:1.0").unwrap();
assert_eq!(mock.inspect_state("crasher"), Some("exited".to_string()));
// Rollback: remove the failed container
mock.remove("crasher").unwrap();
assert!(mock.inspect_state("crasher").is_none());
}
#[test]
fn no_image_after_pull_is_error() {
let mock = MockPodman::new();
// Don't pull — image doesn't exist
let result = mock.create_and_start("no-image", "missing:1.0");
assert!(result.is_err());
}
}
// ── Health Monitor Logic ──────────────────────────────────────────────
mod health_monitor_logic {
use crate::mock_podman::{MockPodman, MockContainerState};
/// Mirrors the tier ordering from health_monitor.rs
fn container_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "immich_redis" | "penpot-valkey" | "endurain-db" | "nextcloud-db" => 0,
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
| "penpot-frontend" | "penpot-exporter" => 4,
_ => 3,
}
}
#[test]
fn tier_ordering_databases_first() {
assert!(container_tier("btcpay-db") < container_tier("bitcoin-knots"));
assert!(container_tier("mempool-db") < container_tier("lnd"));
}
#[test]
fn tier_ordering_core_before_services() {
assert!(container_tier("bitcoin-knots") < container_tier("lnd"));
assert!(container_tier("bitcoin-knots") < container_tier("electrumx"));
}
#[test]
fn tier_ordering_services_before_apps() {
assert!(container_tier("lnd") < container_tier("grafana"));
assert!(container_tier("electrumx") < container_tier("filebrowser"));
}
#[test]
fn tier_ordering_apps_before_uis() {
assert!(container_tier("grafana") < container_tier("bitcoin-ui"));
assert!(container_tier("filebrowser") < container_tier("lnd-ui"));
}
#[test]
fn user_stopped_containers_skipped() {
let user_stopped: std::collections::HashSet<String> =
["archy-grafana".to_string(), "filebrowser".to_string()].into();
// Simulated unhealthy containers
let unhealthy = vec!["archy-grafana", "filebrowser", "lnd"];
let to_restart: Vec<&str> = unhealthy
.into_iter()
.filter(|name| !user_stopped.contains(*name))
.collect();
assert_eq!(to_restart, vec!["lnd"]);
}
#[test]
fn all_long_running_containers_monitored() {
// Health monitor now checks ALL containers except ephemeral build/init ones.
// Backend services and UI containers are monitored for auto-restart.
let containers = vec![
("bitcoin-knots", "exited"),
("archy-bitcoin-ui", "exited"),
("archy-lnd-ui", "exited"),
("grafana", "exited"),
("nbxplorer", "exited"),
("indeedhub-build_api_1", "exited"),
("btcpay-init", "exited"),
];
let to_check: Vec<&str> = containers
.iter()
.filter(|(name, _)| {
!name.starts_with("indeedhub-build_") && !name.contains("-init")
})
.map(|(name, _)| *name)
.collect();
assert_eq!(to_check, vec![
"bitcoin-knots", "archy-bitcoin-ui", "archy-lnd-ui",
"grafana", "nbxplorer",
]);
}
#[test]
fn restart_sorted_by_tier() {
let mut unhealthy = vec![
"grafana", // tier 3
"lnd", // tier 2
"btcpay-db", // tier 0
"bitcoin-knots", // tier 1
];
unhealthy.sort_by_key(|name| container_tier(name));
assert_eq!(unhealthy, vec!["btcpay-db", "bitcoin-knots", "lnd", "grafana"]);
}
}
// ── Crash Recovery ────────────────────────────────────────────────────
mod crash_recovery {
use tempfile::TempDir;
#[derive(serde::Serialize, serde::Deserialize)]
struct ContainerSnapshot {
timestamp: u64,
containers: Vec<RunningContainerRecord>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct RunningContainerRecord {
name: String,
image: String,
}
#[test]
fn snapshot_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("running-containers.json");
let snapshot = ContainerSnapshot {
timestamp: 1700000000,
containers: vec![
RunningContainerRecord {
name: "bitcoin-knots".to_string(),
image: "bitcoin-knots:28.1".to_string(),
},
RunningContainerRecord {
name: "lnd".to_string(),
image: "lnd:0.18.5".to_string(),
},
],
};
let json = serde_json::to_string_pretty(&snapshot).unwrap();
std::fs::write(&path, &json).unwrap();
let loaded_json = std::fs::read_to_string(&path).unwrap();
let loaded: ContainerSnapshot = serde_json::from_str(&loaded_json).unwrap();
assert_eq!(loaded.containers.len(), 2);
assert_eq!(loaded.containers[0].name, "bitcoin-knots");
}
#[test]
fn user_stopped_filtering() {
let user_stopped: std::collections::HashSet<String> =
["grafana".to_string()].into();
let snapshot_containers = vec![
"bitcoin-knots".to_string(),
"lnd".to_string(),
"grafana".to_string(),
];
let to_recover: Vec<&String> = snapshot_containers
.iter()
.filter(|name| !user_stopped.contains(name.as_str()))
.collect();
assert_eq!(to_recover.len(), 2);
assert!(!to_recover.iter().any(|n| n.as_str() == "grafana"));
}
#[test]
fn boot_tier_ordering() {
fn boot_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
"btcpay-db" | "mempool-db" => 0,
"bitcoin-knots" | "bitcoin-core" => 1,
"lnd" | "electrumx" => 2,
"mempool-web" | "bitcoin-ui" | "lnd-ui" => 4,
_ => 3,
}
}
let mut containers = vec![
"mempool-web",
"lnd",
"btcpay-db",
"bitcoin-knots",
"grafana",
];
containers.sort_by_key(|name| boot_tier(name));
assert_eq!(containers[0], "btcpay-db");
assert_eq!(containers[1], "bitcoin-knots");
assert_eq!(containers[2], "lnd");
assert_eq!(containers[3], "grafana");
assert_eq!(containers[4], "mempool-web");
}
}

View File

@@ -135,9 +135,14 @@ impl HealthMonitor {
HealthStatus::Unhealthy => {
consecutive_failures += 1;
if consecutive_failures >= max_failures {
error!("Container {} is unhealthy after {} failures",
error!("Container {} is unhealthy after {} failures",
self.container_name, consecutive_failures);
// TODO: Trigger auto-restart or alert
// Auto-restart is handled by the orchestrator-level health monitor
// (core/archipelago/src/health_monitor.rs) which runs every 60s,
// checks all container states via `podman ps`, and restarts
// exited containers with exponential backoff (10s/30s/90s).
// This per-container monitor is for manifest-driven health
// tracking and status change callbacks only.
}
}
_ => {}

View File

@@ -1,18 +0,0 @@
[package]
name = "archipelago-parmanode"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
anyhow = "1.0"
thiserror = "1.0"
archipelago-container = { path = "../container" }
log = "0.4"
tracing = "0.1"
[lib]
name = "archipelago_parmanode"
path = "src/lib.rs"

View File

@@ -1,101 +0,0 @@
// Parmanode to App Manifest converter
// Converts Parmanode module structure to Archipelago app manifest format
use archipelago_container::AppManifest;
use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
use tracing::info;
pub struct ParmanodeConverter;
impl ParmanodeConverter {
pub fn new() -> Self {
Self
}
/// Convert a Parmanode module directory to an App Manifest
pub async fn convert_to_manifest(&self, module_path: &PathBuf) -> Result<AppManifest> {
info!("Converting Parmanode module to manifest: {:?}", module_path);
// Read Parmanode module metadata if available
let module_name = module_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// Try to detect what the module installs
let install_script = module_path.join("install.sh");
let script_content = if install_script.exists() {
fs::read_to_string(&install_script).await.ok()
} else {
None
};
// Infer app details from script content
let (app_id, image) = self.infer_from_script(&script_content)?;
// Create a basic manifest
let manifest_yaml = format!(
r#"
app:
id: {}
name: {}
version: 1.0.0
description: Converted from Parmanode module
container:
image: {}
pull_policy: if-not-present
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
"#,
app_id, module_name, image
);
AppManifest::from_str(&manifest_yaml)
.context("Failed to create manifest from Parmanode module")
}
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
let content = script_content.as_deref().unwrap_or("");
// Try to detect Bitcoin Core
if content.contains("bitcoind") || content.contains("bitcoin-core") {
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:24.0".to_string()));
}
// Try to detect LND
if content.contains("lnd") && !content.contains("lightning") {
return Ok(("lnd".to_string(), "lightninglabs/lnd:v0.18.0".to_string()));
}
// Try to detect Core Lightning
if content.contains("clightning") || content.contains("core-lightning") {
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:v23.08.2".to_string()));
}
// Try to detect Electrs
if content.contains("electrs") {
return Ok(("electrs".to_string(), "romanz/electrs:v0.10.0".to_string()));
}
// Default fallback — pin Alpine to a specific version
Ok(("parmanode-module".to_string(), "alpine:3.19".to_string()))
}
}
impl Default for ParmanodeConverter {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,5 +0,0 @@
pub mod script_runner;
pub mod converter;
pub use script_runner::ParmanodeScriptRunner;
pub use converter::ParmanodeConverter;

View File

@@ -1,128 +0,0 @@
// Parmanode script runner - executes Parmanode installation scripts in containers
// Provides compatibility layer for existing Parmanode modules
use archipelago_container::PodmanClient;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
use tokio::fs;
use tracing::{info, warn};
pub struct ParmanodeScriptRunner {
_podman: PodmanClient,
_scripts_dir: PathBuf,
}
impl ParmanodeScriptRunner {
pub fn new(scripts_dir: PathBuf) -> Self {
Self {
_podman: PodmanClient::new("archipelago".to_string()),
_scripts_dir: scripts_dir,
}
}
/// Detect if a path contains a Parmanode script
pub fn is_parmanode_script(&self, path: &PathBuf) -> bool {
// Check for common Parmanode script patterns
path.file_name()
.and_then(|name| name.to_str())
.map(|name| {
name.ends_with(".sh") && (
name.contains("parmanode") ||
name.contains("bitcoin") ||
name.contains("lightning") ||
name.contains("electrs")
)
})
.unwrap_or(false)
}
/// Run a Parmanode script in an isolated container
pub async fn run_script(&self, script_path: &PathBuf) -> Result<()> {
info!("Running Parmanode script: {:?}", script_path);
// Create a temporary container manifest for the script
let script_name = script_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("parmanode-script");
// Create a minimal container to run the script
let _container_name = format!("parmanode-{}", script_name);
// Copy script to a location accessible by containers
let script_content = fs::read_to_string(script_path).await
.context("Failed to read Parmanode script")?;
// Create a wrapper script that runs in Alpine
let wrapper_script = format!(
r#"#!/bin/sh
set -e
{}
"#,
script_content
);
// Write wrapper to temp location
let temp_script = format!("/tmp/parmanode-{}.sh", script_name);
fs::write(&temp_script, wrapper_script).await
.context("Failed to write wrapper script")?;
// Make executable
Command::new("chmod")
.arg("+x")
.arg(&temp_script)
.output()
.context("Failed to make script executable")?;
// Run script in a temporary Alpine container
let output = Command::new("podman")
.arg("run")
.arg("--rm")
.arg("--volume")
.arg(format!("{}:/script.sh:ro", temp_script))
.arg("alpine:latest")
.arg("sh")
.arg("/script.sh")
.output()
.context("Failed to execute Parmanode script in container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Parmanode script failed: {}", stderr));
}
info!("Parmanode script completed successfully");
Ok(())
}
/// Install a Parmanode module (runs script and sets up container)
pub async fn install_module(&self, module_path: &PathBuf) -> Result<String> {
// Find the main installation script
let install_script = module_path.join("install.sh");
if !install_script.exists() {
return Err(anyhow::anyhow!("No install.sh found in Parmanode module"));
}
// Run the installation script
self.run_script(&install_script).await?;
// Try to convert to app manifest for future management
let converter = crate::converter::ParmanodeConverter::new();
match converter.convert_to_manifest(module_path).await {
Ok(manifest) => {
info!("Converted Parmanode module to app manifest");
// TODO: Save manifest for future use
Ok(manifest.app.id)
}
Err(e) => {
warn!("Failed to convert Parmanode module: {}", e);
// Return a generic ID
Ok(format!("parmanode-{}",
module_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("module")))
}
}
}
}

View File

@@ -1,4 +1,4 @@
FROM 80.71.235.15:3000/archipelago/nginx:1.29.6-alpine
FROM 80.71.235.15:3000/archipelago/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/

View File

@@ -1,4 +1,4 @@
FROM 80.71.235.15:3000/archipelago/nginx:1.29.6-alpine
FROM 80.71.235.15:3000/archipelago/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/

View File

@@ -1,4 +1,4 @@
FROM 80.71.235.15:3000/archipelago/nginx:1.29.6-alpine
FROM 80.71.235.15:3000/archipelago/nginx:1.27.4-alpine
# Copy the HTML file
COPY index.html /usr/share/nginx/html/

View File

@@ -0,0 +1,96 @@
# Beta Test Issues — 2026-03-28 (ISO build 2137)
Hardware: Dell OptiPlex 3020M, i5, 8GB RAM, 465G HDD, UEFI+Legacy
## ISO / Boot (image-recipe)
### 1. UEFI autodetect broken
- **Severity**: High
- **Detail**: Only autodetects/boots in Legacy BIOS mode. UEFI boot does not autodetect the install disk.
- **Where**: `build-auto-installer-iso.sh` GRUB config, EFI boot chain
- **Status**: TODO
### 2. Installation TUI screens need redesign
- **Severity**: Medium
- **Detail**: Current installer output is plain/ugly. Needs polished design.
- **Action**: User will provide .md mockup for each screen, then we implement.
- **Where**: `build-auto-installer-iso.sh` auto-install.sh embedded script
- **Status**: AWAITING DESIGN
### 3. No TUI animations
- **Severity**: Low
- **Detail**: Would like Claude-style spinner/progress animations during install. May not be possible with bash.
- **Where**: auto-install.sh
- **Status**: TODO (investigate)
### 4. USB read errors on boot
- **Severity**: Medium (cosmetic but bad first impression)
- **Detail**: Read errors scroll on screen during USB boot before installer loads. Scares new users.
- **Where**: Kernel/initramfs boot, possibly `quiet` not suppressing early messages
- **Status**: TODO
### 5. GRUB background tiling + text cutoff
- **Severity**: Medium
- **Detail**: Boot menu background image tiles instead of scaling. Menu text ("Install Archipelago", "Failsafe mode") is cut off.
- **Where**: `branding/grub-theme/`, `boot/grub/grub.cfg`, theme.txt resolution settings
- **Status**: TODO
### 6. USB removal drops to command line
- **Severity**: Medium
- **Detail**: After install completes, removing USB drops to shell before user presses Enter to reboot. Confuses non-technical users.
- **Where**: auto-install.sh — end of install, before `read -s` / `reboot`
- **Status**: TODO
## Frontend / UI (neode-ui)
### 7. Broken splash screen flashes before onboarding
- **Severity**: High
- **Detail**: Black screen with "online/offline" top-right, broken archipelago image top-left, "use arrow keys" text. Flashes briefly before onboarding loads.
- **Where**: Likely `RootRedirect.vue` or `SplashScreen.vue` — routing/transition timing
- **Status**: TODO (reported before, persists)
### 8. Skip buttons still visible in onboarding
- **Severity**: Medium
- **Detail**: Onboarding flow still shows skip buttons. Should be removed for clean UX.
- **Where**: `src/views/onboarding/` components
- **Status**: TODO
### 9. App install UX outdated
- **Severity**: High
- **Detail**: Missing the yellow "Installing..." button that persists across navigation. Apps don't show as "installing" in My Apps view during install.
- **Where**: `src/views/marketplace/`, `src/views/myapps/`, app install store
- **Status**: TODO
### 10. Login requires double Enter
- **Severity**: Medium
- **Detail**: Password field on login page requires pressing Enter twice to submit.
- **Where**: `src/views/LoginView.vue` — form submission handler
- **Status**: TODO (reported before, persists)
### 11. No password setting UI
- **Severity**: High
- **Detail**: No way for user to set/change their password from the web UI. Currently hardcoded `password123`.
- **Where**: Settings view, backend auth API
- **Status**: TODO
### 12. Browser login loops (non-kiosk)
- **Severity**: High
- **Detail**: Logging in from a browser (not kiosk) on the same network redirects back to login in a loop. Kiosk mode works fine.
- **Where**: Auth/session handling — possibly cookie `SameSite` or redirect logic in `RootRedirect.vue`
- **Status**: TODO
### 13. Can't exit input fields with arrow keys
- **Severity**: Medium
- **Detail**: When focused on a text input, up/down arrow keys don't move focus to adjacent UI elements. Stuck in the field.
- **Where**: `useControllerNav.ts` — input field focus trap logic
- **Status**: TODO (reported before, persists)
---
## Summary
| Category | Critical | High | Medium | Low |
|----------|----------|------|--------|-----|
| ISO/Boot | 0 | 1 | 4 | 1 |
| Frontend | 0 | 4 | 3 | 0 |
| **Total** | **0** | **5** | **7** | **1** |

View File

@@ -0,0 +1,117 @@
# Archipelago Installer — Screen Designs
Edit these screens to match your vision. I'll implement exactly what you specify.
Each screen is what the user sees at that moment on the console (80 columns wide).
Constraints: bash TUI only (no ncurses). ANSI colors available:
- `\033[1;37m` = bold white, `\033[1;33m` = bold yellow/orange
- `\033[32m` = green, `\033[31m` = red, `\033[37m` = dim gray
- `\033[0m` = reset. Box-drawing chars: ━ ─ │ ╭ ╮ ╰ ╯ ╔ ╗ ╚ ╝ █ ▓ ░ ▌▐
- Spinners possible: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ or ◐◓◑◒ or |/-\
---
## Screen 1: Welcome / Press Enter
```
(clear screen, centered)
a r c h i p e l a g o
━━━━━━━━━━━━━━━━━━━━━
automatic installer
Press Enter to install | Ctrl+C for shell
```
---
## Screen 2: Detecting Disk
```
a r c h i p e l a g o
━━━━━━━━━━━━━━━━━━━━━
[1/7] Checking tools .............. ✓
[2/7] Detecting disks
Found: /dev/sda (465.8G) — TOSHIBA MQ01ACF0
──────────────────────────────────────────
⚠ All data on /dev/sda will be erased.
Press Enter to install | Ctrl+C to cancel
```
---
## Screen 3: Installing (progress)
```
a r c h i p e l a g o
━━━━━━━━━━━━━━━━━━━━━
[1/7] Checking tools .............. ✓
[2/7] Detecting disks ............. ✓
[3/7] Creating partitions ......... ✓
[4/7] Formatting .................. ✓
[5/7] Installing system ........... ✓
[6/7] Encrypting data partition ◐
AES-256-XTS (AES-NI detected)
──────────────────────────────────────────
```
---
## Screen 4: Bootloader
```
a r c h i p e l a g o
━━━━━━━━━━━━━━━━━━━━━
[1/7] Checking tools .............. ✓
[2/7] Detecting disks ............. ✓
[3/7] Creating partitions ......... ✓
[4/7] Formatting .................. ✓
[5/7] Installing system ........... ✓
[6/7] Encrypting data ............. ✓
[7/7] Installing bootloader ....... ✓
──────────────────────────────────────────
```
---
## Screen 5: Complete
```
a r c h i p e l a g o
━━━━━━━━━━━━━━━━━━━━━
Installation Complete
After reboot, open the Web UI from any device:
http://192.168.1.198
SSH: ssh archipelago@192.168.1.198
Password: archipelago
Web Login: password123
──────────────────────────────────────────
>>> REMOVE THE USB DRIVE NOW <<<
Press Enter to reboot
```
---
## Notes for Dorian
- Edit any screen above to match what you want to see
- Add/remove steps, change wording, change layout
- Specify colors per line if you want (e.g. "this line in yellow")
- I can add a spinner animation on the active step
- Box-drawing, progress bars, anything bash can render is fair game
- Once you're happy with the designs I'll implement them exactly

View File

@@ -18,6 +18,7 @@
| **TASK-12** | **Beta telemetry — reporter + toggle + collector POST** | **P1** | IN PROGRESS | - |
| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 |
| **TASK-42** | **LUKS2 full-partition encryption for /var/lib/archipelago/** | **P1** | IN PROGRESS | - |
| **TASK-49** | **Container app reliability — bulletproof installs + recovery** | **P0** | PLANNED | - |
| **BUG-44** | **App iframe shows blank/broken when container is starting or crashed** | **P2** | PLANNED | - |
| **TASK-45** | **Deploy script: auto-chown data dirs after rootful→rootless migration** | **P2** | PLANNED | - |
| **BUG-46** | **FileBrowser missing in unbundled ISO + Cloud auto-login broken** | **P1** | IN PROGRESS | - |
@@ -149,6 +150,99 @@ Encrypt all Archipelago app data at rest using LUKS2 full-partition encryption.
- `core/archipelago/src/api/rpc/system.rs` — password change handler
- `core/archipelago/src/server.rs` — startup checks
### TASK-49: Container app reliability — bulletproof installs + recovery (PLANNED)
**Priority**: P0 — Critical
**Status**: PLANNED (2026-03-29)
Every marketplace app must install cleanly, survive failures, auto-recover from unhealthy states, and uninstall without residue. Currently: some apps fail silently, health checks are inconsistent, and there's no systematic testing.
**Scope**: All 25+ marketplace apps — install, health, restart, uninstall, dependency chains.
#### Phase A: Audit & Fix Install Flow (Days 1-2)
Test every app install on a fresh .198 node. Fix failures as found.
- [ ] **A1**: Create install test matrix — spreadsheet of all apps with columns: installs?, starts?, healthy?, UI loads?, uninstalls?, deps correct?
- [ ] **A2**: Test core apps: Bitcoin Knots, LND, Mempool, BTCPay, Electrumx, FileBrowser
- [ ] **A3**: Test recommended apps: Fedimint, Vaultwarden, Grafana, SearXNG, Tailscale, Portainer
- [ ] **A4**: Test optional apps: Home Assistant, Jellyfin, PhotoPrism, Nextcloud, Ollama, Immich, Penpot, OnlyOffice
- [ ] **A5**: Test web-only/L484 apps: noStrudel, BotFights, NWNN, IndeedHub, DWN
- [ ] **A6**: Test Nostr relay (nostr-rs-relay) install + relay functionality
- [ ] **A7**: Fix all install failures found in A2-A6
#### Phase B: Health Checks & Restart Policies (Days 2-3)
Ensure every container has proper health checks and restart policies.
- [ ] **B1**: Audit all container manifests for `--health-cmd`, `--health-interval`, `--health-retries`
- [ ] **B2**: Add health checks to containers missing them (curl endpoint or process check)
- [ ] **B3**: Verify `--restart unless-stopped` on all containers
- [ ] **B4**: Test failure recovery: `podman kill <container>` → verify auto-restart
- [ ] **B5**: Test OOM recovery: set low memory limit → trigger OOM → verify restart
- [ ] **B6**: Verify container-doctor.sh runs on timer and fixes unhealthy containers
- [ ] **B7**: Verify reconcile-containers.sh detects and recreates missing containers
#### Phase C: Dependency Chain Validation (Day 3)
Apps with dependencies (BTCPay→Bitcoin+Postgres, Mempool→Bitcoin+MariaDB) must handle missing deps gracefully.
- [ ] **C1**: Map all dependency chains (which app needs which)
- [ ] **C2**: Test installing dependent app without dependency → verify error message
- [ ] **C3**: Test stopping dependency while dependent is running → verify graceful degradation
- [ ] **C4**: Test restarting dependency → verify dependent reconnects automatically
- [ ] **C5**: Ensure backend `dependency_resolver.rs` handles all chains correctly
#### Phase D: Uninstall & Cleanup (Day 4)
Every app must uninstall cleanly — no orphaned volumes, networks, or config.
- [ ] **D1**: Test uninstall for each app — verify container, volumes, config removed
- [ ] **D2**: Verify no orphaned podman volumes after uninstall (`podman volume ls`)
- [ ] **D3**: Verify no orphaned networks after uninstall
- [ ] **D4**: Test reinstall after uninstall — must work cleanly
- [ ] **D5**: Fix any cleanup issues found
#### Phase E: Stress & Soak Testing (Day 5)
Multi-day uptime test with all core apps running.
- [ ] **E1**: Install all core + recommended apps on .198
- [ ] **E2**: Let run for 24h — check for crashes, memory leaks, disk growth
- [ ] **E3**: Simulate power failure (hard reboot) — verify all apps come back
- [ ] **E4**: Simulate network failure — verify apps recover when network returns
- [ ] **E5**: Run container-doctor after soak test — should report all healthy
#### Phase E2: FileBrowser Auto-Login (Day 5)
FileBrowser must auto-login seamlessly after install — user should never see a separate login screen. Still protected via nginx session cookie validation.
- [ ] **E2a**: Fix FileBrowser auto-login flow: nginx auth_request validates Archipelago session, injects FileBrowser auth token
- [ ] **E2b**: Verify auto-login works on fresh bundled install (first boot)
- [ ] **E2c**: Verify auto-login works on unbundled install (Marketplace install)
- [ ] **E2d**: Verify FileBrowser is NOT accessible without valid Archipelago session (security)
- [ ] **E2e**: Test auto-login after session expiry → re-login to Archipelago → FileBrowser works again
#### Phase F: Frontend UX (Day 5-6)
The UI must accurately reflect container state at all times.
- [ ] **F1**: Installing state persists across navigation (DONE — TASK-49 server store)
- [ ] **F2**: App card shows correct state: stopped, starting, running, unhealthy, crashed
- [ ] **F3**: App iframe shows contextual error when container is down (BUG-44)
- [ ] **F4**: Uninstall progress shown in My Apps
- [ ] **F5**: Error toast when install fails with actionable message
**Key files**:
- `core/archipelago/src/container/` — PodmanClient, manifests, health
- `core/archipelago/src/api/rpc/package/` — install/uninstall RPC handlers
- `scripts/container-doctor.sh` — health check + auto-fix
- `scripts/reconcile-containers.sh` — recreate missing containers
- `scripts/image-versions.sh` — pinned image versions
- `scripts/first-boot-containers.sh` — first-boot container creation
- `neode-ui/src/views/marketplace/` — install UI
- `neode-ui/src/views/apps/` — My Apps state display
**Testing approach**:
- Fresh .198 install as test bed
- SSH in, run installs via web UI, check with `podman ps -a`
- Automated: `scripts/container-doctor.sh --local` after each test
- Manual: kill containers, pull power, break networks, verify recovery
---
### BUG-44: App iframe shows blank/broken when container is starting or crashed (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-21)

View File

@@ -1,18 +1,39 @@
#!/bin/bash
#
# Archipelago Main Menu
# Interactive setup wizard for Archipelago Bitcoin Node OS
# archipelago main menu
# interactive setup for archipelago bitcoin node os
#
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Colors (256-color — works on Linux console with fbcon)
O=$'\033[38;5;208m' # Orange
W=$'\033[1;37m' # Bold white
D=$'\033[38;5;242m' # Dim
C=$'\033[38;5;37m' # Cyan
G=$'\033[38;5;35m' # Green
R=$'\033[38;5;196m' # Red
Y=$'\033[38;5;220m' # Yellow
N=$'\033[0m' # Reset
# Adaptive centering
get_width() { TW=$(tput cols 2>/dev/null || echo 60); [ "$TW" -gt 120 ] && TW=120; }
get_width
cc() { local s=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g'); local p=$(( (TW - ${#s}) / 2 )); [ $p -lt 0 ] && p=0; printf "%*s" "$p" ""; echo -e "$1"; }
# Box helpers (Claude-style rounded corners)
bw() { echo $((TW > 52 ? 52 : TW - 4)); }
btop() { local w=$(bw); local t="╭"; for i in $(seq 1 $((w-2))); do t="${t}"; done; cc "${D}${t}${N}"; }
bbox() { local w=$(bw); local s=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g'); local pad=$((w - 2 - ${#s})); [ $pad -lt 0 ] && pad=0; local r=""; for i in $(seq 1 $pad); do r="${r} "; done; cc "${D}${N} $1${r}${D}${N}"; }
bbot() { local w=$(bw); local b="╰"; for i in $(seq 1 $((w-2))); do b="${b}"; done; cc "${D}${b}${N}"; }
hrule() { local len=$((TW > 50 ? 50 : TW - 4)); local hr=""; for i in $(seq 1 $len); do hr="${hr}"; done; cc "${D}${hr}${N}"; }
# Install required tools on first run (for live mode)
install_required_tools() {
if [ -f /tmp/.archipelago-tools-installed ]; then
return 0
fi
# Check if we need to install tools
local NEED_TOOLS=0
for tool in parted debootstrap mkfs.ext4 mkfs.vfat; do
if ! command -v $tool >/dev/null 2>&1; then
@@ -20,74 +41,58 @@ install_required_tools() {
break
fi
done
if [ $NEED_TOOLS -eq 1 ]; then
echo ""
echo " 📦 Installing required tools (first run)..."
cc "${D}installing required tools...${N}"
echo ""
sudo apt-get update -qq 2>/dev/null
sudo apt-get install -y parted debootstrap dosfstools e2fsprogs 2>/dev/null
echo " ✅ Tools installed"
cc "${G}tools installed${N}"
echo ""
sleep 1
fi
touch /tmp/.archipelago-tools-installed
}
# Run tool installation at startup
install_required_tools
show_banner() {
get_width
clear
echo ""
echo " ╔═══════════════════════════════════════════════════════════╗"
echo " ║ ║"
echo " ║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║"
echo " ║ ║"
echo " ║ Your sovereign Bitcoin infrastructure ║"
echo " ║ ║"
echo " ╚═══════════════════════════════════════════════════════════╝"
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
echo -e " ${O}▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀${N}"
echo -e " ${D}bitcoin node os${N}"
echo ""
}
show_status() {
echo " System Status:"
echo " ─────────────────────────────────────────────────────────────"
# Check if we're in live mode
if [ -d /run/live ]; then
echo " Mode: 🔴 Live (changes won't persist)"
cc "${R}live mode${N} ${D}(changes won't persist)${N}"
else
echo " Mode: 🟢 Installed"
cc "${G}installed${N}"
fi
# Check Podman
if command -v podman >/dev/null 2>&1; then
echo " Podman: 🟢 Installed"
else
echo " Podman: 🔴 Not installed"
echo ""
local podman_ok=0
command -v podman >/dev/null 2>&1 && podman_ok=1
if [ $podman_ok -eq 1 ] && podman ps 2>/dev/null | grep -q bitcoind; then
local blocks=$(podman exec bitcoind bitcoin-cli getblockcount 2>/dev/null || echo "syncing")
cc "${G}bitcoin${N} ${D}running ($blocks blocks)${N}"
elif [ $podman_ok -eq 1 ] && podman ps -a 2>/dev/null | grep -q bitcoind; then
cc "${Y}bitcoin${N} ${D}stopped${N}"
fi
# Check Bitcoin Core
if podman ps 2>/dev/null | grep -q bitcoind; then
BLOCKS=$(podman exec bitcoind bitcoin-cli getblockcount 2>/dev/null || echo "syncing")
echo " Bitcoin: 🟢 Running (blocks: $BLOCKS)"
elif podman ps -a 2>/dev/null | grep -q bitcoind; then
echo " Bitcoin: 🟡 Stopped"
else
echo " Bitcoin: ⚪ Not configured"
if [ $podman_ok -eq 1 ] && podman ps 2>/dev/null | grep -q lnd; then
cc "${G}lightning${N} ${D}running${N}"
elif [ $podman_ok -eq 1 ] && podman ps -a 2>/dev/null | grep -q lnd; then
cc "${Y}lightning${N} ${D}stopped${N}"
fi
# Check LND
if podman ps 2>/dev/null | grep -q lnd; then
echo " Lightning: 🟢 Running"
elif podman ps -a 2>/dev/null | grep -q lnd; then
echo " Lightning: 🟡 Stopped"
else
echo " Lightning: ⚪ Not configured"
fi
echo ""
}
@@ -95,126 +100,112 @@ main_menu() {
while true; do
show_banner
show_status
# Show Web UI URL prominently
# Connection info
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -z "$IP" ] && IP=$(ip -4 addr show | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' | head -1)
echo " ┌─────────────────────────────────────────────────────────────┐"
if [ -n "$IP" ]; then
# Check if backend is running
if pgrep -f "archipelago" >/dev/null 2>&1; then
echo " │ 🌐 Web UI: http://$IP:5678 (running) │"
cc "${C}web ui${N} ${W}http://$IP${N}"
else
echo " │ 🌐 Web UI: http://$IP:5678 (not started) │"
cc "${C}web ui${N} ${D}http://$IP${N} ${Y}(not started)${N}"
fi
echo " │ 📡 SSH: ssh user@$IP (password: archipelago) │"
cc "${C}ssh${N} ${D}archipelago@$IP${N}"
else
echo " │ 🌐 Web UI: (no network) │"
cc "${D}no network detected${N}"
fi
echo " └─────────────────────────────────────────────────────────────┘"
echo ""
echo " Main Menu:"
echo " ─────────────────────────────────────────────────────────────"
hrule
echo ""
echo " r) Refresh - Update IP/status (no restart needed)"
echo " w) Open Web UI - Launch graphical interface"
cc "${D}r${N} refresh status ${D}w${N} start web ui"
echo ""
echo " 1) Install to Disk - Permanently install Archipelago"
echo " 2) Setup Bitcoin Core - Configure Bitcoin full node"
echo " 3) Setup Lightning (LND) - Configure Lightning Network"
echo " 4) Setup BTCPay Server - Bitcoin payment processor"
echo " 5) View Logs - Monitor running services"
echo " 6) Network Settings - Configure networking"
echo " 7) System Info - View system information"
cc "${O}1${N} install to disk ${O}5${N} view logs"
cc "${O}2${N} setup bitcoin core ${O}6${N} network settings"
cc "${O}3${N} setup lightning ${O}7${N} system info"
cc "${O}4${N} setup btcpay server"
echo ""
echo " q) Quit"
cc "${D}q quit${N}"
echo ""
read -p " Select option: " choice
local pad=$(( (TW - 18) / 2 ))
[ $pad -lt 0 ] && pad=0
printf "%*s" "$pad" ""
read -p "select option: " choice
case $choice in
r|R)
# Refresh - just loop again to show updated IP/status
;;
w|W)
echo ""
# Start the real backend on port 5678
if command -v archipelago >/dev/null 2>&1; then
if pgrep -f "archipelago" >/dev/null 2>&1; then
echo " ✅ Archipelago backend already running on port 5678"
cc "${G}backend already running${N}"
else
echo " 🚀 Starting Archipelago backend on port 5678..."
cc "${D}starting backend on port 5678...${N}"
nohup archipelago >/tmp/archipelago.log 2>&1 &
sleep 2
if pgrep -f "archipelago" >/dev/null 2>&1; then
echo " ✅ Backend started!"
cc "${G}backend started${N}"
else
echo " ⚠️ Failed to start backend. Check /tmp/archipelago.log"
cc "${R}failed — see /tmp/archipelago.log${N}"
fi
fi
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo ""
echo " ┌─────────────────────────────────────────────────────────────┐"
echo " │ 🌐 Open in browser: http://$IP:5678 │"
echo " └─────────────────────────────────────────────────────────────┘"
cc "open in browser: ${W}http://$IP${N}"
else
echo " ⚠️ Archipelago binary not found at /usr/local/bin/archipelago"
echo ""
echo " Try running:"
echo " sudo cp /run/live/medium/archipelago/bin/archipelago /usr/local/bin/"
cc "${R}binary not found at /usr/local/bin/archipelago${N}"
fi
echo ""
read -p " Press Enter to continue..."
read -sp " press enter to continue..."
;;
1)
if [ -f "$SCRIPT_DIR/install-to-disk.sh" ]; then
sudo bash "$SCRIPT_DIR/install-to-disk.sh"
else
echo "Installer not found. Running from: $SCRIPT_DIR"
echo " installer not found at: $SCRIPT_DIR"
fi
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
2)
if [ -f "$SCRIPT_DIR/setup-bitcoin.sh" ]; then
bash "$SCRIPT_DIR/setup-bitcoin.sh"
else
echo "Bitcoin setup script not found."
echo " bitcoin setup script not found."
fi
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
3)
if [ -f "$SCRIPT_DIR/setup-lnd.sh" ]; then
bash "$SCRIPT_DIR/setup-lnd.sh"
else
echo "LND setup script not found."
echo " lnd setup script not found."
fi
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
4)
setup_btcpay
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
5)
view_logs
;;
6)
network_settings
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
7)
system_info
read -p "Press Enter to continue..."
read -sp " press enter to continue..."
;;
q|Q)
echo ""
echo " Goodbye! 🏝️"
echo ""
exit 0
;;
*)
echo "Invalid option"
sleep 1
sleep 0.5
;;
esac
done
@@ -222,62 +213,63 @@ main_menu() {
setup_btcpay() {
show_banner
echo " BTCPay Server Setup"
echo " ─────────────────────────────────────────────────────────────"
cc "${W}btcpay server setup${N}"
cc "${D}self-hosted bitcoin payment processor${N}"
echo ""
echo " BTCPay Server is a self-hosted Bitcoin payment processor."
echo ""
if ! podman ps | grep -q bitcoind; then
echo " ⚠️ Bitcoin Core must be running first."
cc "${R}bitcoin core must be running first${N}"
return
fi
read -p " Setup BTCPay Server? [y/N]: " SETUP
local pad=$(( (TW - 30) / 2 ))
[ $pad -lt 0 ] && pad=0
printf "%*s" "$pad" ""
read -p "setup btcpay server? [y/N]: " SETUP
if [[ ! "$SETUP" =~ ^[Yy]$ ]]; then
return
fi
echo ""
echo " 🐳 Pulling BTCPay Server image..."
cc "${D}pulling btcpay server image...${N}"
podman pull "${BTCPAY_IMAGE}"
# Create data directory
mkdir -p ~/.btcpay
echo ""
echo " BTCPay Server setup is more complex and typically uses docker-compose."
echo " For a full setup, visit: https://docs.btcpayserver.org"
cc "${D}full setup: https://docs.btcpayserver.org${N}"
echo ""
}
view_logs() {
show_banner
echo " View Logs"
echo " ─────────────────────────────────────────────────────────────"
cc "${W}view logs${N}"
echo ""
echo " 1) Bitcoin Core logs"
echo " 2) LND logs"
echo " 3) System logs"
echo " b) Back"
cc "${O}1${N} ${D}bitcoin core${N}"
cc "${O}2${N} ${D}lnd${N}"
cc "${O}3${N} ${D}system journal${N}"
cc "${D}b back${N}"
echo ""
read -p " Select: " choice
local pad=$(( (TW - 10) / 2 ))
[ $pad -lt 0 ] && pad=0
printf "%*s" "$pad" ""
read -p "select: " choice
case $choice in
1)
if podman ps -a | grep -q bitcoind; then
podman logs -f --tail 50 bitcoind
else
echo "Bitcoin Core not running"
read -p "Press Enter..."
cc "${D}bitcoin core not running${N}"
read -sp " press enter..."
fi
;;
2)
if podman ps -a | grep -q lnd; then
podman logs -f --tail 50 lnd
else
echo "LND not running"
read -p "Press Enter..."
cc "${D}lnd not running${N}"
read -sp " press enter..."
fi
;;
3)
@@ -288,57 +280,61 @@ view_logs() {
network_settings() {
show_banner
echo " Network Settings"
echo " ─────────────────────────────────────────────────────────────"
cc "${W}network settings${N}"
echo ""
# Show current IP
IP=$(hostname -I | awk '{print $1}')
echo " Current IP: $IP"
cc "${C}ip${N} ${W}$IP${N}"
echo ""
# Show network interfaces
echo " Network Interfaces:"
cc "${D}interfaces:${N}"
ip -br addr | grep -v "^lo" | while read line; do
echo " $line"
cc " ${D}$line${N}"
done
echo ""
echo " Ports in use:"
echo " 8332 - Bitcoin RPC"
echo " 8333 - Bitcoin P2P"
echo " 9735 - Lightning P2P"
echo " 10009 - Lightning gRPC"
echo " 8080 - Lightning REST"
cc "${D}service ports:${N}"
cc " ${D}8332 bitcoin rpc 9735 lightning p2p${N}"
cc " ${D}8333 bitcoin p2p 10009 lightning grpc${N}"
echo ""
}
system_info() {
show_banner
echo " System Information"
echo " ─────────────────────────────────────────────────────────────"
cc "${W}system information${N}"
echo ""
echo " Hostname: $(hostname)"
echo " Kernel: $(uname -r)"
echo " Uptime: $(uptime -p)"
cc "${C}host${N} ${D}$(hostname)${N}"
cc "${C}kernel${N} ${D}$(uname -r)${N}"
cc "${C}uptime${N} ${D}$(uptime -p 2>/dev/null || echo 'unknown')${N}"
echo ""
echo " CPU: $(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)"
echo " Memory: $(free -h | grep Mem | awk '{print $2}') total, $(free -h | grep Mem | awk '{print $3}') used"
local cpu=$(grep "model name" /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs)
[ -n "$cpu" ] && cc "${C}cpu${N} ${D}${cpu}${N}"
local mem_total=$(free -h 2>/dev/null | grep Mem | awk '{print $2}')
local mem_used=$(free -h 2>/dev/null | grep Mem | awk '{print $3}')
[ -n "$mem_total" ] && cc "${C}memory${N} ${D}${mem_used} / ${mem_total}${N}"
echo ""
echo " Disk Usage:"
df -h / | tail -1 | awk '{print " Root: " $3 " / " $2 " (" $5 " used)"}'
cc "${D}disk:${N}"
df -h / | tail -1 | awk '{printf " root: %s / %s (%s used)\n", $3, $2, $5}' | while read line; do
cc "${D}${line}${N}"
done
if [ -d ~/.bitcoin ]; then
echo " Bitcoin: $(du -sh ~/.bitcoin 2>/dev/null | cut -f1)"
local btc_size=$(du -sh ~/.bitcoin 2>/dev/null | cut -f1)
cc " ${D}bitcoin: $btc_size${N}"
fi
echo ""
# Container status
echo " Containers:"
if command -v podman >/dev/null 2>&1; then
podman ps --format " {{.Names}}: {{.Status}}" 2>/dev/null || echo " No containers running"
cc "${D}containers:${N}"
podman ps --format " {{.Names}}: {{.Status}}" 2>/dev/null | while read line; do
cc "${D}${line}${N}"
done
fi
echo ""
}
# Run main menu
main_menu

View File

@@ -142,6 +142,7 @@ cat > /mnt/archipelago/etc/hosts <<EOF
::1 localhost ip6-localhost ip6-loopback
EOF
chmod 644 /mnt/archipelago/etc/hosts
# Install bootloader and essential packages in chroot
echo "📦 Configuring package sources..."

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

View File

@@ -0,0 +1,48 @@
# Archipelago GRUB Theme
# Dark background with Bitcoin orange accents
# Font references removed — GRUB uses whatever fonts are loaded in grub.cfg
title-text: ""
desktop-color: "#0a0a0a"
desktop-image: "background.png"
desktop-image-scale-method: "stretch"
+ boot_menu {
left = 15%
top = 40%
width = 70%
height = 35%
item_color = "#aaaaaa"
selected_item_color = "#f7931a"
item_height = 40
item_spacing = 10
item_padding = 20
scrollbar = false
}
+ label {
left = 25%
top = 20%
width = 50%
text = "a r c h i p e l a g o"
color = "#f7931a"
align = "center"
}
+ label {
left = 25%
top = 28%
width = 50%
text = "bitcoin node os"
color = "#888888"
align = "center"
}
+ label {
left = 25%
top = 90%
width = 50%
text = "use arrow keys to select, enter to boot"
color = "#555555"
align = "center"
}

Binary file not shown.

View File

@@ -0,0 +1,8 @@
[Plymouth Theme]
Name=Archipelago
Description=Archipelago Bitcoin Node OS — cyberpunk boot splash
ModuleName=script
[script]
ImageDir=/usr/share/plymouth/themes/archipelago
ScriptFile=/usr/share/plymouth/themes/archipelago/archipelago.script

View File

@@ -0,0 +1,109 @@
// Archipelago Plymouth Theme — cyberpunk boot splash
// Dark background, neon orange pixel-art logo, animated progress bar
// Screen dimensions
screen_w = Window.GetWidth();
screen_h = Window.GetHeight();
// Background — solid near-black (the GRUB background handles the fancy stuff)
Window.SetBackgroundTopColor(0.02, 0.02, 0.04);
Window.SetBackgroundBottomColor(0.01, 0.01, 0.02);
// Load logo image (generated during build)
logo_image = Image("logo.png");
logo_sprite = Sprite(logo_image);
logo_w = logo_image.GetWidth();
logo_h = logo_image.GetHeight();
logo_sprite.SetX(screen_w / 2 - logo_w / 2);
logo_sprite.SetY(screen_h / 2 - logo_h / 2 - 60);
logo_sprite.SetOpacity(1.0);
// --- Progress bar ---
// Neon orange bar with glow, centered below logo
bar_w = 300;
bar_h = 4;
bar_x = screen_w / 2 - bar_w / 2;
bar_y = screen_h / 2 + logo_h / 2;
// Progress bar background (dark glass)
bar_bg = Image(bar_w, bar_h);
for (x = 0; x < bar_w; x++) {
for (y = 0; y < bar_h; y++) {
bar_bg.SetPixel(x, y, 0.1, 0.1, 0.12, 0.8);
}
}
bar_bg_sprite = Sprite(bar_bg);
bar_bg_sprite.SetX(bar_x);
bar_bg_sprite.SetY(bar_y);
// Progress bar fill (neon orange)
progress_val = 0;
fun refresh_callback() {
// Animate progress smoothly
if (Plymouth.GetMode() == "boot") {
progress_val = progress_val + 0.002;
if (progress_val > 1.0) progress_val = 1.0;
}
fill_w = Math.Int(bar_w * progress_val);
if (fill_w > 0) {
bar_fill = Image(fill_w, bar_h);
for (x = 0; x < fill_w; x++) {
for (y = 0; y < bar_h; y++) {
// Orange: rgb(251, 146, 60) = 0.984, 0.573, 0.235
bar_fill.SetPixel(x, y, 0.984, 0.573, 0.235, 1.0);
}
}
bar_fill_sprite = Sprite(bar_fill);
bar_fill_sprite.SetX(bar_x);
bar_fill_sprite.SetY(bar_y);
bar_fill_sprite.SetZ(1);
}
}
Plymouth.SetRefreshFunction(refresh_callback);
// --- Boot progress callback ---
fun boot_progress_callback(duration, progress) {
progress_val = progress;
}
Plymouth.SetBootProgressFunction(boot_progress_callback);
// --- Status message (below progress bar) ---
msg_sprite = Sprite();
msg_sprite.SetPosition(screen_w / 2, bar_y + 30, 2);
fun message_callback(text) {
// Plymouth passes boot messages here
// We could render them but keeping it clean — just the logo and bar
}
Plymouth.SetMessageFunction(message_callback);
// --- Password prompt (for LUKS) ---
fun display_password_callback(prompt, bullets) {
// LUKS unlock prompt
pass_image = Image.Text(prompt, 0.984, 0.573, 0.235);
pass_sprite = Sprite(pass_image);
pass_sprite.SetX(screen_w / 2 - pass_image.GetWidth() / 2);
pass_sprite.SetY(screen_h / 2 + 80);
// Bullet dots for password
if (bullets > 0) {
bullet_text = "";
for (i = 0; i < bullets; i++) {
bullet_text = bullet_text + "* ";
}
bullet_image = Image.Text(bullet_text, 0.984, 0.573, 0.235);
bullet_sprite = Sprite(bullet_image);
bullet_sprite.SetX(screen_w / 2 - bullet_image.GetWidth() / 2);
bullet_sprite.SetY(screen_h / 2 + 110);
}
}
Plymouth.SetDisplayPasswordFunction(display_password_callback);
// --- Quit callback ---
fun quit_callback() {
logo_sprite.SetOpacity(0);
}
Plymouth.SetQuitFunction(quit_callback);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Archipelago Container Doctor
After=archipelago.service
[Service]
Type=oneshot
# Runs as root: needs to kill orphaned conmon processes, fix permissions
User=root
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
TimeoutStartSec=120
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Archipelago container doctor (periodic)
[Timer]
# First run 5 minutes after boot, then every 30 minutes
OnBootSec=5min
OnUnitActiveSec=30min
# Jitter to avoid load spikes
RandomizedDelaySec=60
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Archipelago Container Reconciliation
After=archipelago.service
[Service]
Type=oneshot
User=archipelago
Environment="XDG_RUNTIME_DIR=/run/user/1000"
Environment="HOME=/home/archipelago"
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ExecStart=/home/archipelago/archy/scripts/reconcile-containers.sh
TimeoutStartSec=600
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Archipelago container reconciliation (periodic)
[Timer]
# First run 10 minutes after boot, then every 6 hours
OnBootSec=10min
OnUnitActiveSec=6h
# Jitter to avoid load spikes
RandomizedDelaySec=300
# Run missed checks on boot
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -9,12 +9,15 @@ User=archipelago
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
# DEV_MODE disabled in production — enabled via override.conf on dev servers
Environment="XDG_RUNTIME_DIR=/run/user/1000"
ExecStartPre=/bin/bash -c 'mkdir -p /run/user/1000 && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
ExecStartPre=/bin/bash -c 'mkdir -p /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
WatchdogSec=300
TimeoutStartSec=300
# Bitcoin Core needs up to 600s to flush UTXO set on shutdown
TimeoutStopSec=660
# Filesystem protection
ProtectSystem=strict
@@ -22,7 +25,7 @@ ProtectSystem=strict
ProtectHome=no
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
# and must be shared between the service and SSH-created containers
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc
# Privilege restriction — restored with rootless podman (no sudo needed)
NoNewPrivileges=yes

View File

@@ -5,7 +5,6 @@ limit_req_zone $binary_remote_addr zone=peer:10m rate=10r/s;
server {
listen 80;
listen 100.91.10.103:80;
server_name _;
root /opt/archipelago/web-ui;
@@ -1077,6 +1076,8 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_read_timeout 86400s;
}
}

207
image-recipe/dev-branding.sh Executable file
View File

@@ -0,0 +1,207 @@
#!/bin/bash
#
# Boot branding dev — iterate on GRUB theme, Plymouth, and installer visuals
# without rebuilding the ISO. Patches an existing ISO and boots in QEMU.
#
# Usage:
# ./dev-branding.sh [path-to-iso]
#
# If no ISO is found locally, downloads the latest from the build server.
# Edit files in branding/, re-run, see changes in ~10 seconds.
#
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
WORK="/tmp/archipelago-dev-branding"
PATCHED="$SCRIPT_DIR/results/archipelago-dev-patched.iso"
CACHED_ISO="$SCRIPT_DIR/results/archipelago-dev-base.iso"
DEV_SERVER="archipelago@192.168.1.228"
SSH_KEY="$HOME/.ssh/archipelago-deploy"
echo ""
echo " Archipelago Boot Branding Dev"
echo ""
# --- Find or download an ISO ---
ISO="${1:-}"
# Search locally
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
for pattern in \
"$HOME/Desktop/archipelago-dev-"*.iso \
"$HOME/Desktop/archipelago-unbundled-"*.iso \
"$HOME/Desktop/archipelago-"*.iso \
"$SCRIPT_DIR/results/archipelago-dev-base.iso" \
"$SCRIPT_DIR/results/archipelago-"*.iso; do
found=$(ls -t $pattern 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found" ]; then
ISO="$found"
break
fi
done
fi
# Download from server if not found
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo " No ISO found locally. Downloading latest from build server..."
REMOTE_ISO=$(ssh -i "$SSH_KEY" "$DEV_SERVER" \
"ls -t /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso 2>/dev/null | head -1" 2>/dev/null)
if [ -z "$REMOTE_ISO" ]; then
REMOTE_ISO=$(ssh -i "$SSH_KEY" "$DEV_SERVER" \
"ls -t /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | head -1" 2>/dev/null)
fi
if [ -n "$REMOTE_ISO" ]; then
mkdir -p "$SCRIPT_DIR/results"
echo " Downloading: $(basename "$REMOTE_ISO")..."
scp -i "$SSH_KEY" "$DEV_SERVER:$REMOTE_ISO" "$CACHED_ISO"
ISO="$CACHED_ISO"
echo " Saved to: $ISO"
else
echo " No ISO on server either. Run a CI build first."
echo " Or place an ISO on your Desktop."
exit 1
fi
fi
echo " Base ISO: $(basename "$ISO") ($(du -h "$ISO" | cut -f1))"
echo ""
# --- Extract ISO ---
echo " [1/3] Extracting ISO..."
if [ -d "$WORK" ]; then
chmod -R u+w "$WORK" 2>/dev/null || true
fi
rm -rf "$WORK"
mkdir -p "$WORK"
xorriso -osirrox on -indev "$ISO" -extract / "$WORK" 2>/dev/null || {
echo " xorriso extraction failed, trying hdiutil..."
MNT=$(mktemp -d)
hdiutil attach "$ISO" -mountpoint "$MNT" -readonly -nobrowse 2>/dev/null || {
echo " Could not mount ISO. Is it corrupt?"
exit 1
}
cp -a "$MNT"/* "$WORK/" 2>/dev/null || true
hdiutil detach "$MNT" 2>/dev/null || true
rmdir "$MNT" 2>/dev/null || true
}
# Ensure files are writable after extraction
chmod -R u+w "$WORK" 2>/dev/null || true
# --- Patch branding ---
echo " [2/3] Patching branding..."
THEME_DST="$WORK/boot/grub/themes/archipelago"
mkdir -p "$THEME_DST"
# GRUB theme.txt
if [ -f "$SCRIPT_DIR/branding/grub-theme/theme.txt" ]; then
cp "$SCRIPT_DIR/branding/grub-theme/theme.txt" "$THEME_DST/"
echo " theme.txt"
fi
# GRUB background — use static file from branding dir
if [ -f "$SCRIPT_DIR/branding/grub-theme/background.png" ]; then
cp "$SCRIPT_DIR/branding/grub-theme/background.png" "$THEME_DST/background.png"
echo " background.png (static)"
elif [ -f "$SCRIPT_DIR/branding/generate-grub-background.py" ]; then
python3 "$SCRIPT_DIR/branding/generate-grub-background.py" "$THEME_DST/background.png" 2>/dev/null
echo " background.png (generated)"
fi
# Plymouth theme
PLYMOUTH_DST="$WORK/archipelago/plymouth-theme"
mkdir -p "$PLYMOUTH_DST"
if [ -d "$SCRIPT_DIR/branding/plymouth-theme" ]; then
cp "$SCRIPT_DIR/branding/plymouth-theme/"* "$PLYMOUTH_DST/" 2>/dev/null || true
echo " plymouth theme"
fi
# --- Repackage ISO ---
echo " [3/3] Repackaging ISO..."
mkdir -p "$SCRIPT_DIR/results"
# Find isohdpfx.bin — project copy first, then system
ISOHDPFX=""
for p in "$SCRIPT_DIR/branding/isohdpfx.bin" \
"$WORK/isolinux/isohdpfx.bin" \
/usr/lib/ISOLINUX/isohdpfx.bin \
/usr/share/syslinux/isohdpfx.bin \
/opt/homebrew/share/syslinux/isohdpfx.bin; do
[ -f "$p" ] && ISOHDPFX="$p" && break
done
if [ -z "$ISOHDPFX" ]; then
echo " ERROR: No isohdpfx.bin found. Cannot create bootable ISO."
echo " Preview only — open the background:"
open "$THEME_DST/background.png" 2>/dev/null || true
exit 1
fi
EFI_IMG="$WORK/boot/grub/efi.img"
if [ -f "$EFI_IMG" ]; then
xorriso -as mkisofs -o "$PATCHED" \
-volid "ARCHIPELAGO" \
-iso-level 3 -J -joliet-long -R \
-isohybrid-mbr "$ISOHDPFX" \
-c isolinux/boot.cat \
-b isolinux/isolinux.bin \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-eltorito-alt-boot \
-e boot/grub/efi.img \
-no-emul-boot -isohybrid-gpt-basdat \
-partition_offset 16 \
"$WORK" 2>/dev/null
else
xorriso -as mkisofs -o "$PATCHED" \
-volid "ARCHIPELAGO" \
-iso-level 3 -J -joliet-long -R \
-isohybrid-mbr "$ISOHDPFX" \
-c isolinux/boot.cat \
-b isolinux/isolinux.bin \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-partition_offset 16 \
"$WORK" 2>/dev/null
fi
echo ""
echo " Patched: $PATCHED ($(du -h "$PATCHED" | cut -f1))"
echo ""
# --- Boot in QEMU ---
if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo " QEMU not found. Install: brew install qemu"
echo " Opening background preview instead..."
open "$THEME_DST/background.png" 2>/dev/null || true
exit 0
fi
echo " Booting in QEMU (BIOS mode — shows ISOLINUX menu)..."
echo " Press Ctrl+C to stop."
echo ""
# Create test disk (use separate disk from other QEMU instances)
DISK="/tmp/archipelago-branding-test.qcow2"
# Kill any leftover QEMU from previous branding test
pkill -f "archipelago-branding-test" 2>/dev/null || true
sleep 1
if [ ! -f "$DISK" ]; then
qemu-img create -f qcow2 "$DISK" 20G 2>/dev/null
fi
# Boot with BIOS to see the ISOLINUX/GRUB menu
qemu-system-x86_64 \
-machine pc \
-m 4G \
-smp 2 \
-boot d \
-cdrom "$PATCHED" \
-drive if=virtio,format=qcow2,file="$DISK" \
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:80 \
-vga virtio \
-display default \
-serial file:/tmp/archipelago-qemu-serial.log
echo ""
echo " QEMU stopped. Serial log: /tmp/archipelago-qemu-serial.log"
echo " Re-run to test again after editing branding files."

View File

@@ -52,6 +52,15 @@ mkdir -p /home/archipelago/.config/systemd/user
# Enable lingering for archipelago user (allows user services to run without login)
loginctl enable-linger archipelago || true
# Ensure /run/user/1000 exists for podman socket
mkdir -p /run/user/1000
chown archipelago:archipelago /run/user/1000
chmod 700 /run/user/1000
# Enable podman API socket for archipelago user (backend connects via this)
su - archipelago -c "XDG_RUNTIME_DIR=/run/user/1000 systemctl --user enable podman.socket" || true
su - archipelago -c "XDG_RUNTIME_DIR=/run/user/1000 systemctl --user start podman.socket" || true
# Set proper permissions
chown -R archipelago:archipelago /home/archipelago/.config
chown -R archipelago:archipelago /home/archipelago/.local

View File

@@ -1,69 +1,107 @@
#!/bin/bash
# Test Archipelago ISO in QEMU
#
# Usage:
# ./test-iso-qemu.sh [path-to-iso] [--bios] [--nographic]
#
# Options:
# --bios Force legacy BIOS mode (default: UEFI)
# --nographic No GUI window, serial console only (great for logging)
#
# Serial log is always written to /tmp/archipelago-qemu-serial.log
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ISO="${1:-$SCRIPT_DIR/results/archipelago-debian-12-x86_64.iso}"
SERIAL_LOG="/tmp/archipelago-qemu-serial.log"
FORCE_BIOS=false
NOGRAPHIC=false
ISO=""
if [ ! -f "$ISO" ]; then
echo "❌ ISO not found: $ISO"
for arg in "$@"; do
case "$arg" in
--bios) FORCE_BIOS=true ;;
--nographic) NOGRAPHIC=true ;;
*) ISO="$arg" ;;
esac
done
# Auto-detect ISO
if [ -z "$ISO" ]; then
ISO=$(ls -t "$SCRIPT_DIR"/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
fi
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
ISO=$(ls -t "$SCRIPT_DIR"/results/archipelago-*.iso 2>/dev/null | head -1)
fi
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo "ISO not found."
echo ""
echo "Usage: $0 [path-to-iso]"
echo "Usage: $0 [path-to-iso] [--bios] [--nographic]"
echo ""
echo "Build the ISO first with: ./build-debian-iso.sh"
echo "Or place an ISO in: $SCRIPT_DIR/results/"
exit 1
fi
echo "🧪 Testing Archipelago ISO in QEMU"
echo "📀 ISO: $ISO"
echo "💾 RAM: 4GB"
echo "🖥️ CPU: 2 cores"
echo ""
echo "Press Ctrl+Alt+G to release mouse/keyboard from VM"
echo "Press Ctrl+C in this terminal to stop VM"
echo "Testing Archipelago ISO in QEMU"
echo " ISO: $ISO"
echo " Size: $(du -h "$ISO" | cut -f1)"
echo " RAM: 4GB"
echo " CPU: 2 cores"
echo " Serial: $SERIAL_LOG"
echo ""
# Create test disk if it doesn't exist
DISK="/tmp/archipelago-test-disk.qcow2"
if [ ! -f "$DISK" ]; then
echo "Creating test disk..."
echo "Creating 20GB test disk..."
qemu-img create -f qcow2 "$DISK" 20G
fi
echo "Starting VM in 3 seconds..."
sleep 3
# Common QEMU args
QEMU_ARGS=(
-m 4G
-smp 2
-boot d
-cdrom "$ISO"
-drive if=virtio,format=qcow2,file="$DISK"
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:80
-serial file:"$SERIAL_LOG"
)
# Run QEMU with UEFI (more modern, matches real hardware)
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
# macOS with Homebrew QEMU
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
# Linux with OVMF
OVMF="/usr/share/OVMF/OVMF_CODE.fd"
# Display mode
if [ "$NOGRAPHIC" = true ]; then
QEMU_ARGS+=(-nographic -append "console=ttyS0")
else
# Fall back to legacy BIOS
echo "⚠️ UEFI firmware not found, using legacy BIOS..."
qemu-system-x86_64 \
-machine pc \
-m 4G \
-smp 2 \
-boot d \
-cdrom "$ISO" \
-drive if=virtio,format=qcow2,file="$DISK" \
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:8100 \
-vga virtio \
-display default
exit 0
QEMU_ARGS+=(-vga virtio -display default)
fi
# UEFI boot
qemu-system-x86_64 \
-machine q35 \
-bios "$OVMF" \
-m 4G \
-smp 2 \
-boot d \
-cdrom "$ISO" \
-drive if=virtio,format=qcow2,file="$DISK" \
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:8100 \
-vga virtio \
-display default
echo "Starting VM..."
echo "(Serial console logging to $SERIAL_LOG)"
echo "(Press Ctrl+Alt+G to release mouse, Ctrl+C to stop VM)"
echo ""
# Detect UEFI firmware
OVMF=""
if [ "$FORCE_BIOS" = false ]; then
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
OVMF="/usr/share/OVMF/OVMF_CODE.fd"
fi
fi
if [ -n "$OVMF" ]; then
echo " Boot: UEFI ($OVMF)"
qemu-system-x86_64 \
-machine q35 \
-drive if=pflash,format=raw,readonly=on,file="$OVMF" \
"${QEMU_ARGS[@]}"
else
echo " Boot: Legacy BIOS"
qemu-system-x86_64 \
-machine pc \
"${QEMU_ARGS[@]}"
fi
echo ""
echo "VM stopped. Serial log: $SERIAL_LOG"
echo "Last 20 lines:"
tail -20 "$SERIAL_LOG" 2>/dev/null

View File

@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.ld9oh2eb91o"
"revision": "0.huo00jkc7v4"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -0,0 +1,528 @@
# Gamepad Navigation Map
Every arrow key, every position, every page.
`[C]` = Container (red tile, D-pad grid)
`[N]` = Nav bar item (secondary, reached via Up from top row)
`[Y]` = Inner control (entered via Enter on container, exited via Escape)
`[S]` = Sidebar item
---
## Sidebar (all pages)
Vertical list. Up/Down wrap. Right enters page. Left does nothing.
| Position | Up | Down | Right | Left |
|------------|------------|------------|----------------|---------|
| Home | Logout | Apps | First [C] | nothing |
| Apps | Home | Cloud | First [C] | nothing |
| Cloud | Apps | Mesh | First [C] | nothing |
| Mesh | Cloud | Network | First [C] | nothing |
| Network | Mesh | Web5 | First [C] | nothing |
| Web5 | Network | Fleet | First [C] | nothing |
| Fleet | Web5 | Settings | First [C] | nothing |
| Settings | Fleet | AIUI | First [C] | nothing |
| AIUI | Settings | Logout | First [C] | nothing |
| Logout | AIUI | Home | First [C] | nothing |
---
## HOME `/dashboard`
### Nav bar `[N]`
```
[N] Dashboard [N] Setup
```
### Grid `[C]`
```
Row 1: [C] My Apps [C] Cloud
Row 2: [C] Network [C] Wallet
Row 3: [C] System
Row 4: [C] Quick Start (full-width, if visible)
```
| Position | Up | Down | Left | Right | Enter |
|---------------|--------------|--------------|------------|----------|---------------------------|
| [N] Dashboard | nothing | My Apps | nothing | Setup | Switch tab |
| [N] Setup | nothing | My Apps | Dashboard | nothing | Switch tab |
| My Apps | [N] bar | Network | Sidebar | Cloud | /dashboard/apps |
| Cloud | [N] bar | Wallet | My Apps | nothing | /dashboard/cloud |
| Network | My Apps | System | Sidebar | Wallet | /dashboard/server |
| Wallet | Cloud | nothing | Network | nothing | /dashboard/web5 |
| System | Network | Quick Start | Sidebar | nothing | /dashboard/settings |
| Quick Start | System | nothing | Sidebar | nothing | Drill into [Y] |
### Quick Start `[Y]` inner controls
```
[Y] Open a Shop [Y] Accept Payments [Y] File Browser
```
| Position | Left | Right | Escape |
|------------------|------------------|------------------|----------------|
| Open a Shop | nothing | Accept Payments | Back to [C] |
| Accept Payments | Open a Shop | File Browser | Back to [C] |
| File Browser | Accept Payments | nothing | Back to [C] |
---
## APPS `/dashboard/apps`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] All [N] Bitcoin [N] Social (etc) | [N] Search
```
Three groups: page tabs, category filters (dynamic), search input.
| Position | Up | Down | Left | Right | Enter |
|----------------|---------|---------|----------------|----------------|--------------------|
| [N] My Apps | nothing | App1 | nothing | App Store | Switch tab |
| [N] App Store | nothing | App1 | My Apps | Services | /dashboard/discover|
| [N] Services | nothing | App1 | App Store | All filter | Switch tab |
| [N] All | nothing | App1 | Services | Bitcoin (etc) | Filter |
| [N] Search | nothing | App1 | last filter | nothing | Type text |
### Grid `[C]` (3-col)
```
Row 1: [C] App1 [C] App2 [C] App3
Row 2: [C] App4 [C] App5 [C] App6
(etc)
```
| Position | Up | Down | Left | Right | Enter |
|----------------|------------------|-----------|-----------|----------|-----------------|
| App1 (row 1) | [N] bar My Apps | App4 | Sidebar | App2 | Launch app |
| App2 (row 1) | [N] bar My Apps | App5 | App1 | App3 | Launch app |
| App3 (row 1) | [N] bar My Apps | App6 | App2 | nothing | Launch app |
| App4 (row 2) | App1 | App7 | Sidebar | App5 | Launch app |
| App5 (row 2) | App2 | App8 | App4 | App6 | Launch app |
| App6 (row 2) | App3 | App9 | App5 | nothing | Launch app |
| (etc) | above | below | left/side | right | Launch app |
### App `[Y]` inner controls (if no launch action)
```
[Y] Stop [Y] Restart [Y] Uninstall
```
Escape exits back to [C] app card.
---
## CLOUD `/dashboard/cloud`
No nav bar.
### Grid `[C]` (3-col)
```
Row 1: [C] Photos [C] Music [C] Documents
Row 2: [C] Files [C] Peer1 [C] Peer2 (etc)
```
| Position | Up | Down | Left | Right | Enter |
|-------------|------------|-----------|-----------|------------|-------------------|
| Photos | nothing | Files | Sidebar | Music | Open section |
| Music | nothing | Peer1 | Photos | Documents | Open section |
| Documents | nothing | Peer2 | Music | nothing | Open section |
| Files | Photos | nothing | Sidebar | Peer1 | Open section |
| Peer1 | Music | nothing | Files | Peer2 | Open peer files |
| Peer2 | Documents | nothing | Peer1 | nothing | Open peer files |
---
## NETWORK `/dashboard/server`
No nav bar.
### Grid `[C]` (2-col)
```
Row 1: [C] Local Network [C] Web3
Row 2: [C] Quick Actions (etc)
```
| Position | Up | Down | Left | Right | Enter |
|----------------|-----------|---------------|-----------|-----------|------------------|
| Local Network | nothing | Quick Actions | Sidebar | Web3 | Drill into [Y] |
| Web3 | nothing | Quick Actions | Local Net | nothing | Drill into [Y] |
| Quick Actions | Local Net | nothing | Sidebar | nothing | Drill into [Y] |
---
## WEB5 `/dashboard/web5`
No nav bar. Containers from child components stacked vertically + side-by-side.
### Grid `[C]`
```
Row 1: [C] Action1 [C] Action2 [C] Action3 [C] Action4 [C] Action5 [C] Action6
Row 2: [C] Wallet [C] Domains
Row 3: [C] Nostr Relays [C] Node Visibility
Row 4: [C] Connected Nodes
```
Standard spatial grid nav. Left from leftmost = Sidebar. Enter = drill into [Y] controls.
---
## DISCOVER `/dashboard/discover`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] Category filters (etc)
```
### Grid `[C]` (3-col)
```
Row 0: [C] Featured1 [C] Featured2 [C] Featured3
Row 1: [C] App1 [C] App2 [C] App3
(etc)
```
| Position | Up | Down | Left | Right | Enter |
|--------------|-------------|----------|-----------|------------|---------------|
| [N] tabs | nothing | Featured1| left tab | right tab | Switch/filter |
| Featured1 | [N] bar | App1 | Sidebar | Featured2 | View details |
| App1 | Featured1 | App4 | Sidebar | App2 | Install |
| (etc) | above | below | left/side | right | Install |
---
## MESH `/dashboard/mesh`
### Grid `[C]`
```
Row 1: [C] Device Status [C] Chat Panel
Row 2: [C] Peers List [C] Tab Panel (Bitcoin/Dead Man/Map)
```
Spatial grid nav. Enter = drill into controls.
---
## FLEET `/dashboard/fleet`
### Grid `[C]`
```
Row 1: [C] Nodes [C] Online [C] Offline [C] Health
Row 2: [C] Node1 [C] Node2 [C] Node3 (etc)
```
Spatial grid nav. Enter = view node details.
---
## SETTINGS `/dashboard/settings`
### Grid `[C]` (vertical stack)
```
Row 1: [C] Account Info
Row 2: [C] Change Password
Row 3: [C] Two-Factor Auth
Row 4: [C] System Info
Row 5: [C] Danger Zone
```
| Position | Up | Down | Left | Right | Enter |
|-------------------|-----------------|------------------|---------|---------|------------------|
| Account Info | nothing | Change Password | Sidebar | nothing | Drill into [Y] |
| Change Password | Account Info | Two-Factor | Sidebar | nothing | Drill into [Y] |
| Two-Factor | Change Password | System Info | Sidebar | nothing | Drill into [Y] |
| System Info | Two-Factor | Danger Zone | Sidebar | nothing | Drill into [Y] |
| Danger Zone | System Info | nothing | Sidebar | nothing | Drill into [Y] |
---
## LOGIN `/login`
No sidebar, no grid. Three modes on the same route.
`[B]` = Button `[I]` = Input field `[L]` = Link
### Set Password (first visit after onboarding)
Auto-focus: `[I] Password`
```
[I] Password
[I] Confirm Password
[B] Set Password
[L] Replay Intro [L] Restart Onboarding
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|---------------------|---------------------|-------------------|---------------------|--------------------|
| [I] Password | nothing | [I] Confirm | nothing | nothing | Type / Down |
| [I] Confirm | [I] Password | [B] Set Password | nothing | nothing | Type / Down |
| [B] Set Password | [I] Confirm | [L] Replay Intro | nothing | nothing | Submit |
| [L] Replay Intro | [B] Set Password | nothing | nothing | [L] Restart | Replay intro |
| [L] Restart | [B] Set Password | nothing | [L] Replay Intro | nothing | Restart onboarding |
### Normal Login
Auto-focus: `[I] Password`
```
[I] Password
[B] Login
[L] Replay Intro [L] Restart Onboarding
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|------------------|------------------|-------------------|---------------------|---------------|
| [I] Password | nothing | [B] Login | nothing | nothing | Type / Down |
| [B] Login | [I] Password | [L] Replay Intro | nothing | nothing | Submit |
| [L] Replay Intro | [B] Login | nothing | nothing | [L] Restart | Replay intro |
| [L] Restart | [B] Login | nothing | [L] Replay Intro | nothing | Restart |
### TOTP Verification (after password accepted)
Auto-focus: `[I] TOTP Code`
```
[I] TOTP Code
[B] Verify
[L] Use Backup Code
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|------------------|------------------|---------|---------|--------------------|
| [I] TOTP Code | nothing | [B] Verify | nothing | nothing | Type / Down |
| [B] Verify | [I] TOTP Code | [L] Backup Code | nothing | nothing | Submit |
| [L] Use Backup Code | [B] Verify | nothing | nothing | nothing | Toggle backup mode |
---
## ONBOARDING `/onboarding/*`
No sidebar, no grid. Sequential wizard screens.
`[B]` = Button `[I]` = Input field `[C]` = Selectable card `[L]` = Link
**Global onboarding rules:**
- No sidebar or nav bar on any onboarding screen.
- First interactive element auto-focused on each screen (inputs when present, otherwise primary button).
- B button (Escape) = go back to previous onboarding step (where applicable).
- D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. Up/Down exits a focused input to the adjacent element.
- Enter on an input = submit if it's the last field, otherwise move to next field.
- Enter activates the focused element.
---
### INTRO `/onboarding/intro`
Default focus: `[B] Unlock`
```
[B] Unlock your sovereignty
[L] Restore from backup
```
| Position | Up | Down | Left | Right | Enter |
|-------------------|-----------------|-----------------|---------|---------|------------------------------|
| [B] Unlock | nothing | [L] Restore | nothing | nothing | → /onboarding/path |
| [L] Restore | [B] Unlock | nothing | nothing | nothing | Show restore panel |
#### Restore Panel `[Y]` (shown after activating Restore link)
```
[I] File picker
[I] Passphrase
[B] Cancel [B] Restore
```
| Position | Up | Down | Left | Right | Enter | Escape |
|-------------------|-----------------|-----------------|------------|------------|--------------------|----------------|
| [I] File picker | nothing | [I] Passphrase | nothing | nothing | Open file dialog | Close panel |
| [I] Passphrase | [I] File picker | [B] Cancel | nothing | nothing | Type / Down | Close panel |
| [B] Cancel | [I] Passphrase | nothing | nothing | [B] Restore| Close panel | Close panel |
| [B] Restore | [I] Passphrase | nothing | [B] Cancel | nothing | Submit restore | Close panel |
---
### PATH `/onboarding/path`
Default focus: `[C] Fresh Start`
```
[C] Fresh Start [C] Restore (disabled) [C] Connect (disabled)
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|-----------------|---------------|-------------------|-------------------|------------------------|
| [C] Fresh Start | nothing | [B] Continue | nothing | [C] Restore | Select option |
| [C] Restore | nothing | [B] Continue | [C] Fresh Start | [C] Connect | nothing (disabled) |
| [C] Connect | nothing | [B] Continue | [C] Restore | nothing | nothing (disabled) |
| [B] Continue | [C] Fresh Start | nothing | nothing | nothing | → /login (complete) |
---
### OPTIONS `/onboarding/options`
Default focus: `[C] Sovereignty`
```
Row 1: [C] Sovereignty [C] Commerce [C] Projects
Row 2: [C] Transmitter [C] Hoster [C] AI
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|------------------|------------------|--------------------|
| [C] Sovereignty | nothing | [C] Transmitter | nothing | [C] Commerce | nothing (display) |
| [C] Commerce | nothing | [C] Hoster | [C] Sovereignty | [C] Projects | nothing (display) |
| [C] Projects | nothing | [C] AI | [C] Commerce | nothing | nothing (display) |
| [C] Transmitter | [C] Sovereignty | [B] Continue | nothing | [C] Hoster | nothing (display) |
| [C] Hoster | [C] Commerce | [B] Continue | [C] Transmitter | [C] AI | nothing (display) |
| [C] AI | [C] Projects | [B] Continue | [C] Hoster | nothing | nothing (display) |
| [B] Continue | [C] Transmitter | nothing | nothing | nothing | → /onboarding/did |
---
### DID `/onboarding/did`
**Loading state:** No interactive elements. Auto-advances when generation completes.
**After generation:**
Default focus: `[B] Continue`
```
[B] Copy DID
[B] Copy Nostr (if available)
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|---------|---------|-----------------------------|
| [B] Copy DID | nothing | [B] Copy Nostr | nothing | nothing | Copy to clipboard |
| [B] Copy Nostr | [B] Copy DID | [B] Continue | nothing | nothing | Copy to clipboard |
| [B] Continue | [B] Copy Nostr | nothing | nothing | nothing | → /onboarding/identity |
If no Nostr ID: `[B] Copy DID` → Down → `[B] Continue` directly.
---
### IDENTITY `/onboarding/identity`
Auto-focus: `[I] Name`
```
[I] Identity Name
[C] Personal [C] Business [C] Anonymous
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|-----------------|-----------------|-----------------------------|
| [I] Name | nothing | [C] Personal | nothing | nothing | Type / Down |
| [C] Personal | [I] Name | [B] Continue | nothing | [C] Business | Select purpose |
| [C] Business | [I] Name | [B] Continue | [C] Personal | [C] Anonymous | Select purpose |
| [C] Anonymous | [I] Name | [B] Continue | [C] Business | nothing | Select purpose |
| [B] Continue | [C] Personal | nothing | nothing | nothing | → /onboarding/backup |
---
### BACKUP `/onboarding/backup`
Auto-focus: `[I] Passphrase`
```
[I] Passphrase
[B] Download Backup
[B] Continue (disabled until downloaded)
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|---------|---------|-----------------------------|
| [I] Passphrase | nothing | [B] Download | nothing | nothing | Type / Down |
| [B] Download | [I] Passphrase | [B] Continue | nothing | nothing | Create & download backup |
| [B] Continue | [B] Download | nothing | nothing | nothing | → /onboarding/verify |
`[B] Continue` disabled (skip focus) until backup downloaded.
---
### VERIFY `/onboarding/verify`
**Phase 1 — Signing:**
Default focus: `[B] Sign Challenge`
```
[B] Sign Challenge
```
| Position | Up | Down | Left | Right | Enter |
|----------------------|---------|---------|---------|---------|------------------------|
| [B] Sign Challenge | nothing | nothing | nothing | nothing | Sign crypto challenge |
**Phase 2 — After verification:**
Default focus: `[B] Finish`
```
[B] Finish
```
| Position | Up | Down | Left | Right | Enter |
|-------------|---------|---------|---------|---------|------------------------------|
| [B] Finish | nothing | nothing | nothing | nothing | → /onboarding/done |
---
### DONE `/onboarding/done`
Default focus: `[B] Set Password`
```
[C] Identity [C] Backup [C] Ready
[B] Set Password
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|--------------|------------------|---------------|---------------|----------------------|
| [C] Identity | nothing | [B] Set Password | nothing | [C] Backup | nothing (display) |
| [C] Backup | nothing | [B] Set Password | [C] Identity | [C] Ready | nothing (display) |
| [C] Ready | nothing | [B] Set Password | [C] Backup | nothing | nothing (display) |
| [B] Set Password | [C] Identity | nothing | nothing | nothing | → /login |
---
## Onboarding & Login Rules
1. No sidebar or nav bar — linear wizard flow.
2. First interactive element auto-focused (input fields when present, otherwise primary button).
3. D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. You can always D-pad out of a focused field.
4. Left/Right for horizontal card rows only.
5. Disabled elements are skipped in focus order.
6. B button (Escape) navigates back one onboarding step.
7. Enter on input: submits if last field, otherwise advances to next field.
8. No wrap — edges are dead stops.
9. No dead ends — every screen has a forward action.
---
## Rules
1. Sidebar: Up/Down wrap. Right → first [C]. Left → nothing.
2. Grid: arrows move between [C] spatially. No wrap at edges.
3. Left from leftmost [C] → Sidebar active tab.
4. Up from top-row [C] → [N] nav bar (if page has one), else nothing.
5. Enter on [C]: has link → navigate. No link → drill into [Y].
6. Inside [Y]: arrows move between inner controls. Escape → back to [C].
7. Escape from [C] → Sidebar.
8. No dead ends.

View File

@@ -4,7 +4,8 @@
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
<!-- Main App Content - only show after splash and routing is complete -->
<RouterView v-if="!showSplash && isReady" />
<div v-if="!showSplash && !isReady" class="min-h-screen bg-black" />
<RouterView v-else-if="!showSplash && isReady" />
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
@@ -168,7 +169,30 @@ const isReady = ref(false)
* - User has already seen the intro
* - User is on a direct route (refresh/bookmark)
*/
// Fix Chromium backdrop-filter rendering bug: when tab loses/regains focus,
// the compositor fails to repaint backdrop-filter layers over animated
// fixed-position overlays (body::before/after with mix-blend-mode).
// On return: strip backdrop-filter via class, wait a frame, then restore.
function onVisibilityChange() {
if (document.hidden) {
document.documentElement.classList.add('tab-hidden')
} else {
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
document.documentElement.classList.add('no-backdrop')
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
// Use setTimeout — Chromium batches rAFs on tab return
setTimeout(() => {
document.documentElement.classList.remove('no-backdrop')
// Step 3: resume animations after backdrop-filter layers are established
requestAnimationFrame(() => {
document.documentElement.classList.remove('tab-hidden')
})
}, 50)
}
}
onMounted(async () => {
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
window.addEventListener('mousedown', onUserActivity)
@@ -188,14 +212,16 @@ onMounted(async () => {
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
showSplash.value = false
document.body.classList.add('splash-complete')
// Set isReady BEFORE hiding splash to prevent flash of partial content
await router.isReady()
isReady.value = true
showSplash.value = false
document.body.classList.add('splash-complete')
}
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)
window.removeEventListener('mousedown', onUserActivity)

View File

@@ -6,7 +6,7 @@ vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
@@ -34,25 +34,32 @@ function jsonResponse(body: unknown, status = 200): Response {
}
}
/** Set up authenticated state — bypasses jsdom cookie path restrictions */
function setAuthenticated() {
;(fileBrowserClient as any)._authenticated = true
document.cookie = 'auth=test-token'
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
describe('login', () => {
it('authenticates and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
it('authenticates via backend RPC and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
// We need a fresh instance to test login — use the exported singleton
const result = await fileBrowserClient.login('admin', 'admin')
const result = await fileBrowserClient.login()
expect(result).toBe(true)
expect(fileBrowserClient.isAuthenticated).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/app/filebrowser/api/login'),
'/rpc/v1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'admin' }),
body: JSON.stringify({ method: 'app.filebrowser-token' }),
}),
)
})
@@ -60,7 +67,7 @@ describe('FileBrowserClient', () => {
it('returns false on failed login', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
const result = await fileBrowserClient.login('admin', 'wrong')
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
@@ -76,9 +83,7 @@ describe('FileBrowserClient', () => {
describe('listDirectory', () => {
it('lists items in a directory', async () => {
// Ensure authenticated first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
const mockItems = {
items: [
@@ -99,8 +104,7 @@ describe('FileBrowserClient', () => {
})
it('adds leading slash if missing', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
@@ -111,8 +115,7 @@ describe('FileBrowserClient', () => {
})
it('throws on non-OK response', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
@@ -136,8 +139,7 @@ describe('FileBrowserClient', () => {
describe('upload', () => {
it('uploads a file to the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
@@ -152,8 +154,7 @@ describe('FileBrowserClient', () => {
})
it('throws on upload failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
@@ -164,8 +165,7 @@ describe('FileBrowserClient', () => {
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@@ -177,8 +177,7 @@ describe('FileBrowserClient', () => {
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
@@ -188,8 +187,7 @@ describe('FileBrowserClient', () => {
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@@ -201,8 +199,7 @@ describe('FileBrowserClient', () => {
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
@@ -212,9 +209,7 @@ describe('FileBrowserClient', () => {
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
// Login first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
const mockData = {
items: [
@@ -236,8 +231,7 @@ describe('FileBrowserClient', () => {
})
it('returns zeros on failed request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
@@ -265,8 +259,7 @@ describe('FileBrowserClient', () => {
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@@ -279,8 +272,7 @@ describe('FileBrowserClient', () => {
})
it('throws on rename failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))

View File

@@ -52,20 +52,27 @@ class FileBrowserClient {
return match ? match[1]! : null
}
async login(username = 'admin', password = 'admin'): Promise<boolean> {
async login(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/api/login`, {
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
// Use credentials: 'include' and CSRF token for proper auth
const csrfMatch = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1]! : ''
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const rpcRes = await fetch('/rpc/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
headers,
body: JSON.stringify({ method: 'app.filebrowser-token' }),
credentials: 'include',
})
if (!res.ok) return false
const text = await res.text()
// FileBrowser returns the JWT as a plain string (possibly quoted)
const token = text.replace(/^"|"$/g, '')
// Store token as cookie — the only auth mechanism we use
if (!rpcRes.ok) return false
const rpcData = await rpcRes.json()
const token = rpcData?.result?.token
if (!token) return false
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
// Only set Secure flag on HTTPS — on HTTP it silently prevents the cookie from being stored
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `auth=${token}; path=/app/filebrowser; SameSite=Lax${secure}; expires=${expires}`
this._authenticated = true

View File

@@ -62,19 +62,38 @@ class RPCClient {
// Use a single shared timeout to prevent redirect storms when
// multiple parallel requests all get 401 at once
if (response.status === 401 && method !== 'auth.login') {
if (!RPCClient._sessionExpiredRedirecting) {
// Clear stale auth immediately — stops App.vue watcher from
// firing more requests and prevents the router from
// optimistically navigating to /dashboard
try { localStorage.removeItem('neode-auth') } catch { /* noop */ }
const isOnboarding = window.location.pathname.startsWith('/onboarding')
console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`)
if (!isOnboarding && !RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
console.warn(`[RPC] Session expired — redirecting to /login in 300ms`)
setTimeout(() => {
window.location.href = '/login'
}, 300)
}
throw new Error('Session expired')
}
// CSRF 403: retry once after short delay (cookie may have been
// updated by a concurrent Set-Cookie response not yet visible to JS)
if (response.status === 403 && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 300))
continue
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
if (response.status === 403) {
let reason = ''
try {
const body: RPCResponse<unknown> = await response.json()
reason = body.error?.message || ''
} catch { /* body parse failed */ }
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
if (isCsrf && attempt < maxRetries - 1) {
// CSRF mismatch — cookie may have been updated by a concurrent
// Set-Cookie response not yet visible to JS. Retry after delay.
await new Promise((r) => setTimeout(r, 500))
continue
}
throw new Error(reason || `HTTP 403: Forbidden`)
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503

View File

@@ -43,7 +43,8 @@ let deferredPrompt: { prompt: () => Promise<{ outcome: string }> } | null = null
const DISMISS_KEY = 'archipelago_pwa_install_dismissed'
onMounted(() => {
// Don't show if already dismissed this session or if already installed
// Don't show in kiosk mode, if already dismissed, or if already installed
if (window.location.pathname.startsWith('/kiosk')) return
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
if (window.matchMedia('(display-mode: standalone)').matches) return
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return

View File

@@ -277,6 +277,13 @@ if (!storedSeenIntro && isOnDashboard) {
localStorage.setItem('neode_intro_seen', '1')
}
function handleEnterKey(e: KeyboardEvent) {
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
e.preventDefault()
handleTapToStart()
}
}
function onIntroLogoHover() {
introLogoHover.value = true
if (!tapStartTransitioning.value) playKeyboardTypingSound()
@@ -465,10 +472,13 @@ onMounted(() => {
showSplash.value = false
document.body.classList.add('splash-complete')
emit('complete')
} else {
window.addEventListener('keydown', handleEnterKey)
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleEnterKey)
if (introTypingTimeout) {
clearTimeout(introTypingTimeout)
introTypingTimeout = null

View File

@@ -1,266 +1,671 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock vue-router
const mockRoute = { path: '/dashboard' }
const mockRouter = { push: vi.fn().mockResolvedValue(undefined) }
/**
* Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md
*
* Tests the navigation logic (element queries, spatial nav, zone detection)
* without mounting the composable (which needs Vue lifecycle).
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
// ─── Mocks ─────────────────────────────────────────────────────
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => mockRouter,
useRoute: () => ({ path: '/dashboard' }),
useRouter: () => ({ push: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('@/stores/controller', () => ({ useControllerStore: () => ({ setActive: vi.fn(), setGamepadCount: vi.fn() }) }))
vi.mock('@/stores/spotlight', () => ({ useSpotlightStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/stores/cli', () => ({ useCLIStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/composables/useNavSounds', () => ({ playNavSound: vi.fn() }))
// Mock stores
vi.mock('@/stores/controller', () => ({
useControllerStore: () => ({
setActive: vi.fn(),
setGamepadCount: vi.fn(),
isActive: false,
gamepadCount: 0,
}),
}))
// ─── Helpers ───────────────────────────────────────────────────
vi.mock('@/stores/spotlight', () => ({
useSpotlightStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
const FOCUSABLE_SELECTOR = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])', '[data-controller-focus]',
'[data-controller-container]',
].join(', ')
vi.mock('@/stores/cli', () => ({
useCLIStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
)
}
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
function queryContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]')
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]'))
}
// Mock useNavSounds
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
function queryNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]')
if (!zone) return []
return queryFocusable(zone as HTMLElement).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
// Note: The composable uses onMounted/onBeforeUnmount, so full integration tests
// would require a mounted component with Pinia and Router. We test helper logic directly.
function querySidebar(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]')
return zone ? queryFocusable(zone as HTMLElement) : []
}
describe('useControllerNav - helper functions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockRoute.path = '/dashboard'
// ─── Module Export ──────────────────────────────────────────────
// Mock navigator.getGamepads
Object.defineProperty(navigator, 'getGamepads', {
value: vi.fn().mockReturnValue([null, null, null, null]),
configurable: true,
writable: true,
})
})
afterEach(() => {
vi.useRealTimers()
})
// Test the module exports via dynamic import to validate structure
it('exports useControllerNav as a function', async () => {
describe('module', () => {
it('exports useControllerNav', async () => {
const mod = await import('../useControllerNav')
expect(typeof mod.useControllerNav).toBe('function')
})
})
describe('useControllerNav - nav key classification', () => {
it('classifies arrow keys and Enter/Escape as nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('ArrowUp')).toBe(true)
expect(navKeys.includes('ArrowDown')).toBe(true)
expect(navKeys.includes('ArrowLeft')).toBe(true)
expect(navKeys.includes('ArrowRight')).toBe(true)
expect(navKeys.includes('Enter')).toBe(true)
expect(navKeys.includes('Escape')).toBe(true)
// ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ──────
describe('sidebar navigation (NAV-MAP: Sidebar)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('finds all sidebar nav items', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard">Home</a>
<a href="/dashboard/apps">Apps</a>
<a href="/dashboard/cloud">Cloud</a>
<button>AIUI</button>
<button>Logout</button>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
expect(querySidebar().length).toBe(5)
})
it('does not classify regular keys as nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('a')).toBe(false)
expect(navKeys.includes('Space')).toBe(false)
expect(navKeys.includes('Tab')).toBe(false)
it('wraps down: Logout → Home', () => {
const items = ['Home', 'Apps', 'Cloud', 'Logout']
const lastIdx = items.length - 1
expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home
})
it('recognizes detail page patterns', () => {
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
expect(pattern.test('/apps/bitcoin')).toBe(true)
expect(pattern.test('/marketplace/electrs')).toBe(true)
expect(pattern.test('/cloud/photos')).toBe(true)
expect(pattern.test('/dashboard')).toBe(false)
expect(pattern.test('/apps')).toBe(false)
it('wraps up: Home → Logout', () => {
const items = ['Home', 'Apps', 'Cloud', 'Logout']
expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout
})
it('recognizes page type patterns', () => {
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/marketplace')).toBe(true)
expect(/^\/dashboard\/cloud(\/|$)/.test('/dashboard/cloud')).toBe(true)
expect(/^\/dashboard\/server(\/|$)/.test('/dashboard/server')).toBe(true)
expect(/^\/dashboard\/web5(\/|$)/.test('/dashboard/web5')).toBe(true)
expect(/^\/dashboard\/settings(\/|$)/.test('/dashboard/settings')).toBe(true)
it('right from sidebar targets first container, not nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab">Tab</button>
<div data-controller-container tabindex="0" id="card1">Card</div>
</div>
`
const containers = queryContainers()
expect(containers[0]?.id).toBe('card1')
})
it('left from sidebar does nothing (no target exists)', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
`
const sidebar = querySidebar()
const el = sidebar[0]!
// Nothing to the left of sidebar
expect(el.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
})
})
describe('useControllerNav - spatial navigation helpers', () => {
// Test the internal helper functions indirectly via the FOCUSABLE_SELECTOR concept
// ─── HOME: 2-col grid + nav bar ────────────────────────────────
it('identifies focusable elements', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Click'
const link = document.createElement('a')
link.href = '/test'
link.textContent = 'Link'
const disabledBtn = document.createElement('button')
disabledBtn.disabled = true
disabledBtn.textContent = 'Disabled'
const input = document.createElement('input')
describe('HOME grid (NAV-MAP: HOME /dashboard)', () => {
afterEach(() => { document.body.innerHTML = '' })
container.appendChild(button)
container.appendChild(link)
container.appendChild(disabledBtn)
container.appendChild(input)
document.body.appendChild(container)
const focusable = container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
// Should find button, link, and input but NOT disabled button
expect(focusable.length).toBe(3)
document.body.removeChild(container)
it('has Dashboard and Setup nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div role="tablist">
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
</div>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
</div>
`
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
expect(navItems[0]?.id).toBe('dashTab')
expect(navItems[1]?.id).toBe('setupTab')
})
it('respects data-controller-ignore attribute', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Visible'
const ignoredBtn = document.createElement('button')
ignoredBtn.textContent = 'Ignored'
ignoredBtn.setAttribute('data-controller-ignore', '')
container.appendChild(button)
container.appendChild(ignoredBtn)
document.body.appendChild(container)
const focusable = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => !el.hasAttribute('data-controller-ignore'))
expect(focusable.length).toBe(1)
expect(focusable[0]?.textContent).toBe('Visible')
document.body.removeChild(container)
it('containers exclude nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<button class="mode-switcher-btn">Dashboard</button>
<button class="mode-switcher-btn">Setup</button>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
<div data-controller-container tabindex="0" id="network">Network</div>
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
<div data-controller-container tabindex="0" id="system">System</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(5)
expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system'])
// Nav bar items are separate
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
})
it('identifies sidebar and main zones', () => {
const sidebar = document.createElement('div')
sidebar.setAttribute('data-controller-zone', 'sidebar')
const main = document.createElement('div')
main.setAttribute('data-controller-zone', 'main')
it('inner controls are not in the container grid', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="myApps">
<a href="/dashboard/apps">Go</a>
<button id="browseStore">Browse Store</button>
<button id="manageApps">Manage Apps</button>
</div>
</div>
`
// Only 1 container in grid
expect(queryContainers().length).toBe(1)
// Nav bar is empty (all focusables are inside the container)
expect(queryNavBarItems().length).toBe(0)
})
})
const sideBtn = document.createElement('button')
sideBtn.textContent = 'Nav'
sidebar.appendChild(sideBtn)
// ─── APPS: 3-col grid + nav bar with tabs/filters/search ───────
const mainBtn = document.createElement('button')
mainBtn.textContent = 'Content'
main.appendChild(mainBtn)
describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => {
afterEach(() => { document.body.innerHTML = '' })
document.body.appendChild(sidebar)
document.body.appendChild(main)
it('nav bar has tabs, filters, and search', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div class="mode-switcher">
<button class="mode-switcher-btn" id="myAppsTab">My Apps</button>
<a href="/dashboard/discover" class="mode-switcher-btn" id="storeTab">App Store</a>
<button class="mode-switcher-btn" id="servicesTab">Services</button>
</div>
<div class="mode-switcher">
<button class="mode-switcher-btn" id="allFilter">All</button>
<button class="mode-switcher-btn" id="btcFilter">Bitcoin</button>
</div>
<input type="text" id="search" />
<div data-controller-container tabindex="0" id="app1">App1</div>
<div data-controller-container tabindex="0" id="app2">App2</div>
<div data-controller-container tabindex="0" id="app3">App3</div>
</div>
`
const navItems = queryNavBarItems()
// 3 tabs + 2 filters + 1 search = 6 nav bar items
expect(navItems.length).toBe(6)
expect(navItems.map(el => el.id)).toEqual(['myAppsTab', 'storeTab', 'servicesTab', 'allFilter', 'btcFilter', 'search'])
// isInZone check
expect(sideBtn.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
expect(mainBtn.closest('[data-controller-zone="main"]')).toBeTruthy()
expect(sideBtn.closest('[data-controller-zone="main"]')).toBeNull()
document.body.removeChild(sidebar)
document.body.removeChild(main)
// 3 containers
expect(queryContainers().length).toBe(3)
})
it('identifies container elements', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
it('app cards with launch attribute are containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container data-controller-launch tabindex="0" id="app1">
<button data-controller-launch-btn>Launch</button>
</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(1)
expect(containers[0]?.hasAttribute('data-controller-launch')).toBe(true)
const launchBtn = containers[0]?.querySelector('[data-controller-launch-btn]')
expect(launchBtn).toBeTruthy()
})
})
const innerBtn = document.createElement('button')
innerBtn.textContent = 'Inner'
container.appendChild(innerBtn)
// ─── CLOUD: 3-col, no nav bar ──────────────────────────────────
document.body.appendChild(container)
describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => {
afterEach(() => { document.body.innerHTML = '' })
// isInsideContainer check
expect(innerBtn.closest('[data-controller-container]')).toBe(container)
expect(container.closest('[data-controller-container]')).toBe(container)
it('has section cards as containers, no nav bar', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="photos">Photos</div>
<div data-controller-container tabindex="0" id="music">Music</div>
<div data-controller-container tabindex="0" id="docs">Documents</div>
<div data-controller-container tabindex="0" id="files">Files</div>
</div>
`
expect(queryContainers().length).toBe(4)
expect(queryNavBarItems().length).toBe(0)
})
})
document.body.removeChild(container)
// ─── NETWORK: 2-col ────────────────────────────────────────────
describe('NETWORK grid (NAV-MAP: NETWORK /dashboard/server)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has Local Network and Web3 containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="localNet">Local Network</div>
<div data-controller-container tabindex="0" id="web3">Web3</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(2)
expect(containers[0]?.id).toBe('localNet')
expect(containers[1]?.id).toBe('web3')
})
})
// ─── SETTINGS: vertical stack ──────────────────────────────────
describe('SETTINGS grid (NAV-MAP: SETTINGS /dashboard/settings)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has stacked section containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="account">Account Info</div>
<div data-controller-container tabindex="0" id="password">Change Password</div>
<div data-controller-container tabindex="0" id="twofa">Two-Factor</div>
<div data-controller-container tabindex="0" id="system">System Info</div>
<div data-controller-container tabindex="0" id="danger">Danger Zone</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(5)
// No nav bar
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── ENTER behavior ────────────────────────────────────────────
describe('enter key behavior (NAV-MAP: Rules 5)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('container with primary link: Enter should navigate', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<a href="/dashboard/apps" id="link">Go</a>
<button>Browse</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
const link = container.querySelector('a[href]')
expect(link).toBeTruthy()
expect(link?.getAttribute('href')).toBe('/dashboard/apps')
})
it('finds inner focusable elements within containers', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
const btn1 = document.createElement('button')
btn1.textContent = 'Action 1'
const btn2 = document.createElement('button')
btn2.textContent = 'Action 2'
container.appendChild(btn1)
container.appendChild(btn2)
document.body.appendChild(container)
const inner = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => el !== container)
it('container without link: Enter drills into inner [Y] controls', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<button id="btn1">Open Shop</button>
<button id="btn2">Accept Payments</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.querySelector('a[href]')).toBeNull()
const inner = Array.from(container.querySelectorAll('button'))
expect(inner.length).toBe(2)
})
document.body.removeChild(container)
it('install container: Enter clicks install button', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-install tabindex="0">
<button data-controller-install-btn>Install</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-install')).toBe(true)
expect(container.querySelector('[data-controller-install-btn]')).toBeTruthy()
})
it('launch container: Enter clicks launch button', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-launch tabindex="0">
<button data-controller-launch-btn>Launch</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-launch')).toBe(true)
expect(container.querySelector('[data-controller-launch-btn]')).toBeTruthy()
})
})
describe('useControllerNav - gamepad detection', () => {
beforeEach(() => {
vi.clearAllMocks()
// ─── INSIDE CONTAINER [Y] ──────────────────────────────────────
describe('inside container navigation (NAV-MAP: Rules 6)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('inner controls are isolated from other containers', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card1">
<button id="stop">Stop</button>
<button id="restart">Restart</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<button id="other">Other</button>
</div>
`
const card1 = document.getElementById('card1')!
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
expect(inner.length).toBe(2)
expect(inner.map(el => el.id)).toEqual(['stop', 'restart'])
// "other" is NOT in card1's inner controls
expect(inner.find(el => el.id === 'other')).toBeUndefined()
})
it('escape from inner control returns to container', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card">
<button id="inner">Action</button>
</div>
`
const inner = document.getElementById('inner')!
const container = inner.closest('[data-controller-container]')
expect(container).toBeTruthy()
expect(container?.id).toBe('card')
expect(container?.getAttribute('tabindex')).toBe('0')
})
it('isInsideContainer is true for nested, false for container itself', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card">
<button id="inside">In</button>
</div>
<button id="outside">Out</button>
`
const inside = document.getElementById('inside')!
const outside = document.getElementById('outside')!
const card = document.getElementById('card')!
// inside: has container ancestor that isn't itself
const insideContainer = inside.closest('[data-controller-container]')
expect(insideContainer && insideContainer !== inside).toBe(true)
// card: IS the container
expect(card.hasAttribute('data-controller-container')).toBe(true)
// outside: no container ancestor
expect(outside.closest('[data-controller-container]')).toBeNull()
})
})
// ─── TEXT INPUT handling ───────────────────────────────────────
describe('text input handling (NAV-MAP: text inputs)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('up/down exits input, left/right stays', () => {
const exitKeys = ['ArrowUp', 'ArrowDown']
const stayKeys = ['ArrowLeft', 'ArrowRight']
exitKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(true))
stayKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(false))
})
it('enter on password clicks next button (submit)', () => {
document.body.innerHTML = `
<input id="pass" type="password" />
<button id="login">Login</button>
`
const all = queryFocusable()
const passIdx = all.findIndex(el => el.id === 'pass')
const next = all[passIdx + 1]
expect(next?.tagName).toBe('BUTTON')
expect(next?.id).toBe('login')
})
})
// ─── FOCUS MEMORY ──────────────────────────────────────────────
describe('focus memory (NAV-MAP: zone transitions)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('remembers and recalls elements', () => {
document.body.innerHTML = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
const btn = document.getElementById('btn')!
memory.set('main', btn)
expect(memory.get('main')).toBe(btn)
expect(document.contains(btn)).toBe(true)
})
it('detects stale (removed) elements', () => {
document.body.innerHTML = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
const btn = document.getElementById('btn')!
memory.set('main', btn)
btn.remove()
expect(document.contains(memory.get('main')!)).toBe(false)
})
it('clears on route change', () => {
const memory = new Map<string, HTMLElement>()
document.body.innerHTML = `<button id="btn">Test</button>`
memory.set('main', document.getElementById('btn')!)
memory.delete('main')
expect(memory.get('main')).toBeUndefined()
})
})
// ─── SPATIAL NAVIGATION ────────────────────────────────────────
describe('spatial navigation', () => {
it('overlap scoring: aligned > offset', () => {
const from = { top: 50, bottom: 200, left: 0, right: 150 }
const aligned = { top: 50, bottom: 200, left: 200, right: 350 }
const offset = { top: 160, bottom: 310, left: 200, right: 350 }
const alignedOv = Math.max(0, Math.min(from.bottom, aligned.bottom) - Math.max(from.top, aligned.top))
const offsetOv = Math.max(0, Math.min(from.bottom, offset.bottom) - Math.max(from.top, offset.top))
expect(alignedOv).toBe(150)
expect(offsetOv).toBe(40)
expect(alignedOv).toBeGreaterThan(offsetOv)
})
it('tiebreaker: up/down prefers leftmost', () => {
// Two elements below, same distance, same overlap
const a = { left: 0 }
const b = { left: 200 }
// Sort: leftmost wins
expect(a.left - b.left).toBeLessThan(0) // a is leftmost
})
it('no wrap in 2D grid (NAV-MAP: Rules 2)', () => {
// At rightmost column, pressing right should find nothing
const from = { left: 400, right: 600, top: 0, bottom: 200 }
const threshold = 50
// No element to the right
const candidate = { left: 0, right: 150 } // far left
expect(candidate.left >= from.right - threshold).toBe(false) // NOT to the right
})
})
// ─── GAMEPAD DETECTION ─────────────────────────────────────────
describe('gamepad detection', () => {
it('counts connected gamepads', () => {
const gamepads = [
{ connected: true } as Gamepad,
null,
{ connected: true } as Gamepad,
null,
]
const count = gamepads.filter((g) => g?.connected).length
expect(count).toBe(2)
const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[]
expect(gp.filter(g => g?.connected).length).toBe(2)
})
it('handles null gamepad list', () => {
// Simulate navigator.getGamepads returning null (some browsers)
function getCount(gp: (Gamepad | null)[] | null): number {
return gp ? gp.filter((g) => g?.connected).length : 0
}
expect(getCount(null)).toBe(0)
})
it('handles empty gamepad list', () => {
const gamepads: (Gamepad | null)[] = [null, null, null, null]
const count = Array.from(gamepads).filter((g) => g?.connected).length
expect(count).toBe(0)
it('handles null list', () => {
const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0
expect(count(null)).toBe(0)
})
})
// ─── DATA-CONTROLLER-IGNORE ────────────────────────────────────
describe('data-controller-ignore', () => {
afterEach(() => { document.body.innerHTML = '' })
it('excluded elements are filtered out', () => {
document.body.innerHTML = `
<button data-controller-ignore>Skip</button>
<div data-controller-ignore><button>Nested ignored</button></div>
<button id="real">Real</button>
`
const all = queryFocusable()
expect(all.length).toBe(1)
expect(all[0]?.id).toBe('real')
})
})
// ─── NAV BAR [N] DETECTION ─────────────────────────────────────
describe('nav bar detection', () => {
afterEach(() => { document.body.innerHTML = '' })
it('nav bar items are in main zone but not inside containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab1">Dashboard</button>
<button class="mode-switcher-btn" id="tab2">Setup</button>
<div data-controller-container tabindex="0" id="card">
<button id="inner">Inner</button>
</div>
</div>
`
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
expect(navItems[0]?.id).toBe('tab1')
expect(navItems[1]?.id).toBe('tab2')
// Inner button is NOT a nav bar item
expect(navItems.find(el => el.id === 'inner')).toBeUndefined()
})
it('pages without nav bar return empty', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── DISCOVER: featured + grid ─────────────────────────────────
describe('DISCOVER grid (NAV-MAP: DISCOVER /dashboard/discover)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has nav bar + featured + app grid', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<a href="/dashboard/apps" class="mode-switcher-btn" id="myApps">My Apps</a>
<a href="/dashboard/discover" class="mode-switcher-btn" id="appStore">App Store</a>
<div data-controller-container data-controller-install tabindex="0" id="feat1">Featured 1</div>
<div data-controller-container data-controller-install tabindex="0" id="feat2">Featured 2</div>
<div data-controller-container data-controller-install tabindex="0" id="app1">App 1</div>
<div data-controller-container data-controller-install tabindex="0" id="app2">App 2</div>
</div>
`
expect(queryNavBarItems().length).toBe(2)
expect(queryContainers().length).toBe(4)
})
})
// ─── MESH / FLEET / SETTINGS containers exist ──────────────────
describe('pages have containers (NAV-MAP: all pages)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('mesh has panel containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="device">Device Status</div>
<div data-controller-container tabindex="0" id="chat">Chat Panel</div>
<div data-controller-container tabindex="0" id="peers">Peers</div>
</div>
`
expect(queryContainers().length).toBe(3)
})
it('fleet has stat + node containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Nodes</div>
<div data-controller-container tabindex="0">Online</div>
<div data-controller-container tabindex="0">Offline</div>
<div data-controller-container tabindex="0">Health</div>
<div data-controller-container tabindex="0">Node 1</div>
</div>
`
expect(queryContainers().length).toBe(5)
})
})
// ─── FULL FLOW: sidebar → container → inner → back ─────────────
describe('full navigation flow (NAV-MAP: Rules 1-8)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('complete roundtrip: sidebar → container → inner → escape → sidebar', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard" class="nav-tab-active" id="sideHome">Home</a>
<a href="/dashboard/apps" id="sideApps">Apps</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="card1">
<button id="inner1">Browse</button>
<button id="inner2">Manage</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<a href="/dashboard/cloud">Go</a>
</div>
</div>
`
// Step 1: Sidebar exists, has active tab
const sidebar = querySidebar()
expect(sidebar.length).toBe(2)
const activeTab = document.querySelector('.nav-tab-active') as HTMLElement
expect(activeTab?.id).toBe('sideHome')
// Step 2: Right from sidebar → first container
const containers = queryContainers()
expect(containers[0]?.id).toBe('card1')
// Step 3: Enter on card1 (no primary link) → drill into inner controls
const card1 = document.getElementById('card1')!
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
expect(inner.length).toBe(2)
expect(inner[0]?.id).toBe('inner1')
// Step 4: Escape from inner → back to card1
const innerEl = document.getElementById('inner1')!
const parentContainer = innerEl.closest('[data-controller-container]')
expect(parentContainer?.id).toBe('card1')
// Step 5: Escape from card1 → sidebar active tab
expect(activeTab?.id).toBe('sideHome')
// Step 6: card2 has primary link → Enter navigates
const card2 = document.getElementById('card2')!
const primaryLink = card2.querySelector('a[href]')
expect(primaryLink?.getAttribute('href')).toBe('/dashboard/cloud')
})
it('no dead ends: every container can reach sidebar', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/" class="nav-tab-active">Home</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="c1">C1</div>
<div data-controller-container tabindex="0" id="c2">C2</div>
</div>
`
// Every container is in main zone
const containers = queryContainers()
containers.forEach(c => {
expect(c.closest('[data-controller-zone="main"]')).toBeTruthy()
})
// Sidebar has at least one item
expect(querySidebar().length).toBeGreaterThan(0)
// Active tab exists for Left → sidebar
expect(document.querySelector('.nav-tab-active')).toBeTruthy()
})
})

View File

@@ -149,7 +149,7 @@ describe('useMessageToast', () => {
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
})
it('stops polling on 401 error', async () => {

View File

@@ -1,9 +1,28 @@
/**
* Xbox-style controller / gamepad navigation for Archipelago.
* - Left: Go to side menu only when on leftmost main content
* - Right: Go to main content (from side menu)
* - Main: spatial/grid navigation (up/down/left/right like a game)
* - Enter enters container's inner actions; actions get celebratory sound
* Controller / gamepad navigation for Archipelago.
*
* Navigation model (from the design spec):
*
* SIDEBAR (vertical list):
* Up/Down = move between items, wraps top↔bottom, auto-navigates
* Right = jump to first container in main content
* Left = does nothing
*
* MAIN CONTENT (container tile grid):
* Arrows = move between containers spatially (the red tile grid)
* Enter = trigger container's primary action (navigate link / launch)
* Escape = back to sidebar
* Left from leftmost container = back to sidebar
*
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
* Arrows = move between inner controls spatially
* Escape = exit back to the container tile
* Cannot move to other containers without exiting first
*
* TEXT INPUTS:
* Up/Down = exit field, navigate to nearest element
* Enter = submit (click next button)
* Left/Right = cursor movement (stay in field)
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
@@ -14,6 +33,8 @@ import { useCLIStore } from '@/stores/cli'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { playNavSound } from '@/composables/useNavSounds'
// ─── Element Queries ────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
@@ -25,9 +46,9 @@ const FOCUSABLE_SELECTOR = [
'[data-controller-container]',
].join(', ')
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) =>
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el =>
!el.hasAttribute('disabled') &&
el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') &&
@@ -35,10 +56,44 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
)
}
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
if (!container) return []
return getFocusableElements(container)
/** Sidebar items */
function getSidebarElements(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
return zone ? getFocusableElements(zone) : []
}
/** Main zone containers only — the [C] tile grid */
function getContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
el => el.offsetParent !== null
)
}
/** Nav bar items [N] — focusable elements in main zone that are NOT inside any container
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
function getNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return getFocusableElements(zone).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false
return isInZone(el, 'main') &&
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
}
/** Inner focusables within a container (buttons, links — not the container itself) */
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter(
el => el !== container && !el.hasAttribute('data-controller-container')
)
}
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
@@ -46,80 +101,102 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
return !!el.closest(`[data-controller-zone="${zone}"]`)
}
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
}
function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false
const container = el.closest('[data-controller-container]')
return !!container && container !== el
}
/** Spatial navigation: find nearest focusable in direction (game-style grid) */
function isContainer(el: HTMLElement | null): boolean {
return !!el?.hasAttribute('data-controller-container')
}
// ─── Spatial Navigation ─────────────────────────────────────────
function findNearestInDirection(
from: HTMLElement,
candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const fromRect = from.getBoundingClientRect()
const fromCenterX = fromRect.left + fromRect.width / 2
const fromCenterY = fromRect.top + fromRect.height / 2
const threshold = 50 // px overlap allowed
const fromCX = fromRect.left + fromRect.width / 2
const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50
const filtered = candidates.filter((el) => {
const filtered = candidates.filter(el => {
if (el === from) return false
const r = el.getBoundingClientRect()
switch (direction) {
case 'left':
return r.right <= fromRect.left + threshold
case 'right':
return r.left >= fromRect.right - threshold
case 'up':
return r.bottom <= fromRect.top + threshold
case 'down':
return r.top >= fromRect.bottom - threshold
default:
return false
case 'left': return r.right <= fromRect.left + threshold
case 'right': return r.left >= fromRect.right - threshold
case 'up': return r.bottom <= fromRect.top + threshold
case 'down': return r.top >= fromRect.bottom - threshold
}
})
if (filtered.length === 0) return null
if (!filtered.length) return null
// Pick best: most overlap on perpendicular axis, then closest
const scored = filtered.map((el) => {
const scored = filtered.map(el => {
const r = el.getBoundingClientRect()
const centerX = r.left + r.width / 2
const centerY = r.top + r.height / 2
let overlap: number
let dist: number
switch (direction) {
case 'left':
case 'right':
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
dist = Math.abs(centerX - fromCenterX)
break
case 'up':
case 'down':
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
dist = Math.abs(centerY - fromCenterY)
break
default:
overlap = 0
dist = Infinity
}
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const isVertical = direction === 'up' || direction === 'down'
const overlap = isVertical
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
return { el, overlap, dist }
})
scored.sort((a, b) => {
const isVertical = direction === 'up' || direction === 'down'
// For vertical nav: prefer closest element first, use overlap as tiebreaker.
// This prevents a distant full-width element from winning over a closer narrow one.
if (isVertical) {
// Both have overlap — prefer closer distance
if (a.overlap > 0 && b.overlap > 0) {
if (a.dist !== b.dist) return a.dist - b.dist
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
}
// One has overlap, the other doesn't — prefer the one with overlap
if (a.overlap !== b.overlap) return b.overlap - a.overlap
return a.dist - b.dist
}
// Horizontal: overlap first (same row), then distance
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.dist - b.dist
})
return scored[0]?.el ?? null
}
// ─── Focus Memory ───────────────────────────────────────────────
const zoneFocusMemory = new Map<string, HTMLElement>()
function rememberFocus(zone: string, el: HTMLElement) {
zoneFocusMemory.set(zone, el)
}
function recallFocus(zone: string): HTMLElement | null {
const el = zoneFocusMemory.get(zone)
if (!el) return null
if (document.contains(el) && el.offsetParent !== null) return el
zoneFocusMemory.delete(zone)
return null
}
// ─── Focus Helper ───────────────────────────────────────────────
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
playNavSound(sound)
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
// ─── Main Composable ────────────────────────────────────────────
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const route = useRoute()
const router = useRouter()
@@ -131,325 +208,454 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
store.setActive(isControllerActive.value)
store.setGamepadCount(gamepadCount.value)
}, { immediate: true })
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
let pollIntervalId: ReturnType<typeof setInterval> | null = null
function checkGamepads() {
const gamepads = navigator.getGamepads?.()
const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
if (count !== gamepadCount.value) {
gamepadCount.value = count
isControllerActive.value = count > 0
}
}
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return
const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
// Enter in input: click next button (submit pattern)
e.preventDefault()
const all = getFocusableElements(containerRef?.value ?? document)
const idx = all.indexOf(target)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
next.focus()
next.click()
} else if (next) {
next.focus()
}
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) {
focusEl(nearest)
} else {
// Spatial nav failed — try containers directly (e.g. search bar → first container)
const containers = getContainers()
const containerNearest = containers.length
? findNearestInDirection(target, containers, dir)
: null
if (containerNearest) {
focusEl(containerNearest)
} else {
// Last fallback: tab order
const idx = all.indexOf(target)
const fallback = dir === 'down' ? all[idx + 1] : all[idx - 1]
if (fallback) focusEl(fallback)
}
}
return
}
// Left/Right: cursor movement in field, but exit at edges
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const input = target as HTMLInputElement
const atStart = input.selectionStart === 0 && input.selectionEnd === 0
const atEnd = input.selectionStart === (input.value?.length ?? 0)
if ((e.key === 'ArrowLeft' && atStart) || (e.key === 'ArrowRight' && atEnd)) {
e.preventDefault()
const dir = e.key === 'ArrowLeft' ? 'left' as const : 'right' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) focusEl(nearest)
}
return
}
// Other keys (Escape): handled below.
if (e.key !== 'Escape') return
}
const root = containerRef?.value ?? document
const focusable = getFocusableElements(root)
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
const activeEl = document.activeElement as HTMLElement
// --- ESCAPE ---
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) {
useAppLauncherStore().close()
e.preventDefault()
e.stopPropagation()
return
}
if (useSpotlightStore().isOpen) {
useSpotlightStore().close()
e.preventDefault()
e.stopPropagation()
return
}
if (useCLIStore().isOpen) {
useCLIStore().close()
e.preventDefault()
e.stopPropagation()
return
}
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
// Inside container inner controls → exit to container
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
if (container && container.tabIndex >= 0) {
playNavSound('back')
container.focus()
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
focusEl(container, 'back')
e.preventDefault()
return
}
}
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
if (isDetailPage) {
// On a container or anywhere in main → go to sidebar
if (isInZone(activeEl, 'main')) {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) {
rememberFocus('main', activeEl)
focusEl(target, 'back')
e.preventDefault()
}
return
}
// Detail pages: go back
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
playNavSound('back')
window.history.back()
e.preventDefault()
return
}
const sidebarEls = getElementsInZone('sidebar')
const firstSidebar = sidebarEls[0]
if (firstSidebar && isInZone(activeEl, 'main')) {
playNavSound('back')
firstSidebar.focus()
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
playNavSound('back')
window.history.back()
e.preventDefault()
return
}
// --- ENTER ---
// ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') {
if (currentIndex >= 0 && focusable[currentIndex]) {
const el = focusable[currentIndex] as HTMLElement
if (el.hasAttribute('data-controller-container')) {
// Marketplace: Enter = install (click install button)
if (el.hasAttribute('data-controller-install')) {
const installBtn = el.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (installBtn) {
playNavSound('action')
installBtn.click()
e.preventDefault()
return
}
}
// My Apps: Enter = launch (click Launch button when app is runnable)
if (el.hasAttribute('data-controller-launch')) {
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (launchBtn) {
playNavSound('action')
launchBtn.click()
e.preventDefault()
return
}
}
// My Apps, etc: Enter = focus first inner control
const inner = getInnerFocusables(el)
const firstInner = inner[0]
if (firstInner) {
playNavSound('action')
firstInner.focus()
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
}
playNavSound('action')
el.click()
}
e.preventDefault()
return
}
// --- ARROWS ---
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
isControllerActive.value = true
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => {
isControllerActive.value = gamepadCount.value > 0
}, 3000)
const sidebarEls = getElementsInZone('sidebar')
const mainEls = getElementsInZone('main')
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
// Right: from sidebar → main
// - On Home: go to My Apps container
// - On Apps/Marketplace: go to first app container
// - On Cloud: go to first folder (Pictures)
// - On Network (server): go to Services container
// - On Web5: go to Networking Profits container
// - On Settings: go to Change Password container
// - Otherwise: go to top right (App Switcher)
const mainZone = document.querySelector('[data-controller-zone="main"]')
const isHome = /^\/dashboard(\/)?$/.test(route.path)
const isAppsOrMarketplace = /^\/dashboard\/(apps|marketplace)(\/|$)/.test(route.path)
const isCloud = /^\/dashboard\/cloud(\/|$)/.test(route.path)
const isNetwork = /^\/dashboard\/server(\/|$)/.test(route.path)
const isWeb5 = /^\/dashboard\/web5(\/|$)/.test(route.path)
const isSettings = /^\/dashboard\/settings(\/|$)/.test(route.path)
const firstAppContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
const topRightEntry = mainZone?.querySelector<HTMLElement>('[data-controller-main-entry]')
const firstFocusableInTopRight = topRightEntry ? getFocusableElements(topRightEntry)[0] : null
const firstMain = ((isHome || isAppsOrMarketplace || isCloud || isNetwork || isWeb5 || isSettings) && firstAppContainer)
? firstAppContainer
: (firstFocusableInTopRight ?? mainEls[0])
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
playNavSound('move')
firstMain.focus()
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
// Main zone: spatial navigation (game-style grid)
if (hasZones && isInZone(activeEl, 'main')) {
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
const next = findNearestInDirection(activeEl, mainEls, dir)
if (next) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
if (isContainer(activeEl)) {
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Prioritised action: launch button
if (activeEl.hasAttribute('data-controller-launch')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Primary link (e.g. dashboard cards with a[href])
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
if (primaryLink) {
playNavSound('action')
primaryLink.click()
return
}
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
if (e.key === 'ArrowLeft' && dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeNavTab ?? sidebarEls[0]
if (target) {
playNavSound('move')
target.focus()
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
const inner = getInnerFocusables(activeEl)
const actionBtn = inner.find(el =>
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
!el.closest('[class*="absolute top"]')
) ?? inner[0]
if (actionBtn) {
focusEl(actionBtn, 'action')
return
}
// Last resort: click the container itself (triggers goToApp on AppCard)
playNavSound('action')
activeEl.click()
return
}
// Inside container: spatial nav among inner elements
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
if (container) {
const inner = getInnerFocusables(container)
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
const next = findNearestInDirection(activeEl, inner, dir)
if (next) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
// Regular element: click it
if (activeEl) {
playNavSound('action')
activeEl.click()
}
return
}
// ── ARROW KEYS ──────────────────────────────────────────
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
e.preventDefault()
// Mark controller as active
isControllerActive.value = true
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
: 'down' as const
// ── SIDEBAR ─────────────────────────────────────────────
if (isInZone(activeEl, 'sidebar')) {
const items = getSidebarElements()
const idx = items.indexOf(activeEl)
if (dir === 'up' || dir === 'down') {
// Linear wrap
if (idx < 0) return
const nextIdx = dir === 'down'
? (idx >= items.length - 1 ? 0 : idx + 1)
: (idx <= 0 ? items.length - 1 : idx - 1)
const next = items[nextIdx]
if (next && next !== activeEl) {
focusEl(next)
// Auto-navigate sidebar links (not buttons — Logout etc. require Enter)
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute('href')
if (href?.startsWith('/')) router.push(href).catch(() => {})
}
}
return
}
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home)
if (isInZone(activeEl, 'sidebar')) {
const idx = sidebarEls.indexOf(activeEl)
if (idx >= 0) {
const isDown = e.key === 'ArrowDown'
let nextIdx: number
if (isDown) {
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
} else {
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
}
const next = sidebarEls[nextIdx]
if (next && next !== activeEl) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute?.('href')
if (href && href.startsWith('/')) router.push(href).catch(() => {})
if (dir === 'right') {
// Jump to first container in main
rememberFocus('sidebar', activeEl)
const remembered = recallFocus('main')
// Only use remembered if it's a container (not a nav bar button)
const target = (remembered && isContainer(remembered)) ? remembered : null
const containers = getContainers()
const dest = target ?? containers[0]
if (dest) {
focusEl(dest)
} else {
// Containers not rendered yet (route transition / animation in progress)
// Poll until they appear, up to 1s
let attempts = 0
const poll = setInterval(() => {
attempts++
const retryContainers = getContainers()
if (retryContainers[0]) {
clearInterval(poll)
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
// No containers on this page (e.g. Settings) — focus first focusable element
const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) }
}
e.preventDefault()
return
}
}, 100)
}
return
}
// Fallback: linear navigation
let nextIndex = currentIndex
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
if (focusable.length === 0) return
// Left from sidebar: does nothing
return
}
if (currentIndex < 0) {
nextIndex = isForward ? 0 : focusable.length - 1
} else {
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
if (nextIndex < 0) nextIndex = focusable.length - 1
if (nextIndex >= focusable.length) nextIndex = 0
// ── INSIDE CONTAINER (inner controls) ───────────────────
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
const inner = getInnerFocusables(container)
const next = findNearestInDirection(activeEl, inner, dir)
if (next) focusEl(next)
// Can't leave container via arrows — must use Escape
return
}
// ── NAV BAR [N] — secondary controls above the grid ────
if (isNavBarItem(activeEl)) {
const navItems = getNavBarItems()
if (dir === 'left' || dir === 'right') {
// Spatial nav between nav bar items
const next = findNearestInDirection(activeEl, navItems, dir)
if (next) { focusEl(next); return }
// Left from leftmost nav item → sidebar
if (dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
}
return
}
const next = focusable[nextIndex]
if (dir === 'down') {
// Down from nav bar → jump to containers (remember tab for Up return)
rememberFocus('navBar', activeEl)
const containers = getContainers()
const nearest = findNearestInDirection(activeEl, containers, 'down')
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
// Fallback: just focus first container
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return }
// Containers not rendered yet — poll until they appear
let attempts = 0
const poll = setInterval(() => {
attempts++
const retryContainers = getContainers()
if (retryContainers[0]) {
clearInterval(poll)
rememberFocus('main', retryContainers[0])
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
}
}, 100)
return
}
// Up from nav bar → nothing (use Escape to go to sidebar)
return
}
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
if (isInZone(activeEl, 'main')) {
const containers = getContainers()
// Try spatial nav to another container
const next = findNearestInDirection(activeEl, containers, dir)
if (next) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
const href = (next as HTMLAnchorElement).getAttribute?.('href')
if (href && href.startsWith('/') && next.tagName === 'A') {
router.push(href).catch(() => {})
rememberFocus('main', next)
focusEl(next)
return
}
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
if (dir === 'up') {
const remembered = recallFocus('navBar')
if (remembered) { focusEl(remembered); return }
const navItems = getNavBarItems()
if (navItems.length) {
const nearest = findNearestInDirection(activeEl, navItems, 'up')
if (nearest) { focusEl(nearest); return }
const first = navItems[0]
if (first) { focusEl(first); return }
}
// No nav bar items — try any focusable element above (linear page nav)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const above = findNearestInDirection(activeEl, allFocusable, 'up')
if (above) { rememberFocus('main', above); focusEl(above) }
}
return
}
// Left from leftmost container → sidebar
if (dir === 'left') {
rememberFocus('main', activeEl)
const remembered = recallFocus('sidebar')
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = remembered ?? activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
return
}
// At grid edges: try containers + nav bar items as fallback
// (prevents dead ends, but never jumps into container inner controls)
if (dir === 'down' || dir === 'right') {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
if (fallback) {
rememberFocus('main', fallback)
focusEl(fallback)
}
}
e.preventDefault()
}
return
}
// ── FALLBACK: unhandled focusable element ───────────────
// Covers standalone buttons/links in empty/error states, modals, etc.
// that aren't inside a recognized zone or container.
if (dir === 'left') {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) { rememberFocus('main', activeEl); focusEl(target) }
} else {
// Exclude container inner buttons to prevent focus getting lost
const all = getFocusableElements().filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const next = findNearestInDirection(activeEl, all, dir)
if (next) focusEl(next)
}
}
function handleGamepadInput() {
checkGamepads()
}
// ─── Gamepad Detection ──────────────────────────────────────
function handleGamepadConnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true
}
function handleGamepadDisconnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
isControllerActive.value = gamepadCount.value > 0
}
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
function getScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
let p = el?.parentElement
while (p) {
const style = getComputedStyle(p)
const oy = style.overflowY
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p
p = p.parentElement
}
return null
}
// ─── Scroll Support ────────────────────────────────────────
/** Ensure wheel scrolls the scrollable area containing the focused element */
function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null
if (!active) return
const scrollable = getScrollableAncestor(active)
if (!scrollable) return
if (e.deltaY !== 0) {
scrollable.scrollTop += e.deltaY
e.preventDefault()
}
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
scrollable.scrollLeft += e.deltaX
e.preventDefault()
let p = active.parentElement
while (p) {
const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
return
}
p = p.parentElement
}
}
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
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
if (document.querySelector('[role="dialog"]')) return
if (isInZone(active, 'sidebar')) return
requestAnimationFrame(() => {
// Re-check sidebar after RAF — user may still be navigating
if (isInZone(document.activeElement as HTMLElement, 'sidebar')) return
const remembered = recallFocus('main')
if (remembered) { remembered.focus({ preventScroll: true }); return }
const containers = getContainers()
if (containers[0]) containers[0].focus({ preventScroll: true })
})
}
watch(() => route.path, () => {
zoneFocusMemory.delete('main')
zoneFocusMemory.delete('navBar')
setTimeout(autoFocusMain, 150)
})
// ─── Lifecycle ─────────────────────────────────────────────
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(handleGamepadInput, 500)
pollIntervalId = setInterval(() => checkGamepads(), 500)
setTimeout(autoFocusMain, 300)
})
onBeforeUnmount(() => {
@@ -461,8 +667,5 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (keyNavTimeout) clearTimeout(keyNavTimeout)
})
return {
isControllerActive,
gamepadCount,
}
return { isControllerActive, gamepadCount }
}

View File

@@ -177,15 +177,15 @@
"loggedIn": "Currently logged in",
"didHelper": "Decentralized identifier for passwordless auth",
"onionHelper": "Onion address for node interface and peer discovery over Tor",
"changePassword": "Change Password",
"changePassword": "Set Password",
"enable2fa": "Enable 2FA",
"disable2fa": "Disable 2FA",
"logout": "Logout",
"loggingOut": "Logging out...",
"twoFactorAuth": "Two-Factor Authentication",
"twoFaProtect": "Protect your account with an authenticator app",
"changePasswordTitle": "Change Password",
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
"changePasswordTitle": "Set Password",
"changePasswordDesc": "Set a new password for web login and SSH access. Default password is 'password123'. Use a strong password (12+ chars, upper, lower, digit, special).",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password",

View File

@@ -287,6 +287,19 @@ router.beforeEach(async (to, _from, next) => {
next()
return
}
// Check if this is a fresh install that needs onboarding
try {
const { isOnboardingComplete } = await import('@/composables/useOnboarding')
const setupDone = await isOnboardingComplete()
if (!setupDone) {
next('/onboarding/intro')
return
}
} catch {
// If we can't check, assume fresh install and show onboarding
next('/onboarding/intro')
return
}
next({ path: '/login', query: { redirect: to.fullPath } })
return
}

View File

@@ -32,7 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
// Initialize data structure immediately so dashboard can render
await sync.initializeData()
// Connect WebSocket in background - don't block login flow
// Verify session cookies are established before WebSocket connect.
// Without this, the WS upgrade can race ahead of cookie processing → 401.
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
} catch {
// Non-fatal: WS reconnect logic will handle it
}
// Connect WebSocket in background
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
@@ -52,6 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
const sync = useSyncStore()
await sync.initializeData()
// Verify session cookies are established before WebSocket connect
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
} catch {
// Non-fatal: WS reconnect logic will handle it
}
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})

View File

@@ -0,0 +1,82 @@
// Install store — tracks in-progress app installations across navigation.
// Marketplace.vue writes here; Apps.vue reads to show "Installing..." cards.
import { defineStore } from 'pinia'
import { reactive, computed } from 'vue'
export interface InstallEntry {
id: string
title: string
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
progress: number
message: string
}
export const useInstallStore = defineStore('install', () => {
// Reactive map: appId -> InstallEntry
const entries = reactive(new Map<string, InstallEntry>())
/** All app IDs currently installing */
const installingIds = computed(() => new Set(entries.keys()))
/** Start tracking an install */
function trackInstall(id: string, title: string) {
entries.set(id, {
id,
title,
status: 'downloading',
progress: 0,
message: 'Preparing installation...',
})
}
/** Update progress for an in-flight install */
function updateProgress(id: string, update: Partial<Omit<InstallEntry, 'id'>>) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, ...update })
}
/** Mark install complete and auto-clear after delay */
function completeInstall(id: string) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
setTimeout(() => entries.delete(id), 2000)
}
/** Mark install as failed and auto-clear after delay */
function failInstall(id: string, message: string) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, status: 'error', progress: 0, message })
setTimeout(() => entries.delete(id), 5000)
}
/** Remove tracking (e.g. when backend reports the app is installed) */
function clearInstall(id: string) {
entries.delete(id)
}
/** Check if an app is currently installing */
function isInstalling(id: string): boolean {
return entries.has(id)
}
/** Get progress for an app, or undefined */
function getProgress(id: string): InstallEntry | undefined {
return entries.get(id)
}
return {
entries,
installingIds,
trackInstall,
updateProgress,
completeInstall,
failInstall,
clearInstall,
isInstalling,
getProgress,
}
})

View File

@@ -1,13 +1,37 @@
// Server store — computed server state and RPC action proxies
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
import type { InstallProgress } from '../views/marketplace/marketplaceData'
export const useServerStore = defineStore('server', () => {
const sync = useSyncStore()
// Global install tracking — persists across navigation
const installingApps = ref<Map<string, InstallProgress>>(new Map())
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
const existing = installingApps.value.get(appId)
installingApps.value.set(appId, {
status: 'downloading',
progress: 0,
message: 'Preparing...',
attempt: 0,
...existing,
...progress,
})
}
function clearInstallProgress(appId: string) {
installingApps.value.delete(appId)
}
function isInstalling(appId: string): boolean {
return installingApps.value.has(appId)
}
// Computed — derived from sync store's data
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
@@ -70,6 +94,12 @@ export const useServerStore = defineStore('server', () => {
isShuttingDown,
isOffline,
// Install tracking (global, persists across navigation)
installingApps,
setInstallProgress,
clearInstallProgress,
isInstalling,
// Actions
installPackage,
uninstallPackage,

View File

@@ -27,7 +27,8 @@
overflow: hidden;
z-index: 9999;
}
.skip-to-content:focus {
.skip-to-content:focus,
.skip-to-content:focus-visible {
position: fixed;
top: 12px;
left: 50%;
@@ -44,15 +45,41 @@
font-weight: 500;
text-decoration: none;
backdrop-filter: blur(12px);
outline: none;
}
/* Controller / keyboard navigation - soft glow only (no box outline) */
/* Controller / keyboard navigation — only for elements without their own focus styles.
Elements with existing hover/active styles (glass-button, sidebar-nav-item, etc.) keep theirs. */
*:focus-visible {
outline: none;
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
box-shadow:
0 0 12px rgba(251, 146, 60, 0.2),
0 0 24px rgba(251, 146, 60, 0.08);
transition: box-shadow 0.2s ease;
}
/* Elements with existing styles: suppress the global glow, let their own styles handle it */
.glass-card:focus-visible,
.sidebar-nav-item:focus-visible,
.path-option-card:focus-visible,
.kiosk-app-tile:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
box-shadow: unset;
outline: none;
}
/* Glass button: orange glow on gamepad/keyboard focus */
.glass-button:focus-visible {
outline: none;
box-shadow:
0 0 0 1px rgba(251, 146, 60, 0.5),
0 0 16px rgba(251, 146, 60, 0.25),
0 0 32px rgba(251, 146, 60, 0.1);
border-color: rgba(251, 146, 60, 0.4);
}
/* 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"]) {
@@ -95,16 +122,21 @@ input[type="radio"]:active + * {
/* Containers: base scale for smooth grow animation */
[data-controller-container] {
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
outline: none !important;
}
/* Containers get subtle grow + inner glow when focused (gamepad selection) */
[data-controller-container]:focus-visible {
transform: scale(1.02);
/* 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 {
outline: none;
transform: translateY(-4px) scale(1.01) translateZ(0);
box-shadow:
0 0 24px rgba(120, 180, 255, 0.15),
0 0 48px rgba(100, 160, 255, 0.08),
inset 0 0 24px rgba(255, 255, 255, 0.03);
0 0 6px 2px rgba(251, 146, 60, 0.35),
0 0 20px rgba(251, 146, 60, 0.15),
0 0 40px rgba(251, 146, 60, 0.08);
}
/* Global glassmorphism utilities */
@@ -115,14 +147,18 @@ input[type="radio"]:active + * {
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
transform: translateZ(0);
isolation: isolate;
}
.glass-strong {
background-color: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
transform: translateZ(0);
isolation: isolate;
}
.glass-card {
@@ -134,6 +170,11 @@ input[type="radio"]:active + * {
border-radius: 1rem;
overflow-x: hidden;
overflow-y: visible;
/* Fix Chromium compositor bug: backdrop-filter + fixed animated overlays
causes cards to render as black rectangles on scroll/tab-switch.
Own layer + isolation prevents stacking context confusion. */
transform: translateZ(0);
isolation: isolate;
}
/* Mode switcher - sidebar toggle */
@@ -160,10 +201,10 @@ input[type="radio"]:active + * {
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
transition: color 0.2s ease, background-color 0.2s ease;
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
cursor: pointer;
text-align: center;
border: none;
border: 1px solid transparent;
background: transparent;
}
@@ -180,9 +221,22 @@ input[type="radio"]:active + * {
}
.mode-switcher-btn-active {
background: rgba(255, 255, 255, 0.15);
background: rgba(251, 146, 60, 0.15);
color: rgba(255, 255, 255, 0.95);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.3),
0 0 8px rgba(251, 146, 60, 0.12),
inset 0 1px 0 rgba(251, 146, 60, 0.2);
border-color: rgba(251, 146, 60, 0.25);
}
.mode-switcher-btn:focus-visible {
background: rgba(251, 146, 60, 0.1);
color: rgba(255, 255, 255, 0.9);
box-shadow:
0 0 0 1px rgba(251, 146, 60, 0.4),
0 0 12px rgba(251, 146, 60, 0.2);
border-color: rgba(251, 146, 60, 0.3);
}
/* Chat launcher button — sidebar (desktop) */
@@ -767,6 +821,8 @@ input[type="radio"]:active + * {
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
border: none;
transform: translateZ(0);
isolation: isolate;
}
.path-option-card:active {
@@ -934,7 +990,7 @@ input[type="radio"]:active + * {
transform: translateY(1px);
}
/* Active Navigation Tab Style - matches hover container */
/* Active Navigation Tab Style — sidebar selected item */
.nav-tab-active {
position: relative;
background: rgba(0, 0, 0, 0.35) !important;
@@ -952,8 +1008,8 @@ input[type="radio"]:active + * {
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
@@ -965,11 +1021,9 @@ input[type="radio"]:active + * {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.sidebar-nav-item:focus-visible {
transform: scale(1.02) !important;
box-shadow:
0 0 24px rgba(120, 180, 255, 0.15),
0 0 48px rgba(100, 160, 255, 0.08),
inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
outline: none !important;
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
}
}
@@ -1175,6 +1229,23 @@ body::after {
animation-fill-mode: backwards;
}
/* Pause background animations when tab is hidden to prevent
Chromium compositor from corrupting backdrop-filter layers on tab return */
html.tab-hidden body::before,
html.tab-hidden body::after,
html.tab-hidden::before {
animation-play-state: paused !important;
will-change: auto !important;
}
/* Strip all backdrop-filters to force compositor layer rebuild on tab return */
html.no-backdrop *,
html.no-backdrop *::before,
html.no-backdrop *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Dashboard: full viewport width, no letterboxing, no body scroll */
body.dashboard-active {
overflow: hidden;
@@ -1274,7 +1345,8 @@ html:has(body.video-background-active)::before {
background: rgba(255, 255, 255, 0.1);
}
.cloud-file-item:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
outline: none;
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
}
.cloud-file-item-thumb {
@@ -1452,7 +1524,8 @@ html:has(body.video-background-active)::before {
transform: translateY(0);
}
.cloud-grid-card:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
outline: none;
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
}
.cloud-grid-card-cover {
@@ -2087,19 +2160,6 @@ html:has(body.video-background-active)::before {
font-size: 0.8125rem;
}
.discover-featured-card {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
border-color: rgba(255, 255, 255, 0.12);
}
.discover-featured-card:hover {
transform: translateY(-3px);
border-color: rgba(251, 146, 60, 0.25);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.6),
0 0 40px rgba(251, 146, 60, 0.06);
}
.discover-installed-badge {
display: inline-flex;
align-items: center;
@@ -2126,15 +2186,6 @@ html:has(body.video-background-active)::before {
transform: translateY(-2px);
}
.discover-app-card {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
}
.discover-app-card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.discover-manifesto {
border-color: rgba(251, 146, 60, 0.1);
background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(251, 146, 60, 0.03) 100%);

View File

@@ -107,6 +107,7 @@ export interface Manifest {
'donation-url': string | null
author?: string
website?: string
tier?: string
interfaces?: {
main?: {
ui?: string

View File

@@ -101,6 +101,8 @@
:index="index"
:show-stagger="showStagger"
:is-loading="!!actions.loadingActions.value[id as string]"
:is-installing="serverStore.isInstalling(id as string)"
:install-progress="serverStore.installingApps.get(id as string)"
:is-uninstalling="actions.uninstallingApps.value.has(id as string)"
@go-to-app="goToApp"
@launch="launchApp"
@@ -141,6 +143,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import AppCard from './apps/AppCard.vue'
@@ -155,6 +158,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
// Only stagger-animate on first mount

View File

@@ -27,6 +27,7 @@
v-for="app in bundledApps"
:key="app.id"
data-controller-container
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
tabindex="0"
class="glass-card p-6 hover:bg-white/5 transition-colors"
>
@@ -134,6 +135,7 @@
</button>
<button
type="button"
data-controller-launch-btn
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
@click="launchApp(app)"
>

View File

@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Skip to main content link for keyboard users -->
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
<!-- Skip-to-content handled by controller nav sidebarmain transition -->
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container">
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
@@ -126,7 +126,6 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue'
@@ -140,8 +139,6 @@ import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import '@/views/dashboard/dashboard-styles.css'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()

View File

@@ -140,6 +140,7 @@ let discoverAnimationDone = false
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
@@ -147,11 +148,12 @@ import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
import FilterModal from './discover/FilterModal.vue'
import type { MarketplaceApp, FeaturedApp, InstallProgress } from './discover/types'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
const router = useRouter()
const store = useAppStore()
const serverStore = useServerStore()
const showStagger = !discoverAnimationDone
const { setCurrentApp } = useMarketplaceApp()
@@ -173,20 +175,20 @@ const categories = computed(() => [
{ id: 'other', name: 'Other' }
])
// Installation state
const installingApps = ref<Map<string, InstallProgress>>(new Map())
// Installation state — uses global store so it persists across navigation
const installingApps = serverStore.installingApps
const maxAttempts = ref(60)
watch(() => store.packages, (packages) => {
if (!packages) return
for (const [appId, pkg] of Object.entries(packages)) {
const progress = pkg['install-progress']
if (progress && pkg.state === 'installing' && installingApps.value.has(appId)) {
const current = installingApps.value.get(appId)!
if (progress && pkg.state === 'installing' && installingApps.has(appId)) {
const current = installingApps.get(appId)!
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
installingApps.value.set(appId, {
installingApps.set(appId, {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
@@ -409,50 +411,50 @@ onBeforeUnmount(() => {
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.value.get(appId)
const current = installingApps.get(appId)
if (!current) { clearTrackedInterval(interval); return }
const newAttempt = current.attempt + 1
installingApps.value.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
if (isInstalled(appId)) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.delete(appId) }, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.delete(appId) }, 5000)
}
}, 1000)
}
async function installApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
try {
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 })
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}

View File

@@ -12,6 +12,7 @@
<!-- Desktop: tabs inline with header -->
<div
v-if="!uiMode.isChat"
role="tablist"
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
@@ -48,8 +49,8 @@
<template v-if="!uiMode.isChat">
<!-- Mobile: full-width tabs -->
<div
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
role="tablist"
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
@@ -215,12 +216,12 @@
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
</div>
</div>
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
</div>
</template>
<script setup lang="ts">

View File

@@ -15,7 +15,8 @@
<!-- Title -->
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
<span v-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
<span v-if="isCheckingSetup">&nbsp;</span>
<span v-else-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
<span v-else>{{ t('login.title') }}</span>
</h1>
@@ -38,8 +39,16 @@
{{ error }}
</div>
<!-- Checking setup state -->
<div v-if="isCheckingSetup" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" xmlns="http://www.w3.org/2000/svg" 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>
<!-- Setup Mode: Password Setup -->
<template v-if="isSetupMode && !isSetup">
<template v-else-if="isSetupMode && !isSetup">
<div class="mb-4 p-4 bg-white/5 border border-white/10 rounded-lg text-white/80 text-sm">
<p class="mb-2">Create a password to secure your Archipelago node.</p>
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
@@ -53,9 +62,11 @@
id="setup-password"
v-model="password"
type="password"
autocomplete="new-password"
data-form-type="other"
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')"
@keyup.enter="handleSetupWithSound"
@keydown.enter="handleSetupWithSound"
:disabled="loading || formDisabled"
/>
</div>
@@ -68,9 +79,11 @@
id="setup-confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
data-form-type="other"
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')"
@keyup.enter="handleSetupWithSound"
@keydown.enter="handleSetupWithSound"
:disabled="loading || formDisabled"
/>
</div>
@@ -151,9 +164,11 @@
id="login-password"
v-model="password"
type="password"
autocomplete="current-password"
data-form-type="other"
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')"
@keyup.enter="handleLoginWithSound"
@keydown.enter="handleLoginWithSound"
:disabled="loading || formDisabled"
/>
</div>
@@ -244,10 +259,11 @@ const startupProgress = ref(0)
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
// Check if we're in setup mode (original StartOS node setup)
const isSetupMode = computed(() => {
return import.meta.env.VITE_DEV_MODE === 'setup'
})
// Whether we're in setup mode (no password created yet)
const isSetupMode = ref(false)
// Whether we're still checking the setup state (prevents flash of wrong form)
const isCheckingSetup = ref(true)
// Whether the login form should be disabled (server not ready)
const formDisabled = computed(() => !serverReady.value)
@@ -339,16 +355,16 @@ onMounted(async () => {
await pollServerStartup()
}
// Only check setup mode after server is confirmed ready
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
} catch {
isSetup.value = false
}
} else {
isSetup.value = true
// Check if password has been set up — show setup form if not
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
isSetupMode.value = !isSetup.value
} catch {
isSetup.value = false
isSetupMode.value = true
} finally {
isCheckingSetup.value = false
}
})
@@ -380,11 +396,19 @@ async function handleSetup() {
params: { password: password.value.trim() }
})
await store.login(password.value.trim())
// Verify session cookie works before navigating (prevents connection lost on first login)
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
} catch {
error.value = 'Setup succeeded but session could not be established. Try refreshing.'
store.logout()
return
}
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await store.login(password.value.trim())
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
@@ -425,6 +449,14 @@ async function handleLogin() {
setTimeout(() => totpInputRef.value?.focus(), 100)
return
}
// Verify session cookie works before navigating (prevents login loop on LAN)
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
} catch {
error.value = 'Login succeeded but session could not be established. Try clearing cookies and refreshing.'
store.logout()
return
}
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()

View File

@@ -112,6 +112,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
@@ -119,7 +120,6 @@ import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
import {
type MarketplaceApp,
type InstallProgress,
INSTALLED_ALIASES,
getAppTier,
categorizeCommunityApp,
@@ -129,6 +129,7 @@ import {
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const server = useServerStore()
const { t } = useI18n()
const showStagger = !marketplaceAnimationDone
@@ -152,8 +153,8 @@ const categories = computed(() => [
{ id: 'other', name: t('marketplace.other') }
])
// Installation state - support multiple concurrent installations
const installingApps = ref<Map<string, InstallProgress>>(new Map())
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
const maxAttempts = ref(60)
// Watch WebSocket data for real install progress from backend
@@ -162,8 +163,8 @@ watch(() => store.packages, (packages) => {
for (const [appId, pkg] of Object.entries(packages)) {
if ((pkg.state as string) === 'installing') {
const progress = pkg['install-progress']
if (!installingApps.value.has(appId)) {
installingApps.value.set(appId, {
if (!installingApps.has(appId)) {
installingApps.set(appId, {
id: appId,
title: pkg.manifest?.title || appId,
status: 'downloading',
@@ -173,19 +174,19 @@ watch(() => store.packages, (packages) => {
})
}
if (progress) {
const current = installingApps.value.get(appId)!
const current = installingApps.get(appId)!
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
installingApps.value.set(appId, {
installingApps.set(appId, {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
})
}
} else if (installingApps.value.has(appId) && (pkg.state as string) !== 'installing') {
installingApps.value.delete(appId)
} else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') {
installingApps.delete(appId)
}
}
}, { deep: true })
@@ -402,11 +403,11 @@ onBeforeUnmount(() => {
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.value.get(appId)
const current = installingApps.get(appId)
if (!current) { clearTrackedInterval(interval); return }
const newAttempt = current.attempt + 1
installingApps.value.set(appId, {
installingApps.set(appId, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
@@ -415,49 +416,49 @@ function startInstallPolling(appId: string, statusMessage: string) {
if (isInstalled(appId)) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.delete(appId) }, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.delete(appId) }, 5000)
}
}, 1000)
}
async function installApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.value.set(app.id, {
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.value.set(app.id, {
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
try {
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({
method: 'package.install',
@@ -465,13 +466,13 @@ async function installCommunityApp(app: MarketplaceApp) {
timeout: 180000
})
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}
</script>

View File

@@ -344,9 +344,9 @@ function truncatePubkey(hex: string | null): string {
<!-- Responsive column layout -->
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status -->
<div class="glass-card mesh-status-card">
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
<div class="mesh-status-header">
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2>
@@ -410,7 +410,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- Actions row -->
<div class="mesh-actions">
<div class="mesh-actions" data-controller-container tabindex="0">
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
</button>
@@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- Peers list -->
<div class="glass-card mesh-peers-card">
<div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
@@ -441,7 +441,10 @@ function truncatePubkey(hex: string | null): string {
<div
class="mesh-peer-row is-channel"
:class="{ active: archChannelActive }"
tabindex="0"
role="button"
@click="openArchChannel"
@keydown.enter="openArchChannel"
>
<div class="mesh-peer-avatar channel" style="background: rgba(251,146,60,0.2); color: #fb923c;">A</div>
<div class="mesh-peer-info">
@@ -454,7 +457,10 @@ function truncatePubkey(hex: string | null): string {
<div
class="mesh-peer-row is-channel"
:class="{ active: activeChatChannel?.index === 0 }"
tabindex="0"
role="button"
@click="openChannelChat(publicChannel)"
@keydown.enter="openChannelChat(publicChannel)"
>
<div class="mesh-peer-avatar channel">#</div>
<div class="mesh-peer-info">
@@ -466,7 +472,10 @@ function truncatePubkey(hex: string | null): string {
v-for="peer in sortedPeers" :key="peer.contact_id"
class="mesh-peer-row"
:class="{ active: activeChatPeer?.contact_id === peer.contact_id, 'is-archy': isArchyNode(peer) }"
tabindex="0"
role="button"
@click="openChat(peer)"
@keydown.enter="openChat(peer)"
>
<div class="mesh-peer-avatar" :class="{ archy: isArchyNode(peer) }">
<AnimatedLogo v-if="isArchyNode(peer)" size="sm" />
@@ -493,7 +502,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right" :class="{ 'mobile-hidden': !mobileShowChat }">
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }">
<!-- Tab bar (medium desktop only) -->
<div v-if="showTabBar" class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
@@ -512,7 +521,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- Chat Panel -->
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
<div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p>
@@ -614,8 +623,8 @@ function truncatePubkey(hex: string | null): string {
</template>
</div>
<!-- Tools panels -->
<div class="mesh-tools-wrapper">
<!-- Tools panels (3rd column on wide screens) -->
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
<!-- Tools tab bar (wide desktop only) -->
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">

View File

@@ -15,7 +15,7 @@
<!-- Content Area -->
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. You can try again shortly or skip this step.</p>
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<!-- Passphrase Input -->
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
@@ -30,6 +30,7 @@
</svg>
</div>
<input
ref="passphraseInput"
v-model="passphrase"
type="password"
placeholder="Enter a strong passphrase"
@@ -48,7 +49,7 @@
:disabled="!passphrase || isDownloading"
class="path-action-button path-action-button--continue w-full"
>
<span v-if="!isDownloading && !downloaded">Download Backup</span>
<span v-if="!isDownloading && !downloaded">Backup to Continue</span>
<span v-else-if="isDownloading" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -74,14 +75,9 @@
</div>
<!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
ref="continueButton"
@click="proceed"
:disabled="!downloaded"
class="path-action-button path-action-button--continue disabled:opacity-50"
@@ -94,12 +90,21 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const passphraseInput = ref<HTMLInputElement | null>(null)
const continueButton = ref<HTMLButtonElement | null>(null)
const passphrase = ref('')
onMounted(() => {
setTimeout(() => {
passphraseInput.value?.focus({ preventScroll: true })
}, 500)
})
const isDownloading = ref(false)
const downloaded = ref(false)
const errorMessage = ref('')
@@ -133,6 +138,10 @@ async function downloadBackup() {
downloaded.value = true
localStorage.setItem('neode_backup_created', '1')
// Focus Continue button after backup completes
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 100)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
@@ -146,11 +155,9 @@ async function downloadBackup() {
}
function proceed() {
playNavSound('action')
router.push('/onboarding/verify').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/verify').catch(() => {})
}
</script>

View File

@@ -98,15 +98,10 @@
</div>
<!-- Action Buttons -->
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
<button
v-if="generatedDid"
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
@@ -118,11 +113,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const generatedDid = ref<string>('')
const nostrNpub = ref<string>('')
const isGenerating = ref(false)
@@ -185,6 +181,16 @@ async function fetchDid() {
}
}
watch(generatedDid, (did) => {
if (did) {
nextTick(() => {
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 100)
})
}
})
onMounted(() => {
const cached = localStorage.getItem('neode_did')
const cachedNpub = localStorage.getItem('neode_nostr_npub')
@@ -205,11 +211,6 @@ function proceed() {
router.push('/onboarding/identity').catch(() => {})
}
function skipForNow() {
stopTimers()
router.push('/onboarding/identity').catch(() => {})
}
function copyDid() {
if (!generatedDid.value) return
navigator.clipboard.writeText(generatedDid.value).catch(() => {})

View File

@@ -42,12 +42,14 @@
</div>
</div>
<!-- Go to Login Button -->
<!-- Set Password Button -->
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
<button
ref="setPasswordButton"
@click="goToLogin"
class="path-action-button path-action-button--continue mx-auto"
>
Go to Login
Set Password
</button>
</div>
</div>
@@ -55,11 +57,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const setPasswordButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
setTimeout(() => {
setPasswordButton.value?.focus({ preventScroll: true })
}, 500)
})
function goToLogin() {
playNavSound('action')
router.push('/login').catch(() => {})
}
</script>

View File

@@ -18,6 +18,7 @@
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
<input
ref="nameInput"
v-model="identityName"
type="text"
placeholder="Personal"
@@ -32,7 +33,7 @@
<button
v-for="p in purposes"
:key="p.value"
@click="selectedPurpose = p.value"
@click="playNavSound('action'); selectedPurpose = p.value"
class="px-4 py-3 rounded-lg border text-left transition-all"
:class="selectedPurpose === p.value
? 'bg-white/15 border-white/30 text-white'
@@ -60,13 +61,7 @@
<p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
<!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skip"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="createIdentity"
:disabled="isCreating"
@@ -81,12 +76,20 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const nameInput = ref<HTMLInputElement | null>(null)
const identityName = ref('Personal')
onMounted(() => {
setTimeout(() => {
nameInput.value?.focus({ preventScroll: true })
}, 500)
})
const selectedPurpose = ref('personal')
const isCreating = ref(false)
const errorMessage = ref('')
@@ -115,6 +118,7 @@ async function createIdentity() {
purpose: selectedPurpose.value
}
})
playNavSound('action')
router.push('/onboarding/backup').catch(() => {})
} catch (err) {
if (isServerStartingError(err)) {
@@ -127,7 +131,4 @@ async function createIdentity() {
}
}
function skip() {
router.push('/onboarding/backup').catch(() => {})
}
</script>

View File

@@ -18,6 +18,7 @@
</p>
<button
ref="ctaButton"
@click="goToOptions"
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
>
@@ -25,8 +26,11 @@
</button>
<a
tabindex="0"
role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
@click="showRestore = true"
@keydown.enter="showRestore = true"
>
Restore from backup
</a>
@@ -65,14 +69,24 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const ctaButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
setTimeout(() => {
ctaButton.value?.focus({ preventScroll: true })
}, 2100)
})
function goToOptions() {
playNavSound('action')
router.push('/onboarding/path').catch(() => {})
}

View File

@@ -82,6 +82,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { completeOnboarding } from '@/composables/useOnboarding'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const selected = ref<string | null>(null)
@@ -100,6 +101,7 @@ async function proceed() {
} catch (e) {
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
}
playNavSound('action')
router.push('/login').catch(() => {})
}
</script>

View File

@@ -82,6 +82,7 @@
<!-- Action Buttons -->
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
@@ -93,11 +94,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 500)
})
function proceed() {
playNavSound('action')
router.push('/onboarding/did').catch(() => {})
}
</script>

View File

@@ -14,10 +14,11 @@
<!-- Content Area -->
<div class="flex flex-col items-center gap-6 mb-6">
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. You can try again shortly or skip this step.</p>
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<!-- Sign Button (if not verified yet) -->
<button
ref="signButton"
v-if="!verified"
@click="signChallenge"
:disabled="isSigning"
@@ -63,14 +64,9 @@
</div>
<!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
ref="finishButton"
v-if="verified"
@click="proceed"
class="path-action-button path-action-button--continue"
@@ -83,13 +79,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { completeOnboarding } from '@/composables/useOnboarding'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const signButton = ref<HTMLButtonElement | null>(null)
const finishButton = ref<HTMLButtonElement | null>(null)
const verified = ref(false)
onMounted(() => {
setTimeout(() => {
signButton.value?.focus({ preventScroll: true })
}, 500)
})
const isSigning = ref(false)
const signature = ref('')
const currentChallenge = ref('')
@@ -125,6 +130,9 @@ async function signChallenge() {
} else {
verified.value = true
}
nextTick(() => {
setTimeout(() => finishButton.value?.focus({ preventScroll: true }), 100)
})
return
} catch (err) {
const msg = err instanceof Error ? err.message : ''
@@ -133,7 +141,7 @@ async function signChallenge() {
if (isRetryable) {
serverStarting.value = true
} else {
errorMessage.value = msg || 'Failed to sign challenge. You can retry or skip this step.'
errorMessage.value = msg || 'Failed to sign challenge. Please try again.'
}
} else {
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
@@ -144,6 +152,7 @@ async function signChallenge() {
}
async function proceed() {
playNavSound('action')
try {
await completeOnboarding()
} catch {
@@ -152,13 +161,5 @@ async function proceed() {
router.push('/onboarding/done').catch(() => {})
}
async function skipForNow() {
try {
await completeOnboarding()
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/onboarding/done').catch(() => {})
}
</script>

View File

@@ -21,6 +21,14 @@ import BootScreen from '@/components/BootScreen.vue'
const router = useRouter()
const showBootScreen = ref(false)
function log(msg: string, data?: unknown) {
const ts = new Date().toISOString()
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
console.log(entry)
const prev = sessionStorage.getItem('archipelago_boot_log') || ''
sessionStorage.setItem('archipelago_boot_log', prev + entry + '\n')
}
async function quickHealthCheck(): Promise<boolean> {
try {
const ac = new AbortController()
@@ -32,8 +40,11 @@ async function quickHealthCheck(): Promise<boolean> {
signal: ac.signal,
})
clearTimeout(t)
return res.status !== 502 && res.status !== 503
} catch {
const ok = res.status !== 502 && res.status !== 503
log('healthCheck', { status: res.status, ok })
return ok
} catch (e) {
log('healthCheck FAILED', { error: String(e) })
return false
}
}
@@ -44,24 +55,27 @@ async function checkOnboarded(): Promise<boolean> {
isOnboardingComplete(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
])
log('checkOnboarded', { result })
return result
} catch {
// Backend unreachable — fall back to localStorage only as last resort
return localStorage.getItem('neode_onboarding_complete') === '1'
} catch (e) {
const fallback = localStorage.getItem('neode_onboarding_complete') === '1'
log('checkOnboarded ERROR, localStorage fallback', { error: String(e), fallback })
return fallback
}
}
async function proceedToApp() {
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
log('proceedToApp devMode', { devMode })
router.replace('/login').catch(() => {})
return
}
// Always check backend for authoritative onboarding state
// (localStorage can be stale from a previous install on the same IP)
const onboarded = await checkOnboarded()
router.replace(onboarded ? '/login' : '/onboarding/intro').catch(() => {})
const dest = onboarded ? '/login' : '/onboarding/intro'
log('proceedToApp navigating', { onboarded, dest })
router.replace(dest).catch(() => {})
}
function onServerReady() {
@@ -74,44 +88,42 @@ function onServerReady() {
onMounted(async () => {
const devMode = import.meta.env.VITE_DEV_MODE
log('mounted', { devMode, from_boot: sessionStorage.getItem('archipelago_from_boot'), from_splash: sessionStorage.getItem('archipelago_from_splash') })
// Coming back from boot screen — let App.vue's SplashScreen take over
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
log('from_boot=1, deferring to SplashScreen')
return
}
// Splash already completed this session — go to app
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
log('from_splash=1, proceedToApp')
proceedToApp()
return
}
// Standard dev modes
if (devMode === 'setup' || devMode === 'existing') {
log('devMode shortcut', { devMode })
proceedToApp()
return
}
// Boot dev mode — always show boot screen (first load only)
if (devMode === 'boot') {
log('devMode=boot, showing boot screen')
showBootScreen.value = true
return
}
// Production: check server health
const isUp = await quickHealthCheck()
log('production flow', { isUp })
if (isUp) {
// Server is up — check if onboarding is complete
const onboarded = await checkOnboarded()
if (onboarded) {
// Returning user, server is up — go straight to login
log('server up + onboarded → proceedToApp')
proceedToApp()
return
}
// First boot: server is up but onboarding not done yet.
// Show boot animation anyway — it lets services fully warm up
// (containers, DID resolver, etc.) before onboarding starts.
log('server up + NOT onboarded → boot screen')
}
// Server not ready OR first boot — show boot screen

View File

@@ -49,7 +49,7 @@
<!-- Overview Cards -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<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">
@@ -129,7 +129,7 @@
</div>
<!-- Web3 Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
@@ -156,7 +156,7 @@
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6">
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import AccountSection from '@/views/settings/AccountSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import SystemSection from '@/views/settings/SystemSection.vue'
</script>
<template>
<div class="pb-6">
<AccountSection />
<ChangePasswordSection />
<SystemSection />
</div>
</template>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { shallowMount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
@@ -43,10 +43,12 @@ vi.mock('@/components/AnimatedLogo.vue', () => ({
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
}))
const pushMock = vi.fn()
const pushMock = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ query: {} }),
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
createWebHistory: vi.fn(),
}))
// Stub fetch for server health check
@@ -87,6 +89,12 @@ describe('Login View', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
pushMock.mockResolvedValue(undefined)
// Mock health check so Login renders the form (not "Starting server...")
mockedRpc.call.mockImplementation(async (opts: any) => {
if (opts.method === 'server.echo') return { message: 'pong' }
if (opts.method === 'auth.isSetup') return { isSetup: true }
return null
})
})
function mountLogin() {
@@ -106,19 +114,22 @@ describe('Login View', () => {
expect(wrapper.exists()).toBe(true)
})
it('contains a password input', () => {
it('contains a password input', async () => {
const wrapper = mountLogin()
await flushPromises()
const input = wrapper.find('input[type="password"]')
expect(input.exists()).toBe(true)
})
it('shows title text', () => {
it('shows title text', async () => {
const wrapper = mountLogin()
await flushPromises()
expect(wrapper.text()).toContain('Welcome Back')
})
it('has a login button', () => {
it('has a login button', async () => {
const wrapper = mountLogin()
await flushPromises()
const buttons = wrapper.findAll('button')
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
expect(loginBtn).toBeDefined()

View File

@@ -1,317 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
// Mock rpc-client before importing anything that uses it
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ backups: [] }),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
totpStatus: vi.fn().mockResolvedValue({ enabled: false }),
totpSetupBegin: vi.fn(),
totpSetupConfirm: vi.fn(),
totpDisable: vi.fn(),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
},
}))
// Mock websocket module
vi.mock('@/api/websocket', () => ({
wsClient: {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
subscribe: vi.fn(),
isConnected: vi.fn().mockReturnValue(false),
onConnectionStateChange: vi.fn(),
},
applyDataPatch: vi.fn(),
}))
// Stub the ControllerIndicator component
vi.mock('@/components/ControllerIndicator.vue', () => ({
default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }),
}))
// Mock useModalKeyboard composable
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
// Stub vue-router
const pushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushMock,
}),
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
}))
// Stub global fetch for the Claude status check in onMounted
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available')))
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import Settings from '../Settings.vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
const mockedRpc = vi.mocked(rpcClient)
function mountSettings(storeOverrides?: Partial<ReturnType<typeof useAppStore>>): VueWrapper {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
// Set default store state for tests
store.isAuthenticated = true
store.$patch({
data: {
'server-info': {
id: 'test-node',
version: '0.1.0-alpha',
name: 'Test Node',
pubkey: 'test-pubkey',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': '192.168.1.100',
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
if (storeOverrides) {
store.$patch(storeOverrides as Record<string, unknown>)
}
return shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
}
describe('Settings View', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
mockedRpc.call.mockResolvedValue({ backups: [] })
mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null })
pushMock.mockResolvedValue(undefined)
})
it('renders without errors', () => {
const wrapper = mountSettings()
expect(wrapper.exists()).toBe(true)
})
it('displays the Account section heading', () => {
const wrapper = mountSettings()
const heading = wrapper.find('h2')
expect(heading.exists()).toBe(true)
expect(heading.text()).toBe('Account')
})
it('displays the Account section with server name and version', () => {
const wrapper = mountSettings()
const html = wrapper.html()
// Account section heading
const sectionHeadings = wrapper.findAll('h2')
const accountHeading = sectionHeadings.find((h) => h.text() === 'Account')
expect(accountHeading).toBeDefined()
// Server name rendered
expect(html).toContain('Test Node')
// Version rendered
expect(html).toContain('0.1.0')
})
it('displays the version from server info', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('0.1.0')
expect(html).toContain('Version')
})
it('displays the Interface Mode section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode')
expect(modeHeading).toBeDefined()
})
it('displays the Claude Authentication section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication')
expect(claudeHeading).toBeDefined()
})
it('displays the AI Data Access section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access')
expect(aiHeading).toBeDefined()
})
it('displays the System Updates section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates')
expect(updatesHeading).toBeDefined()
})
it('displays the Backup & Restore section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup'))
expect(backupHeading).toBeDefined()
})
it('displays the Network section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const networkHeading = sectionHeadings.find((h) => h.text() === 'Network')
expect(networkHeading).toBeDefined()
})
it('displays a Logout button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
expect(logoutButton!.exists()).toBe(true)
})
it('logout button triggers store logout and navigates to login', async () => {
const wrapper = mountSettings()
const store = useAppStore()
const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
await logoutButton!.trigger('click')
// Allow async handlers to settle
await vi.dynamicImportSettled()
expect(logoutSpy).toHaveBeenCalled()
expect(pushMock).toHaveBeenCalledWith('/login')
})
it('displays a Change Password button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const changePasswordButton = buttons.find((b) => b.text().includes('Change Password'))
expect(changePasswordButton).toBeDefined()
expect(changePasswordButton!.exists()).toBe(true)
})
it('displays Two-Factor Authentication section with status', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('Two-Factor Authentication')
})
it('shows Enable 2FA button when TOTP is not enabled', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA'))
expect(enable2faButton).toBeDefined()
})
it('displays session status as currently logged in', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Currently logged in')
})
it('shows server name from the store', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Server Name')
expect(wrapper.html()).toContain('Test Node')
})
it('defaults version to 0.0.0 when server info has no version', () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
store.$patch({
isAuthenticated: true,
data: {
'server-info': {
id: 'test',
version: '',
name: null,
pubkey: '',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': null,
'tor-address': null,
unread: 0,
'wifi-ssids': [],
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
const wrapper = shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
// When version is empty string, computed returns '0.0.0' from the fallback
const html = wrapper.html()
expect(html).toContain('0.0.0')
})
it('calls totpStatus on mount to check 2FA state', async () => {
mountSettings()
// onMounted calls loadTotpStatus which calls rpcClient.totpStatus
expect(mockedRpc.totpStatus).toHaveBeenCalled()
})
it('calls backup.list on mount to load backups', async () => {
mountSettings()
// onMounted calls loadBackups which calls rpcClient.call with backup.list
expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' })
it('renders AccountSection and SystemSection', () => {
setActivePinia(createPinia())
const wrapper = shallowMount(Settings)
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
})
})

View File

@@ -8,8 +8,25 @@
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="$emit('goToApp', id)"
@keydown.enter="$emit('goToApp', id)"
@keydown.enter="handleEnter"
>
<!-- Installing overlay -->
<div
v-if="isInstalling"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl gap-2"
>
<div class="flex items-center gap-3 text-amber-400">
<svg class="animate-spin h-5 w-5" 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>
<span class="text-sm font-medium">{{ installProgress?.message || t('common.installing') }}...</span>
</div>
<div v-if="installProgress" class="w-3/4 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-amber-500 rounded-full transition-all" :style="{ width: `${installProgress.progress}%` }"></div>
</div>
</div>
<!-- Uninstalling overlay -->
<div
v-if="isUninstalling"
@@ -39,43 +56,51 @@
<div class="flex items-start gap-4">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
:alt="pkg.manifest?.title || String(id)"
class="w-16 h-16 rounded-lg object-cover bg-white/10"
:src="icon"
:alt="title"
class="w-14 h-14 rounded-lg object-cover bg-white/10"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
{{ pkg.manifest.title }}
</h3>
<p class="text-sm text-white/70 mb-2 truncate">
{{ pkg.manifest?.description?.short || '' }}
</p>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 mb-0.5">
<h3 class="text-lg font-semibold text-white truncate" :title="title">
{{ title }}
</h3>
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<svg
v-if="isTransitioning"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
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>
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-xs text-white/50">
v{{ pkg.manifest.version }}
</span>
v-if="tier && tier !== 'optional'"
class="tier-badge"
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tier }}</span>
</div>
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
</div>
</div>
<p class="text-white/70 text-sm mt-3 mb-3 line-clamp-2">
{{ description }}
</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<svg
v-if="isTransitioning"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
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>
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
</div>
<!-- Quick Actions -->
<div v-if="!isUninstalling" class="mt-4 flex gap-2">
<button
@@ -145,19 +170,25 @@ import {
isWebOnlyApp, opensInTab, canLaunch,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const { t } = useI18n()
// Build a lookup map for enriching sparse backend data during install
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
const props = defineProps<{
id: string
pkg: PackageDataEntry
index: number
showStagger: boolean
isLoading: boolean
isInstalling?: boolean
installProgress?: { status: string; progress: number; message: string }
isUninstalling: boolean
}>()
defineEmits<{
const emit = defineEmits<{
goToApp: [id: string]
launch: [id: string]
start: [id: string]
@@ -166,8 +197,43 @@ defineEmits<{
showUninstall: [id: string, pkg: PackageDataEntry]
}>()
function handleEnter(e: KeyboardEvent) {
// Controller nav already handled this Enter (preventDefault was called) — skip to avoid double navigation
if (e.defaultPrevented) return
emit('goToApp', props.id)
}
const isWebOnly = computed(() => isWebOnlyApp(props.id))
// Enrich from marketplace when backend data is sparse (e.g. during install)
const curated = computed(() => curatedMap.get(props.id))
const title = computed(() => {
const t = props.pkg.manifest?.title
return (t && t !== props.id) ? t : (curated.value?.title || t || props.id)
})
const description = computed(() => {
const d = props.pkg.manifest?.description?.short
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
})
const icon = computed(() => {
const i = props.pkg['static-files']?.icon
return i || curated.value?.icon || `/assets/img/app-icons/${props.id}.png`
})
const version = computed(() => {
const v = props.pkg.manifest?.version
return v || curated.value?.version || ''
})
const author = computed(() => props.pkg.manifest?.author || curated.value?.author || '')
const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'
})
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health

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