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
|
name: Node inventory and SSH access
|
||||||
description: Complete list of all Tailscale node IPs and hostnames for SSH access
|
description: Complete list of all Archipelago nodes — LAN and Tailscale IPs, SSH commands, build capabilities, deploy methods
|
||||||
type: reference
|
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
|
## LAN Nodes
|
||||||
| Name | IP | SSH |
|
| Name | IP | SSH | Notes |
|
||||||
|------|-----|-----|
|
|------|-----|-----|-------|
|
||||||
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` |
|
| 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` |
|
| 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
|
name: build-iso
|
||||||
description: Build a new Archipelago auto-installer ISO image (bundled or unbundled)
|
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.
|
||||||
disable-model-invocation: true
|
allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Agent
|
||||||
allowed-tools: Bash, Read
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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)
|
Custom debootstrap-based installer. NO Debian Live ISO download.
|
||||||
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/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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)
|
## Build Pipeline (6 Steps)
|
||||||
No pre-bundled container images. Apps install on-demand from Marketplace (requires internet).
|
|
||||||
|
**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
|
```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 \
|
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)
|
| File | Role |
|
||||||
All container images pre-bundled for offline install.
|
|------|------|
|
||||||
|
| `image-recipe/build-auto-installer-iso.sh` | Main build script (~2600 lines) |
|
||||||
```bash
|
| `image-recipe/build-unbundled-iso.sh` | Wrapper: sets UNBUNDLED=1 |
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
| `image-recipe/branding/isohdpfx.bin` | Proven MBR (432 bytes) |
|
||||||
'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh'
|
| `image-recipe/branding/grub-theme/` | GRUB theme + background |
|
||||||
```
|
| `image-recipe/branding/plymouth-theme/` | Plymouth boot splash |
|
||||||
|
| `scripts/image-versions.sh` | Pinned container image versions |
|
||||||
Output: `results/archipelago-installer-x86_64.iso`
|
| `.gitea/workflows/build-iso-dev.yml` | CI for dev-iso branch |
|
||||||
|
| `image-recipe/test-iso-qemu.sh` | QEMU test script |
|
||||||
## Post-build: ALWAYS publish to FileBrowser
|
| `image-recipe/dev-branding.sh` | Quick branding iteration |
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
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
|
name: Build Archipelago ISO
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -14,34 +12,119 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
clean: false
|
||||||
|
|
||||||
- name: Build backend
|
- name: Build backend
|
||||||
run: |
|
run: |
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
cargo build --release --manifest-path core/Cargo.toml
|
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
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
echo "PWD: $(pwd)"
|
rm -rf web/dist/neode-ui
|
||||||
ls -la neode-ui/package.json || echo "neode-ui/package.json NOT FOUND"
|
cd neode-ui && npm ci && npm run build
|
||||||
cd neode-ui
|
|
||||||
npm ci
|
- name: Type check frontend
|
||||||
npm run build
|
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
|
- name: Build unbundled ISO
|
||||||
run: |
|
run: |
|
||||||
cd image-recipe
|
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
|
- name: Copy to Builds
|
||||||
run: |
|
run: |
|
||||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||||
if [ -n "$ISO" ]; then
|
if [ -n "$ISO" ]; then
|
||||||
DATE=$(date +%Y%m%d-%H%M)
|
DATE=$(date +%Y%m%d-%H%M)
|
||||||
sudo cp "$ISO" "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||||
sudo chown 1000:1000 "/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 "ISO: archipelago-unbundled-${DATE}.iso"
|
||||||
|
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||||
|
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||||
fi
|
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
|
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 12
|
||||||
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
|
**Version**: 0.1.0 | **Target**: x86_64 and ARM64
|
||||||
**Current version**: 0.1.0
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend local dev (mock backend on :5959, Vite on :8100)
|
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
|
||||||
cd neode-ui && npm start
|
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
|
||||||
|
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Debian 12 (Bookworm)
|
Debian 12
|
||||||
├── Podman (rootless containers)
|
├── Podman (rootless, user archipelago)
|
||||||
├── Nginx (port 80 → proxies /rpc/, /ws/, /health to backend)
|
├── Nginx (80/443 → backend, app proxies)
|
||||||
├── Rust Backend (core/) — binary on port 5678
|
├── Rust Backend (core/) on 127.0.0.1:5678
|
||||||
│ ├── core/archipelago/ — Main binary, RPC endpoints
|
│ ├── core/archipelago/ — Binary, RPC, auth, sessions
|
||||||
│ ├── core/container/ — PodmanClient, manifest parser, dependency resolver, health monitor
|
│ └── core/container/ — PodmanClient, manifests, health
|
||||||
│ ├── core/security/ — AppArmor profiles, secrets manager, Cosign image verifier
|
|
||||||
│ ├── core/performance/ — Resource manager
|
|
||||||
│ └── core/parmanode/ — Parmanode compatibility layer
|
|
||||||
└── Vue.js UI (neode-ui/)
|
└── Vue.js UI (neode-ui/)
|
||||||
├── src/api/ — RPC client (rpc-client.ts), WebSocket, container client
|
├── src/api/rpc-client.ts — All backend communication
|
||||||
├── src/stores/ — Pinia stores
|
├── src/stores/ — Pinia state
|
||||||
├── src/views/ — Page components
|
├── src/views/ — Pages
|
||||||
├── src/components/ — Reusable components
|
└── src/style.css — ALL styling (global classes only)
|
||||||
├── src/router/ — Vue Router
|
|
||||||
├── src/types/ — TypeScript type definitions
|
|
||||||
└── src/style.css — Global styles + Tailwind utilities
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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}/`
|
## Critical Rules
|
||||||
- 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 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
|
```bash
|
||||||
# Deploy does this automatically:
|
# Manual build on .228:
|
||||||
./scripts/deploy-to-target.sh --live
|
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
|
**Debugging fresh installs** — SSH in and check:
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
```bash
|
```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)
|
**Kiosk**: X11 on VT7, console on VT1. `Ctrl+Alt+F1` for terminal, `Ctrl+Alt+F7` for kiosk.
|
||||||
|
Toggle: `sudo archipelago-kiosk enable|disable|toggle`
|
||||||
### 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)
|
|
||||||
|
|
||||||
## App Integration Checklist
|
## 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
|
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
|
||||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — `needs_archy_net`: add if app needs container DNS
|
Push to: `git push tx1138 main`
|
||||||
- [ ] `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
|
|
||||||
|
|||||||
15
core/Cargo.lock
generated
15
core/Cargo.lock
generated
@@ -84,7 +84,6 @@ version = "1.2.0-alpha"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
"archipelago-parmanode",
|
|
||||||
"archipelago-performance",
|
"archipelago-performance",
|
||||||
"archipelago-security",
|
"archipelago-security",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -160,20 +159,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "archipelago-parmanode"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"archipelago-container",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_yaml",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago-performance"
|
name = "archipelago-performance"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"archipelago",
|
"archipelago",
|
||||||
"container",
|
"container",
|
||||||
"parmanode",
|
|
||||||
"performance",
|
"performance",
|
||||||
"security",
|
"security",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ futures-util = "0.3"
|
|||||||
archipelago-container = { path = "../container" }
|
archipelago-container = { path = "../container" }
|
||||||
archipelago-security = { path = "../security" }
|
archipelago-security = { path = "../security" }
|
||||||
archipelago-performance = { path = "../performance" }
|
archipelago-performance = { path = "../performance" }
|
||||||
archipelago-parmanode = { path = "../parmanode" }
|
|
||||||
|
|
||||||
# Database (optional for now - can use SQLite or skip)
|
# Database (optional for now - can use SQLite or skip)
|
||||||
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ impl RpcHandler {
|
|||||||
if !is_setup {
|
if !is_setup {
|
||||||
// Dev mode: allow default password so UI can log in without running setup
|
// Dev mode: allow default password so UI can log in without running setup
|
||||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||||
|
tracing::info!("[onboarding] login via dev default password");
|
||||||
return Ok(serde_json::Value::Null);
|
return Ok(serde_json::Value::Null);
|
||||||
}
|
}
|
||||||
|
tracing::warn!("[onboarding] login attempt before setup complete");
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"User not set up. Please complete setup first."
|
"User not set up. Please complete setup first."
|
||||||
));
|
));
|
||||||
@@ -25,13 +27,16 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let valid = self.auth_manager.verify_password(password).await?;
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
if !valid {
|
if !valid {
|
||||||
|
tracing::warn!("[onboarding] login failed — wrong password");
|
||||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!("[onboarding] login successful");
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||||
|
tracing::info!("[onboarding] logout");
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ impl RpcHandler {
|
|||||||
// Prevent re-setup if already set up
|
// Prevent re-setup if already set up
|
||||||
let is_setup = self.auth_manager.is_setup().await?;
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
if is_setup {
|
if is_setup {
|
||||||
|
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||||
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
|
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"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||||
|
|
||||||
if password.len() < 8 {
|
if password.len() < 8 {
|
||||||
|
tracing::warn!("[onboarding] setup rejected — password too short");
|
||||||
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.auth_manager.setup_user(password).await?;
|
self.auth_manager.setup_user(password).await?;
|
||||||
|
tracing::info!("[onboarding] user setup complete");
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
self.auth_manager.complete_onboarding().await?;
|
self.auth_manager.complete_onboarding().await?;
|
||||||
|
tracing::info!("[onboarding] onboarding marked complete");
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
let complete = self.auth_manager.is_onboarding_complete().await?;
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||||
|
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
|
||||||
Ok(serde_json::json!(complete))
|
Ok(serde_json::json!(complete))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +127,12 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let valid = self.auth_manager.verify_password(password).await?;
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
if !valid {
|
if !valid {
|
||||||
|
tracing::warn!("[onboarding] reset rejected — wrong password");
|
||||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.auth_manager.reset_onboarding().await?;
|
self.auth_manager.reset_onboarding().await?;
|
||||||
|
tracing::info!("[onboarding] onboarding reset");
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
|||||||
"package.stop" => self.handle_package_stop(params).await,
|
"package.stop" => self.handle_package_stop(params).await,
|
||||||
"package.restart" => self.handle_package_restart(params).await,
|
"package.restart" => self.handle_package_restart(params).await,
|
||||||
"package.uninstall" => self.handle_package_uninstall(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 management (for pre-loaded container images)
|
||||||
"bundled-app-start" => self.handle_bundled_app_start(params).await,
|
"bundled-app-start" => self.handle_bundled_app_start(params).await,
|
||||||
@@ -392,7 +393,7 @@ impl RpcHandler {
|
|||||||
"status": status,
|
"status": status,
|
||||||
"crash_recovery_complete": recovery_complete,
|
"crash_recovery_complete": recovery_complete,
|
||||||
"uptime_seconds": uptime,
|
"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.setup",
|
||||||
"auth.onboardingComplete",
|
"auth.onboardingComplete",
|
||||||
"health",
|
"health",
|
||||||
|
// Server readiness check (Login.vue polls this before showing form)
|
||||||
|
"server.echo",
|
||||||
// Onboarding flow (before user has a session — DID creation, signing, backup)
|
// Onboarding flow (before user has a session — DID creation, signing, backup)
|
||||||
"node.did",
|
"node.did",
|
||||||
"node.signChallenge",
|
"node.signChallenge",
|
||||||
"node.nostr-pubkey",
|
"node.nostr-pubkey",
|
||||||
"node.createBackup",
|
"node.createBackup",
|
||||||
|
"identity.create",
|
||||||
"identity.verify",
|
"identity.verify",
|
||||||
"identity.resolve-did",
|
"identity.resolve-did",
|
||||||
// Onboarding restore (before user account exists)
|
// Onboarding restore (before user account exists)
|
||||||
|
|||||||
@@ -144,8 +144,21 @@ impl RpcHandler {
|
|||||||
Arc::clone(&self.mesh_service)
|
Arc::clone(&self.mesh_service)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cookie_suffix(&self) -> &'static str {
|
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
|
||||||
if self.config.dev_mode { "" } else { "; Secure" }
|
// 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(
|
pub async fn handle(
|
||||||
@@ -155,6 +168,7 @@ impl RpcHandler {
|
|||||||
// Extract session cookie before consuming the request
|
// Extract session cookie before consuming the request
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let session_token = session::extract_session_cookie(&parts.headers);
|
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
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
.context("Failed to read body")?;
|
.context("Failed to read body")?;
|
||||||
@@ -203,8 +217,15 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
// 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.
|
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
||||||
if !is_unauthenticated && new_session_cookies.is_none() {
|
// 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
|
let csrf_header = parts
|
||||||
.headers
|
.headers
|
||||||
.get("x-csrf-token")
|
.get("x-csrf-token")
|
||||||
@@ -231,12 +252,24 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !csrf_valid {
|
if !csrf_valid {
|
||||||
tracing::warn!(
|
// Debug: log expected vs received for diagnosis
|
||||||
method = %rpc_req.method,
|
if let (Some(token), Some(header)) = (&session_token, &csrf_header) {
|
||||||
has_session = session_token.is_some(),
|
let expected = derive_csrf_token(token).await;
|
||||||
has_header = csrf_header.is_some(),
|
tracing::warn!(
|
||||||
"403 CSRF validation failed — rejecting RPC call"
|
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));
|
return Ok(self.error_response(403, "CSRF token missing or invalid", StatusCode::FORBIDDEN));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,6 +360,7 @@ impl RpcHandler {
|
|||||||
&login_params,
|
&login_params,
|
||||||
&new_session_cookies,
|
&new_session_cookies,
|
||||||
client_ip,
|
client_ip,
|
||||||
|
secure_suffix,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
@@ -372,6 +406,7 @@ impl RpcHandler {
|
|||||||
login_params: &Option<serde_json::Value>,
|
login_params: &Option<serde_json::Value>,
|
||||||
new_session_cookies: &Option<(String, String)>,
|
new_session_cookies: &Option<(String, String)>,
|
||||||
client_ip: std::net::IpAddr,
|
client_ip: std::net::IpAddr,
|
||||||
|
secure_suffix: &str,
|
||||||
) {
|
) {
|
||||||
// Track failed login attempts for rate limiting
|
// Track failed login attempts for rate limiting
|
||||||
if method == "auth.login" && rpc_resp.error.is_some() {
|
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) {
|
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
||||||
let token = self.session_store.create_pending(secret).await;
|
let token = self.session_store.create_pending(secret).await;
|
||||||
let csrf_token = derive_csrf_token(&token).await;
|
let csrf_token = derive_csrf_token(&token).await;
|
||||||
self.set_session_cookie(response, &token);
|
self.set_session_cookie(response, &token, secure_suffix);
|
||||||
self.set_csrf_cookie(response, &csrf_token);
|
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||||
let totp_body = serde_json::json!({
|
let totp_body = serde_json::json!({
|
||||||
"result": { "requires_totp": true },
|
"result": { "requires_totp": true },
|
||||||
"error": null
|
"error": null
|
||||||
@@ -406,9 +441,9 @@ impl RpcHandler {
|
|||||||
let token = self.session_store.create().await;
|
let token = self.session_store.create().await;
|
||||||
let csrf_token = derive_csrf_token(&token).await;
|
let csrf_token = derive_csrf_token(&token).await;
|
||||||
let remember_token = self.session_store.create_remember_token().await;
|
let remember_token = self.session_store.create_remember_token().await;
|
||||||
self.set_session_cookie(response, &token);
|
self.set_session_cookie(response, &token, secure_suffix);
|
||||||
self.set_csrf_cookie(response, &csrf_token);
|
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||||
self.set_remember_cookie(response, &remember_token);
|
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,9 +461,9 @@ impl RpcHandler {
|
|||||||
if let Some(new_token) = new_token_opt {
|
if let Some(new_token) = new_token_opt {
|
||||||
let csrf_token = derive_csrf_token(&new_token).await;
|
let csrf_token = derive_csrf_token(&new_token).await;
|
||||||
let remember_token = self.session_store.create_remember_token().await;
|
let remember_token = self.session_store.create_remember_token().await;
|
||||||
self.set_session_cookie(response, &new_token);
|
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||||
self.set_csrf_cookie(response, &csrf_token);
|
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||||
self.set_remember_cookie(response, &remember_token);
|
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||||
// Strip the token from the response body
|
// Strip the token from the response body
|
||||||
if let Some(result) = rpc_resp.result.as_mut() {
|
if let Some(result) = rpc_resp.result.as_mut() {
|
||||||
if let Some(obj) = result.as_object_mut() {
|
if let Some(obj) = result.as_object_mut() {
|
||||||
@@ -445,8 +480,8 @@ impl RpcHandler {
|
|||||||
if let Some(token) = session_token {
|
if let Some(token) = session_token {
|
||||||
let new_token = self.session_store.rotate(token).await;
|
let new_token = self.session_store.rotate(token).await;
|
||||||
let csrf_token = derive_csrf_token(&new_token).await;
|
let csrf_token = derive_csrf_token(&new_token).await;
|
||||||
self.set_session_cookie(response, &new_token);
|
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||||
self.set_csrf_cookie(response, &csrf_token);
|
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +490,6 @@ impl RpcHandler {
|
|||||||
if let Some(token) = session_token {
|
if let Some(token) = session_token {
|
||||||
self.session_store.remove(token).await;
|
self.session_store.remove(token).await;
|
||||||
}
|
}
|
||||||
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
|
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
|
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 session was auto-restored from remember-me, set new cookies
|
||||||
if let Some((new_session, new_csrf)) = new_session_cookies {
|
if let Some((new_session, new_csrf)) = new_session_cookies {
|
||||||
self.set_session_cookie(response, new_session);
|
self.set_session_cookie(response, new_session, secure_suffix);
|
||||||
self.set_csrf_cookie(response, new_csrf);
|
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(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"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(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"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(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"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> {
|
pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||||
match app_id {
|
match app_id {
|
||||||
// Apps that need user switching and file ownership changes
|
// 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![
|
| "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![
|
||||||
"--cap-add=CHOWN".to_string(),
|
"--cap-add=CHOWN".to_string(),
|
||||||
"--cap-add=SETUID".to_string(),
|
"--cap-add=SETUID".to_string(),
|
||||||
"--cap-add=SETGID".to_string(),
|
"--cap-add=SETGID".to_string(),
|
||||||
"--cap-add=DAC_OVERRIDE".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 needs to bind low ports
|
||||||
"nginx-proxy-manager" => vec![
|
"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=SETGID".to_string(),
|
||||||
"--cap-add=NET_BIND_SERVICE".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"
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
|
||||||
| "fedimint-gateway" => vec![
|
| "fedimint-gateway" => vec![
|
||||||
"--cap-add=CHOWN".to_string(),
|
"--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=SETUID".to_string(),
|
||||||
"--cap-add=SETGID".to_string(),
|
"--cap-add=SETGID".to_string(),
|
||||||
"--cap-add=DAC_OVERRIDE".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 needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||||
"vaultwarden" => vec![
|
"vaultwarden" => vec![
|
||||||
@@ -97,8 +109,21 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
|||||||
"--cap-add=SETUID".to_string(),
|
"--cap-add=SETUID".to_string(),
|
||||||
"--cap-add=SETGID".to_string(),
|
"--cap-add=SETGID".to_string(),
|
||||||
],
|
],
|
||||||
// Minimal apps (searxng, filebrowser, etc.) need no extra caps
|
// FileBrowser needs DAC_OVERRIDE for volume access + NET_BIND_SERVICE to bind port 80
|
||||||
_ => vec![],
|
"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)
|
/// 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>> {
|
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
let output = tokio::process::Command::new("podman")
|
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 stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
let patterns: Vec<String> = match package_id {
|
let patterns = all_container_names(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 mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for name in all {
|
for name in all {
|
||||||
for pat in &patterns {
|
if patterns.iter().any(|p| p == name) {
|
||||||
if name == pat {
|
result.push(name.to_string());
|
||||||
result.push(name.to_string());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -378,9 +426,21 @@ pub(super) async fn get_app_config(
|
|||||||
"8080:8080".to_string(),
|
"8080:8080".to_string(),
|
||||||
],
|
],
|
||||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||||
vec!["BITCOIN_ACTIVE=1".to_string()],
|
vec![],
|
||||||
None,
|
|
||||||
None,
|
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" => (
|
"btcpay-server" | "btcpayserver" => (
|
||||||
vec!["23000:49392".to_string()],
|
vec!["23000:49392".to_string()],
|
||||||
@@ -469,7 +529,7 @@ pub(super) async fn get_app_config(
|
|||||||
),
|
),
|
||||||
"searxng" => (
|
"searxng" => (
|
||||||
vec!["8888:8080".to_string()],
|
vec!["8888:8080".to_string()],
|
||||||
vec![],
|
vec!["/var/lib/archipelago/searxng:/etc/searxng".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -562,10 +622,18 @@ pub(super) async fn get_app_config(
|
|||||||
.unwrap_or(8083);
|
.unwrap_or(8083);
|
||||||
(
|
(
|
||||||
vec![format!("{}:80", host_port)],
|
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![],
|
vec![],
|
||||||
None,
|
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" => (
|
"nginx-proxy-manager" => (
|
||||||
@@ -628,7 +696,11 @@ pub(super) async fn get_app_config(
|
|||||||
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
None,
|
Some(vec![
|
||||||
|
"--data-dir".to_string(),
|
||||||
|
"/data".to_string(),
|
||||||
|
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
"fedimint-gateway" => (
|
"fedimint-gateway" => (
|
||||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||||
|
|||||||
@@ -173,6 +173,22 @@ impl RpcHandler {
|
|||||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
|
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)
|
// Port mappings (skip for host-network containers)
|
||||||
if !is_tailscale {
|
if !is_tailscale {
|
||||||
for port in &ports {
|
for port in &ports {
|
||||||
@@ -228,6 +244,11 @@ impl RpcHandler {
|
|||||||
|
|
||||||
if !run_output.status.success() {
|
if !run_output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
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));
|
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +256,43 @@ impl RpcHandler {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.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
|
// Post-install hooks
|
||||||
self.run_post_install_hooks(package_id).await;
|
self.run_post_install_hooks(package_id).await;
|
||||||
|
|
||||||
@@ -301,11 +359,43 @@ impl RpcHandler {
|
|||||||
Ok(has_local_fallback)
|
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(
|
async fn pull_image_with_progress(
|
||||||
&self,
|
&self,
|
||||||
package_id: &str,
|
package_id: &str,
|
||||||
docker_image: &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<()> {
|
) -> Result<()> {
|
||||||
debug!("Pulling image: {}", docker_image);
|
debug!("Pulling image: {}", docker_image);
|
||||||
self.set_install_progress(package_id, 0, 0).await;
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
@@ -336,8 +426,20 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to wait for image pull")?;
|
.context("Failed to wait for image pull")?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
self.clear_install_progress(package_id).await;
|
return Err(anyhow::anyhow!("podman pull exited with non-zero status"));
|
||||||
return Err(anyhow::anyhow!("Failed to pull image"));
|
}
|
||||||
|
|
||||||
|
// 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;
|
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/.
|
/// 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]) {
|
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 {
|
for volume in volumes {
|
||||||
if let Some(host_path) = volume.split(':').next() {
|
if let Some(host_path) = volume.split(':').next() {
|
||||||
if host_path.starts_with("/var/lib/archipelago/") {
|
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")
|
let create_dir = tokio::process::Command::new("sudo")
|
||||||
.args(["mkdir", "-p", host_path])
|
.args(["mkdir", "-p", host_path])
|
||||||
.output()
|
.output()
|
||||||
@@ -357,13 +479,11 @@ impl RpcHandler {
|
|||||||
if let Err(e) = create_dir {
|
if let Err(e) = create_dir {
|
||||||
debug!("Failed to create directory {}: {}", host_path, e);
|
debug!("Failed to create directory {}: {}", host_path, e);
|
||||||
}
|
}
|
||||||
// Grafana runs as UID 472 — fix permissions
|
// Set ownership to the mapped UID for rootless podman
|
||||||
if package_id == "grafana" && host_path.contains("grafana") {
|
let _ = tokio::process::Command::new("sudo")
|
||||||
let _ = tokio::process::Command::new("sudo")
|
.args(["chown", "-R", &uid_str, host_path])
|
||||||
.args(["chown", "-R", "472:472", host_path])
|
.output()
|
||||||
.output()
|
.await;
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,6 +524,67 @@ printtoconsole=1\n",
|
|||||||
|
|
||||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
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" {
|
if package_id == "nextcloud" {
|
||||||
let host_ip = self.config.host_ip.clone();
|
let host_ip = self.config.host_ip.clone();
|
||||||
tokio::spawn(async move {
|
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 {
|
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")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
|
.args(["build", "-t", &image, &ui_dir])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["rm", "-f", "bitcoin-ui"])
|
.args(["rm", "-f", &name])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args([
|
.args([
|
||||||
"run",
|
"run", "-d",
|
||||||
"-d",
|
"--name", &name,
|
||||||
"--name",
|
|
||||||
"bitcoin-ui",
|
|
||||||
"--restart=unless-stopped",
|
"--restart=unless-stopped",
|
||||||
"-p",
|
"--network=archy-net",
|
||||||
"8334:80",
|
"--cap-drop=ALL",
|
||||||
"localhost/bitcoin-ui:latest",
|
"--cap-add=NET_BIND_SERVICE",
|
||||||
|
"--memory=64m",
|
||||||
|
"-p", &port,
|
||||||
|
&format!("{}:latest", image),
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.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 crate::api::rpc::RpcHandler;
|
||||||
use anyhow::{Context, Result};
|
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 {
|
impl RpcHandler {
|
||||||
/// Start a package: start all containers in dependency order.
|
/// Start a package: start all containers in dependency order.
|
||||||
pub(in crate::api::rpc) async fn handle_package_start(
|
pub(in crate::api::rpc) async fn handle_package_start(
|
||||||
@@ -18,6 +34,10 @@ impl RpcHandler {
|
|||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
let to_start = ordered_containers_for_start(package_id).await?;
|
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
|
// Clear user-stopped flag — user explicitly started this app
|
||||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
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;
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in to_start {
|
let mut errors = Vec::new();
|
||||||
let _ = tokio::process::Command::new("podman")
|
for name in &to_start {
|
||||||
.args(["start", &name])
|
tracing::info!("Starting container: {}", name);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", name])
|
||||||
.output()
|
.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)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,31 +78,36 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(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?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
if containers.is_empty() {
|
if containers.is_empty() {
|
||||||
let container_name = format!("archy-{}", package_id);
|
tracing::warn!("package.stop {}: no containers found", package_id);
|
||||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
.await;
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["stop", &container_name])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
return Ok(serde_json::Value::Null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
for name in &containers {
|
||||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||||
}
|
}
|
||||||
for name in containers {
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
let mut errors = Vec::new();
|
||||||
.args(["stop", &name])
|
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()
|
.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)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,21 +125,47 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
if containers.is_empty() {
|
if containers.is_empty() {
|
||||||
let container_name = format!("archy-{}", package_id);
|
tracing::warn!("package.restart {}: no containers found", package_id);
|
||||||
let _ = tokio::process::Command::new("podman")
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
.args(["restart", &container_name])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
return Ok(serde_json::Value::Null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in containers {
|
let mut errors = Vec::new();
|
||||||
let _ = tokio::process::Command::new("podman")
|
for name in &containers {
|
||||||
.args(["restart", &name])
|
tracing::info!("Restarting container: {}", name);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["restart", "-t", stop_timeout_secs(name), name])
|
||||||
.output()
|
.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)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +197,7 @@ impl RpcHandler {
|
|||||||
for name in &containers_to_remove {
|
for name in &containers_to_remove {
|
||||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||||
let stop_out = tokio::process::Command::new("podman")
|
let stop_out = tokio::process::Command::new("podman")
|
||||||
.args(["stop", "-t", "10", name])
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
match stop_out {
|
match stop_out {
|
||||||
@@ -344,7 +406,7 @@ impl RpcHandler {
|
|||||||
validate_app_id(app_id)?;
|
validate_app_id(app_id)?;
|
||||||
|
|
||||||
let output = tokio::process::Command::new("podman")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["stop", app_id])
|
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.context("Failed to stop container")?;
|
.context("Failed to stop container")?;
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ use crate::api::rpc::RpcHandler;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::info;
|
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 {
|
impl RpcHandler {
|
||||||
/// Install Immich stack (postgres + redis + server).
|
/// Install Immich stack (postgres + redis + server).
|
||||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
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",
|
"80.71.235.15:3000/archipelago/immich-server:release",
|
||||||
];
|
];
|
||||||
for img in &images {
|
for img in &images {
|
||||||
let _ = tokio::process::Command::new("podman")
|
pull_image_with_retry(img).await?;
|
||||||
.args(["pull", img])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tokio::process::Command::new("sudo")
|
let _ = tokio::process::Command::new("sudo")
|
||||||
@@ -168,10 +200,7 @@ impl RpcHandler {
|
|||||||
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
||||||
];
|
];
|
||||||
for img in &images {
|
for img in &images {
|
||||||
let _ = tokio::process::Command::new("podman")
|
pull_image_with_retry(img).await?;
|
||||||
.args(["pull", img])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tokio::process::Command::new("sudo")
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ impl AuthManager {
|
|||||||
Self { data_dir }
|
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> {
|
pub async fn is_setup(&self) -> Result<bool> {
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
Ok(user_file.exists())
|
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.
|
/// Spawn a background task that periodically saves the container snapshot.
|
||||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -33,11 +33,18 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
|||||||
Ok((used, total, percent))
|
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).
|
/// Returns (used_bytes, total_bytes, used_percent).
|
||||||
pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
|
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")
|
let output = tokio::process::Command::new("df")
|
||||||
.args(["--block-size=1", "--output=used,size", "/"])
|
.args(["--block-size=1", "--output=used,size", data_path])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.context("Failed to run df")?;
|
.context("Failed to run df")?;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
use crate::data_model::{Notification, NotificationLevel};
|
use crate::data_model::{Notification, NotificationLevel};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use crate::webhooks::{self, WebhookEvent};
|
use crate::webhooks::{self, WebhookEvent};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{debug, info, warn};
|
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.
|
/// Query container memory stats from podman.
|
||||||
async fn check_container_memory() -> HashMap<String, u64> {
|
async fn check_container_memory() -> HashMap<String, u64> {
|
||||||
let output = match tokio::time::timeout(
|
let output = match tokio::time::timeout(
|
||||||
@@ -262,13 +326,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
|||||||
let containers: Vec<serde_json::Value> =
|
let containers: Vec<serde_json::Value> =
|
||||||
serde_json::from_str(&stdout).unwrap_or_default();
|
serde_json::from_str(&stdout).unwrap_or_default();
|
||||||
|
|
||||||
// Backend services and one-shot init containers to skip
|
// Monitor ALL long-running containers for health — backend services (databases,
|
||||||
let skip = [
|
// nbxplorer, mempool-api) and UI containers need auto-restart too.
|
||||||
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
|
// Only skip ephemeral containers (build infrastructure, init one-shots).
|
||||||
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
|
|
||||||
"penpot-mailcatch", "immich_postgres", "immich_redis",
|
|
||||||
"endurain-db", "nextcloud-db",
|
|
||||||
];
|
|
||||||
|
|
||||||
containers
|
containers
|
||||||
.iter()
|
.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
|
// Skip podman-compose infrastructure and one-shot init containers
|
||||||
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let app_id = name
|
||||||
|
.strip_prefix("archy-")
|
||||||
|
.unwrap_or(&name)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let state = c.get("State")
|
let state = c.get("State")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown")
|
.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 mem_check_counter: u32 = 0;
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
|
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 {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
mem_check_counter += 1;
|
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 {
|
if tracker.attempt_count(&container.name) > 0 {
|
||||||
info!("Container {} is healthy again after restart", container.name);
|
info!("Container {} is healthy again after restart", container.name);
|
||||||
tracker.clear(&container.name);
|
tracker.clear(&container.name);
|
||||||
|
restart_history.clear(&container.name);
|
||||||
|
history_dirty = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -430,6 +493,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
if tracker.should_reset_failed(&container.name) {
|
if tracker.should_reset_failed(&container.name) {
|
||||||
info!("Resetting restart counter for {} after {}s stability window", container.name, STABILITY_RESET_SECS);
|
info!("Resetting restart counter for {} after {}s stability window", container.name, STABILITY_RESET_SECS);
|
||||||
tracker.clear(&container.name);
|
tracker.clear(&container.name);
|
||||||
|
restart_history.clear(&container.name);
|
||||||
|
history_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if tracker.attempt_count(&container.name) >= MAX_RESTART_ATTEMPTS {
|
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);
|
prev_tier = Some(tier);
|
||||||
|
|
||||||
if tracker.record_attempt(&container.name) {
|
if tracker.record_attempt(&container.name) {
|
||||||
|
restart_history.record_attempt(&container.name);
|
||||||
|
history_dirty = true;
|
||||||
let attempt = tracker.attempt_count(&container.name);
|
let attempt = tracker.attempt_count(&container.name);
|
||||||
info!("Restarting {} (tier {:?}, attempt {}/{}, backoff {}s)",
|
info!("Restarting {} (tier {:?}, attempt {}/{}, backoff {}s)",
|
||||||
container.name, tier, attempt, MAX_RESTART_ATTEMPTS,
|
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;
|
state.update_data(data).await;
|
||||||
debug!("Health monitor: state updated with notifications");
|
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
|
// Signal to health monitor that boot recovery is done
|
||||||
crash_recovery::mark_recovery_complete();
|
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
|
// Ensure a default user exists so login works after install/onboarding.
|
||||||
if config.dev_mode {
|
// In production, the default password is "password123" (shown during install).
|
||||||
let auth = AuthManager::new(config.data_dir.clone());
|
// In dev mode, the dev default password is used.
|
||||||
if !auth.is_setup().await? {
|
// Don't auto-create default user — let onboarding flow handle password setup
|
||||||
auth.setup_user(DEV_DEFAULT_PASSWORD).await?;
|
// via auth.setup RPC. The Login page detects is_setup=false and shows
|
||||||
info!("👤 Created default dev user (password: {})", DEV_DEFAULT_PASSWORD);
|
// "Create Password" form instead of login form.
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
let server = Server::new(config.clone()).await?;
|
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 => {
|
HealthStatus::Unhealthy => {
|
||||||
consecutive_failures += 1;
|
consecutive_failures += 1;
|
||||||
if consecutive_failures >= max_failures {
|
if consecutive_failures >= max_failures {
|
||||||
error!("Container {} is unhealthy after {} failures",
|
error!("Container {} is unhealthy after {} failures",
|
||||||
self.container_name, consecutive_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 index.html /usr/share/nginx/html/
|
||||||
COPY 50x.html /usr/share/nginx/html/
|
COPY 50x.html /usr/share/nginx/html/
|
||||||
COPY assets/ /usr/share/nginx/html/assets/
|
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 index.html /usr/share/nginx/html/
|
||||||
COPY 50x.html /usr/share/nginx/html/
|
COPY 50x.html /usr/share/nginx/html/
|
||||||
COPY assets/ /usr/share/nginx/html/assets/
|
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 the HTML file
|
||||||
COPY index.html /usr/share/nginx/html/
|
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-12** | **Beta telemetry — reporter + toggle + collector POST** | **P1** | IN PROGRESS | - |
|
||||||
| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 |
|
| **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-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 | - |
|
| **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 | - |
|
| **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 | - |
|
| **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/api/rpc/system.rs` — password change handler
|
||||||
- `core/archipelago/src/server.rs` — startup checks
|
- `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)
|
### BUG-44: App iframe shows blank/broken when container is starting or crashed (PLANNED)
|
||||||
**Priority**: P2 — Medium
|
**Priority**: P2 — Medium
|
||||||
**Status**: PLANNED (2026-03-21)
|
**Status**: PLANNED (2026-03-21)
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Archipelago Main Menu
|
# archipelago main menu
|
||||||
# Interactive setup wizard for Archipelago Bitcoin Node OS
|
# interactive setup for archipelago bitcoin node os
|
||||||
#
|
#
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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 on first run (for live mode)
|
||||||
install_required_tools() {
|
install_required_tools() {
|
||||||
if [ -f /tmp/.archipelago-tools-installed ]; then
|
if [ -f /tmp/.archipelago-tools-installed ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if we need to install tools
|
|
||||||
local NEED_TOOLS=0
|
local NEED_TOOLS=0
|
||||||
for tool in parted debootstrap mkfs.ext4 mkfs.vfat; do
|
for tool in parted debootstrap mkfs.ext4 mkfs.vfat; do
|
||||||
if ! command -v $tool >/dev/null 2>&1; then
|
if ! command -v $tool >/dev/null 2>&1; then
|
||||||
@@ -20,74 +41,58 @@ install_required_tools() {
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ $NEED_TOOLS -eq 1 ]; then
|
if [ $NEED_TOOLS -eq 1 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " 📦 Installing required tools (first run)..."
|
cc "${D}installing required tools...${N}"
|
||||||
echo ""
|
echo ""
|
||||||
sudo apt-get update -qq 2>/dev/null
|
sudo apt-get update -qq 2>/dev/null
|
||||||
sudo apt-get install -y parted debootstrap dosfstools e2fsprogs 2>/dev/null
|
sudo apt-get install -y parted debootstrap dosfstools e2fsprogs 2>/dev/null
|
||||||
echo " ✅ Tools installed"
|
cc "${G}tools installed${N}"
|
||||||
echo ""
|
echo ""
|
||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
touch /tmp/.archipelago-tools-installed
|
touch /tmp/.archipelago-tools-installed
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run tool installation at startup
|
|
||||||
install_required_tools
|
install_required_tools
|
||||||
|
|
||||||
show_banner() {
|
show_banner() {
|
||||||
|
get_width
|
||||||
clear
|
clear
|
||||||
echo ""
|
echo ""
|
||||||
echo " ╔═══════════════════════════════════════════════════════════╗"
|
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
|
||||||
echo " ║ ║"
|
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
|
||||||
echo " ║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║"
|
echo -e " ${O}▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀${N}"
|
||||||
echo " ║ ║"
|
echo -e " ${D}bitcoin node os${N}"
|
||||||
echo " ║ Your sovereign Bitcoin infrastructure ║"
|
|
||||||
echo " ║ ║"
|
|
||||||
echo " ╚═══════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
show_status() {
|
show_status() {
|
||||||
echo " System Status:"
|
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
|
||||||
|
|
||||||
# Check if we're in live mode
|
|
||||||
if [ -d /run/live ]; then
|
if [ -d /run/live ]; then
|
||||||
echo " Mode: 🔴 Live (changes won't persist)"
|
cc "${R}live mode${N} ${D}(changes won't persist)${N}"
|
||||||
else
|
else
|
||||||
echo " Mode: 🟢 Installed"
|
cc "${G}installed${N}"
|
||||||
fi
|
fi
|
||||||
|
echo ""
|
||||||
# Check Podman
|
|
||||||
if command -v podman >/dev/null 2>&1; then
|
local podman_ok=0
|
||||||
echo " Podman: 🟢 Installed"
|
command -v podman >/dev/null 2>&1 && podman_ok=1
|
||||||
else
|
|
||||||
echo " Podman: 🔴 Not installed"
|
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
|
fi
|
||||||
|
|
||||||
# Check Bitcoin Core
|
if [ $podman_ok -eq 1 ] && podman ps 2>/dev/null | grep -q lnd; then
|
||||||
if podman ps 2>/dev/null | grep -q bitcoind; then
|
cc "${G}lightning${N} ${D}running${N}"
|
||||||
BLOCKS=$(podman exec bitcoind bitcoin-cli getblockcount 2>/dev/null || echo "syncing")
|
elif [ $podman_ok -eq 1 ] && podman ps -a 2>/dev/null | grep -q lnd; then
|
||||||
echo " Bitcoin: 🟢 Running (blocks: $BLOCKS)"
|
cc "${Y}lightning${N} ${D}stopped${N}"
|
||||||
elif podman ps -a 2>/dev/null | grep -q bitcoind; then
|
|
||||||
echo " Bitcoin: 🟡 Stopped"
|
|
||||||
else
|
|
||||||
echo " Bitcoin: ⚪ Not configured"
|
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,126 +100,112 @@ main_menu() {
|
|||||||
while true; do
|
while true; do
|
||||||
show_banner
|
show_banner
|
||||||
show_status
|
show_status
|
||||||
|
|
||||||
# Show Web UI URL prominently
|
# Connection info
|
||||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
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)
|
[ -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
|
if [ -n "$IP" ]; then
|
||||||
# Check if backend is running
|
|
||||||
if pgrep -f "archipelago" >/dev/null 2>&1; then
|
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
|
else
|
||||||
echo " │ 🌐 Web UI: http://$IP:5678 (not started) │"
|
cc "${C}web ui${N} ${D}http://$IP${N} ${Y}(not started)${N}"
|
||||||
fi
|
fi
|
||||||
echo " │ 📡 SSH: ssh user@$IP (password: archipelago) │"
|
cc "${C}ssh${N} ${D}archipelago@$IP${N}"
|
||||||
else
|
else
|
||||||
echo " │ 🌐 Web UI: (no network) │"
|
cc "${D}no network detected${N}"
|
||||||
fi
|
fi
|
||||||
echo " └─────────────────────────────────────────────────────────────┘"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
hrule
|
||||||
echo " Main Menu:"
|
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " r) Refresh - Update IP/status (no restart needed)"
|
cc "${D}r${N} refresh status ${D}w${N} start web ui"
|
||||||
echo " w) Open Web UI - Launch graphical interface"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1) Install to Disk - Permanently install Archipelago"
|
cc "${O}1${N} install to disk ${O}5${N} view logs"
|
||||||
echo " 2) Setup Bitcoin Core - Configure Bitcoin full node"
|
cc "${O}2${N} setup bitcoin core ${O}6${N} network settings"
|
||||||
echo " 3) Setup Lightning (LND) - Configure Lightning Network"
|
cc "${O}3${N} setup lightning ${O}7${N} system info"
|
||||||
echo " 4) Setup BTCPay Server - Bitcoin payment processor"
|
cc "${O}4${N} setup btcpay server"
|
||||||
echo " 5) View Logs - Monitor running services"
|
|
||||||
echo " 6) Network Settings - Configure networking"
|
|
||||||
echo " 7) System Info - View system information"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " q) Quit"
|
cc "${D}q quit${N}"
|
||||||
echo ""
|
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
|
case $choice in
|
||||||
r|R)
|
r|R)
|
||||||
# Refresh - just loop again to show updated IP/status
|
|
||||||
;;
|
;;
|
||||||
w|W)
|
w|W)
|
||||||
echo ""
|
echo ""
|
||||||
# Start the real backend on port 5678
|
|
||||||
if command -v archipelago >/dev/null 2>&1; then
|
if command -v archipelago >/dev/null 2>&1; then
|
||||||
if pgrep -f "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
|
else
|
||||||
echo " 🚀 Starting Archipelago backend on port 5678..."
|
cc "${D}starting backend on port 5678...${N}"
|
||||||
nohup archipelago >/tmp/archipelago.log 2>&1 &
|
nohup archipelago >/tmp/archipelago.log 2>&1 &
|
||||||
sleep 2
|
sleep 2
|
||||||
if pgrep -f "archipelago" >/dev/null 2>&1; then
|
if pgrep -f "archipelago" >/dev/null 2>&1; then
|
||||||
echo " ✅ Backend started!"
|
cc "${G}backend started${N}"
|
||||||
else
|
else
|
||||||
echo " ⚠️ Failed to start backend. Check /tmp/archipelago.log"
|
cc "${R}failed — see /tmp/archipelago.log${N}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
echo ""
|
echo ""
|
||||||
echo " ┌─────────────────────────────────────────────────────────────┐"
|
cc "open in browser: ${W}http://$IP${N}"
|
||||||
echo " │ 🌐 Open in browser: http://$IP:5678 │"
|
|
||||||
echo " └─────────────────────────────────────────────────────────────┘"
|
|
||||||
else
|
else
|
||||||
echo " ⚠️ Archipelago binary not found at /usr/local/bin/archipelago"
|
cc "${R}binary not found at /usr/local/bin/archipelago${N}"
|
||||||
echo ""
|
|
||||||
echo " Try running:"
|
|
||||||
echo " sudo cp /run/live/medium/archipelago/bin/archipelago /usr/local/bin/"
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
read -p " Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
1)
|
1)
|
||||||
if [ -f "$SCRIPT_DIR/install-to-disk.sh" ]; then
|
if [ -f "$SCRIPT_DIR/install-to-disk.sh" ]; then
|
||||||
sudo bash "$SCRIPT_DIR/install-to-disk.sh"
|
sudo bash "$SCRIPT_DIR/install-to-disk.sh"
|
||||||
else
|
else
|
||||||
echo "Installer not found. Running from: $SCRIPT_DIR"
|
echo " installer not found at: $SCRIPT_DIR"
|
||||||
fi
|
fi
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
if [ -f "$SCRIPT_DIR/setup-bitcoin.sh" ]; then
|
if [ -f "$SCRIPT_DIR/setup-bitcoin.sh" ]; then
|
||||||
bash "$SCRIPT_DIR/setup-bitcoin.sh"
|
bash "$SCRIPT_DIR/setup-bitcoin.sh"
|
||||||
else
|
else
|
||||||
echo "Bitcoin setup script not found."
|
echo " bitcoin setup script not found."
|
||||||
fi
|
fi
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
3)
|
3)
|
||||||
if [ -f "$SCRIPT_DIR/setup-lnd.sh" ]; then
|
if [ -f "$SCRIPT_DIR/setup-lnd.sh" ]; then
|
||||||
bash "$SCRIPT_DIR/setup-lnd.sh"
|
bash "$SCRIPT_DIR/setup-lnd.sh"
|
||||||
else
|
else
|
||||||
echo "LND setup script not found."
|
echo " lnd setup script not found."
|
||||||
fi
|
fi
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
4)
|
4)
|
||||||
setup_btcpay
|
setup_btcpay
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
5)
|
5)
|
||||||
view_logs
|
view_logs
|
||||||
;;
|
;;
|
||||||
6)
|
6)
|
||||||
network_settings
|
network_settings
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
7)
|
7)
|
||||||
system_info
|
system_info
|
||||||
read -p "Press Enter to continue..."
|
read -sp " press enter to continue..."
|
||||||
;;
|
;;
|
||||||
q|Q)
|
q|Q)
|
||||||
echo ""
|
|
||||||
echo " Goodbye! 🏝️"
|
|
||||||
echo ""
|
echo ""
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Invalid option"
|
sleep 0.5
|
||||||
sleep 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -222,62 +213,63 @@ main_menu() {
|
|||||||
|
|
||||||
setup_btcpay() {
|
setup_btcpay() {
|
||||||
show_banner
|
show_banner
|
||||||
echo " BTCPay Server Setup"
|
cc "${W}btcpay server setup${N}"
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
cc "${D}self-hosted bitcoin payment processor${N}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " BTCPay Server is a self-hosted Bitcoin payment processor."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if ! podman ps | grep -q bitcoind; then
|
if ! podman ps | grep -q bitcoind; then
|
||||||
echo " ⚠️ Bitcoin Core must be running first."
|
cc "${R}bitcoin core must be running first${N}"
|
||||||
return
|
return
|
||||||
fi
|
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
|
if [[ ! "$SETUP" =~ ^[Yy]$ ]]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " 🐳 Pulling BTCPay Server image..."
|
cc "${D}pulling btcpay server image...${N}"
|
||||||
podman pull "${BTCPAY_IMAGE}"
|
podman pull "${BTCPAY_IMAGE}"
|
||||||
|
|
||||||
# Create data directory
|
|
||||||
mkdir -p ~/.btcpay
|
mkdir -p ~/.btcpay
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " BTCPay Server setup is more complex and typically uses docker-compose."
|
cc "${D}full setup: https://docs.btcpayserver.org${N}"
|
||||||
echo " For a full setup, visit: https://docs.btcpayserver.org"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
view_logs() {
|
view_logs() {
|
||||||
show_banner
|
show_banner
|
||||||
echo " View Logs"
|
cc "${W}view logs${N}"
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1) Bitcoin Core logs"
|
cc "${O}1${N} ${D}bitcoin core${N}"
|
||||||
echo " 2) LND logs"
|
cc "${O}2${N} ${D}lnd${N}"
|
||||||
echo " 3) System logs"
|
cc "${O}3${N} ${D}system journal${N}"
|
||||||
echo " b) Back"
|
cc "${D}b back${N}"
|
||||||
echo ""
|
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
|
case $choice in
|
||||||
1)
|
1)
|
||||||
if podman ps -a | grep -q bitcoind; then
|
if podman ps -a | grep -q bitcoind; then
|
||||||
podman logs -f --tail 50 bitcoind
|
podman logs -f --tail 50 bitcoind
|
||||||
else
|
else
|
||||||
echo "Bitcoin Core not running"
|
cc "${D}bitcoin core not running${N}"
|
||||||
read -p "Press Enter..."
|
read -sp " press enter..."
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
if podman ps -a | grep -q lnd; then
|
if podman ps -a | grep -q lnd; then
|
||||||
podman logs -f --tail 50 lnd
|
podman logs -f --tail 50 lnd
|
||||||
else
|
else
|
||||||
echo "LND not running"
|
cc "${D}lnd not running${N}"
|
||||||
read -p "Press Enter..."
|
read -sp " press enter..."
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
3)
|
3)
|
||||||
@@ -288,57 +280,61 @@ view_logs() {
|
|||||||
|
|
||||||
network_settings() {
|
network_settings() {
|
||||||
show_banner
|
show_banner
|
||||||
echo " Network Settings"
|
cc "${W}network settings${N}"
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Show current IP
|
|
||||||
IP=$(hostname -I | awk '{print $1}')
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
echo " Current IP: $IP"
|
cc "${C}ip${N} ${W}$IP${N}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Show network interfaces
|
cc "${D}interfaces:${N}"
|
||||||
echo " Network Interfaces:"
|
|
||||||
ip -br addr | grep -v "^lo" | while read line; do
|
ip -br addr | grep -v "^lo" | while read line; do
|
||||||
echo " $line"
|
cc " ${D}$line${N}"
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo " Ports in use:"
|
cc "${D}service ports:${N}"
|
||||||
echo " 8332 - Bitcoin RPC"
|
cc " ${D}8332 bitcoin rpc 9735 lightning p2p${N}"
|
||||||
echo " 8333 - Bitcoin P2P"
|
cc " ${D}8333 bitcoin p2p 10009 lightning grpc${N}"
|
||||||
echo " 9735 - Lightning P2P"
|
|
||||||
echo " 10009 - Lightning gRPC"
|
|
||||||
echo " 8080 - Lightning REST"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
system_info() {
|
system_info() {
|
||||||
show_banner
|
show_banner
|
||||||
echo " System Information"
|
cc "${W}system information${N}"
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Hostname: $(hostname)"
|
|
||||||
echo " Kernel: $(uname -r)"
|
cc "${C}host${N} ${D}$(hostname)${N}"
|
||||||
echo " Uptime: $(uptime -p)"
|
cc "${C}kernel${N} ${D}$(uname -r)${N}"
|
||||||
|
cc "${C}uptime${N} ${D}$(uptime -p 2>/dev/null || echo 'unknown')${N}"
|
||||||
echo ""
|
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 ""
|
||||||
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
|
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
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Container status
|
|
||||||
echo " Containers:"
|
|
||||||
if command -v podman >/dev/null 2>&1; then
|
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
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run main menu
|
|
||||||
main_menu
|
main_menu
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ cat > /mnt/archipelago/etc/hosts <<EOF
|
|||||||
|
|
||||||
::1 localhost ip6-localhost ip6-loopback
|
::1 localhost ip6-localhost ip6-loopback
|
||||||
EOF
|
EOF
|
||||||
|
chmod 644 /mnt/archipelago/etc/hosts
|
||||||
|
|
||||||
# Install bootloader and essential packages in chroot
|
# Install bootloader and essential packages in chroot
|
||||||
echo "📦 Configuring package sources..."
|
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"
|
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
|
||||||
# DEV_MODE disabled in production — enabled via override.conf on dev servers
|
# DEV_MODE disabled in production — enabled via override.conf on dev servers
|
||||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
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'
|
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
|
ExecStart=/usr/local/bin/archipelago
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
WatchdogSec=300
|
WatchdogSec=300
|
||||||
TimeoutStartSec=300
|
TimeoutStartSec=300
|
||||||
|
# Bitcoin Core needs up to 600s to flush UTXO set on shutdown
|
||||||
|
TimeoutStopSec=660
|
||||||
|
|
||||||
# Filesystem protection
|
# Filesystem protection
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
@@ -22,7 +25,7 @@ ProtectSystem=strict
|
|||||||
ProtectHome=no
|
ProtectHome=no
|
||||||
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
|
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
|
||||||
# and must be shared between the service and SSH-created containers
|
# 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)
|
# Privilege restriction — restored with rootless podman (no sudo needed)
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ limit_req_zone $binary_remote_addr zone=peer:10m rate=10r/s;
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen 100.91.10.103:80;
|
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /opt/archipelago/web-ui;
|
root /opt/archipelago/web-ui;
|
||||||
@@ -1077,6 +1076,8 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
proxy_read_timeout 86400s;
|
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)
|
# Enable lingering for archipelago user (allows user services to run without login)
|
||||||
loginctl enable-linger archipelago || true
|
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
|
# Set proper permissions
|
||||||
chown -R archipelago:archipelago /home/archipelago/.config
|
chown -R archipelago:archipelago /home/archipelago/.config
|
||||||
chown -R archipelago:archipelago /home/archipelago/.local
|
chown -R archipelago:archipelago /home/archipelago/.local
|
||||||
|
|||||||
@@ -1,69 +1,107 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Test Archipelago ISO in QEMU
|
# 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)"
|
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
|
for arg in "$@"; do
|
||||||
echo "❌ ISO not found: $ISO"
|
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 ""
|
||||||
echo "Usage: $0 [path-to-iso]"
|
echo "Usage: $0 [path-to-iso] [--bios] [--nographic]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build the ISO first with: ./build-debian-iso.sh"
|
echo "Or place an ISO in: $SCRIPT_DIR/results/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🧪 Testing Archipelago ISO in QEMU"
|
echo "Testing Archipelago ISO in QEMU"
|
||||||
echo "📀 ISO: $ISO"
|
echo " ISO: $ISO"
|
||||||
echo "💾 RAM: 4GB"
|
echo " Size: $(du -h "$ISO" | cut -f1)"
|
||||||
echo "🖥️ CPU: 2 cores"
|
echo " RAM: 4GB"
|
||||||
echo ""
|
echo " CPU: 2 cores"
|
||||||
echo "Press Ctrl+Alt+G to release mouse/keyboard from VM"
|
echo " Serial: $SERIAL_LOG"
|
||||||
echo "Press Ctrl+C in this terminal to stop VM"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Create test disk if it doesn't exist
|
# Create test disk if it doesn't exist
|
||||||
DISK="/tmp/archipelago-test-disk.qcow2"
|
DISK="/tmp/archipelago-test-disk.qcow2"
|
||||||
if [ ! -f "$DISK" ]; then
|
if [ ! -f "$DISK" ]; then
|
||||||
echo "Creating test disk..."
|
echo "Creating 20GB test disk..."
|
||||||
qemu-img create -f qcow2 "$DISK" 20G
|
qemu-img create -f qcow2 "$DISK" 20G
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting VM in 3 seconds..."
|
# Common QEMU args
|
||||||
sleep 3
|
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)
|
# Display mode
|
||||||
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
|
if [ "$NOGRAPHIC" = true ]; then
|
||||||
# macOS with Homebrew QEMU
|
QEMU_ARGS+=(-nographic -append "console=ttyS0")
|
||||||
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"
|
|
||||||
else
|
else
|
||||||
# Fall back to legacy BIOS
|
QEMU_ARGS+=(-vga virtio -display default)
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# UEFI boot
|
echo "Starting VM..."
|
||||||
qemu-system-x86_64 \
|
echo "(Serial console logging to $SERIAL_LOG)"
|
||||||
-machine q35 \
|
echo "(Press Ctrl+Alt+G to release mouse, Ctrl+C to stop VM)"
|
||||||
-bios "$OVMF" \
|
echo ""
|
||||||
-m 4G \
|
|
||||||
-smp 2 \
|
# Detect UEFI firmware
|
||||||
-boot d \
|
OVMF=""
|
||||||
-cdrom "$ISO" \
|
if [ "$FORCE_BIOS" = false ]; then
|
||||||
-drive if=virtio,format=qcow2,file="$DISK" \
|
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
|
||||||
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:8100 \
|
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
|
||||||
-vga virtio \
|
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
|
||||||
-display default
|
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"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.ld9oh2eb91o"
|
"revision": "0.huo00jkc7v4"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
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" />
|
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||||
|
|
||||||
<!-- Main App Content - only show after splash and routing is complete -->
|
<!-- 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) -->
|
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||||
<SpotlightSearch />
|
<SpotlightSearch />
|
||||||
@@ -168,7 +169,30 @@ const isReady = ref(false)
|
|||||||
* - User has already seen the intro
|
* - User has already seen the intro
|
||||||
* - User is on a direct route (refresh/bookmark)
|
* - 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 () => {
|
onMounted(async () => {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
window.addEventListener('mousemove', onUserActivity)
|
window.addEventListener('mousemove', onUserActivity)
|
||||||
window.addEventListener('mousedown', onUserActivity)
|
window.addEventListener('mousedown', onUserActivity)
|
||||||
@@ -188,14 +212,16 @@ onMounted(async () => {
|
|||||||
showSplash.value = true
|
showSplash.value = true
|
||||||
} else {
|
} else {
|
||||||
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
||||||
showSplash.value = false
|
// Set isReady BEFORE hiding splash to prevent flash of partial content
|
||||||
document.body.classList.add('splash-complete')
|
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
|
showSplash.value = false
|
||||||
|
document.body.classList.add('splash-complete')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('mousemove', onUserActivity)
|
window.removeEventListener('mousemove', onUserActivity)
|
||||||
window.removeEventListener('mousedown', onUserActivity)
|
window.removeEventListener('mousedown', onUserActivity)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.stubGlobal('fetch', mockFetch)
|
|||||||
|
|
||||||
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
||||||
Object.defineProperty(window, 'location', {
|
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,
|
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', () => {
|
describe('FileBrowserClient', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset()
|
mockFetch.mockReset()
|
||||||
|
;(fileBrowserClient as any)._authenticated = false
|
||||||
|
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('authenticates and stores token', async () => {
|
it('authenticates via backend RPC and stores token', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
|
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()
|
||||||
const result = await fileBrowserClient.login('admin', 'admin')
|
|
||||||
|
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('/app/filebrowser/api/login'),
|
'/rpc/v1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
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 () => {
|
it('returns false on failed login', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||||
|
|
||||||
const result = await fileBrowserClient.login('admin', 'wrong')
|
const result = await fileBrowserClient.login()
|
||||||
|
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -76,9 +83,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('listDirectory', () => {
|
describe('listDirectory', () => {
|
||||||
it('lists items in a directory', async () => {
|
it('lists items in a directory', async () => {
|
||||||
// Ensure authenticated first
|
setAuthenticated()
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
const mockItems = {
|
const mockItems = {
|
||||||
items: [
|
items: [
|
||||||
@@ -99,8 +104,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds leading slash if missing', async () => {
|
it('adds leading slash if missing', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
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 () => {
|
it('throws on non-OK response', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||||
|
|
||||||
@@ -136,8 +139,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('upload', () => {
|
describe('upload', () => {
|
||||||
it('uploads a file to the correct path', async () => {
|
it('uploads a file to the correct path', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||||
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
||||||
@@ -152,8 +154,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws on upload failure', async () => {
|
it('throws on upload failure', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
||||||
const file = new File(['data'], 'big.bin')
|
const file = new File(['data'], 'big.bin')
|
||||||
@@ -164,8 +165,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('createFolder', () => {
|
describe('createFolder', () => {
|
||||||
it('creates a folder at the correct path', async () => {
|
it('creates a folder at the correct path', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||||
|
|
||||||
@@ -177,8 +177,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws on failure', async () => {
|
it('throws on failure', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||||
|
|
||||||
@@ -188,8 +187,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('deleteItem', () => {
|
describe('deleteItem', () => {
|
||||||
it('sends DELETE request for the item', async () => {
|
it('sends DELETE request for the item', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||||
|
|
||||||
@@ -201,8 +199,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws on failure', async () => {
|
it('throws on failure', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||||
|
|
||||||
@@ -212,9 +209,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('getUsage', () => {
|
describe('getUsage', () => {
|
||||||
it('returns usage summary for root directory', async () => {
|
it('returns usage summary for root directory', async () => {
|
||||||
// Login first
|
setAuthenticated()
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
const mockData = {
|
const mockData = {
|
||||||
items: [
|
items: [
|
||||||
@@ -236,8 +231,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns zeros on failed request', async () => {
|
it('returns zeros on failed request', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||||
|
|
||||||
@@ -265,8 +259,7 @@ describe('FileBrowserClient', () => {
|
|||||||
|
|
||||||
describe('rename', () => {
|
describe('rename', () => {
|
||||||
it('sends PATCH request with new destination', async () => {
|
it('sends PATCH request with new destination', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||||
|
|
||||||
@@ -279,8 +272,7 @@ describe('FileBrowserClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws on rename failure', async () => {
|
it('throws on rename failure', async () => {
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
setAuthenticated()
|
||||||
await fileBrowserClient.login()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
||||||
|
|
||||||
|
|||||||
@@ -52,20 +52,27 @@ class FileBrowserClient {
|
|||||||
return match ? match[1]! : null
|
return match ? match[1]! : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(username = 'admin', password = 'admin'): Promise<boolean> {
|
async login(): Promise<boolean> {
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ method: 'app.filebrowser-token' }),
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return false
|
if (!rpcRes.ok) return false
|
||||||
const text = await res.text()
|
const rpcData = await rpcRes.json()
|
||||||
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
const token = rpcData?.result?.token
|
||||||
const token = text.replace(/^"|"$/g, '')
|
if (!token) return false
|
||||||
// Store token as cookie — the only auth mechanism we use
|
|
||||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
|
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' : ''
|
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||||
document.cookie = `auth=${token}; path=/app/filebrowser; SameSite=Lax${secure}; expires=${expires}`
|
document.cookie = `auth=${token}; path=/app/filebrowser; SameSite=Lax${secure}; expires=${expires}`
|
||||||
this._authenticated = true
|
this._authenticated = true
|
||||||
|
|||||||
@@ -62,19 +62,38 @@ class RPCClient {
|
|||||||
// Use a single shared timeout to prevent redirect storms when
|
// Use a single shared timeout to prevent redirect storms when
|
||||||
// multiple parallel requests all get 401 at once
|
// multiple parallel requests all get 401 at once
|
||||||
if (response.status === 401 && method !== 'auth.login') {
|
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
|
RPCClient._sessionExpiredRedirecting = true
|
||||||
|
console.warn(`[RPC] Session expired — redirecting to /login in 300ms`)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
throw new Error('Session expired')
|
throw new Error('Session expired')
|
||||||
}
|
}
|
||||||
// CSRF 403: retry once after short delay (cookie may have been
|
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
|
||||||
// updated by a concurrent Set-Cookie response not yet visible to JS)
|
if (response.status === 403) {
|
||||||
if (response.status === 403 && attempt < maxRetries - 1) {
|
let reason = ''
|
||||||
await new Promise((r) => setTimeout(r, 300))
|
try {
|
||||||
continue
|
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 err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
const isRetryable = response.status === 502 || response.status === 503
|
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'
|
const DISMISS_KEY = 'archipelago_pwa_install_dismissed'
|
||||||
|
|
||||||
onMounted(() => {
|
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 (sessionStorage.getItem(DISMISS_KEY) === '1') return
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) return
|
if (window.matchMedia('(display-mode: standalone)').matches) return
|
||||||
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
|
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
|
||||||
|
|||||||
@@ -277,6 +277,13 @@ if (!storedSeenIntro && isOnDashboard) {
|
|||||||
localStorage.setItem('neode_intro_seen', '1')
|
localStorage.setItem('neode_intro_seen', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEnterKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleTapToStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onIntroLogoHover() {
|
function onIntroLogoHover() {
|
||||||
introLogoHover.value = true
|
introLogoHover.value = true
|
||||||
if (!tapStartTransitioning.value) playKeyboardTypingSound()
|
if (!tapStartTransitioning.value) playKeyboardTypingSound()
|
||||||
@@ -465,10 +472,13 @@ onMounted(() => {
|
|||||||
showSplash.value = false
|
showSplash.value = false
|
||||||
document.body.classList.add('splash-complete')
|
document.body.classList.add('splash-complete')
|
||||||
emit('complete')
|
emit('complete')
|
||||||
|
} else {
|
||||||
|
window.addEventListener('keydown', handleEnterKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleEnterKey)
|
||||||
if (introTypingTimeout) {
|
if (introTypingTimeout) {
|
||||||
clearTimeout(introTypingTimeout)
|
clearTimeout(introTypingTimeout)
|
||||||
introTypingTimeout = null
|
introTypingTimeout = null
|
||||||
|
|||||||
@@ -1,266 +1,671 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
/**
|
||||||
|
* Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md
|
||||||
// Mock vue-router
|
*
|
||||||
const mockRoute = { path: '/dashboard' }
|
* Tests the navigation logic (element queries, spatial nav, zone detection)
|
||||||
const mockRouter = { push: vi.fn().mockResolvedValue(undefined) }
|
* without mounting the composable (which needs Vue lifecycle).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
// ─── Mocks ─────────────────────────────────────────────────────
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => mockRoute,
|
useRoute: () => ({ path: '/dashboard' }),
|
||||||
useRouter: () => mockRouter,
|
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
|
// ─── Helpers ───────────────────────────────────────────────────
|
||||||
vi.mock('@/stores/controller', () => ({
|
|
||||||
useControllerStore: () => ({
|
|
||||||
setActive: vi.fn(),
|
|
||||||
setGamepadCount: vi.fn(),
|
|
||||||
isActive: false,
|
|
||||||
gamepadCount: 0,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/stores/spotlight', () => ({
|
const FOCUSABLE_SELECTOR = [
|
||||||
useSpotlightStore: () => ({
|
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||||
isOpen: false,
|
'select:not([disabled])', 'textarea:not([disabled])',
|
||||||
close: vi.fn(),
|
'[tabindex]:not([tabindex="-1"])', '[data-controller-focus]',
|
||||||
}),
|
'[data-controller-container]',
|
||||||
}))
|
].join(', ')
|
||||||
|
|
||||||
vi.mock('@/stores/cli', () => ({
|
function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] {
|
||||||
useCLIStore: () => ({
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
isOpen: false,
|
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
|
||||||
close: vi.fn(),
|
)
|
||||||
}),
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/stores/appLauncher', () => ({
|
function queryContainers(): HTMLElement[] {
|
||||||
useAppLauncherStore: () => ({
|
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||||
isOpen: false,
|
if (!zone) return []
|
||||||
close: vi.fn(),
|
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]'))
|
||||||
}),
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock useNavSounds
|
function queryNavBarItems(): HTMLElement[] {
|
||||||
vi.mock('@/composables/useNavSounds', () => ({
|
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||||
playNavSound: vi.fn(),
|
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
|
function querySidebar(): HTMLElement[] {
|
||||||
// would require a mounted component with Pinia and Router. We test helper logic directly.
|
const zone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||||
|
return zone ? queryFocusable(zone as HTMLElement) : []
|
||||||
|
}
|
||||||
|
|
||||||
describe('useControllerNav - helper functions', () => {
|
// ─── Module Export ──────────────────────────────────────────────
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
vi.useFakeTimers()
|
|
||||||
mockRoute.path = '/dashboard'
|
|
||||||
|
|
||||||
// Mock navigator.getGamepads
|
describe('module', () => {
|
||||||
Object.defineProperty(navigator, 'getGamepads', {
|
it('exports useControllerNav', async () => {
|
||||||
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 () => {
|
|
||||||
const mod = await import('../useControllerNav')
|
const mod = await import('../useControllerNav')
|
||||||
expect(typeof mod.useControllerNav).toBe('function')
|
expect(typeof mod.useControllerNav).toBe('function')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useControllerNav - nav key classification', () => {
|
// ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ──────
|
||||||
it('classifies arrow keys and Enter/Escape as nav keys', () => {
|
|
||||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
describe('sidebar navigation (NAV-MAP: Sidebar)', () => {
|
||||||
expect(navKeys.includes('ArrowUp')).toBe(true)
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
expect(navKeys.includes('ArrowDown')).toBe(true)
|
|
||||||
expect(navKeys.includes('ArrowLeft')).toBe(true)
|
it('finds all sidebar nav items', () => {
|
||||||
expect(navKeys.includes('ArrowRight')).toBe(true)
|
document.body.innerHTML = `
|
||||||
expect(navKeys.includes('Enter')).toBe(true)
|
<div data-controller-zone="sidebar">
|
||||||
expect(navKeys.includes('Escape')).toBe(true)
|
<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', () => {
|
it('wraps down: Logout → Home', () => {
|
||||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||||
expect(navKeys.includes('a')).toBe(false)
|
const lastIdx = items.length - 1
|
||||||
expect(navKeys.includes('Space')).toBe(false)
|
expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home
|
||||||
expect(navKeys.includes('Tab')).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('recognizes detail page patterns', () => {
|
it('wraps up: Home → Logout', () => {
|
||||||
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
|
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||||
expect(pattern.test('/apps/bitcoin')).toBe(true)
|
expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout
|
||||||
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('recognizes page type patterns', () => {
|
it('right from sidebar targets first container, not nav bar items', () => {
|
||||||
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
|
document.body.innerHTML = `
|
||||||
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
|
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
|
||||||
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
|
<div data-controller-zone="main">
|
||||||
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/marketplace')).toBe(true)
|
<button class="mode-switcher-btn" id="tab">Tab</button>
|
||||||
expect(/^\/dashboard\/cloud(\/|$)/.test('/dashboard/cloud')).toBe(true)
|
<div data-controller-container tabindex="0" id="card1">Card</div>
|
||||||
expect(/^\/dashboard\/server(\/|$)/.test('/dashboard/server')).toBe(true)
|
</div>
|
||||||
expect(/^\/dashboard\/web5(\/|$)/.test('/dashboard/web5')).toBe(true)
|
`
|
||||||
expect(/^\/dashboard\/settings(\/|$)/.test('/dashboard/settings')).toBe(true)
|
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', () => {
|
// ─── HOME: 2-col grid + nav bar ────────────────────────────────
|
||||||
// Test the internal helper functions indirectly via the FOCUSABLE_SELECTOR concept
|
|
||||||
|
|
||||||
it('identifies focusable elements', () => {
|
describe('HOME grid (NAV-MAP: HOME /dashboard)', () => {
|
||||||
const container = document.createElement('div')
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
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')
|
|
||||||
|
|
||||||
container.appendChild(button)
|
it('has Dashboard and Setup nav bar items', () => {
|
||||||
container.appendChild(link)
|
document.body.innerHTML = `
|
||||||
container.appendChild(disabledBtn)
|
<div data-controller-zone="main">
|
||||||
container.appendChild(input)
|
<div role="tablist">
|
||||||
document.body.appendChild(container)
|
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
|
||||||
|
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
|
||||||
const focusable = container.querySelectorAll(
|
</div>
|
||||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||||
)
|
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||||
|
</div>
|
||||||
// Should find button, link, and input but NOT disabled button
|
`
|
||||||
expect(focusable.length).toBe(3)
|
const navItems = queryNavBarItems()
|
||||||
|
expect(navItems.length).toBe(2)
|
||||||
document.body.removeChild(container)
|
expect(navItems[0]?.id).toBe('dashTab')
|
||||||
|
expect(navItems[1]?.id).toBe('setupTab')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respects data-controller-ignore attribute', () => {
|
it('containers exclude nav bar items', () => {
|
||||||
const container = document.createElement('div')
|
document.body.innerHTML = `
|
||||||
const button = document.createElement('button')
|
<div data-controller-zone="main">
|
||||||
button.textContent = 'Visible'
|
<button class="mode-switcher-btn">Dashboard</button>
|
||||||
const ignoredBtn = document.createElement('button')
|
<button class="mode-switcher-btn">Setup</button>
|
||||||
ignoredBtn.textContent = 'Ignored'
|
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||||
ignoredBtn.setAttribute('data-controller-ignore', '')
|
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||||
|
<div data-controller-container tabindex="0" id="network">Network</div>
|
||||||
container.appendChild(button)
|
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
|
||||||
container.appendChild(ignoredBtn)
|
<div data-controller-container tabindex="0" id="system">System</div>
|
||||||
document.body.appendChild(container)
|
</div>
|
||||||
|
`
|
||||||
const focusable = Array.from(
|
const containers = queryContainers()
|
||||||
container.querySelectorAll<HTMLElement>('button:not([disabled])')
|
expect(containers.length).toBe(5)
|
||||||
).filter(el => !el.hasAttribute('data-controller-ignore'))
|
expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system'])
|
||||||
|
// Nav bar items are separate
|
||||||
expect(focusable.length).toBe(1)
|
const navItems = queryNavBarItems()
|
||||||
expect(focusable[0]?.textContent).toBe('Visible')
|
expect(navItems.length).toBe(2)
|
||||||
|
|
||||||
document.body.removeChild(container)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('identifies sidebar and main zones', () => {
|
it('inner controls are not in the container grid', () => {
|
||||||
const sidebar = document.createElement('div')
|
document.body.innerHTML = `
|
||||||
sidebar.setAttribute('data-controller-zone', 'sidebar')
|
<div data-controller-zone="main">
|
||||||
const main = document.createElement('div')
|
<div data-controller-container tabindex="0" id="myApps">
|
||||||
main.setAttribute('data-controller-zone', 'main')
|
<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')
|
// ─── APPS: 3-col grid + nav bar with tabs/filters/search ───────
|
||||||
sideBtn.textContent = 'Nav'
|
|
||||||
sidebar.appendChild(sideBtn)
|
|
||||||
|
|
||||||
const mainBtn = document.createElement('button')
|
describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => {
|
||||||
mainBtn.textContent = 'Content'
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
main.appendChild(mainBtn)
|
|
||||||
|
|
||||||
document.body.appendChild(sidebar)
|
it('nav bar has tabs, filters, and search', () => {
|
||||||
document.body.appendChild(main)
|
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
|
// 3 containers
|
||||||
expect(sideBtn.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
|
expect(queryContainers().length).toBe(3)
|
||||||
expect(mainBtn.closest('[data-controller-zone="main"]')).toBeTruthy()
|
|
||||||
expect(sideBtn.closest('[data-controller-zone="main"]')).toBeNull()
|
|
||||||
|
|
||||||
document.body.removeChild(sidebar)
|
|
||||||
document.body.removeChild(main)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('identifies container elements', () => {
|
it('app cards with launch attribute are containers', () => {
|
||||||
const container = document.createElement('div')
|
document.body.innerHTML = `
|
||||||
container.setAttribute('data-controller-container', '')
|
<div data-controller-zone="main">
|
||||||
container.tabIndex = 0
|
<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')
|
// ─── CLOUD: 3-col, no nav bar ──────────────────────────────────
|
||||||
innerBtn.textContent = 'Inner'
|
|
||||||
container.appendChild(innerBtn)
|
|
||||||
|
|
||||||
document.body.appendChild(container)
|
describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => {
|
||||||
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
|
|
||||||
// isInsideContainer check
|
it('has section cards as containers, no nav bar', () => {
|
||||||
expect(innerBtn.closest('[data-controller-container]')).toBe(container)
|
document.body.innerHTML = `
|
||||||
expect(container.closest('[data-controller-container]')).toBe(container)
|
<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', () => {
|
it('container without link: Enter drills into inner [Y] controls', () => {
|
||||||
const container = document.createElement('div')
|
document.body.innerHTML = `
|
||||||
container.setAttribute('data-controller-container', '')
|
<div data-controller-container tabindex="0">
|
||||||
container.tabIndex = 0
|
<button id="btn1">Open Shop</button>
|
||||||
|
<button id="btn2">Accept Payments</button>
|
||||||
const btn1 = document.createElement('button')
|
</div>
|
||||||
btn1.textContent = 'Action 1'
|
`
|
||||||
const btn2 = document.createElement('button')
|
const container = document.querySelector('[data-controller-container]')!
|
||||||
btn2.textContent = 'Action 2'
|
expect(container.querySelector('a[href]')).toBeNull()
|
||||||
|
const inner = Array.from(container.querySelectorAll('button'))
|
||||||
container.appendChild(btn1)
|
|
||||||
container.appendChild(btn2)
|
|
||||||
document.body.appendChild(container)
|
|
||||||
|
|
||||||
const inner = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>('button:not([disabled])')
|
|
||||||
).filter(el => el !== container)
|
|
||||||
|
|
||||||
expect(inner.length).toBe(2)
|
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', () => {
|
// ─── INSIDE CONTAINER [Y] ──────────────────────────────────────
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
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', () => {
|
it('counts connected gamepads', () => {
|
||||||
const gamepads = [
|
const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[]
|
||||||
{ connected: true } as Gamepad,
|
expect(gp.filter(g => g?.connected).length).toBe(2)
|
||||||
null,
|
|
||||||
{ connected: true } as Gamepad,
|
|
||||||
null,
|
|
||||||
]
|
|
||||||
|
|
||||||
const count = gamepads.filter((g) => g?.connected).length
|
|
||||||
expect(count).toBe(2)
|
|
||||||
})
|
})
|
||||||
|
it('handles null list', () => {
|
||||||
it('handles null gamepad list', () => {
|
const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0
|
||||||
// Simulate navigator.getGamepads returning null (some browsers)
|
expect(count(null)).toBe(0)
|
||||||
function getCount(gp: (Gamepad | null)[] | null): number {
|
})
|
||||||
return gp ? gp.filter((g) => g?.connected).length : 0
|
})
|
||||||
}
|
|
||||||
expect(getCount(null)).toBe(0)
|
// ─── DATA-CONTROLLER-IGNORE ────────────────────────────────────
|
||||||
})
|
|
||||||
|
describe('data-controller-ignore', () => {
|
||||||
it('handles empty gamepad list', () => {
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
const gamepads: (Gamepad | null)[] = [null, null, null, null]
|
|
||||||
const count = Array.from(gamepads).filter((g) => g?.connected).length
|
it('excluded elements are filtered out', () => {
|
||||||
expect(count).toBe(0)
|
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()
|
toast.dismissToastAndOpenMessages()
|
||||||
|
|
||||||
expect(toast.toastMessage.value.show).toBe(false)
|
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 () => {
|
it('stops polling on 401 error', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Xbox-style controller / gamepad navigation for Archipelago.
|
* Controller / gamepad navigation for Archipelago.
|
||||||
* - Left: Go to side menu only when on leftmost main content
|
*
|
||||||
* - Right: Go to main content (from side menu)
|
* Navigation model (from the design spec):
|
||||||
* - Main: spatial/grid navigation (up/down/left/right like a game)
|
*
|
||||||
* - Enter enters container's inner actions; actions get celebratory sound
|
* 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'
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
@@ -14,6 +33,8 @@ import { useCLIStore } from '@/stores/cli'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { playNavSound } from '@/composables/useNavSounds'
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
|
// ─── Element Queries ────────────────────────────────────────────
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = [
|
const FOCUSABLE_SELECTOR = [
|
||||||
'a[href]',
|
'a[href]',
|
||||||
'button:not([disabled])',
|
'button:not([disabled])',
|
||||||
@@ -25,9 +46,9 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
'[data-controller-container]',
|
'[data-controller-container]',
|
||||||
].join(', ')
|
].join(', ')
|
||||||
|
|
||||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
|
||||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
(el) =>
|
el =>
|
||||||
!el.hasAttribute('disabled') &&
|
!el.hasAttribute('disabled') &&
|
||||||
el.offsetParent !== null &&
|
el.offsetParent !== null &&
|
||||||
!el.hasAttribute('data-controller-ignore') &&
|
!el.hasAttribute('data-controller-ignore') &&
|
||||||
@@ -35,10 +56,44 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
/** Sidebar items */
|
||||||
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
function getSidebarElements(): HTMLElement[] {
|
||||||
if (!container) return []
|
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
|
||||||
return getFocusableElements(container)
|
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 {
|
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}"]`)
|
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 {
|
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||||
if (!el) return false
|
if (!el) return false
|
||||||
const container = el.closest('[data-controller-container]')
|
const container = el.closest('[data-controller-container]')
|
||||||
return !!container && container !== el
|
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(
|
function findNearestInDirection(
|
||||||
from: HTMLElement,
|
from: HTMLElement,
|
||||||
candidates: HTMLElement[],
|
candidates: HTMLElement[],
|
||||||
direction: 'up' | 'down' | 'left' | 'right'
|
direction: 'up' | 'down' | 'left' | 'right'
|
||||||
): HTMLElement | null {
|
): HTMLElement | null {
|
||||||
const fromRect = from.getBoundingClientRect()
|
const fromRect = from.getBoundingClientRect()
|
||||||
const fromCenterX = fromRect.left + fromRect.width / 2
|
const fromCX = fromRect.left + fromRect.width / 2
|
||||||
const fromCenterY = fromRect.top + fromRect.height / 2
|
const fromCY = fromRect.top + fromRect.height / 2
|
||||||
const threshold = 50 // px overlap allowed
|
const threshold = 50
|
||||||
|
|
||||||
const filtered = candidates.filter((el) => {
|
const filtered = candidates.filter(el => {
|
||||||
if (el === from) return false
|
if (el === from) return false
|
||||||
const r = el.getBoundingClientRect()
|
const r = el.getBoundingClientRect()
|
||||||
|
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case 'left':
|
case 'left': return r.right <= fromRect.left + threshold
|
||||||
return r.right <= fromRect.left + threshold
|
case 'right': return r.left >= fromRect.right - threshold
|
||||||
case 'right':
|
case 'up': return r.bottom <= fromRect.top + threshold
|
||||||
return r.left >= fromRect.right - threshold
|
case 'down': return r.top >= fromRect.bottom - threshold
|
||||||
case 'up':
|
|
||||||
return r.bottom <= fromRect.top + threshold
|
|
||||||
case 'down':
|
|
||||||
return r.top >= fromRect.bottom - threshold
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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 r = el.getBoundingClientRect()
|
||||||
const centerX = r.left + r.width / 2
|
const cx = r.left + r.width / 2
|
||||||
const centerY = r.top + r.height / 2
|
const cy = r.top + r.height / 2
|
||||||
|
const isVertical = direction === 'up' || direction === 'down'
|
||||||
let overlap: number
|
const overlap = isVertical
|
||||||
let dist: number
|
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||||
switch (direction) {
|
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||||
case 'left':
|
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
|
||||||
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
|
|
||||||
}
|
|
||||||
return { el, overlap, dist }
|
return { el, overlap, dist }
|
||||||
})
|
})
|
||||||
|
|
||||||
scored.sort((a, b) => {
|
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
|
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||||
return a.dist - b.dist
|
return a.dist - b.dist
|
||||||
})
|
})
|
||||||
|
|
||||||
return scored[0]?.el ?? null
|
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 }) {
|
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -131,325 +208,454 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
store.setActive(isControllerActive.value)
|
store.setActive(isControllerActive.value)
|
||||||
store.setGamepadCount(gamepadCount.value)
|
store.setGamepadCount(gamepadCount.value)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
|
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null
|
let pollIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function checkGamepads() {
|
function checkGamepads() {
|
||||||
const gamepads = navigator.getGamepads?.()
|
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) {
|
if (count !== gamepadCount.value) {
|
||||||
gamepadCount.value = count
|
gamepadCount.value = count
|
||||||
isControllerActive.value = count > 0
|
isControllerActive.value = count > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard Handler ───────────────────────────────────────
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||||
if (!navKeys.includes(e.key)) return
|
if (!navKeys.includes(e.key)) return
|
||||||
|
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
const activeEl = document.activeElement as HTMLElement
|
||||||
|
|
||||||
|
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
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
|
if (e.key !== 'Escape') return
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = containerRef?.value ?? document
|
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
|
||||||
const focusable = getFocusableElements(root)
|
|
||||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
|
||||||
const activeEl = document.activeElement as HTMLElement
|
|
||||||
|
|
||||||
// --- ESCAPE ---
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (useAppLauncherStore().isOpen) {
|
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
|
||||||
useAppLauncherStore().close()
|
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
|
||||||
e.preventDefault()
|
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
|
||||||
e.stopPropagation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (useSpotlightStore().isOpen) {
|
|
||||||
useSpotlightStore().close()
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (useCLIStore().isOpen) {
|
|
||||||
useCLIStore().close()
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Inside container inner controls → exit to container
|
||||||
if (isInsideContainer(activeEl)) {
|
if (isInsideContainer(activeEl)) {
|
||||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
||||||
if (container && container.tabIndex >= 0) {
|
if (container && container.tabIndex >= 0) {
|
||||||
playNavSound('back')
|
focusEl(container, 'back')
|
||||||
container.focus()
|
|
||||||
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
// On a container or anywhere in main → go to sidebar
|
||||||
if (isDetailPage) {
|
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')
|
playNavSound('back')
|
||||||
window.history.back()
|
window.history.back()
|
||||||
e.preventDefault()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ENTER ---
|
// ── ENTER ───────────────────────────────────────────────
|
||||||
if (e.key === '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()
|
e.preventDefault()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ARROWS ---
|
if (isContainer(activeEl)) {
|
||||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
// Prioritised action: install button
|
||||||
isControllerActive.value = true
|
if (activeEl.hasAttribute('data-controller-install')) {
|
||||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||||
keyNavTimeout = setTimeout(() => {
|
if (btn) { playNavSound('action'); btn.click(); return }
|
||||||
isControllerActive.value = gamepadCount.value > 0
|
}
|
||||||
}, 3000)
|
// Prioritised action: launch button
|
||||||
|
if (activeEl.hasAttribute('data-controller-launch')) {
|
||||||
const sidebarEls = getElementsInZone('sidebar')
|
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
||||||
const mainEls = getElementsInZone('main')
|
if (btn) { playNavSound('action'); btn.click(); return }
|
||||||
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
}
|
||||||
|
// Primary link (e.g. dashboard cards with a[href])
|
||||||
// Right: from sidebar → main
|
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
|
||||||
// - On Home: go to My Apps container
|
if (primaryLink) {
|
||||||
// - On Apps/Marketplace: go to first app container
|
playNavSound('action')
|
||||||
// - On Cloud: go to first folder (Pictures)
|
primaryLink.click()
|
||||||
// - 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()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
|
||||||
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
|
const inner = getInnerFocusables(activeEl)
|
||||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
const actionBtn = inner.find(el =>
|
||||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
|
||||||
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
|
||||||
const target = activeNavTab ?? sidebarEls[0]
|
!el.closest('[class*="absolute top"]')
|
||||||
if (target) {
|
) ?? inner[0]
|
||||||
playNavSound('move')
|
if (actionBtn) {
|
||||||
target.focus()
|
focusEl(actionBtn, 'action')
|
||||||
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
return
|
||||||
e.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Last resort: click the container itself (triggers goToApp on AppCard)
|
||||||
|
playNavSound('action')
|
||||||
|
activeEl.click()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inside container: spatial nav among inner elements
|
// Regular element: click it
|
||||||
if (isInsideContainer(activeEl)) {
|
if (activeEl) {
|
||||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
playNavSound('action')
|
||||||
if (container) {
|
activeEl.click()
|
||||||
const inner = getInnerFocusables(container)
|
}
|
||||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
return
|
||||||
const next = findNearestInDirection(activeEl, inner, dir)
|
}
|
||||||
if (next) {
|
|
||||||
playNavSound('move')
|
// ── ARROW KEYS ──────────────────────────────────────────
|
||||||
next.focus()
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
||||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
e.preventDefault()
|
||||||
e.preventDefault()
|
|
||||||
return
|
// 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 (dir === 'right') {
|
||||||
if (isInZone(activeEl, 'sidebar')) {
|
// Jump to first container in main
|
||||||
const idx = sidebarEls.indexOf(activeEl)
|
rememberFocus('sidebar', activeEl)
|
||||||
if (idx >= 0) {
|
const remembered = recallFocus('main')
|
||||||
const isDown = e.key === 'ArrowDown'
|
// Only use remembered if it's a container (not a nav bar button)
|
||||||
let nextIdx: number
|
const target = (remembered && isContainer(remembered)) ? remembered : null
|
||||||
if (isDown) {
|
const containers = getContainers()
|
||||||
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
|
const dest = target ?? containers[0]
|
||||||
} else {
|
if (dest) {
|
||||||
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
|
focusEl(dest)
|
||||||
}
|
} else {
|
||||||
const next = sidebarEls[nextIdx]
|
// Containers not rendered yet (route transition / animation in progress)
|
||||||
if (next && next !== activeEl) {
|
// Poll until they appear, up to 1s
|
||||||
playNavSound('move')
|
let attempts = 0
|
||||||
next.focus()
|
const poll = setInterval(() => {
|
||||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
attempts++
|
||||||
if (next.tagName === 'A') {
|
const retryContainers = getContainers()
|
||||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
if (retryContainers[0]) {
|
||||||
if (href && href.startsWith('/')) router.push(href).catch(() => {})
|
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()
|
}, 100)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: linear navigation
|
// Left from sidebar: does nothing
|
||||||
let nextIndex = currentIndex
|
return
|
||||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
}
|
||||||
if (focusable.length === 0) return
|
|
||||||
|
|
||||||
if (currentIndex < 0) {
|
// ── INSIDE CONTAINER (inner controls) ───────────────────
|
||||||
nextIndex = isForward ? 0 : focusable.length - 1
|
if (isInsideContainer(activeEl)) {
|
||||||
} else {
|
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||||
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
|
const inner = getInnerFocusables(container)
|
||||||
if (nextIndex < 0) nextIndex = focusable.length - 1
|
const next = findNearestInDirection(activeEl, inner, dir)
|
||||||
if (nextIndex >= focusable.length) nextIndex = 0
|
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) {
|
if (next) {
|
||||||
playNavSound('move')
|
rememberFocus('main', next)
|
||||||
next.focus()
|
focusEl(next)
|
||||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
return
|
||||||
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
}
|
||||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
|
||||||
if (href && href.startsWith('/') && next.tagName === 'A') {
|
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
|
||||||
router.push(href).catch(() => {})
|
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() {
|
// ─── Gamepad Detection ──────────────────────────────────────
|
||||||
checkGamepads()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGamepadConnected() {
|
function handleGamepadConnected() {
|
||||||
const gamepads = navigator.getGamepads?.()
|
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
|
isControllerActive.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGamepadDisconnected() {
|
function handleGamepadDisconnected() {
|
||||||
const gamepads = navigator.getGamepads?.()
|
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
|
isControllerActive.value = gamepadCount.value > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
|
// ─── Scroll Support ────────────────────────────────────────
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure wheel scrolls the scrollable area containing the focused element */
|
|
||||||
function handleWheel(e: WheelEvent) {
|
function handleWheel(e: WheelEvent) {
|
||||||
const active = document.activeElement as HTMLElement | null
|
const active = document.activeElement as HTMLElement | null
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const scrollable = getScrollableAncestor(active)
|
let p = active.parentElement
|
||||||
if (!scrollable) return
|
while (p) {
|
||||||
if (e.deltaY !== 0) {
|
const style = getComputedStyle(p)
|
||||||
scrollable.scrollTop += e.deltaY
|
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||||
e.preventDefault()
|
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
|
||||||
}
|
return
|
||||||
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
|
}
|
||||||
scrollable.scrollLeft += e.deltaX
|
p = p.parentElement
|
||||||
e.preventDefault()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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(() => {
|
onMounted(() => {
|
||||||
checkGamepads()
|
checkGamepads()
|
||||||
window.addEventListener('keydown', handleKeyDown, true)
|
window.addEventListener('keydown', handleKeyDown, true)
|
||||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||||
pollIntervalId = setInterval(handleGamepadInput, 500)
|
pollIntervalId = setInterval(() => checkGamepads(), 500)
|
||||||
|
setTimeout(autoFocusMain, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -461,8 +667,5 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return { isControllerActive, gamepadCount }
|
||||||
isControllerActive,
|
|
||||||
gamepadCount,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,15 +177,15 @@
|
|||||||
"loggedIn": "Currently logged in",
|
"loggedIn": "Currently logged in",
|
||||||
"didHelper": "Decentralized identifier for passwordless auth",
|
"didHelper": "Decentralized identifier for passwordless auth",
|
||||||
"onionHelper": "Onion address for node interface and peer discovery over Tor",
|
"onionHelper": "Onion address for node interface and peer discovery over Tor",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Set Password",
|
||||||
"enable2fa": "Enable 2FA",
|
"enable2fa": "Enable 2FA",
|
||||||
"disable2fa": "Disable 2FA",
|
"disable2fa": "Disable 2FA",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"twoFactorAuth": "Two-Factor Authentication",
|
"twoFactorAuth": "Two-Factor Authentication",
|
||||||
"twoFaProtect": "Protect your account with an authenticator app",
|
"twoFaProtect": "Protect your account with an authenticator app",
|
||||||
"changePasswordTitle": "Change Password",
|
"changePasswordTitle": "Set Password",
|
||||||
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
|
"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",
|
"currentPassword": "Current Password",
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"confirmNewPassword": "Confirm New Password",
|
"confirmNewPassword": "Confirm New Password",
|
||||||
|
|||||||
@@ -287,6 +287,19 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
next()
|
next()
|
||||||
return
|
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 } })
|
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Initialize data structure immediately so dashboard can render
|
// Initialize data structure immediately so dashboard can render
|
||||||
await sync.initializeData()
|
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) => {
|
sync.connectWebSocket().catch((err) => {
|
||||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', 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()
|
const sync = useSyncStore()
|
||||||
await sync.initializeData()
|
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) => {
|
sync.connectWebSocket().catch((err) => {
|
||||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', 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
|
// Server store — computed server state and RPC action proxies
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useSyncStore } from './sync'
|
import { useSyncStore } from './sync'
|
||||||
|
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
||||||
|
|
||||||
export const useServerStore = defineStore('server', () => {
|
export const useServerStore = defineStore('server', () => {
|
||||||
const sync = useSyncStore()
|
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
|
// Computed — derived from sync store's data
|
||||||
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
|
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
|
||||||
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
|
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
|
||||||
@@ -70,6 +94,12 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
isShuttingDown,
|
isShuttingDown,
|
||||||
isOffline,
|
isOffline,
|
||||||
|
|
||||||
|
// Install tracking (global, persists across navigation)
|
||||||
|
installingApps,
|
||||||
|
setInstallProgress,
|
||||||
|
clearInstallProgress,
|
||||||
|
isInstalling,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
installPackage,
|
installPackage,
|
||||||
uninstallPackage,
|
uninstallPackage,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
.skip-to-content:focus {
|
.skip-to-content:focus,
|
||||||
|
.skip-to-content:focus-visible {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -44,15 +45,41 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
backdrop-filter: blur(12px);
|
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 {
|
*:focus-visible {
|
||||||
outline: none;
|
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;
|
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 */
|
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
|
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
|
||||||
@@ -95,16 +122,21 @@ input[type="radio"]:active + * {
|
|||||||
|
|
||||||
/* Containers: base scale for smooth grow animation */
|
/* Containers: base scale for smooth grow animation */
|
||||||
[data-controller-container] {
|
[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) */
|
/* Containers: console-style focus — lift + ambient orange glow.
|
||||||
[data-controller-container]:focus-visible {
|
Pure glow approach — no border-color or outline changes, avoids
|
||||||
transform: scale(1.02);
|
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:
|
box-shadow:
|
||||||
0 0 24px rgba(120, 180, 255, 0.15),
|
0 0 6px 2px rgba(251, 146, 60, 0.35),
|
||||||
0 0 48px rgba(100, 160, 255, 0.08),
|
0 0 20px rgba(251, 146, 60, 0.15),
|
||||||
inset 0 0 24px rgba(255, 255, 255, 0.03);
|
0 0 40px rgba(251, 146, 60, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global glassmorphism utilities */
|
/* Global glassmorphism utilities */
|
||||||
@@ -115,14 +147,18 @@ input[type="radio"]:active + * {
|
|||||||
-webkit-backdrop-filter: blur(18px);
|
-webkit-backdrop-filter: blur(18px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
transform: translateZ(0);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-strong {
|
.glass-strong {
|
||||||
background-color: rgba(0, 0, 0, 0.35);
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
transform: translateZ(0);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@@ -134,6 +170,11 @@ input[type="radio"]:active + * {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
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 */
|
/* Mode switcher - sidebar toggle */
|
||||||
@@ -160,10 +201,10 @@ input[type="radio"]:active + * {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
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;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +221,22 @@ input[type="radio"]:active + * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-switcher-btn-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);
|
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) */
|
/* Chat launcher button — sidebar (desktop) */
|
||||||
@@ -767,6 +821,8 @@ input[type="radio"]:active + * {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
|
||||||
border: none;
|
border: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-option-card:active {
|
.path-option-card:active {
|
||||||
@@ -934,7 +990,7 @@ input[type="radio"]:active + * {
|
|||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active Navigation Tab Style - matches hover container */
|
/* Active Navigation Tab Style — sidebar selected item */
|
||||||
.nav-tab-active {
|
.nav-tab-active {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(0, 0, 0, 0.35) !important;
|
background: rgba(0, 0, 0, 0.35) !important;
|
||||||
@@ -952,8 +1008,8 @@ input[type="radio"]:active + * {
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||||
-webkit-mask:
|
-webkit-mask:
|
||||||
linear-gradient(#fff 0 0) content-box,
|
linear-gradient(#fff 0 0) content-box,
|
||||||
linear-gradient(#fff 0 0);
|
linear-gradient(#fff 0 0);
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
@@ -965,11 +1021,9 @@ input[type="radio"]:active + * {
|
|||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
.sidebar-nav-item:focus-visible {
|
.sidebar-nav-item:focus-visible {
|
||||||
transform: scale(1.02) !important;
|
outline: none !important;
|
||||||
box-shadow:
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
0 0 24px rgba(120, 180, 255, 0.15),
|
color: white !important;
|
||||||
0 0 48px rgba(100, 160, 255, 0.08),
|
|
||||||
inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,6 +1229,23 @@ body::after {
|
|||||||
animation-fill-mode: backwards;
|
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 */
|
/* Dashboard: full viewport width, no letterboxing, no body scroll */
|
||||||
body.dashboard-active {
|
body.dashboard-active {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1274,7 +1345,8 @@ html:has(body.video-background-active)::before {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
.cloud-file-item:focus-visible {
|
.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 {
|
.cloud-file-item-thumb {
|
||||||
@@ -1452,7 +1524,8 @@ html:has(body.video-background-active)::before {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.cloud-grid-card:focus-visible {
|
.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 {
|
.cloud-grid-card-cover {
|
||||||
@@ -2087,19 +2160,6 @@ html:has(body.video-background-active)::before {
|
|||||||
font-size: 0.8125rem;
|
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 {
|
.discover-installed-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2126,15 +2186,6 @@ html:has(body.video-background-active)::before {
|
|||||||
transform: translateY(-2px);
|
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 {
|
.discover-manifesto {
|
||||||
border-color: rgba(251, 146, 60, 0.1);
|
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%);
|
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
|
'donation-url': string | null
|
||||||
author?: string
|
author?: string
|
||||||
website?: string
|
website?: string
|
||||||
|
tier?: string
|
||||||
interfaces?: {
|
interfaces?: {
|
||||||
main?: {
|
main?: {
|
||||||
ui?: string
|
ui?: string
|
||||||
|
|||||||
@@ -101,6 +101,8 @@
|
|||||||
:index="index"
|
:index="index"
|
||||||
:show-stagger="showStagger"
|
:show-stagger="showStagger"
|
||||||
:is-loading="!!actions.loadingActions.value[id as string]"
|
: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)"
|
:is-uninstalling="actions.uninstallingApps.value.has(id as string)"
|
||||||
@go-to-app="goToApp"
|
@go-to-app="goToApp"
|
||||||
@launch="launchApp"
|
@launch="launchApp"
|
||||||
@@ -141,6 +143,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import type { PackageDataEntry } from '@/types/api'
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
import AppCard from './apps/AppCard.vue'
|
import AppCard from './apps/AppCard.vue'
|
||||||
@@ -155,6 +158,7 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const serverStore = useServerStore()
|
||||||
const actions = useAppsActions()
|
const actions = useAppsActions()
|
||||||
|
|
||||||
// Only stagger-animate on first mount
|
// Only stagger-animate on first mount
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
v-for="app in bundledApps"
|
v-for="app in bundledApps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
|
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -134,6 +135,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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)"
|
@click="launchApp(app)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||||
<!-- Skip to main content link for keyboard users -->
|
<!-- 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 -->
|
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||||
<div class="bg-perspective-container">
|
<div class="bg-perspective-container">
|
||||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||||
@@ -126,7 +126,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import AppSession from '@/views/AppSession.vue'
|
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 { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||||
import '@/views/dashboard/dashboard-styles.css'
|
import '@/views/dashboard/dashboard-styles.css'
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ let discoverAnimationDone = false
|
|||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
@@ -147,11 +148,12 @@ import DiscoverHero from './discover/DiscoverHero.vue'
|
|||||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||||
import AppGrid from './discover/AppGrid.vue'
|
import AppGrid from './discover/AppGrid.vue'
|
||||||
import FilterModal from './discover/FilterModal.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'
|
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const serverStore = useServerStore()
|
||||||
|
|
||||||
const showStagger = !discoverAnimationDone
|
const showStagger = !discoverAnimationDone
|
||||||
const { setCurrentApp } = useMarketplaceApp()
|
const { setCurrentApp } = useMarketplaceApp()
|
||||||
@@ -173,20 +175,20 @@ const categories = computed(() => [
|
|||||||
{ id: 'other', name: 'Other' }
|
{ id: 'other', name: 'Other' }
|
||||||
])
|
])
|
||||||
|
|
||||||
// Installation state
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
const installingApps = serverStore.installingApps
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
watch(() => store.packages, (packages) => {
|
watch(() => store.packages, (packages) => {
|
||||||
if (!packages) return
|
if (!packages) return
|
||||||
for (const [appId, pkg] of Object.entries(packages)) {
|
for (const [appId, pkg] of Object.entries(packages)) {
|
||||||
const progress = pkg['install-progress']
|
const progress = pkg['install-progress']
|
||||||
if (progress && pkg.state === 'installing' && installingApps.value.has(appId)) {
|
if (progress && pkg.state === 'installing' && installingApps.has(appId)) {
|
||||||
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 pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
progress: Math.min(pct, 95),
|
progress: Math.min(pct, 95),
|
||||||
@@ -409,50 +411,50 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function startInstallPolling(appId: string, statusMessage: string) {
|
function startInstallPolling(appId: string, statusMessage: string) {
|
||||||
const interval = trackInterval(() => {
|
const interval = trackInterval(() => {
|
||||||
const current = installingApps.value.get(appId)
|
const current = installingApps.get(appId)
|
||||||
if (!current) { clearTrackedInterval(interval); return }
|
if (!current) { clearTrackedInterval(interval); return }
|
||||||
const newAttempt = current.attempt + 1
|
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)) {
|
if (isInstalled(appId)) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
|
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
||||||
} else if (newAttempt >= maxAttempts.value) {
|
} else if (newAttempt >= maxAttempts.value) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp) {
|
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, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
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 } })
|
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...')
|
startInstallPolling(app.id, 'Starting application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', 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}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp) {
|
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, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
|
||||||
try {
|
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 })
|
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...')
|
startInstallPolling(app.id, 'Initializing application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', 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}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<!-- Desktop: tabs inline with header -->
|
<!-- Desktop: tabs inline with header -->
|
||||||
<div
|
<div
|
||||||
v-if="!uiMode.isChat"
|
v-if="!uiMode.isChat"
|
||||||
|
role="tablist"
|
||||||
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
||||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||||
>
|
>
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
<template v-if="!uiMode.isChat">
|
<template v-if="!uiMode.isChat">
|
||||||
<!-- Mobile: full-width tabs -->
|
<!-- Mobile: full-width tabs -->
|
||||||
<div
|
<div
|
||||||
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
|
||||||
role="tablist"
|
role="tablist"
|
||||||
|
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
||||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
: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>
|
<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]">
|
<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>
|
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Wallet Modals -->
|
<!-- Wallet Modals -->
|
||||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
|
|
||||||
<!-- Title -->
|
<!-- 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)]">
|
<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>
|
<span v-else>{{ t('login.title') }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -38,8 +39,16 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</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 -->
|
<!-- 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">
|
<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="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>
|
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
|
||||||
@@ -53,9 +62,11 @@
|
|||||||
id="setup-password"
|
id="setup-password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="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"
|
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||||
:placeholder="t('login.enterPasswordSetup')"
|
:placeholder="t('login.enterPasswordSetup')"
|
||||||
@keyup.enter="handleSetupWithSound"
|
@keydown.enter="handleSetupWithSound"
|
||||||
:disabled="loading || formDisabled"
|
:disabled="loading || formDisabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,9 +79,11 @@
|
|||||||
id="setup-confirm-password"
|
id="setup-confirm-password"
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
type="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"
|
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||||
@keyup.enter="handleSetupWithSound"
|
@keydown.enter="handleSetupWithSound"
|
||||||
:disabled="loading || formDisabled"
|
:disabled="loading || formDisabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,9 +164,11 @@
|
|||||||
id="login-password"
|
id="login-password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="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"
|
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||||
@keyup.enter="handleLoginWithSound"
|
@keydown.enter="handleLoginWithSound"
|
||||||
:disabled="loading || formDisabled"
|
:disabled="loading || formDisabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,10 +259,11 @@ const startupProgress = ref(0)
|
|||||||
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// Check if we're in setup mode (original StartOS node setup)
|
// Whether we're in setup mode (no password created yet)
|
||||||
const isSetupMode = computed(() => {
|
const isSetupMode = ref(false)
|
||||||
return import.meta.env.VITE_DEV_MODE === 'setup'
|
|
||||||
})
|
// 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)
|
// Whether the login form should be disabled (server not ready)
|
||||||
const formDisabled = computed(() => !serverReady.value)
|
const formDisabled = computed(() => !serverReady.value)
|
||||||
@@ -339,16 +355,16 @@ onMounted(async () => {
|
|||||||
await pollServerStartup()
|
await pollServerStartup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check setup mode after server is confirmed ready
|
// Check if password has been set up — show setup form if not
|
||||||
if (isSetupMode.value) {
|
try {
|
||||||
try {
|
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
isSetup.value = Boolean(result)
|
||||||
isSetup.value = Boolean(result)
|
isSetupMode.value = !isSetup.value
|
||||||
} catch {
|
} catch {
|
||||||
isSetup.value = false
|
isSetup.value = false
|
||||||
}
|
isSetupMode.value = true
|
||||||
} else {
|
} finally {
|
||||||
isSetup.value = true
|
isCheckingSetup.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -380,11 +396,19 @@ async function handleSetup() {
|
|||||||
params: { password: password.value.trim() }
|
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()
|
stopSynthwave()
|
||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await store.login(password.value.trim())
|
|
||||||
await new Promise(r => setTimeout(r, 520))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
await router.replace(loginRedirectTo.value).catch(() => {
|
await router.replace(loginRedirectTo.value).catch(() => {
|
||||||
window.location.href = loginRedirectTo.value
|
window.location.href = loginRedirectTo.value
|
||||||
@@ -425,6 +449,14 @@ async function handleLogin() {
|
|||||||
setTimeout(() => totpInputRef.value?.focus(), 100)
|
setTimeout(() => totpInputRef.value?.focus(), 100)
|
||||||
return
|
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()
|
stopSynthwave()
|
||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
@@ -119,7 +120,6 @@ import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
|||||||
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
|
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
|
||||||
import {
|
import {
|
||||||
type MarketplaceApp,
|
type MarketplaceApp,
|
||||||
type InstallProgress,
|
|
||||||
INSTALLED_ALIASES,
|
INSTALLED_ALIASES,
|
||||||
getAppTier,
|
getAppTier,
|
||||||
categorizeCommunityApp,
|
categorizeCommunityApp,
|
||||||
@@ -129,6 +129,7 @@ import {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const server = useServerStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const showStagger = !marketplaceAnimationDone
|
const showStagger = !marketplaceAnimationDone
|
||||||
@@ -152,8 +153,8 @@ const categories = computed(() => [
|
|||||||
{ id: 'other', name: t('marketplace.other') }
|
{ id: 'other', name: t('marketplace.other') }
|
||||||
])
|
])
|
||||||
|
|
||||||
// Installation state - support multiple concurrent installations
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
const installingApps = server.installingApps
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
// Watch WebSocket data for real install progress from backend
|
// 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)) {
|
for (const [appId, pkg] of Object.entries(packages)) {
|
||||||
if ((pkg.state as string) === 'installing') {
|
if ((pkg.state as string) === 'installing') {
|
||||||
const progress = pkg['install-progress']
|
const progress = pkg['install-progress']
|
||||||
if (!installingApps.value.has(appId)) {
|
if (!installingApps.has(appId)) {
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
id: appId,
|
id: appId,
|
||||||
title: pkg.manifest?.title || appId,
|
title: pkg.manifest?.title || appId,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
@@ -173,19 +174,19 @@ watch(() => store.packages, (packages) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (progress) {
|
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 pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
progress: Math.min(pct, 95),
|
progress: Math.min(pct, 95),
|
||||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (installingApps.value.has(appId) && (pkg.state as string) !== 'installing') {
|
} else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') {
|
||||||
installingApps.value.delete(appId)
|
installingApps.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
@@ -402,11 +403,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function startInstallPolling(appId: string, statusMessage: string) {
|
function startInstallPolling(appId: string, statusMessage: string) {
|
||||||
const interval = trackInterval(() => {
|
const interval = trackInterval(() => {
|
||||||
const current = installingApps.value.get(appId)
|
const current = installingApps.get(appId)
|
||||||
if (!current) { clearTrackedInterval(interval); return }
|
if (!current) { clearTrackedInterval(interval); return }
|
||||||
|
|
||||||
const newAttempt = current.attempt + 1
|
const newAttempt = current.attempt + 1
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
attempt: newAttempt,
|
attempt: newAttempt,
|
||||||
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
||||||
@@ -415,49 +416,49 @@ function startInstallPolling(appId: string, statusMessage: string) {
|
|||||||
|
|
||||||
if (isInstalled(appId)) {
|
if (isInstalled(appId)) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
|
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
||||||
} else if (newAttempt >= maxAttempts.value) {
|
} else if (newAttempt >= maxAttempts.value) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp) {
|
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
|
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
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 } })
|
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...')
|
startInstallPolling(app.id, 'Starting application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', 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}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp) {
|
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
|
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
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({
|
await rpcClient.call({
|
||||||
method: 'package.install',
|
method: 'package.install',
|
||||||
@@ -465,13 +466,13 @@ async function installCommunityApp(app: MarketplaceApp) {
|
|||||||
timeout: 180000
|
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...')
|
startInstallPolling(app.id, 'Initializing application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', 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}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -344,9 +344,9 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<!-- Responsive column layout -->
|
<!-- Responsive column layout -->
|
||||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
||||||
<!-- LEFT COLUMN: Status + Peers -->
|
<!-- 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 -->
|
<!-- 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-header">
|
||||||
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
||||||
<h2 class="mesh-section-title">Device</h2>
|
<h2 class="mesh-section-title">Device</h2>
|
||||||
@@ -410,7 +410,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions row -->
|
<!-- 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">
|
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
||||||
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peers list -->
|
<!-- 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>
|
<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">
|
<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
|
<div
|
||||||
class="mesh-peer-row is-channel"
|
class="mesh-peer-row is-channel"
|
||||||
:class="{ active: archChannelActive }"
|
:class="{ active: archChannelActive }"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
@click="openArchChannel"
|
@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-avatar channel" style="background: rgba(251,146,60,0.2); color: #fb923c;">A</div>
|
||||||
<div class="mesh-peer-info">
|
<div class="mesh-peer-info">
|
||||||
@@ -454,7 +457,10 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<div
|
<div
|
||||||
class="mesh-peer-row is-channel"
|
class="mesh-peer-row is-channel"
|
||||||
:class="{ active: activeChatChannel?.index === 0 }"
|
:class="{ active: activeChatChannel?.index === 0 }"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
@click="openChannelChat(publicChannel)"
|
@click="openChannelChat(publicChannel)"
|
||||||
|
@keydown.enter="openChannelChat(publicChannel)"
|
||||||
>
|
>
|
||||||
<div class="mesh-peer-avatar channel">#</div>
|
<div class="mesh-peer-avatar channel">#</div>
|
||||||
<div class="mesh-peer-info">
|
<div class="mesh-peer-info">
|
||||||
@@ -466,7 +472,10 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
v-for="peer in sortedPeers" :key="peer.contact_id"
|
v-for="peer in sortedPeers" :key="peer.contact_id"
|
||||||
class="mesh-peer-row"
|
class="mesh-peer-row"
|
||||||
:class="{ active: activeChatPeer?.contact_id === peer.contact_id, 'is-archy': isArchyNode(peer) }"
|
:class="{ active: activeChatPeer?.contact_id === peer.contact_id, 'is-archy': isArchyNode(peer) }"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
@click="openChat(peer)"
|
@click="openChat(peer)"
|
||||||
|
@keydown.enter="openChat(peer)"
|
||||||
>
|
>
|
||||||
<div class="mesh-peer-avatar" :class="{ archy: isArchyNode(peer) }">
|
<div class="mesh-peer-avatar" :class="{ archy: isArchyNode(peer) }">
|
||||||
<AnimatedLogo v-if="isArchyNode(peer)" size="sm" />
|
<AnimatedLogo v-if="isArchyNode(peer)" size="sm" />
|
||||||
@@ -493,7 +502,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
<!-- 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) -->
|
<!-- Tab bar (medium desktop only) -->
|
||||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||||
@@ -512,7 +521,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Panel -->
|
<!-- 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 v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||||
<div class="mesh-chat-empty-icon">📡</div>
|
<div class="mesh-chat-empty-icon">📡</div>
|
||||||
<p>Select a peer or channel to chat</p>
|
<p>Select a peer or channel to chat</p>
|
||||||
@@ -614,8 +623,8 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools panels -->
|
<!-- Tools panels (3rd column on wide screens) -->
|
||||||
<div class="mesh-tools-wrapper">
|
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||||
<!-- Tools tab bar (wide desktop only) -->
|
<!-- Tools tab bar (wide desktop only) -->
|
||||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<!-- Content Area -->
|
<!-- 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="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">
|
<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>
|
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||||
<!-- Passphrase Input -->
|
<!-- Passphrase Input -->
|
||||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref="passphraseInput"
|
||||||
v-model="passphrase"
|
v-model="passphrase"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter a strong passphrase"
|
placeholder="Enter a strong passphrase"
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
:disabled="!passphrase || isDownloading"
|
:disabled="!passphrase || isDownloading"
|
||||||
class="path-action-button path-action-button--continue w-full"
|
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">
|
<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">
|
<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>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@@ -74,14 +75,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- 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">
|
<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="skipForNow"
|
|
||||||
class="path-action-button path-action-button--skip"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
|
ref="continueButton"
|
||||||
@click="proceed"
|
@click="proceed"
|
||||||
:disabled="!downloaded"
|
:disabled="!downloaded"
|
||||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||||
@@ -94,12 +90,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const passphraseInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||||
const passphrase = ref('')
|
const passphrase = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
passphraseInput.value?.focus({ preventScroll: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
const isDownloading = ref(false)
|
const isDownloading = ref(false)
|
||||||
const downloaded = ref(false)
|
const downloaded = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -133,6 +138,10 @@ async function downloadBackup() {
|
|||||||
|
|
||||||
downloaded.value = true
|
downloaded.value = true
|
||||||
localStorage.setItem('neode_backup_created', '1')
|
localStorage.setItem('neode_backup_created', '1')
|
||||||
|
// Focus Continue button after backup completes
|
||||||
|
setTimeout(() => {
|
||||||
|
continueButton.value?.focus({ preventScroll: true })
|
||||||
|
}, 100)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||||
@@ -146,11 +155,9 @@ async function downloadBackup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function proceed() {
|
function proceed() {
|
||||||
|
playNavSound('action')
|
||||||
router.push('/onboarding/verify').catch(() => {})
|
router.push('/onboarding/verify').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipForNow() {
|
|
||||||
router.push('/onboarding/verify').catch(() => {})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -98,15 +98,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
|
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
|
||||||
<button
|
|
||||||
@click="skipForNow"
|
|
||||||
class="path-action-button path-action-button--skip"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="generatedDid"
|
v-if="generatedDid"
|
||||||
|
ref="continueButton"
|
||||||
@click="proceed"
|
@click="proceed"
|
||||||
class="path-action-button path-action-button--continue"
|
class="path-action-button path-action-button--continue"
|
||||||
>
|
>
|
||||||
@@ -118,11 +113,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||||
const generatedDid = ref<string>('')
|
const generatedDid = ref<string>('')
|
||||||
const nostrNpub = ref<string>('')
|
const nostrNpub = ref<string>('')
|
||||||
const isGenerating = ref(false)
|
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(() => {
|
onMounted(() => {
|
||||||
const cached = localStorage.getItem('neode_did')
|
const cached = localStorage.getItem('neode_did')
|
||||||
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
||||||
@@ -205,11 +211,6 @@ function proceed() {
|
|||||||
router.push('/onboarding/identity').catch(() => {})
|
router.push('/onboarding/identity').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipForNow() {
|
|
||||||
stopTimers()
|
|
||||||
router.push('/onboarding/identity').catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyDid() {
|
function copyDid() {
|
||||||
if (!generatedDid.value) return
|
if (!generatedDid.value) return
|
||||||
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
|
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
|
||||||
|
|||||||
@@ -42,12 +42,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
|
ref="setPasswordButton"
|
||||||
@click="goToLogin"
|
@click="goToLogin"
|
||||||
class="path-action-button path-action-button--continue mx-auto"
|
class="path-action-button path-action-button--continue mx-auto"
|
||||||
>
|
>
|
||||||
Go to Login
|
Set Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,11 +57,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const setPasswordButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordButton.value?.focus({ preventScroll: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
function goToLogin() {
|
function goToLogin() {
|
||||||
|
playNavSound('action')
|
||||||
router.push('/login').catch(() => {})
|
router.push('/login').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
<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>
|
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
|
||||||
<input
|
<input
|
||||||
|
ref="nameInput"
|
||||||
v-model="identityName"
|
v-model="identityName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Personal"
|
placeholder="Personal"
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<button
|
<button
|
||||||
v-for="p in purposes"
|
v-for="p in purposes"
|
||||||
:key="p.value"
|
: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="px-4 py-3 rounded-lg border text-left transition-all"
|
||||||
:class="selectedPurpose === p.value
|
:class="selectedPurpose === p.value
|
||||||
? 'bg-white/15 border-white/30 text-white'
|
? '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>
|
<p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- 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">
|
<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="skip"
|
|
||||||
class="path-action-button path-action-button--skip"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
@click="createIdentity"
|
@click="createIdentity"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
@@ -81,12 +76,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
const identityName = ref('Personal')
|
const identityName = ref('Personal')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
nameInput.value?.focus({ preventScroll: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
const selectedPurpose = ref('personal')
|
const selectedPurpose = ref('personal')
|
||||||
const isCreating = ref(false)
|
const isCreating = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -115,6 +118,7 @@ async function createIdentity() {
|
|||||||
purpose: selectedPurpose.value
|
purpose: selectedPurpose.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
playNavSound('action')
|
||||||
router.push('/onboarding/backup').catch(() => {})
|
router.push('/onboarding/backup').catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isServerStartingError(err)) {
|
if (isServerStartingError(err)) {
|
||||||
@@ -127,7 +131,4 @@ async function createIdentity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function skip() {
|
|
||||||
router.push('/onboarding/backup').catch(() => {})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
ref="ctaButton"
|
||||||
@click="goToOptions"
|
@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"
|
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>
|
</button>
|
||||||
|
|
||||||
<a
|
<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"
|
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||||
@click="showRestore = true"
|
@click="showRestore = true"
|
||||||
|
@keydown.enter="showRestore = true"
|
||||||
>
|
>
|
||||||
Restore from backup
|
Restore from backup
|
||||||
</a>
|
</a>
|
||||||
@@ -65,14 +69,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
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() {
|
function goToOptions() {
|
||||||
|
playNavSound('action')
|
||||||
router.push('/onboarding/path').catch(() => {})
|
router.push('/onboarding/path').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const selected = ref<string | null>(null)
|
const selected = ref<string | null>(null)
|
||||||
@@ -100,6 +101,7 @@ async function proceed() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
|
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
|
||||||
}
|
}
|
||||||
|
playNavSound('action')
|
||||||
router.push('/login').catch(() => {})
|
router.push('/login').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<!-- Action Buttons -->
|
<!-- 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">
|
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||||
<button
|
<button
|
||||||
|
ref="continueButton"
|
||||||
@click="proceed"
|
@click="proceed"
|
||||||
class="path-action-button path-action-button--continue"
|
class="path-action-button path-action-button--continue"
|
||||||
>
|
>
|
||||||
@@ -93,11 +94,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
continueButton.value?.focus({ preventScroll: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
function proceed() {
|
function proceed() {
|
||||||
|
playNavSound('action')
|
||||||
router.push('/onboarding/did').catch(() => {})
|
router.push('/onboarding/did').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="flex flex-col items-center gap-6 mb-6">
|
<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>
|
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||||
<!-- Sign Button (if not verified yet) -->
|
<!-- Sign Button (if not verified yet) -->
|
||||||
<button
|
<button
|
||||||
|
ref="signButton"
|
||||||
v-if="!verified"
|
v-if="!verified"
|
||||||
@click="signChallenge"
|
@click="signChallenge"
|
||||||
:disabled="isSigning"
|
:disabled="isSigning"
|
||||||
@@ -63,14 +64,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- 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">
|
<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="skipForNow"
|
|
||||||
class="path-action-button path-action-button--skip"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
|
ref="finishButton"
|
||||||
v-if="verified"
|
v-if="verified"
|
||||||
@click="proceed"
|
@click="proceed"
|
||||||
class="path-action-button path-action-button--continue"
|
class="path-action-button path-action-button--continue"
|
||||||
@@ -83,13 +79,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const signButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
const finishButton = ref<HTMLButtonElement | null>(null)
|
||||||
const verified = ref(false)
|
const verified = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
signButton.value?.focus({ preventScroll: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
const isSigning = ref(false)
|
const isSigning = ref(false)
|
||||||
const signature = ref('')
|
const signature = ref('')
|
||||||
const currentChallenge = ref('')
|
const currentChallenge = ref('')
|
||||||
@@ -125,6 +130,9 @@ async function signChallenge() {
|
|||||||
} else {
|
} else {
|
||||||
verified.value = true
|
verified.value = true
|
||||||
}
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => finishButton.value?.focus({ preventScroll: true }), 100)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : ''
|
const msg = err instanceof Error ? err.message : ''
|
||||||
@@ -133,7 +141,7 @@ async function signChallenge() {
|
|||||||
if (isRetryable) {
|
if (isRetryable) {
|
||||||
serverStarting.value = true
|
serverStarting.value = true
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||||
@@ -144,6 +152,7 @@ async function signChallenge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function proceed() {
|
async function proceed() {
|
||||||
|
playNavSound('action')
|
||||||
try {
|
try {
|
||||||
await completeOnboarding()
|
await completeOnboarding()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,13 +161,5 @@ async function proceed() {
|
|||||||
router.push('/onboarding/done').catch(() => {})
|
router.push('/onboarding/done').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function skipForNow() {
|
|
||||||
try {
|
|
||||||
await completeOnboarding()
|
|
||||||
} catch {
|
|
||||||
/* localStorage fallback ensures we can proceed */
|
|
||||||
}
|
|
||||||
router.push('/onboarding/done').catch(() => {})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ import BootScreen from '@/components/BootScreen.vue'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const showBootScreen = ref(false)
|
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> {
|
async function quickHealthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
@@ -32,8 +40,11 @@ async function quickHealthCheck(): Promise<boolean> {
|
|||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
})
|
})
|
||||||
clearTimeout(t)
|
clearTimeout(t)
|
||||||
return res.status !== 502 && res.status !== 503
|
const ok = res.status !== 502 && res.status !== 503
|
||||||
} catch {
|
log('healthCheck', { status: res.status, ok })
|
||||||
|
return ok
|
||||||
|
} catch (e) {
|
||||||
|
log('healthCheck FAILED', { error: String(e) })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,24 +55,27 @@ async function checkOnboarded(): Promise<boolean> {
|
|||||||
isOnboardingComplete(),
|
isOnboardingComplete(),
|
||||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
|
||||||
])
|
])
|
||||||
|
log('checkOnboarded', { result })
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Backend unreachable — fall back to localStorage only as last resort
|
const fallback = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
log('checkOnboarded ERROR, localStorage fallback', { error: String(e), fallback })
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proceedToApp() {
|
async function proceedToApp() {
|
||||||
const devMode = import.meta.env.VITE_DEV_MODE
|
const devMode = import.meta.env.VITE_DEV_MODE
|
||||||
if (devMode === 'setup' || devMode === 'existing') {
|
if (devMode === 'setup' || devMode === 'existing') {
|
||||||
|
log('proceedToApp devMode', { devMode })
|
||||||
router.replace('/login').catch(() => {})
|
router.replace('/login').catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always check backend for authoritative onboarding state
|
|
||||||
// (localStorage can be stale from a previous install on the same IP)
|
|
||||||
const onboarded = await checkOnboarded()
|
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() {
|
function onServerReady() {
|
||||||
@@ -74,44 +88,42 @@ function onServerReady() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const devMode = import.meta.env.VITE_DEV_MODE
|
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') {
|
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
|
||||||
|
log('from_boot=1, deferring to SplashScreen')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splash already completed this session — go to app
|
|
||||||
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
|
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
|
||||||
|
log('from_splash=1, proceedToApp')
|
||||||
proceedToApp()
|
proceedToApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard dev modes
|
|
||||||
if (devMode === 'setup' || devMode === 'existing') {
|
if (devMode === 'setup' || devMode === 'existing') {
|
||||||
|
log('devMode shortcut', { devMode })
|
||||||
proceedToApp()
|
proceedToApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot dev mode — always show boot screen (first load only)
|
|
||||||
if (devMode === 'boot') {
|
if (devMode === 'boot') {
|
||||||
|
log('devMode=boot, showing boot screen')
|
||||||
showBootScreen.value = true
|
showBootScreen.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: check server health
|
|
||||||
const isUp = await quickHealthCheck()
|
const isUp = await quickHealthCheck()
|
||||||
|
log('production flow', { isUp })
|
||||||
|
|
||||||
if (isUp) {
|
if (isUp) {
|
||||||
// Server is up — check if onboarding is complete
|
|
||||||
const onboarded = await checkOnboarded()
|
const onboarded = await checkOnboarded()
|
||||||
if (onboarded) {
|
if (onboarded) {
|
||||||
// Returning user, server is up — go straight to login
|
log('server up + onboarded → proceedToApp')
|
||||||
proceedToApp()
|
proceedToApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// First boot: server is up but onboarding not done yet.
|
log('server up + NOT onboarded → boot screen')
|
||||||
// Show boot animation anyway — it lets services fully warm up
|
|
||||||
// (containers, DID resolver, etc.) before onboarding starts.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server not ready OR first boot — show boot screen
|
// Server not ready OR first boot — show boot screen
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<!-- Overview Cards -->
|
<!-- Overview Cards -->
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Local Network Card -->
|
<!-- 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 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">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web3 Card -->
|
<!-- 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 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">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
||||||
<!-- Network Interfaces -->
|
<!-- 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 class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||||
|
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
|
<ChangePasswordSection />
|
||||||
<SystemSection />
|
<SystemSection />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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 { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
@@ -43,10 +43,12 @@ vi.mock('@/components/AnimatedLogo.vue', () => ({
|
|||||||
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
|
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const pushMock = vi.fn()
|
const pushMock = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRouter: () => ({ push: pushMock }),
|
useRouter: () => ({ push: pushMock }),
|
||||||
useRoute: () => ({ query: {} }),
|
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
|
// Stub fetch for server health check
|
||||||
@@ -87,6 +89,12 @@ describe('Login View', () => {
|
|||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
pushMock.mockResolvedValue(undefined)
|
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() {
|
function mountLogin() {
|
||||||
@@ -106,19 +114,22 @@ describe('Login View', () => {
|
|||||||
expect(wrapper.exists()).toBe(true)
|
expect(wrapper.exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contains a password input', () => {
|
it('contains a password input', async () => {
|
||||||
const wrapper = mountLogin()
|
const wrapper = mountLogin()
|
||||||
|
await flushPromises()
|
||||||
const input = wrapper.find('input[type="password"]')
|
const input = wrapper.find('input[type="password"]')
|
||||||
expect(input.exists()).toBe(true)
|
expect(input.exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows title text', () => {
|
it('shows title text', async () => {
|
||||||
const wrapper = mountLogin()
|
const wrapper = mountLogin()
|
||||||
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('Welcome Back')
|
expect(wrapper.text()).toContain('Welcome Back')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has a login button', () => {
|
it('has a login button', async () => {
|
||||||
const wrapper = mountLogin()
|
const wrapper = mountLogin()
|
||||||
|
await flushPromises()
|
||||||
const buttons = wrapper.findAll('button')
|
const buttons = wrapper.findAll('button')
|
||||||
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
|
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
|
||||||
expect(loginBtn).toBeDefined()
|
expect(loginBtn).toBeDefined()
|
||||||
|
|||||||
@@ -1,317 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { shallowMount, VueWrapper } from '@vue/test-utils'
|
import { shallowMount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
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 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', () => {
|
describe('Settings View', () => {
|
||||||
beforeEach(() => {
|
it('renders AccountSection and SystemSection', () => {
|
||||||
vi.clearAllMocks()
|
setActivePinia(createPinia())
|
||||||
localStorage.clear()
|
const wrapper = shallowMount(Settings)
|
||||||
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
|
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
|
||||||
mockedRpc.call.mockResolvedValue({ backups: [] })
|
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
|
||||||
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' })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,25 @@
|
|||||||
:class="{ 'card-stagger': showStagger }"
|
:class="{ 'card-stagger': showStagger }"
|
||||||
:style="{ '--stagger-index': index }"
|
:style="{ '--stagger-index': index }"
|
||||||
@click="$emit('goToApp', id)"
|
@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 -->
|
<!-- Uninstalling overlay -->
|
||||||
<div
|
<div
|
||||||
v-if="isUninstalling"
|
v-if="isUninstalling"
|
||||||
@@ -39,43 +56,51 @@
|
|||||||
|
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<img
|
<img
|
||||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
|
:src="icon"
|
||||||
:alt="pkg.manifest?.title || String(id)"
|
:alt="title"
|
||||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
class="w-14 h-14 rounded-lg object-cover bg-white/10"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0 overflow-hidden">
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
{{ pkg.manifest.title }}
|
<h3 class="text-lg font-semibold text-white truncate" :title="title">
|
||||||
</h3>
|
{{ title }}
|
||||||
<p class="text-sm text-white/70 mb-2 truncate">
|
</h3>
|
||||||
{{ pkg.manifest?.description?.short || '' }}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
v-if="tier && tier !== 'optional'"
|
||||||
:class="getStatusClass(pkg.state, pkg.health)"
|
class="tier-badge"
|
||||||
>
|
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||||
<svg
|
>{{ tier }}</span>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
</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 -->
|
<!-- Quick Actions -->
|
||||||
<div v-if="!isUninstalling" class="mt-4 flex gap-2">
|
<div v-if="!isUninstalling" class="mt-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -145,19 +170,25 @@ import {
|
|||||||
isWebOnlyApp, opensInTab, canLaunch,
|
isWebOnlyApp, opensInTab, canLaunch,
|
||||||
getStatusClass, getStatusLabel, handleImageError,
|
getStatusClass, getStatusLabel, handleImageError,
|
||||||
} from './appsConfig'
|
} from './appsConfig'
|
||||||
|
import { getCuratedAppList } from '../discover/curatedApps'
|
||||||
|
|
||||||
const { t } = useI18n()
|
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<{
|
const props = defineProps<{
|
||||||
id: string
|
id: string
|
||||||
pkg: PackageDataEntry
|
pkg: PackageDataEntry
|
||||||
index: number
|
index: number
|
||||||
showStagger: boolean
|
showStagger: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
isInstalling?: boolean
|
||||||
|
installProgress?: { status: string; progress: number; message: string }
|
||||||
isUninstalling: boolean
|
isUninstalling: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
goToApp: [id: string]
|
goToApp: [id: string]
|
||||||
launch: [id: string]
|
launch: [id: string]
|
||||||
start: [id: string]
|
start: [id: string]
|
||||||
@@ -166,8 +197,43 @@ defineEmits<{
|
|||||||
showUninstall: [id: string, pkg: PackageDataEntry]
|
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))
|
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 isTransitioning = computed(() => {
|
||||||
const s = props.pkg.state
|
const s = props.pkg.state
|
||||||
const h = props.pkg.health
|
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