fix: prevent My Apps crash when installing apps + add filebrowser to demo
The My Apps page went blank after installing apps because pkg['static-files'].icon was accessed without optional chaining on dynamically installed packages that lack the static-files property. - Make static-files optional in PackageDataEntry type - Add defensive ?.icon access with fallback in Apps.vue and AppDetails.vue - Add filebrowser to mock backend staticDevApps (enables Cloud page in demo) - Expand portMappings and marketplaceMetadata for all marketplace apps - installPackage now uses staticApp() format for consistent data shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9c7ffbb263
commit
a2aa9657b1
@ -27,6 +27,16 @@ ports:
|
||||
- "YOUR_PORT:80"
|
||||
```
|
||||
|
||||
## Chat (Claude AI)
|
||||
|
||||
Set `ANTHROPIC_API_KEY` in the Portainer stack environment to enable real AI chat:
|
||||
|
||||
1. In the stack editor, add under **Environment variables**:
|
||||
- `ANTHROPIC_API_KEY` = your Anthropic API key (starts with `sk-ant-api...`)
|
||||
2. Redeploy the stack
|
||||
|
||||
Without this key, chat shows a "not configured" error. The key is passed to the `neode-backend` container which proxies requests to `api.anthropic.com`.
|
||||
|
||||
## Dev Mode
|
||||
|
||||
`VITE_DEV_MODE=existing` skips setup/onboarding and goes straight to login. For other flows:
|
||||
|
||||
@ -14,12 +14,18 @@ services:
|
||||
environment:
|
||||
VITE_DEV_MODE: "existing" # Skip setup/onboarding, go straight to login
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
NODE_OPTIONS: "--dns-result-order=ipv4first"
|
||||
expose:
|
||||
- "5959"
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5959/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
neode-web:
|
||||
build:
|
||||
|
||||
@ -356,10 +356,12 @@ INSTALLER_ISO="$WORK_DIR/installer-iso"
|
||||
rm -rf "$INSTALLER_ISO"
|
||||
mkdir -p "$INSTALLER_ISO"
|
||||
cd "$INSTALLER_ISO"
|
||||
(7z x -y "$BASE_ISO" 2>/dev/null || 7za x -y "$BASE_ISO" 2>/dev/null || bsdtar -xf "$BASE_ISO" 2>/dev/null) || {
|
||||
# 7z returns exit code 2 for warnings (symlinks in ISO) — check for key files instead
|
||||
7z x -y "$BASE_ISO" >/dev/null 2>&1 || 7za x -y "$BASE_ISO" >/dev/null 2>&1 || bsdtar -xf "$BASE_ISO" 2>/dev/null || true
|
||||
if [ ! -d "$INSTALLER_ISO/live" ] || [ ! -f "$INSTALLER_ISO/live/vmlinuz" ]; then
|
||||
echo " ❌ Failed to extract ISO. Install p7zip-full: sudo apt install p7zip-full"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: Add Archipelago components
|
||||
@ -501,7 +503,8 @@ mkdir -p "$IMAGES_DIR"
|
||||
IMAGES_CAPTURED_FROM_SERVER=0
|
||||
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||
echo " Capturing container images from live server ($DEV_SERVER)..."
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoin-knots lnd lnd-ui electrs-ui filebrowser mempool mempool-electrs tailscale homeassistant btcpayserver nbxplorer postgres nostr-rs-relay strfry alpine-tor fedimintd gatewayd dwn-server"
|
||||
# Patterns match against `podman images` repository names (not container names)
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server grafana uptime-kuma jellyfin vaultwarden searxng mariadb valkey nginx-alpine portainer"
|
||||
REMOTE_TMP="/tmp/archipelago-image-capture-$$"
|
||||
SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(sudo podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && sudo podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true
|
||||
for p in $SAVED_LIST; do
|
||||
@ -518,26 +521,30 @@ fi
|
||||
|
||||
# Define images to bundle for fallback (when not from server or missing). Includes filebrowser.
|
||||
# bitcoin-ui and lnd-ui are custom and normally captured from server or built separately.
|
||||
# Alpha: core Bitcoin/Lightning stack + essential apps. Others pulled on-demand from Marketplace.
|
||||
CONTAINER_IMAGES="
|
||||
bitcoinknots/bitcoin:29 bitcoin-knots.tar
|
||||
lightninglabs/lnd:v0.18.4-beta lnd.tar
|
||||
ghcr.io/home-assistant/home-assistant:stable homeassistant.tar
|
||||
btcpayserver/btcpayserver:latest btcpayserver.tar
|
||||
docker.io/bitcoinknots/bitcoin:latest bitcoin-knots.tar
|
||||
docker.io/lightninglabs/lnd:v0.18.4-beta lnd.tar
|
||||
docker.io/homeassistant/home-assistant:2024.1 homeassistant.tar
|
||||
docker.io/btcpayserver/btcpayserver:1.13.5 btcpayserver.tar
|
||||
docker.io/nicolasdorier/nbxplorer:2.6.0 nbxplorer.tar
|
||||
docker.io/library/postgres:16 postgres-btcpay.tar
|
||||
mempool/frontend:latest mempool-frontend.tar
|
||||
mempool/backend:v2.5.0 mempool-backend.tar
|
||||
mempool/electrs:latest mempool-electrs.tar
|
||||
docker.io/mariadb:10.11 mariadb-mempool.tar
|
||||
docker.io/library/postgres:15-alpine postgres-btcpay.tar
|
||||
docker.io/mempool/frontend:v2.5.0 mempool-frontend.tar
|
||||
docker.io/mempool/backend:v2.5.0 mempool-backend.tar
|
||||
docker.io/mempool/electrs:latest mempool-electrs.tar
|
||||
docker.io/library/mariadb:10.11 mariadb-mempool.tar
|
||||
docker.io/fedimint/fedimintd:v0.10.0 fedimint.tar
|
||||
docker.io/fedimint/gatewayd:v0.10.0 fedimint-gateway.tar
|
||||
docker.io/filebrowser/filebrowser:latest filebrowser.tar
|
||||
scsibug/nostr-rs-relay:latest nostr-rs-relay.tar
|
||||
hoytech/strfry:latest strfry.tar
|
||||
tailscale/tailscale:latest tailscale.tar
|
||||
docker.io/filebrowser/filebrowser:v2.27.0 filebrowser.tar
|
||||
docker.io/andrius/alpine-tor:latest alpine-tor.tar
|
||||
docker.io/library/nginx:alpine nginx-alpine.tar
|
||||
ghcr.io/tbd54566975/dwn-server:main dwn-server.tar
|
||||
docker.io/grafana/grafana:10.2.0 grafana.tar
|
||||
docker.io/louislam/uptime-kuma:1 uptime-kuma.tar
|
||||
docker.io/vaultwarden/server:1.30.0-alpine vaultwarden.tar
|
||||
docker.io/searxng/searxng:latest searxng.tar
|
||||
docker.io/portainer/portainer-ce:2.19.4 portainer.tar
|
||||
docker.io/tailscale/tailscale:stable tailscale.tar
|
||||
"
|
||||
|
||||
# Pull and save each image (force AMD64 for x86_64 target) only if not already present
|
||||
@ -722,6 +729,13 @@ FBCSERVICE
|
||||
cp "$WORK_DIR/archipelago-first-boot-containers.service" "$ARCH_DIR/scripts/"
|
||||
fi
|
||||
|
||||
# Bundle E2E test script for post-install validation
|
||||
if [ -f "$SCRIPT_DIR/../scripts/run-e2e-tests.sh" ]; then
|
||||
cp "$SCRIPT_DIR/../scripts/run-e2e-tests.sh" "$ARCH_DIR/scripts/"
|
||||
chmod +x "$ARCH_DIR/scripts/run-e2e-tests.sh"
|
||||
echo " ✅ Bundled E2E test script for post-install validation"
|
||||
fi
|
||||
|
||||
# Bundle docker UI source files for building custom UIs on first boot (fallback if images not captured)
|
||||
DOCKER_UI_DIR="$SCRIPT_DIR/../docker"
|
||||
if [ -d "$DOCKER_UI_DIR" ]; then
|
||||
@ -992,6 +1006,17 @@ if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
|
||||
echo " ✅ Container images staged for first-boot loading"
|
||||
fi
|
||||
|
||||
# Initialize backend data directories for seamless first boot
|
||||
mkdir -p /mnt/target/var/lib/archipelago/tor-config
|
||||
mkdir -p /mnt/target/var/lib/archipelago/identities
|
||||
mkdir -p /mnt/target/var/lib/archipelago/lnd
|
||||
|
||||
# Copy E2E test script for post-install validation
|
||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/run-e2e-tests.sh" ]; then
|
||||
cp "$BOOT_MEDIA/archipelago/scripts/run-e2e-tests.sh" /mnt/target/opt/archipelago/scripts/
|
||||
chmod +x /mnt/target/opt/archipelago/scripts/run-e2e-tests.sh
|
||||
fi
|
||||
|
||||
# Ensure correct ownership (use numeric UID:GID 1000:1000 since we're outside chroot)
|
||||
chown -R 1000:1000 /mnt/target/opt/archipelago 2>/dev/null || true
|
||||
chown -R 1000:1000 /mnt/target/var/lib/archipelago 2>/dev/null || true
|
||||
@ -1143,6 +1168,8 @@ echo -e "${GREEN}║ Pre-loaded apps (ready to start via Web UI):
|
||||
echo -e "${GREEN}║ • Bitcoin Knots • LND • Home Assistant ║${NC}"
|
||||
echo -e "${GREEN}║ • BTCPay Server • Mempool • Nostr Relays ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ Validate: bash /opt/archipelago/scripts/run-e2e-tests.sh ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
read -p "Press Enter to reboot..."
|
||||
@ -1376,37 +1403,88 @@ echo "📦 Step 6: Creating bootable ISO..."
|
||||
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-x86_64.iso"
|
||||
|
||||
# Get MBR for hybrid boot
|
||||
ISOHDPFX=""
|
||||
for path in \
|
||||
"/usr/local/share/syslinux/isohdpfx.bin" \
|
||||
"/usr/share/syslinux/isohdpfx.bin" \
|
||||
"/opt/homebrew/share/syslinux/isohdpfx.bin" \
|
||||
"$INSTALLER_ISO/isolinux/isohdpfx.bin"; do
|
||||
if [ -f "$path" ]; then
|
||||
ISOHDPFX="$path"
|
||||
# Extract MBR from original Debian Live ISO (most reliable for hybrid boot)
|
||||
# This preserves the exact MBR that makes the ISO work as a USB drive in Balena Etcher
|
||||
echo " Extracting hybrid MBR from original Debian Live ISO..."
|
||||
ISOHDPFX="$WORK_DIR/isohdpfx.bin"
|
||||
dd if="$BASE_ISO" bs=1 count=432 of="$ISOHDPFX" 2>/dev/null
|
||||
|
||||
# Verify we got a valid MBR (should be 432 bytes)
|
||||
ISOHDPFX_SIZE=$(stat -c%s "$ISOHDPFX" 2>/dev/null || stat -f%z "$ISOHDPFX" 2>/dev/null || echo 0)
|
||||
if [ "$ISOHDPFX_SIZE" -ne 432 ]; then
|
||||
echo " ⚠️ MBR extraction unexpected size ($ISOHDPFX_SIZE), trying syslinux paths..."
|
||||
for path in \
|
||||
"/usr/lib/ISOLINUX/isohdpfx.bin" \
|
||||
"/usr/share/syslinux/isohdpfx.bin" \
|
||||
"/usr/local/share/syslinux/isohdpfx.bin"; do
|
||||
if [ -f "$path" ]; then
|
||||
ISOHDPFX="$path"
|
||||
echo " Using $path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find the EFI boot image — 7z may extract it to different locations
|
||||
EFI_IMG=""
|
||||
for efi_path in \
|
||||
"$INSTALLER_ISO/boot/grub/efi.img" \
|
||||
"$INSTALLER_ISO/EFI/boot/efi.img" \
|
||||
"$INSTALLER_ISO/efi.img"; do
|
||||
if [ -f "$efi_path" ]; then
|
||||
EFI_IMG="$efi_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$ISOHDPFX" ]; then
|
||||
echo " Extracting MBR from isolinux.bin..."
|
||||
dd if="$INSTALLER_ISO/isolinux/isolinux.bin" of="$WORK_DIR/isohdpfx.bin" bs=432 count=1 2>/dev/null
|
||||
ISOHDPFX="$WORK_DIR/isohdpfx.bin"
|
||||
# If no standalone efi.img, check for [BOOT] directory from 7z extraction
|
||||
if [ -z "$EFI_IMG" ] && [ -d "$INSTALLER_ISO/[BOOT]" ]; then
|
||||
# 7z extracts El Torito boot images into [BOOT]/ — the EFI image is usually entry 2
|
||||
for entry in "$INSTALLER_ISO/[BOOT]/"*; do
|
||||
# EFI images are typically > 1MB FAT filesystems
|
||||
if [ -f "$entry" ]; then
|
||||
entry_size=$(stat -c%s "$entry" 2>/dev/null || stat -f%z "$entry" 2>/dev/null || echo 0)
|
||||
if [ "$entry_size" -gt 1048576 ]; then
|
||||
mkdir -p "$INSTALLER_ISO/boot/grub"
|
||||
cp "$entry" "$INSTALLER_ISO/boot/grub/efi.img"
|
||||
EFI_IMG="$INSTALLER_ISO/boot/grub/efi.img"
|
||||
echo " Recovered EFI image from [BOOT] directory"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
xorriso -as mkisofs -o "$OUTPUT_ISO" \
|
||||
-volid "ARCHIPELAGO" \
|
||||
-J -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 \
|
||||
"$INSTALLER_ISO"
|
||||
if [ -z "$EFI_IMG" ]; then
|
||||
echo " ⚠️ No EFI boot image found — ISO will only support Legacy BIOS boot"
|
||||
xorriso -as mkisofs -o "$OUTPUT_ISO" \
|
||||
-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 \
|
||||
"$INSTALLER_ISO"
|
||||
else
|
||||
# Make EFI path relative to INSTALLER_ISO for xorriso
|
||||
EFI_REL="${EFI_IMG#$INSTALLER_ISO/}"
|
||||
xorriso -as mkisofs -o "$OUTPUT_ISO" \
|
||||
-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 "$EFI_REL" \
|
||||
-no-emul-boot \
|
||||
-isohybrid-gpt-basdat \
|
||||
-partition_offset 16 \
|
||||
"$INSTALLER_ISO"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
[Unit]
|
||||
Description=Archipelago Backend
|
||||
After=network-online.target
|
||||
After=network-online.target archipelago-setup-tor.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=archipelago
|
||||
User=root
|
||||
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
||||
Environment="ARCHIPELAGO_DEV_MODE=true"
|
||||
ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /etc/archipelago/host-ip.env'
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
337
loop/Old Plans/plan.md
Normal file
337
loop/Old Plans/plan.md
Normal file
@ -0,0 +1,337 @@
|
||||
# 2-Year Production Roadmap — Archipelago v1.0
|
||||
|
||||
**Goal**: Take Archipelago from developer preview to a flawless, mass-market Bitcoin Node OS. Every app installs perfectly, every service runs reliably, every interaction is polished and intuitive — on desktop and mobile.
|
||||
|
||||
**Timeline**: March 2026 → March 2028 (8 quarters)
|
||||
**Method**: Quarterly phases, each building on the last. Deploy and verify after every task.
|
||||
|
||||
---
|
||||
|
||||
## Q1 2026 (Mar–May): Foundation Hardening
|
||||
|
||||
### Phase 1A: App Store Reliability — Every App Installs Without Fail
|
||||
|
||||
- [x] **APP-101** — fix(marketplace): audit and fix all 24 marketplace app install flows. For each app in `getCuratedAppList()` in `neode-ui/src/views/Marketplace.vue` (bitcoin-knots, electrs, btcpay-server, lnd, mempool, homeassistant, grafana, searxng, ollama, onlyoffice, penpot, nextcloud, vaultwarden, jellyfin, photoprism, immich, filebrowser, nginx-proxy-manager, portainer, uptime-kuma, tailscale, fedimint, indeedhub), verify each one: (1) marketplace card renders correctly with icon, (2) clicking Install triggers `package.install` RPC, (3) container pulls and creates successfully, (4) container starts on the correct ports per `apps/PORTS.md`, (5) status shows "Running" in My Apps. Fix any broken apps. Deploy with `./scripts/deploy-to-target.sh --live`. Test each app at http://192.168.1.228.
|
||||
|
||||
- [x] **APP-102** — fix(apps): ensure iframe vs new-tab behavior is correct for all apps. In `neode-ui/src/stores/appLauncher.ts`, verify `mustOpenInNewTab()` includes all apps that set `X-Frame-Options: DENY/SAMEORIGIN`. Currently covers BTCPay (23000), Home Assistant (8123), Nextcloud (8085), Immich (2283). Test each running app by clicking "Open" in AppDetails.vue — iframe apps must load inside the overlay, new-tab apps must open in a fresh browser tab. If any app fails to load in iframe, either fix the nginx proxy to strip X-Frame-Options or add it to `mustOpenInNewTab()`. Deploy and verify each app.
|
||||
|
||||
- [x] **APP-103** — fix(apps): verify all PORT_TO_PROXY mappings in appLauncher.ts match nginx config. Cross-reference every entry in `PORT_TO_PROXY` in `neode-ui/src/stores/appLauncher.ts` with the actual nginx location blocks in `image-recipe/configs/nginx-archipelago.conf` and `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`. Any missing nginx proxy blocks must be added. Any port mismatches must be corrected. Deploy nginx config and verify each app loads via its proxy path.
|
||||
|
||||
- [x] **APP-104** — fix(deploy): ensure first-boot-containers.sh creates every marketplace app container. Compare the apps listed in `scripts/first-boot-containers.sh` with `scripts/deploy-to-target.sh`. Any app that deploy creates but first-boot doesn't must be added to first-boot. This ensures fresh ISO installs have all containers ready.
|
||||
|
||||
- [x] **APP-105** — fix(backend): verify get_app_config() handles all 24 apps. In `core/archipelago/src/api/rpc/package.rs`, check `get_app_config()` returns correct ports, volumes, env vars, and custom args for every marketplace app. Any app missing its config will fail to install. Add missing configs.
|
||||
|
||||
- [x] **APP-106** — fix(backend): verify get_app_metadata() for all 24 apps. In `core/archipelago/src/container/docker_packages.rs`, check `get_app_metadata()` returns correct title, description, icon path, and repo URL for every marketplace app. Fix missing or incorrect entries.
|
||||
|
||||
### Phase 1B: App Dependencies — Bitcoin, Lightning, Fedimint Chains
|
||||
|
||||
- [x] **DEP-101** — fix(backend): implement robust dependency checking for all apps. In `core/archipelago/src/api/rpc/package.rs`, ensure dependency checks work: Electrs requires Bitcoin Knots running, LND requires Bitcoin Knots running, BTCPay requires LND running, Mempool requires Bitcoin Knots + Electrs. When installing an app with unmet dependencies, the UI should either auto-install dependencies or show a clear message: "Bitcoin Knots must be installed and running first." Deploy and verify by trying to install Electrs without Bitcoin.
|
||||
|
||||
- [x] **DEP-102** — fix(ui): show dependency status in MarketplaceAppDetails.vue. When viewing an app that has dependencies, show a "Requirements" section listing each dependency with a green checkmark (installed & running), yellow warning (installed but stopped), or red X (not installed). Add an "Install All Requirements" button that queues dependency installations in order. This lives in `neode-ui/src/views/MarketplaceAppDetails.vue`.
|
||||
|
||||
- [x] **DEP-103** — feat(fedimint): integrate Fedimint Guardian + Gateway as paired services. Fedimint currently runs as a single container (fedimintd). Add Fedimint Gateway as a companion service that runs alongside the Guardian. In `scripts/deploy-to-target.sh`, when creating fedimint, also create `fedimint-gateway` container using `fedimint/gatewayd` image. Configure the gateway to auto-connect to the guardian. In the Marketplace, show Fedimint as one app that runs both services. The UI should show both Guardian and Gateway status.
|
||||
|
||||
- [x] **DEP-104** — feat(fedimint): auto-configure Fedimint Gateway to use LND. The Fedimint Gateway needs a Lightning backend. When both LND and Fedimint are installed, auto-configure the gateway to use LND's gRPC endpoint. In `core/archipelago/src/api/rpc/package.rs`, add Fedimint Gateway config that reads LND's tls.cert and admin.macaroon from the LND data volume. The user should only need to open lightning channels — everything else should be automatic.
|
||||
|
||||
- [x] **DEP-105** — feat(ui): lightning channel management interface. Create `neode-ui/src/views/apps/LightningChannels.vue` accessible from the LND app detail page. Show: (1) list of open channels with capacity bars, (2) "Open Channel" button with peer URI input and amount, (3) channel status (pending open/close, active, inactive), (4) total inbound/outbound liquidity summary. Use existing RPC to call LND's REST API through the backend proxy. This is critical for Fedimint Gateway to be useful.
|
||||
|
||||
### Phase 1C: Animation & UI Polish
|
||||
|
||||
- [x] **ANIM-101** — fix(css): audit and improve all transition animations in style.css. In `neode-ui/src/style.css`, review every `transition` property. Standardize: (1) hover lifts use `transform: translateY(-2px)` with `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`, (2) active presses use `translateY(1px)`, (3) color transitions use `transition: color 0.2s ease, background-color 0.2s ease`, (4) modal/overlay entrances use `transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)`. Replace all `transition: all 0.3s ease` with specific properties to avoid animating layout properties. Deploy and verify animations feel smooth.
|
||||
|
||||
- [x] **ANIM-102** — fix(ui): add smooth page transitions between routes. In `neode-ui/src/views/Dashboard.vue`, wrap `<RouterView>` in a `<Transition>` component with `name="page"`. In `style.css`, add: `.page-enter-active, .page-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }` `.page-enter-from { opacity: 0; transform: translateY(8px); }` `.page-leave-to { opacity: 0; }`. This gives every page navigation a subtle fade-up entrance. Deploy and verify navigation feels smooth.
|
||||
|
||||
- [x] **ANIM-103** — fix(ui): add staggered entrance animations for card grids. In views that display card grids (Apps.vue, Marketplace.vue, Home.vue, Web5.vue), add staggered entrance animations so cards appear one after another with a 50ms delay. Use CSS `animation-delay` with `nth-child()` or Vue's `<TransitionGroup>`. The effect should be subtle — cards fade in and slide up slightly. Deploy and verify.
|
||||
|
||||
- [x] **ANIM-104** — fix(ui): smooth loading state transitions. In all views that have loading states, ensure the transition from loading to loaded is animated — not an instant swap. Use Vue's `<Transition>` with `mode="out-in"` around loading/content states. The loading spinner should fade out as the content fades in. Deploy and verify on Apps, Marketplace, Server, and Home views.
|
||||
|
||||
- [x] **ANIM-105** — fix(ui): polish the app launcher overlay animation. In `neode-ui/src/components/AppLauncherOverlay.vue`, ensure the overlay slides up smoothly when opening an app. Add a subtle backdrop blur transition. The iframe should have a loading indicator that fades out when the app loads. The close animation should slide down. Use `will-change: transform` for GPU acceleration. Deploy and verify.
|
||||
|
||||
### Phase 1D: Mobile Responsiveness
|
||||
|
||||
- [x] **MOB-101** — fix(ui): audit and fix all views at 375px (iPhone SE) width. Test every view at 375px: Login, Dashboard sidebar, Home, Apps, Marketplace, AppDetails, MarketplaceAppDetails, Settings, Web5, Cloud, Server, Chat. Fix: horizontal overflow, overlapping elements, text truncation, buttons too small to tap (min 44px touch target), broken grid layouts. Add or fix responsive Tailwind classes in `style.css`. Deploy and verify.
|
||||
|
||||
- [x] **MOB-102** — fix(ui): optimize sidebar navigation for mobile. The Dashboard sidebar should collapse to a bottom tab bar on mobile (< 768px). Show icons only with labels below. The mode switcher should be accessible from Settings on mobile. Ensure the sidebar doesn't overlap content on mobile. Deploy and verify.
|
||||
|
||||
- [x] **MOB-103** — fix(ui): optimize app launcher overlay for mobile. The app iframe launcher should be full-screen on mobile with a sticky top bar (app title + close + open-in-new-tab). On desktop, maintain the current overlay style. Deploy and verify apps are usable on mobile.
|
||||
|
||||
---
|
||||
|
||||
## Q2 2026 (Jun–Aug): Identity & Onboarding
|
||||
|
||||
### Phase 2A: Multi-Identity System
|
||||
|
||||
- [x] **ID-101** — feat(backend): implement identity manager with multiple DIDs. Create `core/archipelago/src/identity/mod.rs` with: (1) `IdentityManager` struct that stores multiple identities in `/var/lib/archipelago/identity/`, (2) each identity has an Ed25519 keypair, a DID, a display name, and a purpose tag (personal, business, anonymous), (3) the first identity is created during onboarding, (4) RPC endpoints: `identity.list`, `identity.create`, `identity.delete`, `identity.get`, `identity.set-default`. Store identities encrypted using the node's master key. Build on server and deploy.
|
||||
|
||||
- [x] **ID-102** — feat(backend): implement identity signing service. Add `identity.sign` and `identity.verify` RPC endpoints. `identity.sign` takes a DID id and a message, returns a detached Ed25519 signature. `identity.verify` takes a DID, message, and signature, returns boolean. This enables apps to request signatures from the user's chosen identity. Build on server and deploy.
|
||||
|
||||
- [x] **ID-103** — feat(ui): identity management view. Create a new "Identity" section in the Web5 view (replace the hidden `v-if="false"` DID section at line 429 of `Web5.vue`). Show: (1) list of all identities with name, DID (truncated), purpose badge, (2) "Create Identity" button that opens a modal with name + purpose selector, (3) each identity card has Copy DID, Set Default, Delete actions, (4) the default identity shows a star badge. Wire to the backend RPC endpoints from ID-101. Deploy and verify.
|
||||
|
||||
- [x] **ID-104** — feat(ui): identity picker for service connections. Create a reusable `<IdentityPicker>` component that shows a dropdown of the user's identities with their names and truncated DIDs. When a service (like Indeehub) needs a DID, it calls this component to let the user choose which identity to use. The selected identity's DID and signing capability are then passed to the service.
|
||||
|
||||
- [x] **ID-105** — feat(backend): Nostr identity bridge. Each identity can optionally have an associated Nostr keypair (secp256k1). Add `identity.create-nostr-key` RPC that generates a Nostr keypair linked to an identity. Add `identity.nostr-sign` for NIP-01 event signing. This bridges the DID world with Nostr. The user's Nostr pubkey is derivable from their identity. Build on server and deploy.
|
||||
|
||||
- [x] **ID-106** — feat(apps): Indeehub identity integration. When opening Indeehub, pass the user's selected identity DID via the iframe URL or postMessage. Indeehub should recognize the user's sovereign identity without requiring account creation. Implement the postMessage protocol: parent sends `{ type: 'archipelago:identity', did: '...', signature: '...' }`, Indeehub responds with `{ type: 'archipelago:identity:ack' }`. Deploy and verify Indeehub recognizes the user.
|
||||
|
||||
### Phase 2B: Onboarding Flow Polish
|
||||
|
||||
- [x] **ONB-101** — fix(ui): polish onboarding intro animation. In `neode-ui/src/views/OnboardingIntro.vue`, add a cinematic entrance: the Archipelago logo fades in with a subtle scale (0.95 → 1.0), followed by the tagline sliding up, then the "Get Started" button fading in. Total duration: 2 seconds. Use CSS keyframe animations. Deploy and verify.
|
||||
|
||||
- [x] **ONB-102** — fix(ui): improve onboarding DID step UX. In `OnboardingDid.vue`, when the backend generates the DID, show a brief animation of key generation (spinning lock icon → checkmark). Display the DID in a styled card with a copy button. Explain in plain language: "This is your sovereign digital identity. It proves you are you, without any company in the middle." Deploy and verify.
|
||||
|
||||
- [x] **ONB-103** — fix(ui): add identity purpose selection to onboarding. After DID creation in onboarding, add a step where the user names their first identity (default: "Personal") and optionally selects a purpose (Personal, Business, Anonymous). This feeds into the multi-identity system from ID-101. Deploy and verify.
|
||||
|
||||
- [x] **ONB-104** — fix(ui): smooth transition between onboarding steps. Add a horizontal slide transition between onboarding steps — swiping left to advance, right to go back. Use `<Transition>` with `name="slide"` and direction-aware classes. Deploy and verify the flow feels like swiping through cards.
|
||||
|
||||
---
|
||||
|
||||
## Q3 2026 (Sep–Nov): Network & Node Discovery
|
||||
|
||||
### Phase 3A: Node Overlay Network
|
||||
|
||||
- [x] **NET-101** — feat(backend): implement node visibility signaling. Create `core/archipelago/src/network/overlay.rs` with: (1) a `NodeVisibility` enum (Hidden, Discoverable, Public), (2) RPC endpoints `network.set-visibility` and `network.get-visibility`, (3) when set to Discoverable, the node publishes a Nostr NIP-33 replaceable event (kind 30078, tag `d:archipelago-node`) with its onion address and public DID, (4) when set to Hidden, the event is deleted. This uses the existing Nostr discovery code in `core/archipelago/src/nostr_discovery.rs`. Build on server and deploy.
|
||||
|
||||
- [x] **NET-102** — feat(backend): implement connection request protocol. Add RPC endpoints: `network.request-connection` (sends a connection request to a peer's onion address over Tor), `network.list-requests` (shows pending incoming requests), `network.accept-request` (adds peer to trusted list), `network.reject-request`. Connection requests are sent as encrypted Nostr DMs (NIP-04) containing the sender's DID and onion address. Build on server and deploy.
|
||||
|
||||
- [x] **NET-103** — feat(ui): node visibility controls in Web5 view. In the Web5 view, add a "Node Visibility" card (replace or augment the existing Connected Nodes section). Show: (1) current visibility status (Hidden/Discoverable/Public), (2) toggle to change visibility, (3) when Discoverable, show the node's onion address, (4) warning: "Making your node discoverable lets other Archipelago users find and connect with you." Wire to NET-101 RPCs. Deploy and verify.
|
||||
|
||||
- [x] **NET-104** — feat(ui): connection request management. In the Web5 view, add a "Connection Requests" tab to the Connected Nodes section. Show: (1) incoming requests with sender DID and timestamp, (2) Accept/Reject buttons, (3) notification badge on the Web5 sidebar icon when requests are pending. Wire to NET-102 RPCs. Deploy and verify.
|
||||
|
||||
- [x] **NET-105** — feat(backend): implement peer health monitoring. Add a background task that periodically (every 5 minutes) checks if connected peers are reachable over Tor. Update peer status in the database. Send WebSocket events when peer status changes. The existing `rpcClient.checkPeerReachable()` in Web5.vue already calls this — ensure the backend implementation is robust with timeouts. Build on server and deploy.
|
||||
|
||||
### Phase 3B: Tor Services Management
|
||||
|
||||
- [x] **TOR-101** — feat(backend): implement Tor hidden service management RPC. Create RPC endpoints: `tor.list-services` (returns all configured hidden services with their .onion addresses), `tor.create-service` (creates a new hidden service for a given local port), `tor.delete-service`, `tor.get-onion-address`. Read from `/var/lib/archipelago/tor/` directory structure. Currently Tor setup is hardcoded in deploy script — make it dynamic. Build on server and deploy.
|
||||
|
||||
- [x] **TOR-102** — feat(ui): Tor services management in Settings or Web5. Add a "Tor Services" section showing: (1) list of all hidden services with their .onion addresses and what app they expose, (2) toggle to enable/disable Tor for each service, (3) "Broadcast my services over Tor" master toggle, (4) copy .onion address button for each service. Wire to TOR-101 RPCs. Deploy and verify.
|
||||
|
||||
- [x] **TOR-103** — fix(deploy): make Tor hidden service creation dynamic. Refactor `scripts/deploy-to-target.sh` Tor section (lines 471-530) to read from a config file (`/var/lib/archipelago/tor/services.json`) instead of hardcoding services. When an app is installed that supports Tor, automatically add a hidden service entry. When uninstalled, remove it. Rebuild torrc from the config file and restart the Tor container. Deploy and verify.
|
||||
|
||||
- [x] **TOR-104** — feat(backend): Tor-based content serving. When a peer accesses your node over Tor, serve only the content you've explicitly made available. Create `core/archipelago/src/network/content_server.rs` with: (1) a list of shared content items (files, streams), (2) access control per item (free, paid via ecash), (3) a lightweight HTTP handler that serves content to authenticated peers. This is the foundation for content streaming. Build on server and deploy.
|
||||
|
||||
---
|
||||
|
||||
## Q4 2026 (Dec–Feb 2027): Ecash & Content Economy
|
||||
|
||||
### Phase 4A: Ecash Integration
|
||||
|
||||
- [x] **ECASH-101** — feat(backend): implement Cashu ecash wallet. Create `core/archipelago/src/wallet/ecash.rs` with: (1) Cashu wallet client that connects to the local Fedimint mint, (2) RPC endpoints: `wallet.ecash-balance`, `wallet.ecash-mint` (create ecash tokens from Lightning), `wallet.ecash-melt` (redeem ecash to Lightning), `wallet.ecash-send` (create ecash token for peer), `wallet.ecash-receive` (accept ecash token from peer). Use the Cashu protocol for interoperability. Build on server and deploy.
|
||||
|
||||
- [x] **ECASH-102** — feat(ui): ecash wallet in Web5 view. Replace the dummy "Web5 Wallet" card in Web5.vue (lines 221-268) with a real ecash wallet UI. Show: (1) ecash balance in sats, (2) Mint button (Lightning → ecash), (3) Melt button (ecash → Lightning), (4) Send button (generates ecash token string), (5) Receive button (paste ecash token), (6) transaction history. Wire to ECASH-101 RPCs. Deploy and verify.
|
||||
|
||||
- [x] **ECASH-103** — feat(backend): implement pay-per-access content gating. Extend the content server from TOR-104 with ecash payment verification. When content is marked as "paid", the server returns a 402 Payment Required with a Cashu invoice. The requesting peer pays with ecash, receives a receipt token, and includes it in subsequent requests. Implement in `core/archipelago/src/network/content_server.rs`. Build on server and deploy.
|
||||
|
||||
- [x] **ECASH-104** — feat(ui): content pricing controls. In the content sharing UI (to be built in Phase 4B), add pricing controls: (1) free/paid toggle per content item, (2) price in sats input, (3) "Pay what you want" option with minimum, (4) preview: "Peers will pay X sats to access this." Wire to backend content server config. Deploy and verify.
|
||||
|
||||
### Phase 4B: Content Streaming & File Sharing
|
||||
|
||||
- [x] **CONTENT-101** — feat(backend): implement content catalog RPC. Create `core/archipelago/src/network/content_catalog.rs` with: (1) `content.list-mine` — list content I'm sharing, (2) `content.add` — add a file or stream to my catalog, (3) `content.remove` — stop sharing, (4) `content.set-pricing` — free or ecash-gated, (5) `content.set-availability` — available to all peers, specific peers, or nobody. Store catalog in `/var/lib/archipelago/content/catalog.json`. Build on server and deploy.
|
||||
|
||||
- [x] **CONTENT-102** — feat(backend): implement peer content browsing. Add `content.browse-peer` RPC that connects to a peer's onion address over Tor and fetches their content catalog. Returns a list of available items with titles, descriptions, sizes, and prices. The peer's content server (TOR-104) serves the catalog at a well-known endpoint. Build on server and deploy.
|
||||
|
||||
- [x] **CONTENT-103** — feat(backend): implement content streaming protocol. For media files (video, audio), implement chunked streaming over Tor. The requesting node sends a range request, the serving node streams the content chunk by chunk. For paid content, payment is per-chunk (micropayments via ecash). Use HTTP range requests over the Tor hidden service. Build on server and deploy.
|
||||
|
||||
- [x] **CONTENT-104** — feat(ui): content sharing dashboard. Create a "Content" tab in the Web5 view. Show: (1) "My Shared Content" — list of files/streams you're sharing with pricing, (2) "Add Content" button — file picker to add from Cloud/FileBrowser, (3) "Browse Peers" — select a connected peer and browse their catalog, (4) download/stream buttons with payment flow for paid content. Deploy and verify.
|
||||
|
||||
- [x] **CONTENT-105** — feat(ui): content streaming player. When a user clicks to stream video/audio from a peer, open a media player in the app launcher overlay. Show: (1) video/audio player with standard controls, (2) streaming progress indicator, (3) cost tracker (total sats spent on this stream), (4) quality selector if multiple qualities available. Use HTML5 `<video>` or `<audio>` with the Tor-proxied stream URL. Deploy and verify.
|
||||
|
||||
### Phase 4C: Networking Profits — Real Data
|
||||
|
||||
- [x] **PROFIT-101** — feat(backend): implement networking profit tracking. Replace the dummy "₿0.024" in Web5.vue with real data. Create `core/archipelago/src/wallet/profits.rs` with: (1) track all ecash received from content sharing, (2) track Lightning routing fees (from LND), (3) RPC endpoint `wallet.networking-profits` that returns total earnings, breakdown by source, and time series. Build on server and deploy.
|
||||
|
||||
- [x] **PROFIT-102** — feat(ui): real networking profits display. Update the "Networking Profits" quick action in Web5.vue (lines 12-23) to show real data from PROFIT-101. Show total earnings, breakdown (content sales, routing fees), and a mini sparkline chart of recent earnings. Deploy and verify.
|
||||
|
||||
---
|
||||
|
||||
## Q1 2027 (Mar–May): Web5 & Decentralized Services
|
||||
|
||||
### Phase 5A: DWN (Decentralized Web Node) Integration
|
||||
|
||||
- [x] **DWN-101** — feat(backend): implement DWN container management. Add a DWN service (using TBD's `dwn-server` or equivalent) as a marketplace app. The DWN stores the user's personal data and makes it accessible via DID-based protocols. In `core/archipelago/src/api/rpc/package.rs`, add DWN app config with proper ports and volumes. In Marketplace.vue, add DWN to the curated list. Deploy and verify.
|
||||
|
||||
- [x] **DWN-102** — feat(backend): implement DWN sync protocol. Create `core/archipelago/src/network/dwn_sync.rs` that: (1) syncs the user's DWN data with their other devices, (2) allows connected peers to query your DWN for data you've shared, (3) implements DWN protocol handlers for standard message types. Replace the `_syncDWNs()` TODO in Web5.vue with real functionality. Build on server and deploy.
|
||||
|
||||
- [x] **DWN-103** — feat(ui): make the DWN section in Web5 functional. Replace the hidden (`v-if="false"`) DWN section in Web5.vue (lines 481-530) with a real interface. Show: (1) DWN status (running/stopped/syncing), (2) storage usage, (3) sync status with connected nodes, (4) data protocols registered, (5) "Manage DWN" button that opens the DWN admin interface. Wire to DWN-102 RPCs. Deploy and verify.
|
||||
|
||||
### Phase 5B: Bitcoin Domain Names
|
||||
|
||||
- [x] **DOMAIN-101** — feat(backend): implement BNS (Bitcoin Name System) integration. Research and integrate a Bitcoin naming system (e.g., BNS on Stacks, or Nostr NIP-05 verification). Create `core/archipelago/src/identity/names.rs` with: (1) name registration, (2) name resolution, (3) linking a name to a DID. RPC endpoints: `identity.register-name`, `identity.resolve-name`, `identity.list-names`. Build on server and deploy.
|
||||
|
||||
- [x] **DOMAIN-102** — feat(ui): make Bitcoin Domain Names section functional. Replace the dummy "Bitcoin Domain Names" card in Web5.vue (lines 170-219) with real data. Show: (1) owned names with status, (2) registration flow, (3) name → DID linking, (4) expiry management. Wire to DOMAIN-101 RPCs. Deploy and verify.
|
||||
|
||||
### Phase 5C: Nostr Relay Management
|
||||
|
||||
- [x] **NOSTR-101** — feat(backend): implement Nostr relay management. Create RPC endpoints: `nostr.list-relays` (returns configured relays with connection status), `nostr.add-relay` (add a relay URL), `nostr.remove-relay`, `nostr.get-stats` (events stored, connected clients). Currently relay count is hardcoded to 8 in Web5.vue — make it real. Build on server and deploy.
|
||||
|
||||
- [x] **NOSTR-102** — feat(ui): make Nostr Relays section functional. Replace the dummy "Nostr Relays" card in Web5.vue (lines 270-319) with real data. Replace hardcoded `nostrRelaysConnected = ref(8)` with live data from NOSTR-101. Show: (1) connected relay count, (2) relay list with status indicators, (3) add/remove relay controls, (4) events stored count. Wire `manageRelays()` function to open a relay management modal. Deploy and verify.
|
||||
|
||||
- [x] **NOSTR-103** — feat(apps): run your own Nostr relay. Add `nostr-rs-relay` or `strfry` to the marketplace (already listed in PORTS.md). When installed, the user's node runs its own Nostr relay that: (1) stores their events locally, (2) can be made public for others, (3) gets a Tor hidden service automatically, (4) feeds into the node's relay list in the Nostr management UI. Deploy and verify.
|
||||
|
||||
### Phase 5D: Self-Sovereign Identity Service — Real Implementation
|
||||
|
||||
- [x] **SSI-101** — feat(backend): implement credential issuance and verification. Extend the identity manager with Verifiable Credential (VC) support: `identity.issue-credential` (issue a VC from one of your DIDs), `identity.verify-credential` (verify a VC against a DID), `identity.list-credentials`. Use W3C VC Data Model. Build on server and deploy.
|
||||
|
||||
- [x] **SSI-102** — feat(ui): make SSI section functional. Replace the hidden (`v-if="false"`) SSI section in Web5.vue (lines 532-581) with real data. Show: (1) managed identities count, (2) issued credentials list, (3) service status, (4) credential issuance flow. Deploy and verify.
|
||||
|
||||
---
|
||||
|
||||
## Q2 2027 (Jun–Aug): Router & Network Infrastructure
|
||||
|
||||
### Phase 6A: Router Integration
|
||||
|
||||
- [x] **ROUTER-101** — feat(backend): implement UPnP port forwarding. Create `core/archipelago/src/network/router.rs` with: (1) UPnP device discovery, (2) automatic port forwarding for exposed services, (3) RPC endpoints: `router.discover`, `router.list-forwards`, `router.add-forward`, `router.remove-forward`. When a user enables "expose service X", automatically create UPnP port forwards. Build on server and deploy.
|
||||
|
||||
- [x] **ROUTER-102** — feat(backend): implement network diagnostics. Add `network.diagnostics` RPC that returns: (1) WAN IP address, (2) NAT type detection, (3) UPnP availability, (4) open ports test, (5) Tor connectivity status, (6) DNS resolution test, (7) recommended actions for improving connectivity. Build on server and deploy.
|
||||
|
||||
- [x] **ROUTER-103** — feat(ui): network settings dashboard. Create a "Network" view (or section in Settings) showing: (1) network status overview (WAN IP, NAT type, Tor status), (2) port forwarding management, (3) UPnP status and controls, (4) "Fix Network" wizard that guides users through common issues (double NAT, blocked ports), (5) Tailscale integration status. Wire to ROUTER-101/102 RPCs. Deploy and verify.
|
||||
|
||||
- [x] **ROUTER-104** — feat(backend): open-source router compatibility layer. Research OpenWrt, pfSense, and OPNsense APIs. Implement a router abstraction layer that can communicate with these routers directly (not just UPnP). When a compatible router is detected, offer enhanced features: direct port management, firewall rules, DNS configuration. Build on server and deploy.
|
||||
|
||||
### Phase 6B: Wallet & Payments Polish
|
||||
|
||||
- [x] **WALLET-101** — feat(ui): replace dummy wallet data with real backend. The Web5 wallet section currently shows hardcoded "₿0.025" balance and "12 pending" transactions. Connect to LND's wallet RPC to show: (1) real on-chain balance, (2) real Lightning balance, (3) ecash balance, (4) recent transactions. Deploy and verify.
|
||||
|
||||
- [x] **WALLET-102** — feat(ui): unified send/receive flow. Create a send/receive modal accessible from the wallet card. Support: (1) on-chain Bitcoin send/receive, (2) Lightning invoice create/pay, (3) ecash send/receive, (4) automatic method selection based on amount (ecash for small, Lightning for medium, on-chain for large). Deploy and verify.
|
||||
|
||||
- [x] **WALLET-103** — feat(backend): implement wallet connect protocol. Create a standard protocol for apps to request payments from the user's wallet. When an app (in iframe) needs a payment, it sends a postMessage to the parent. The parent shows a payment confirmation dialog. On confirm, the wallet makes the payment and returns a receipt. This replaces the `connectWallet` TODO in Web5.vue. Build on server and deploy.
|
||||
|
||||
---
|
||||
|
||||
## Q3 2027 (Sep–Nov): Easy Mode & Goal System
|
||||
|
||||
### Phase 7A: Easy Mode Implementation
|
||||
|
||||
- [x] **EASY-101** — feat(ui): implement the Easy Mode home screen. Following `docs/three-mode-ui-design.md`, build `neode-ui/src/components/EasyHome.vue` with goal cards: Open a Shop, Accept Payments, Store My Photos, Store My Files, Run a Lightning Node, Create My Identity, Back Up Everything. Each card shows title, description, estimated time, difficulty, and a "Start" button. Use the existing glass-card design system. Deploy and verify.
|
||||
|
||||
- [x] **EASY-102** — feat(ui): implement the goal workflow wizard. Build `neode-ui/src/views/GoalDetail.vue` (may already exist partially) as a multi-step wizard. For each goal: (1) show all steps with status (completed/in-progress/pending), (2) auto-complete steps where the app is already installed, (3) real-time progress from WebSocket for installations, (4) "configure" steps open the app in iframe for user to complete. Wire to app installation RPCs. Deploy and verify with "Accept Payments" goal (Bitcoin + LND).
|
||||
|
||||
- [x] **EASY-103** — feat(stores): implement goal progress tracking. Create `neode-ui/src/stores/goals.ts` that: (1) tracks which goals the user has started/completed, (2) persists to backend via UIData, (3) computes step completion based on installed app status, (4) emits events for goal completion celebrations. Deploy and verify.
|
||||
|
||||
- [x] **EASY-104** — feat(ui): mode switcher in sidebar. Build `neode-ui/src/components/ModeSwitcher.vue` as a three-segment toggle (Easy / Pro / Chat). Place it in the Dashboard sidebar below the logo. When switching modes, sidebar navigation items change per the spec in `three-mode-ui-design.md`. Persist mode choice to localStorage and backend. Deploy and verify.
|
||||
|
||||
### Phase 7B: Pro Mode Enhancements
|
||||
|
||||
- [x] **PRO-101** — feat(ui): add Quick Start Goals to Pro mode home. At the bottom of the Pro mode Home view, add a "Quick Start Goals" section showing horizontal-scrolling goal cards. These link to the same GoalDetail wizard. Gives power users access to guided workflows without switching to Easy mode. Deploy and verify.
|
||||
|
||||
- [x] **PRO-102** — feat(ui): add goals to Spotlight Search. In `neode-ui/src/data/helpTree.ts`, add all goal definitions as searchable items with the "Quick Start Goals" category. When selected, navigate to the goal wizard. Deploy and verify goals appear in Cmd+K search.
|
||||
|
||||
### Phase 7C: Chat Mode — AIUI Integration
|
||||
|
||||
- [x] **CHAT-101** — feat(ui): implement Chat mode home with full AIUI integration. The existing Chat.vue loads AIUI in an iframe. In Chat mode, make this the primary interface. Add context-aware prompts: "What apps are installed?", "Set up Lightning", "How much disk space is left?". Wire to the context broker service. Deploy and verify.
|
||||
|
||||
- [x] **CHAT-102** — feat(backend): extend context broker for goal execution. When the user tells AIUI "Set up a Lightning node", the context broker should: (1) identify this as the "Run a Lightning Node" goal, (2) execute goal steps via RPC, (3) stream progress back to the chat. This bridges natural language to the goal system. Deploy and verify.
|
||||
|
||||
---
|
||||
|
||||
## Q4 2027 (Dec–Feb 2028): Testing & Reliability
|
||||
|
||||
### Phase 8A: Comprehensive App Testing
|
||||
|
||||
- [x] **TEST-201** — test(apps): automated install/uninstall test for all 24 marketplace apps. Create a test script that: (1) installs each app via RPC, (2) waits for container to start, (3) verifies health check passes, (4) verifies UI loads (curl the app port), (5) uninstalls the app, (6) verifies container is removed. Run on the dev server. Fix any failures.
|
||||
|
||||
- [x] **TEST-202** — test(apps): dependency chain test. Test all dependency chains: (1) Install Electrs → should prompt for Bitcoin first, (2) Install BTCPay → should install Bitcoin + LND + BTCPay in order, (3) Install Mempool → should install Bitcoin + Electrs + Mempool in order, (4) Install Fedimint Gateway → should require Fedimint Guardian + LND. Fix any broken chains.
|
||||
|
||||
- [x] **TEST-203** — test(apps): iframe/new-tab verification for all apps. For each running app, verify: (1) apps that should iframe actually load in iframe (test with fetch + check X-Frame-Options header), (2) apps that should open in new tab are correctly in `mustOpenInNewTab()`, (3) no mixed content errors on HTTPS. Fix any issues.
|
||||
|
||||
### Phase 8B: Network Testing
|
||||
|
||||
- [x] **TEST-204** — test(network): peer discovery and connection flow. Test: (1) enable node visibility → verify Nostr event published, (2) second node discovers first via Nostr, (3) connection request sent over Tor, (4) request accepted, peer added to list, (5) message sent between peers over Tor, (6) message received and displayed in UI. Fix any failures.
|
||||
|
||||
- [x] **TEST-205** — test(network): content sharing and ecash payments. Test: (1) share a file with ecash pricing, (2) peer browses content catalog, (3) peer pays ecash for content, (4) content downloads successfully, (5) ecash appears in seller's wallet, (6) free content downloads without payment. Fix any failures.
|
||||
|
||||
- [x] **TEST-206** — test(network): Tor hidden service reliability. Test: (1) all configured hidden services are reachable from outside the network, (2) hidden service survives container restart, (3) hidden service survives full node reboot, (4) new hidden services can be created dynamically, (5) removing a service removes the .onion address. Fix any failures.
|
||||
|
||||
### Phase 8C: Identity Testing
|
||||
|
||||
- [x] **TEST-207** — test(identity): multi-identity lifecycle. Test: (1) create identity during onboarding, (2) create additional identities, (3) sign a message with each identity, (4) verify signatures, (5) delete a non-default identity, (6) switch default identity, (7) use identity with Indeehub, (8) Nostr key generation and event signing. Fix any failures.
|
||||
|
||||
### Phase 8D: Performance & Stress Testing
|
||||
|
||||
- [x] **TEST-208** — test(perf): load test with all apps running simultaneously. Start all 24 apps on the dev server. Verify: (1) system remains responsive (UI loads < 3s), (2) no OOM kills, (3) WebSocket stays connected, (4) resource manager reports accurate usage, (5) no container crashes after 24 hours. Fix any issues.
|
||||
|
||||
- [x] **TEST-209** — test(perf): mobile performance audit. Test all views on a real mobile device (or emulator). Verify: (1) initial load < 5s on 4G, (2) route navigation < 1s, (3) smooth scrolling (60fps), (4) no janky animations, (5) app launcher overlay is usable on mobile. Fix any issues.
|
||||
|
||||
---
|
||||
|
||||
## Q1 2028 (Mar–May): Final Polish & Release Prep
|
||||
|
||||
### Phase 9A: UX Micro-Interactions
|
||||
|
||||
- [x] **UX-101** — fix(ui): add haptic-like feedback to all interactive elements. Every button press, toggle switch, and card tap should have a subtle visual feedback (scale 0.97 on press, brighten on hover). Ensure consistent feel across the entire UI. Deploy and verify.
|
||||
|
||||
- [x] **UX-102** — fix(ui): add success/error toast animations. Create a polished toast notification system with: slide-in animation, auto-dismiss after 3s, swipe-to-dismiss on mobile, stacking for multiple toasts, success (green), error (red), info (blue) variants. Replace all `console.log` feedback with toasts. Deploy and verify.
|
||||
|
||||
- [x] **UX-103** — fix(ui): add skeleton loading screens for every view. Every view that fetches data should show a skeleton screen (animated gray placeholders matching the layout) instead of a blank page or spinner. Use a reusable `<SkeletonCard>` component. Deploy and verify.
|
||||
|
||||
- [x] **UX-104** — fix(ui): add empty state illustrations. For views with no data (no apps installed, no peers connected, no content shared), show a friendly empty state with an illustration, explanation text, and a call-to-action button. Deploy and verify.
|
||||
|
||||
### Phase 9B: Security Audit
|
||||
|
||||
- [x] **SEC-201** — security: comprehensive penetration test. Run a full penetration test covering: (1) authentication bypass attempts, (2) session management, (3) API input validation, (4) path traversal, (5) SSRF, (6) container escape, (7) ecash double-spend, (8) Tor deanonymization risks, (9) XSS/injection. Document all findings and fix critical/high issues.
|
||||
|
||||
- [x] **SEC-202** — security: secrets audit. Verify: (1) no hardcoded credentials in codebase, (2) all secrets use the secrets manager, (3) ecash wallet keys are encrypted at rest, (4) identity private keys are encrypted at rest, (5) backup encryption is sound, (6) TOTP secrets are encrypted. Fix any issues.
|
||||
|
||||
- [x] **SEC-203** — security: dependency audit. Run `npm audit` on frontend, `cargo audit` on backend. Fix all critical and high vulnerabilities. Pin all dependency versions. Verify no supply-chain risks.
|
||||
|
||||
### Phase 9C: ISO & Distribution
|
||||
|
||||
- [x] **ISO-101** — fix(iso): update ISO build to include all new features. Update `image-recipe/build-auto-installer-iso.sh` to: (1) include all new container images, (2) include DWN and Nostr relay containers, (3) include Fedimint Guardian + Gateway, (4) include all identity system files, (5) include updated nginx configs with all proxy blocks, (6) include updated first-boot script. Build and test ISO.
|
||||
|
||||
- [x] **ISO-102** — fix(iso): implement ISO auto-update mechanism. Create an update system: (1) node checks for updates via a Nostr event or signed manifest, (2) downloads delta updates (not full ISO), (3) applies updates with rollback capability, (4) updates frontend, backend binary, container images independently. Deploy and verify.
|
||||
|
||||
- [x] **ISO-103** — docs: create user-facing documentation. Write: (1) Getting Started guide (flash USB, install, first boot), (2) App Store guide (installing, managing apps), (3) Identity guide (creating DIDs, using with services), (4) Networking guide (connecting peers, sharing content), (5) Troubleshooting FAQ. Host in the UI as a help section.
|
||||
|
||||
### Phase 9D: Final Verification
|
||||
|
||||
- [x] **FINAL-201** — test(final): fresh install end-to-end test. Build a fresh ISO, install on clean hardware, and walk through the entire user journey: (1) boot and install, (2) onboarding with identity creation, (3) install Bitcoin + LND + Fedimint, (4) open Lightning channels, (5) share content, (6) connect to another node, (7) send ecash payment, (8) use Easy mode goal system, (9) use AIUI chat, (10) manage Tor services, (11) create multiple identities, (12) sign into Indeehub. Everything must work flawlessly.
|
||||
|
||||
- [x] **FINAL-202** — test(final): 72-hour stability test. Run the fully configured node for 72 continuous hours. Verify: (1) no memory leaks, (2) no container crashes, (3) WebSocket stays connected, (4) Tor services remain accessible, (5) peer connections survive, (6) ecash wallet balance is accurate, (7) all app UIs still load. Fix any issues.
|
||||
|
||||
- [x] **FINAL-203** — test(final): multi-node network test. Set up 3 Archipelago nodes. Verify: (1) all three discover each other via Nostr, (2) connection requests and acceptance work, (3) content sharing works between all pairs, (4) ecash payments work between all pairs, (5) peer-to-peer messaging works, (6) node going offline/online is handled gracefully by other nodes.
|
||||
|
||||
---
|
||||
|
||||
## Release Criteria (v1.0)
|
||||
|
||||
Before releasing to the public, ALL of these must be true:
|
||||
|
||||
- [x] All 24+ marketplace apps install, run, and open without errors
|
||||
- [x] Iframe apps load in iframe, new-tab apps open in new tab — zero exceptions
|
||||
- [x] App dependency chains install correctly in order
|
||||
- [x] Fedimint Guardian + Gateway work together out of the box
|
||||
- [x] Lightning channel management is easy and intuitive
|
||||
- [x] Multi-identity system works with DID creation, signing, and service integration
|
||||
- [x] Indeehub recognizes sovereign identity without account creation
|
||||
- [x] Node overlay network: discover, connect, message over Tor
|
||||
- [x] Content sharing with ecash micropayments works trustlessly
|
||||
- [x] All Web5 sections show real data (no dummy content)
|
||||
- [x] Easy mode goals guide users through complex multi-app setups
|
||||
- [x] Chat mode leverages AIUI for natural language node management
|
||||
- [x] Tor hidden services are manageable via UI
|
||||
- [x] Router integration works with UPnP and open-source routers
|
||||
- [x] Animations are smooth (60fps) on desktop and mobile
|
||||
- [x] Mobile responsive on all screen sizes
|
||||
- [x] Fresh ISO install → full functionality in under 1 hour
|
||||
- [x] 72-hour stability test passes
|
||||
- [x] Security audit passes with no critical/high findings
|
||||
- [x] Zero TypeScript errors, zero Rust warnings, zero linter errors
|
||||
|
||||
---
|
||||
|
||||
## Post-Completion
|
||||
|
||||
```bash
|
||||
# Final verification on live server
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'echo "EwPDR8q45l0Upx@" | sudo -S systemctl status archipelago'
|
||||
|
||||
# Check all containers running
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'sudo podman ps --format "table {{.Names}}\t{{.Status}}"'
|
||||
|
||||
# Run frontend checks
|
||||
cd neode-ui && npm run type-check && npm run build
|
||||
|
||||
# Run backend checks on server
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'cd ~/archy && cargo clippy --all-targets --all-features && cargo fmt --all --check'
|
||||
|
||||
# Visit http://192.168.1.228 and verify everything works
|
||||
```
|
||||
@ -121,9 +121,39 @@ const runningContainers = new Map()
|
||||
const portMappings = {
|
||||
'atob': 8102,
|
||||
'k484': 8103,
|
||||
'amin': 8104
|
||||
'amin': 8104,
|
||||
'filebrowser': 8083,
|
||||
'bitcoin-knots': 8332,
|
||||
'electrs': 50001,
|
||||
'btcpay-server': 23000,
|
||||
'lnd': 8080,
|
||||
'mempool': 4080,
|
||||
'homeassistant': 8123,
|
||||
'grafana': 3000,
|
||||
'searxng': 8888,
|
||||
'ollama': 11434,
|
||||
'nextcloud': 8082,
|
||||
'vaultwarden': 8222,
|
||||
'jellyfin': 8096,
|
||||
'photoprism': 2342,
|
||||
'immich': 2283,
|
||||
'portainer': 9443,
|
||||
'uptime-kuma': 3001,
|
||||
'tailscale': 41641,
|
||||
'fedimint': 8174,
|
||||
'nostr-rs-relay': 7000,
|
||||
'syncthing': 8384,
|
||||
'penpot': 9001,
|
||||
'onlyoffice': 8044,
|
||||
'nginx-proxy-manager': 8181,
|
||||
'indeedhub': 8190,
|
||||
'dwn': 3000,
|
||||
'tor': 9050,
|
||||
}
|
||||
|
||||
// Auto-assign port for unknown apps (start at 8200, increment)
|
||||
let nextAutoPort = 8200
|
||||
|
||||
// Helper: Query real Docker containers
|
||||
async function getDockerContainers() {
|
||||
try {
|
||||
@ -311,50 +341,66 @@ async function isContainerRuntimeAvailable() {
|
||||
}
|
||||
}
|
||||
|
||||
// Marketplace metadata lookup for install (title, description, icon, version)
|
||||
const marketplaceMetadata = {
|
||||
'bitcoin-knots': { title: 'Bitcoin Knots', shortDesc: 'Full Bitcoin node — validate and relay blocks and transactions', icon: '/assets/img/app-icons/bitcoin-knots.webp' },
|
||||
'electrs': { title: 'Electrs', shortDesc: 'Electrum protocol indexer for Bitcoin', icon: '/assets/img/app-icons/electrs.svg' },
|
||||
'btcpay-server': { title: 'BTCPay Server', shortDesc: 'Self-hosted Bitcoin payment processor', icon: '/assets/img/app-icons/btcpay-server.png' },
|
||||
'lnd': { title: 'LND', shortDesc: 'Lightning Network Daemon', icon: '/assets/img/app-icons/lnd.svg' },
|
||||
'mempool': { title: 'Mempool Explorer', shortDesc: 'Bitcoin blockchain and mempool visualizer', icon: '/assets/img/app-icons/mempool.webp' },
|
||||
'homeassistant': { title: 'Home Assistant', shortDesc: 'Open-source home automation platform', icon: '/assets/img/app-icons/homeassistant.png' },
|
||||
'grafana': { title: 'Grafana', shortDesc: 'Analytics and monitoring dashboards', icon: '/assets/img/app-icons/grafana.png' },
|
||||
'searxng': { title: 'SearXNG', shortDesc: 'Privacy-respecting metasearch engine', icon: '/assets/img/app-icons/searxng.png' },
|
||||
'ollama': { title: 'Ollama', shortDesc: 'Run large language models locally', icon: '/assets/img/app-icons/ollama.png' },
|
||||
'onlyoffice': { title: 'OnlyOffice', shortDesc: 'Office suite for document collaboration', icon: '/assets/img/app-icons/onlyoffice.webp' },
|
||||
'penpot': { title: 'Penpot', shortDesc: 'Open-source design and prototyping platform', icon: '/assets/img/app-icons/penpot.webp' },
|
||||
'nextcloud': { title: 'Nextcloud', shortDesc: 'Self-hosted cloud storage and collaboration', icon: '/assets/img/app-icons/nextcloud.webp' },
|
||||
'vaultwarden': { title: 'Vaultwarden', shortDesc: 'Self-hosted password manager (Bitwarden-compatible)', icon: '/assets/img/app-icons/vaultwarden.webp' },
|
||||
'jellyfin': { title: 'Jellyfin', shortDesc: 'Free media server for movies, music, and photos', icon: '/assets/img/app-icons/jellyfin.webp' },
|
||||
'photoprism': { title: 'PhotoPrism', shortDesc: 'AI-powered photo management', icon: '/assets/img/app-icons/photoprims.svg' },
|
||||
'immich': { title: 'Immich', shortDesc: 'High-performance photo and video backup', icon: '/assets/img/app-icons/immich.png' },
|
||||
'filebrowser': { title: 'File Browser', shortDesc: 'Web-based file manager', icon: '/assets/img/app-icons/file-browser.webp' },
|
||||
'nginx-proxy-manager': { title: 'Nginx Proxy Manager', shortDesc: 'Easy proxy management with SSL', icon: '/assets/img/app-icons/nginx.svg' },
|
||||
'portainer': { title: 'Portainer', shortDesc: 'Container management UI', icon: '/assets/img/app-icons/portainer.webp' },
|
||||
'uptime-kuma': { title: 'Uptime Kuma', shortDesc: 'Self-hosted monitoring tool', icon: '/assets/img/app-icons/uptime-kuma.webp' },
|
||||
'tailscale': { title: 'Tailscale', shortDesc: 'Zero-config VPN for secure remote access', icon: '/assets/img/app-icons/tailscale.webp' },
|
||||
'fedimint': { title: 'Fedimint', shortDesc: 'Federated Bitcoin mint with Guardian UI', icon: '/assets/img/app-icons/fedimint.png' },
|
||||
'indeedhub': { title: 'Indeehub', shortDesc: 'Bitcoin documentary streaming platform', icon: '/assets/img/app-icons/indeedhub.png' },
|
||||
'dwn': { title: 'Decentralized Web Node', shortDesc: 'Store and sync personal data with DID-based access', icon: '/assets/img/app-icons/dwn.svg' },
|
||||
'nostr-rs-relay': { title: 'Nostr Relay', shortDesc: 'Run your own Nostr relay', icon: '/assets/img/app-icons/nostr-rs-relay.svg' },
|
||||
'syncthing': { title: 'Syncthing', shortDesc: 'Peer-to-peer file synchronization', icon: '/assets/img/app-icons/syncthing.png' },
|
||||
'tor': { title: 'Tor', shortDesc: 'Anonymous communication over the Tor network', icon: '/assets/img/app-icons/tor.png' },
|
||||
'atob': { title: 'A to B Bitcoin', shortDesc: 'Bitcoin tools for seamless transactions', icon: '/assets/img/atob.png' },
|
||||
'k484': { title: 'K484', shortDesc: 'Point of Sale and Admin system', icon: '/assets/img/k484.png' },
|
||||
'amin': { title: 'Amin', shortDesc: 'Administrative interface for Archipelago', icon: '/assets/icon/pwa-192x192-v2.png' },
|
||||
}
|
||||
|
||||
// Helper: Install package with container runtime (if available) or simulate
|
||||
async function installPackage(id, manifestUrl) {
|
||||
async function installPackage(id, manifestUrl, opts = {}) {
|
||||
console.log(`[Package] 📦 Installing ${id}...`)
|
||||
|
||||
|
||||
try {
|
||||
// Check if already installed
|
||||
if (mockData['package-data'][id]) {
|
||||
throw new Error(`Package ${id} is already installed`)
|
||||
}
|
||||
|
||||
const version = '0.1.0'
|
||||
|
||||
const version = opts.version || '0.1.0'
|
||||
const runtime = await isContainerRuntimeAvailable()
|
||||
|
||||
// Get package metadata
|
||||
const packageMetadata = {
|
||||
'atob': {
|
||||
title: 'A to B Bitcoin',
|
||||
shortDesc: 'Bitcoin tools and services for seamless transactions',
|
||||
longDesc: 'A to B Bitcoin provides tools and services for Bitcoin transactions.',
|
||||
icon: '/assets/img/atob.png'
|
||||
},
|
||||
'k484': {
|
||||
title: 'K484',
|
||||
shortDesc: 'Point of Sale and Admin system',
|
||||
longDesc: 'K484 provides a complete POS and administration system.',
|
||||
icon: '/assets/img/k484.png'
|
||||
},
|
||||
'amin': {
|
||||
title: 'Amin',
|
||||
shortDesc: 'Administrative interface for Archipelago',
|
||||
longDesc: 'Amin provides administrative tools and monitoring.',
|
||||
icon: '/assets/icon/pwa-192x192-v2.png'
|
||||
}
|
||||
|
||||
// Get package metadata from marketplace lookup, then fallback
|
||||
const metadata = marketplaceMetadata[id] || {
|
||||
title: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
shortDesc: `${id} application`,
|
||||
icon: `/assets/img/app-icons/${id}.png`
|
||||
}
|
||||
|
||||
const metadata = packageMetadata[id] || {
|
||||
title: id.charAt(0).toUpperCase() + id.slice(1),
|
||||
shortDesc: `${id} application`,
|
||||
longDesc: `${id} application for Archipelago`,
|
||||
icon: '/assets/icon/pwa-192x192-v2.png'
|
||||
// Determine port — use known mapping, or auto-assign a unique one
|
||||
let assignedPort = portMappings[id]
|
||||
if (!assignedPort) {
|
||||
while (usedPorts.has(nextAutoPort)) nextAutoPort++
|
||||
assignedPort = nextAutoPort++
|
||||
}
|
||||
|
||||
// Determine port
|
||||
const assignedPort = portMappings[id] || 8105
|
||||
usedPorts.add(assignedPort)
|
||||
|
||||
let containerMode = false
|
||||
@ -428,32 +474,21 @@ async function installPackage(id, manifestUrl) {
|
||||
runningContainers.set(id, { port: assignedPort, containerId: null, runtime: null })
|
||||
}
|
||||
|
||||
// Add to mock data
|
||||
// Add to mock data using staticApp format for consistency
|
||||
mockData['package-data'][id] = {
|
||||
title: metadata.title,
|
||||
version: version,
|
||||
status: 'running',
|
||||
state: 'running',
|
||||
...staticApp({
|
||||
id,
|
||||
title: metadata.title,
|
||||
version,
|
||||
shortDesc: metadata.shortDesc,
|
||||
longDesc: metadata.shortDesc,
|
||||
state: 'running',
|
||||
lanPort: assignedPort,
|
||||
icon: metadata.icon,
|
||||
}),
|
||||
port: assignedPort,
|
||||
containerMode: containerMode,
|
||||
actuallyRunning: actuallyRunning,
|
||||
manifest: {
|
||||
id: id,
|
||||
title: metadata.title,
|
||||
version: version,
|
||||
description: {
|
||||
short: metadata.shortDesc,
|
||||
long: metadata.longDesc
|
||||
},
|
||||
icon: metadata.icon,
|
||||
interfaces: {
|
||||
main: {
|
||||
name: 'Web Interface',
|
||||
description: `${metadata.title} web interface`,
|
||||
ui: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
@ -661,6 +696,16 @@ const staticDevApps = {
|
||||
state: 'running',
|
||||
lanPort: null,
|
||||
}),
|
||||
filebrowser: staticApp({
|
||||
id: 'filebrowser',
|
||||
title: 'File Browser',
|
||||
version: '2.27.0',
|
||||
shortDesc: 'Web-based file manager',
|
||||
longDesc: 'Browse, upload, and manage files through an elegant web interface. Drag-and-drop uploads, media previews, and sharing.',
|
||||
state: 'running',
|
||||
lanPort: 8083,
|
||||
icon: '/assets/img/app-icons/file-browser.webp',
|
||||
}),
|
||||
}
|
||||
|
||||
function mergePackageData(dockerApps) {
|
||||
@ -983,12 +1028,12 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'package.install': {
|
||||
const { id, url } = params
|
||||
|
||||
installPackage(id, url).catch(err => {
|
||||
const { id, url, dockerImage, version } = params
|
||||
|
||||
installPackage(id, url, { dockerImage, version }).catch(err => {
|
||||
console.error(`[RPC] Installation failed:`, err.message)
|
||||
})
|
||||
|
||||
|
||||
return res.json({ result: `job-${Date.now()}` })
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||
<SpotlightSearch />
|
||||
|
||||
<!-- CLI popup (Cmd+C / Ctrl+C) -->
|
||||
<!-- CLI popup (F key) -->
|
||||
<CLIPopup />
|
||||
|
||||
<!-- App launcher overlay (iframe popup) -->
|
||||
@ -125,8 +125,8 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
spotlightStore.toggle()
|
||||
return
|
||||
}
|
||||
// Cmd+C / Ctrl+C - CLI popup (skip when in input so copy still works)
|
||||
if (mod && (e.key === 'c' || e.key === 'C') && !isInput) {
|
||||
// F key - CLI popup (skip when in input or modifier held)
|
||||
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
|
||||
e.preventDefault()
|
||||
cliStore.toggle()
|
||||
return
|
||||
|
||||
@ -34,9 +34,10 @@ export class WebSocketClient {
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.log('[WebSocket] Page became visible, checking connection...')
|
||||
// Reconnect if connection was lost while tab was hidden
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
||||
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
})
|
||||
@ -47,8 +48,11 @@ export class WebSocketClient {
|
||||
|
||||
// Handle network online/offline events
|
||||
this.onlineHandler = () => {
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (!this.shouldReconnect) return
|
||||
console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
})
|
||||
@ -108,10 +112,11 @@ export class WebSocketClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset shouldReconnect flag when explicitly connecting
|
||||
this.shouldReconnect = true
|
||||
// Reset reconnect attempts only if we're explicitly connecting (not auto-reconnecting)
|
||||
// This allows reconnection attempts to continue
|
||||
// Only enable reconnect if not explicitly disconnected
|
||||
// (shouldReconnect is set to false by disconnect())
|
||||
if (this.shouldReconnect !== false) {
|
||||
this.shouldReconnect = true
|
||||
}
|
||||
|
||||
// In development, Vite proxies /ws to the backend
|
||||
// In production, use the same host as the page
|
||||
@ -262,10 +267,10 @@ export class WebSocketClient {
|
||||
|
||||
// Check if we've received a message recently
|
||||
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
||||
|
||||
// If no message for more than 60 seconds, assume connection is stale
|
||||
if (timeSinceLastMessage > 60000) {
|
||||
console.warn('[WebSocket] No messages for 60s, reconnecting...')
|
||||
|
||||
// If no message for more than 5 minutes, assume connection is stale
|
||||
if (timeSinceLastMessage > 300000) {
|
||||
console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
this.ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
|
||||
</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">⌘C</kbd> / <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">Ctrl+C</kbd> to open this anytime.
|
||||
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">F</kbd> to open this anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
type="button"
|
||||
data-controller-ignore
|
||||
class="w-full flex items-center gap-2 text-white/80 hover:text-white transition-colors"
|
||||
title="Open CLI (⌘C / Ctrl+C)"
|
||||
title="Open CLI (F)"
|
||||
@click="openCLI"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
|
||||
@ -50,6 +50,15 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="cloud-file-item-actions" @click.stop>
|
||||
<button
|
||||
class="cloud-file-action-btn cloud-file-action-share"
|
||||
title="Share with peers"
|
||||
@click.stop="$emit('share', item.path, item.name, item.isDir)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
v-if="!item.isDir"
|
||||
:href="downloadHref"
|
||||
@ -92,6 +101,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
|
||||
@ -80,6 +80,15 @@
|
||||
|
||||
<!-- Actions overlay at top-left (visible on hover) -->
|
||||
<div class="cloud-grid-card-actions" @click.stop>
|
||||
<button
|
||||
class="cloud-file-action-btn cloud-file-action-share"
|
||||
title="Share with peers"
|
||||
@click.stop="emit('share', item.path, item.name, item.isDir)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
v-if="!item.isDir"
|
||||
:href="downloadHref"
|
||||
@ -96,7 +105,7 @@
|
||||
v-if="!item.isDir"
|
||||
class="cloud-file-action-btn cloud-file-action-delete"
|
||||
title="Delete"
|
||||
@click.stop="$emit('delete', item.path)"
|
||||
@click.stop="emit('delete', item.path)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@ -122,6 +131,7 @@ const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
|
||||
@ -40,6 +40,7 @@
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@play="(path, name) => $emit('play', path, name)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -51,6 +52,7 @@
|
||||
:item="item"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,5 +75,6 @@ defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
257
neode-ui/src/components/cloud/ShareModal.vue
Normal file
257
neode-ui/src/components/cloud/ShareModal.vue
Normal file
@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="share-modal-backdrop" @click.self="$emit('close')">
|
||||
<div class="share-modal glass-card">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
|
||||
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="share-modal-close" @click="$emit('close')">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Share Toggle -->
|
||||
<div class="share-modal-row">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
|
||||
</div>
|
||||
<label class="share-toggle">
|
||||
<input type="checkbox" v-model="shared" />
|
||||
<span class="share-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Access Type (only when shared) -->
|
||||
<div v-if="shared" class="mt-4 space-y-3">
|
||||
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
|
||||
<div class="share-access-options">
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'free' }"
|
||||
@click="accessType = 'free'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Free</span>
|
||||
<span class="text-xs text-white/40">Open access</span>
|
||||
</button>
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
|
||||
@click="accessType = 'peers_only'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Peers Only</span>
|
||||
<span class="text-xs text-white/40">Authenticated</span>
|
||||
</button>
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'paid' }"
|
||||
@click="accessType = 'paid'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Paid</span>
|
||||
<span class="text-xs text-white/40">Earn sats</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Price Input (only for paid) -->
|
||||
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
|
||||
<div class="share-price-icon">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="priceSats"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000"
|
||||
placeholder="Price in sats"
|
||||
class="share-price-input"
|
||||
/>
|
||||
<span class="share-price-unit">sats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div v-if="saving" class="share-modal-status mt-4">
|
||||
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/60">Saving...</span>
|
||||
</div>
|
||||
<div v-if="errorMsg" class="share-modal-error mt-4">
|
||||
<span class="text-sm text-red-400">{{ errorMsg }}</span>
|
||||
</div>
|
||||
<div v-if="successMsg" class="share-modal-success mt-4">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-sm text-green-400">{{ successMsg }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
||||
<button
|
||||
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
||||
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
|
||||
@click="save"
|
||||
>
|
||||
{{ shared ? 'Share' : 'Stop Sharing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
filepath: string
|
||||
isDir: boolean
|
||||
existingItemId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const shared = ref(false)
|
||||
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
|
||||
const priceSats = ref<number>(100)
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
const successMsg = ref<string | null>(null)
|
||||
|
||||
// If we have an existing item, load its state
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
|
||||
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||
}> }>({ method: 'content.list-mine' })
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)
|
||||
if (match) {
|
||||
shared.value = true
|
||||
const access = match.access
|
||||
if (typeof access === 'string') {
|
||||
if (access === 'free') accessType.value = 'free'
|
||||
else if (access === 'peersonly') accessType.value = 'peers_only'
|
||||
} else if (access && typeof access === 'object') {
|
||||
if ('paid' in access && access.paid) {
|
||||
accessType.value = 'paid'
|
||||
priceSats.value = access.paid.price_sats || 100
|
||||
} else if ('peersonly' in access) {
|
||||
accessType.value = 'peers_only'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not shared yet, defaults are fine
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
errorMsg.value = null
|
||||
successMsg.value = null
|
||||
|
||||
try {
|
||||
if (!shared.value) {
|
||||
// Find and remove from catalog
|
||||
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)
|
||||
if (match) {
|
||||
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
||||
}
|
||||
successMsg.value = 'Sharing disabled'
|
||||
} else {
|
||||
// Check if already in catalog
|
||||
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
let itemId = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)?.id
|
||||
|
||||
// Add if not in catalog
|
||||
if (!itemId) {
|
||||
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
||||
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
||||
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
|
||||
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
|
||||
}
|
||||
const addRes = await rpcClient.call<{ item: { id: string } }>({
|
||||
method: 'content.add',
|
||||
params: {
|
||||
filename: props.filepath || props.filename,
|
||||
mime_type: mimeMap[ext] || 'application/octet-stream',
|
||||
description: '',
|
||||
},
|
||||
})
|
||||
itemId = addRes.item.id
|
||||
}
|
||||
|
||||
// Set pricing
|
||||
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
||||
if (accessType.value === 'paid') {
|
||||
pricingParams.price_sats = priceSats.value
|
||||
}
|
||||
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
||||
|
||||
// Set availability to all peers
|
||||
await rpcClient.call({
|
||||
method: 'content.set-availability',
|
||||
params: { id: itemId, availability: 'all_peers' },
|
||||
})
|
||||
|
||||
const label =
|
||||
accessType.value === 'paid'
|
||||
? `Shared for ${priceSats.value} sats`
|
||||
: accessType.value === 'peers_only'
|
||||
? 'Shared with peers'
|
||||
: 'Shared (free)'
|
||||
successMsg.value = label
|
||||
}
|
||||
|
||||
setTimeout(() => emit('saved'), 800)
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -43,6 +43,11 @@ export function useMessageToast() {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
} catch (e) {
|
||||
// Stop polling on auth failure — session expired, no point retrying
|
||||
if (e instanceof Error && /401|Unauthorized/i.test(e.message)) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
|
||||
@ -116,7 +116,6 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
// Handle real backend format: {rev: 0, data: {...}}
|
||||
else if (update?.data && update?.rev !== undefined) {
|
||||
console.log('[Store] Received dump from real backend at revision', update.rev)
|
||||
data.value = update.data
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
|
||||
@ -1311,6 +1311,225 @@ html:has(body.video-background-active)::before {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Share Modal ──── */
|
||||
|
||||
.share-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
animation: share-modal-fade-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes share-modal-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.share-modal {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
animation: share-modal-slide-in 0.25s ease-out;
|
||||
}
|
||||
@keyframes share-modal-slide-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.share-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.share-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.share-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 2.75rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.share-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.share-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
left: 0.1875rem;
|
||||
bottom: 0.1875rem;
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.share-toggle input:checked + .share-toggle-slider {
|
||||
background: #fb923c;
|
||||
}
|
||||
.share-toggle input:checked + .share-toggle-slider::before {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
/* Access type options */
|
||||
.share-access-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.share-access-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.share-access-option:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.share-access-option-active {
|
||||
background: rgba(251, 146, 60, 0.1);
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
color: #fb923c;
|
||||
}
|
||||
.share-access-option-active:hover {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
border-color: rgba(251, 146, 60, 0.5);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Price input */
|
||||
.share-price-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.share-price-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.share-price-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.625rem 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.share-price-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.share-price-unit {
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status messages */
|
||||
.share-modal-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.share-modal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
.share-modal-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
.share-modal-save {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
border-color: rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
.share-modal-save:hover:not(:disabled) {
|
||||
background: rgba(251, 146, 60, 0.25);
|
||||
}
|
||||
.share-modal-save:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Share action button highlight */
|
||||
.cloud-file-action-share:hover {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Smooth loading → content transition */
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
|
||||
@ -68,7 +68,7 @@ export type PackageState = typeof PackageState[keyof typeof PackageState]
|
||||
|
||||
export interface PackageDataEntry {
|
||||
state: PackageState
|
||||
'static-files': {
|
||||
'static-files'?: {
|
||||
license: string
|
||||
instructions: string
|
||||
icon: string
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
@ -120,7 +120,7 @@
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
|
||||
@ -62,8 +62,8 @@
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:alt="pkg.manifest.title"
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
|
||||
:alt="pkg.manifest?.title || String(id)"
|
||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@ -72,7 +72,7 @@
|
||||
{{ pkg.manifest.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-2 truncate">
|
||||
{{ pkg.manifest.description.short }}
|
||||
{{ pkg.manifest?.description?.short || '' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
|
||||
@ -112,6 +112,7 @@
|
||||
@navigate="cloudStore.navigate($event)"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
/>
|
||||
|
||||
<!-- Mini Audio Player -->
|
||||
@ -152,6 +153,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<ShareModal
|
||||
v-if="shareTarget"
|
||||
:filename="shareTarget.name"
|
||||
:filepath="shareTarget.path"
|
||||
:is-dir="shareTarget.isDir"
|
||||
@close="shareTarget = null"
|
||||
@saved="shareTarget = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -162,6 +172,7 @@ import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
|
||||
const router = useRouter()
|
||||
@ -284,6 +295,12 @@ watch(useNativeUI, async (native) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||
|
||||
function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
const uploadError = ref<string | null>(null)
|
||||
const draggingOver = ref(false)
|
||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@ -483,20 +483,17 @@ const isResettingOnboarding = ref(false)
|
||||
async function restartOnboarding() {
|
||||
if (isResettingOnboarding.value) return
|
||||
isResettingOnboarding.value = true
|
||||
try {
|
||||
await rpcClient.resetOnboarding()
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
localStorage.removeItem('neode_did')
|
||||
localStorage.removeItem('neode_did_state')
|
||||
localStorage.removeItem('neode_backup_created')
|
||||
await router.push('/onboarding/intro')
|
||||
// Local-only reset — no RPC needed since user isn't logged in.
|
||||
// Onboarding pages are all public, so clearing localStorage is enough.
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
localStorage.removeItem('neode_did')
|
||||
localStorage.removeItem('neode_did_state')
|
||||
localStorage.removeItem('neode_backup_created')
|
||||
router.push('/onboarding/intro').then(() => {
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
console.error('Failed to reset onboarding:', err)
|
||||
error.value = err instanceof Error ? err.message : 'Failed to reset onboarding'
|
||||
} finally {
|
||||
isResettingOnboarding.value = false
|
||||
}
|
||||
}).catch(() => {
|
||||
window.location.href = '/onboarding/intro'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="glass-card p-12 pt-20 text-center relative overflow-visible onb-card">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10 onb-logo">
|
||||
<div class="absolute -top-10 left-0 right-0 flex justify-center z-10 onb-logo">
|
||||
<div class="logo-gradient-border w-20 h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
|
||||
@ -162,10 +162,36 @@ fi
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE '^lnd$'; then
|
||||
log "Creating LND..."
|
||||
mkdir -p /var/lib/archipelago/lnd
|
||||
# Create lnd.conf so LND auto-connects to Bitcoin Knots via archy-net
|
||||
if [ ! -f /var/lib/archipelago/lnd/lnd.conf ]; then
|
||||
cat > /var/lib/archipelago/lnd/lnd.conf <<'LNDCONF'
|
||||
[Application Options]
|
||||
listen=0.0.0.0:9735
|
||||
rpclisten=0.0.0.0:10009
|
||||
restlisten=0.0.0.0:8080
|
||||
debuglevel=info
|
||||
noseedbackup=true
|
||||
tor.active=false
|
||||
|
||||
[Bitcoin]
|
||||
bitcoin.mainnet=true
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
[Bitcoind]
|
||||
bitcoind.rpchost=bitcoin-knots:8332
|
||||
bitcoind.rpcuser=archipelago
|
||||
bitcoind.rpcpass=archipelago123
|
||||
bitcoind.rpcpolling=true
|
||||
bitcoind.estimatemode=ECONOMICAL
|
||||
|
||||
[autopilot]
|
||||
autopilot.active=false
|
||||
LNDCONF
|
||||
log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)"
|
||||
fi
|
||||
$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \
|
||||
-p 9735:9735 -p 10009:10009 -p 8080:8080 \
|
||||
-v /var/lib/archipelago/lnd:/root/.lnd \
|
||||
-e BITCOIN_ACTIVE=1 \
|
||||
docker.io/lightninglabs/lnd:v0.18.4-beta 2>>"$LOG" || true
|
||||
fi
|
||||
|
||||
@ -475,4 +501,38 @@ for ui in bitcoin-ui lnd-ui; do
|
||||
fi
|
||||
done
|
||||
|
||||
# 10. Initialize backend data directories
|
||||
# tor-config: backend stores tor service configs here (writable by archipelago user)
|
||||
mkdir -p /var/lib/archipelago/tor-config
|
||||
SERVICES_JSON=/var/lib/archipelago/tor-config/services.json
|
||||
if [ ! -f "$SERVICES_JSON" ]; then
|
||||
cat > "$SERVICES_JSON" <<'SJSON'
|
||||
{"services":[
|
||||
{"name":"archipelago","local_port":80,"enabled":true},
|
||||
{"name":"lnd","local_port":8081,"enabled":true},
|
||||
{"name":"btcpay","local_port":23000,"enabled":true},
|
||||
{"name":"mempool","local_port":4080,"enabled":true},
|
||||
{"name":"fedimint","local_port":8175,"enabled":true}
|
||||
]}
|
||||
SJSON
|
||||
log "Created initial tor-config/services.json"
|
||||
fi
|
||||
|
||||
# identities: backend identity manager stores DIDs here
|
||||
mkdir -p /var/lib/archipelago/identities
|
||||
|
||||
# Ensure archipelago user can write to these directories
|
||||
chown -R 1000:1000 /var/lib/archipelago/tor-config /var/lib/archipelago/identities 2>/dev/null || true
|
||||
|
||||
# 11. Post-boot validation
|
||||
log "Validating container creation..."
|
||||
TOTAL=0; RUNNING=0
|
||||
for c in bitcoin-knots lnd btcpay-server fedimint homeassistant grafana uptime-kuma; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "$c"; then
|
||||
RUNNING=$((RUNNING + 1))
|
||||
fi
|
||||
done
|
||||
log "Post-boot validation: $RUNNING/$TOTAL core containers running"
|
||||
|
||||
log "First-boot container creation complete"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user