Compare commits
93 Commits
jay-contai
...
dev-iso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65b039914 | ||
|
|
5bd3caf141 | ||
|
|
377195f7e0 | ||
|
|
9ea8877d20 | ||
|
|
1c82b8285e | ||
|
|
b773ba610f | ||
|
|
ff85754aa2 | ||
|
|
ccad4737de | ||
|
|
b214b2f52f | ||
|
|
c85534357e | ||
|
|
70254b1bb7 | ||
|
|
a69aef53b5 | ||
|
|
9dd7539edc | ||
|
|
11f7434866 | ||
|
|
9d437ea476 | ||
|
|
89a9f69a9b | ||
|
|
37f32f4e54 | ||
|
|
2c0d4a7393 | ||
|
|
5b186da770 | ||
|
|
08ddc73c75 | ||
|
|
0b5fb4c90b | ||
|
|
e8735b39ec | ||
|
|
25b789bd3f | ||
|
|
9b49ab6d99 | ||
|
|
cba87e2c28 | ||
|
|
48e87d0cfb | ||
|
|
09a9dbc6ca | ||
|
|
9085a7e79f | ||
|
|
d989535a9a | ||
|
|
20289c5bec | ||
|
|
d25969e2e5 | ||
|
|
cb1f252e4d | ||
|
|
39d7bd07b9 | ||
|
|
2e29a41627 | ||
|
|
840ecfaa5f | ||
|
|
b47fec7fba | ||
|
|
6be30b99fa | ||
|
|
4f90cf39cf | ||
|
|
53e62ea25b | ||
|
|
aff9e5111b | ||
|
|
cfe4a03ffb | ||
|
|
aada19754d | ||
|
|
1444bcb0c4 | ||
|
|
2c03dce947 | ||
|
|
7f03e39f58 | ||
|
|
82eeb915a3 | ||
|
|
e28de77596 | ||
|
|
2021de5cda | ||
|
|
9db55b0b34 | ||
|
|
9d38989048 | ||
|
|
782a4a62d5 | ||
|
|
24a5ed7601 | ||
|
|
eecc7e0e71 | ||
|
|
b94428a97b | ||
|
|
3bb91e90f3 | ||
|
|
56be32e55b | ||
|
|
34a476d0a1 | ||
|
|
013b724e02 | ||
|
|
f3f7b8b72f | ||
|
|
e8c80263f3 | ||
|
|
9e3c0b85ea | ||
|
|
93b2af203a | ||
|
|
0212bfdc1d | ||
|
|
c1ff912cb1 | ||
|
|
71b93548c3 | ||
|
|
69c62eb47a | ||
|
|
7183ebfa2b | ||
|
|
39857c775a | ||
|
|
f940b4562a | ||
|
|
4325c15541 | ||
|
|
127a36c5c8 | ||
|
|
b684c2972e | ||
|
|
320c9f5b19 | ||
|
|
bc5121b33f | ||
|
|
0bef26badd | ||
|
|
1ddf90ae50 | ||
|
|
ab48266353 | ||
|
|
493a659ed4 | ||
|
|
e4bdc775e4 | ||
|
|
13b832fdd3 | ||
|
|
3db9ff9216 | ||
|
|
5b60d13693 | ||
|
|
71d7d8c918 | ||
|
|
fad79ff955 | ||
|
|
732b04c9df | ||
|
|
6063ac553c | ||
|
|
bda8b38a95 | ||
|
|
9354a27909 | ||
|
|
3a31c2aa95 | ||
|
|
1eea46542e | ||
|
|
1a64b14354 | ||
|
|
f7a57b8f1f | ||
|
|
1d9fe06f97 |
@@ -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
|
||||
|
||||
243
.claude/plans/silly-wondering-flamingo.md
Normal file
243
.claude/plans/silly-wondering-flamingo.md
Normal 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 |
|
||||
@@ -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 |
|
||||
|
||||
107
.claude/skills/design-pixel-retro/SKILL.md
Normal file
107
.claude/skills/design-pixel-retro/SKILL.md
Normal 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
|
||||
114
.claude/skills/gamepad-nav/SKILL.md
Normal file
114
.claude/skills/gamepad-nav/SKILL.md
Normal 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)
|
||||
146
.claude/skills/iso-branding/SKILL.md
Normal file
146
.claude/skills/iso-branding/SKILL.md
Normal 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`
|
||||
175
.claude/skills/iso-debug/SKILL.md
Normal file
175
.claude/skills/iso-debug/SKILL.md
Normal 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 |
|
||||
383
.claude/skills/iso-debug/references/boot-chain-reference.md
Normal file
383
.claude/skills/iso-debug/references/boot-chain-reference.md
Normal 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
|
||||
204
.gitea/workflows/build-iso-dev.yml
Normal file
204
.gitea/workflows/build-iso-dev.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
60
.gitea/workflows/container-tests.yml
Normal file
60
.gitea/workflows/container-tests.yml
Normal 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
|
||||
72
.gitea/workflows/post-install-tests.yml
Normal file
72
.gitea/workflows/post-install-tests.yml
Normal 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
453
CLAUDE.md
@@ -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
15
core/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -4,7 +4,6 @@ resolver = "2"
|
||||
members = [
|
||||
"archipelago",
|
||||
"container",
|
||||
"parmanode",
|
||||
"performance",
|
||||
"security",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
265
core/archipelago/src/container/mock_podman.rs
Normal file
265
core/archipelago/src/container/mock_podman.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
499
core/archipelago/tests/orchestration_tests.rs
Normal file
499
core/archipelago/tests/orchestration_tests.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod script_runner;
|
||||
pub mod converter;
|
||||
|
||||
pub use script_runner::ParmanodeScriptRunner;
|
||||
pub use converter::ParmanodeConverter;
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
96
docs/BETA-ISSUES-20260328.md
Normal file
96
docs/BETA-ISSUES-20260328.md
Normal 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** |
|
||||
117
docs/INSTALL-SCREENS-DESIGN.md
Normal file
117
docs/INSTALL-SCREENS-DESIGN.md
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
BIN
image-recipe/branding/grub-theme/background.png
Normal file
BIN
image-recipe/branding/grub-theme/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 837 KiB |
48
image-recipe/branding/grub-theme/theme.txt
Normal file
48
image-recipe/branding/grub-theme/theme.txt
Normal 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"
|
||||
}
|
||||
BIN
image-recipe/branding/isohdpfx.bin
Normal file
BIN
image-recipe/branding/isohdpfx.bin
Normal file
Binary file not shown.
@@ -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
|
||||
109
image-recipe/branding/plymouth-theme/archipelago.script
Normal file
109
image-recipe/branding/plymouth-theme/archipelago.script
Normal 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);
|
||||
BIN
image-recipe/branding/plymouth-theme/logo.png
Normal file
BIN
image-recipe/branding/plymouth-theme/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because it is too large
Load Diff
12
image-recipe/configs/archipelago-doctor.service
Normal file
12
image-recipe/configs/archipelago-doctor.service
Normal 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
|
||||
12
image-recipe/configs/archipelago-doctor.timer
Normal file
12
image-recipe/configs/archipelago-doctor.timer
Normal 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
|
||||
14
image-recipe/configs/archipelago-reconcile.service
Normal file
14
image-recipe/configs/archipelago-reconcile.service
Normal 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
|
||||
14
image-recipe/configs/archipelago-reconcile.timer
Normal file
14
image-recipe/configs/archipelago-reconcile.timer
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
207
image-recipe/dev-branding.sh
Executable 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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
528
neode-ui/docs/GAMEPAD-NAV-MAP.md
Normal file
528
neode-ui/docs/GAMEPAD-NAV-MAP.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
82
neode-ui/src/stores/install.ts
Normal file
82
neode-ui/src/stores/install.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface Manifest {
|
||||
'donation-url': string | null
|
||||
author?: string
|
||||
website?: string
|
||||
tier?: string
|
||||
interfaces?: {
|
||||
main?: {
|
||||
ui?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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 sidebar→main 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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"> </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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">📡</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'">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user