feat: VPN peer QR code UI, consolidate CI workflows
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 23m10s
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 23m10s
- Add vpn.create-peer, vpn.list-peers, vpn.remove-peer RPC methods - Generate WireGuard config + QR code (SVG) for mobile device connection - Add "Add Device" modal on Network page with QR scanner support - Remove old build-iso.yml (replaced by build-iso-dev.yml) - Remove container-tests.yml (tests run in dev workflow) - Remove container orchestration tests from dev workflow (redundant) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,16 +60,6 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd neode-ui && npx vitest run
|
||||
|
||||
- name: Run container orchestration unit tests
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cd "$HOME/archy"
|
||||
echo "=== Container crate tests ==="
|
||||
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
|
||||
echo ""
|
||||
echo "=== Orchestration integration tests ==="
|
||||
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
|
||||
|
||||
- name: Include AIUI if available
|
||||
run: |
|
||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
name: Build Archipelago ISO
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
# Direct clone using stored credentials (actions/checkout token is broken)
|
||||
REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
echo "=== Syncing to workspace ==="
|
||||
rsync -a --delete --exclude='.git' --exclude='target/' --exclude='node_modules/' \
|
||||
"$REPO_DIR/" "$GITHUB_WORKSPACE/" || cp -a "$REPO_DIR"/* "$GITHUB_WORKSPACE/"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
||||
echo "=== Key files ==="
|
||||
echo " first-boot: $([ -f scripts/first-boot-containers.sh ] && echo PRESENT || echo MISSING)"
|
||||
echo " Cargo.toml: $(grep '^version' core/archipelago/Cargo.toml)"
|
||||
echo " package.json: $(grep '\"version\"' neode-ui/package.json | head -1)"
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo build --release --manifest-path core/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
rm -rf web/dist/neode-ui
|
||||
cd neode-ui && npm ci && npm run build
|
||||
|
||||
- name: Type check frontend
|
||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||
|
||||
- name: Run frontend tests
|
||||
run: cd neode-ui && npx vitest run
|
||||
|
||||
- name: Cache Debian Live ISO
|
||||
run: |
|
||||
WORK_DIR="image-recipe/build/auto-installer"
|
||||
mkdir -p "$WORK_DIR"
|
||||
CACHED="$HOME/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
|
||||
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
|
||||
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
|
||||
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
|
||||
fi
|
||||
|
||||
- name: Configure root podman for insecure registry
|
||||
run: |
|
||||
sudo mkdir -p /etc/containers/registries.conf.d
|
||||
echo '[[registry]]
|
||||
location = "80.71.235.15:3000"
|
||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||
|
||||
- name: Include AIUI if available
|
||||
run: |
|
||||
# Copy AIUI from the deployed system (build server has it at /opt/archipelago/web-ui/aiui/)
|
||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
||||
else
|
||||
echo "WARNING: AIUI not found on build server"
|
||||
fi
|
||||
|
||||
- name: Deploy to dev environment
|
||||
run: |
|
||||
echo "=== Deploying backend + frontend to dev ==="
|
||||
# Deploy backend binary
|
||||
sudo cp core/target/release/archipelago /usr/local/bin/archipelago
|
||||
sudo chmod +x /usr/local/bin/archipelago
|
||||
echo "Backend: $(/usr/local/bin/archipelago --version 2>&1 | head -1 || echo 'deployed')"
|
||||
|
||||
# Deploy frontend
|
||||
rm -rf /opt/archipelago/web-ui/*
|
||||
cp -r web/dist/neode-ui/* /opt/archipelago/web-ui/
|
||||
echo "Frontend: $(ls /opt/archipelago/web-ui/index.html && echo 'OK')"
|
||||
|
||||
# Restart backend
|
||||
sudo systemctl restart archipelago 2>/dev/null || true
|
||||
sleep 2
|
||||
curl -s http://127.0.0.1:5678/health | head -1 || echo "Backend starting..."
|
||||
echo "=== Dev deploy complete ==="
|
||||
|
||||
- name: Build unbundled ISO
|
||||
run: |
|
||||
cd image-recipe
|
||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||
./build-auto-installer-iso.sh
|
||||
|
||||
- name: Copy to Builds
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -n "$ISO" ]; then
|
||||
DATE=$(date +%Y%m%d-%H%M)
|
||||
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
sudo cp "$ISO" "$DEST"
|
||||
sudo chown $(id -u):$(id -g) "$DEST"
|
||||
echo "ISO: archipelago-unbundled-${DATE}.iso"
|
||||
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||
fi
|
||||
|
||||
- name: Build report
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +eo pipefail
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "BUILD REPORT"
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
||||
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
|
||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Runner: $(hostname)"
|
||||
echo ""
|
||||
echo "── Artifacts ──"
|
||||
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
||||
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
|
||||
echo ""
|
||||
echo "── Rootfs contents check ──"
|
||||
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||
if [ -n "$ROOTFS" ]; then
|
||||
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " AIUI: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/aiui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " claude-api-proxy: $(sudo tar tf "$ROOTFS" ./opt/archipelago/claude-api-proxy.py 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
else
|
||||
echo " rootfs.tar not found in workspace"
|
||||
fi
|
||||
echo ""
|
||||
echo "── ISO contents check ──"
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
||||
if [ -n "$ISO" ]; then
|
||||
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
ISO_MOUNT=$(mktemp -d)
|
||||
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
||||
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
||||
else
|
||||
echo " Could not mount ISO for inspection"
|
||||
fi
|
||||
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
||||
fi
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
- name: Fix workspace permissions
|
||||
if: always()
|
||||
run: |
|
||||
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
||||
sudo chmod -R u+rwX . 2>/dev/null || true
|
||||
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
||||
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Container Orchestration Tests
|
||||
on:
|
||||
push:
|
||||
branches: [dev-iso, main]
|
||||
paths:
|
||||
- 'core/archipelago/src/**'
|
||||
- 'core/container/src/**'
|
||||
- 'scripts/container-*.sh'
|
||||
- 'scripts/reconcile-*.sh'
|
||||
- 'scripts/image-versions.sh'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
core/target
|
||||
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
|
||||
|
||||
- name: Run orchestration unit tests
|
||||
working-directory: core
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
echo "=== Container crate tests ==="
|
||||
cargo test -p archipelago-container --no-fail-fast 2>&1
|
||||
|
||||
echo ""
|
||||
echo "=== Orchestration integration tests ==="
|
||||
cargo test --test orchestration_tests --no-fail-fast 2>&1
|
||||
|
||||
- name: Verify cargo check (full crate)
|
||||
working-directory: core
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo check --release 2>&1
|
||||
|
||||
smoke-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit-tests
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run container smoke tests on .228
|
||||
env:
|
||||
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
|
||||
run: |
|
||||
# Only run if SSH key exists (CI runner has deploy access)
|
||||
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
|
||||
bash scripts/dev-container-test.sh --once
|
||||
else
|
||||
echo "⚠ SSH key not available — skipping live smoke tests"
|
||||
echo " To enable: add archipelago-deploy key to CI runner"
|
||||
fi
|
||||
@@ -240,6 +240,9 @@ impl RpcHandler {
|
||||
"vpn.status" => self.handle_vpn_status().await,
|
||||
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
||||
"vpn.create-peer" => self.handle_vpn_create_peer(params).await,
|
||||
"vpn.list-peers" => self.handle_vpn_list_peers().await,
|
||||
"vpn.remove-peer" => self.handle_vpn_remove_peer(params).await,
|
||||
"remote.setup" => self.handle_remote_setup(params).await,
|
||||
|
||||
// Marketplace
|
||||
|
||||
@@ -201,4 +201,149 @@ impl RpcHandler {
|
||||
info!("VPN disconnected");
|
||||
Ok(serde_json::json!({ "disconnected": true }))
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Mobile");
|
||||
|
||||
// Get server status for endpoint info
|
||||
let status = vpn::get_status().await;
|
||||
if !status.connected {
|
||||
anyhow::bail!("NostrVPN is not running. Start VPN first.");
|
||||
}
|
||||
|
||||
// Generate a keypair for the new peer via nvpn keygen
|
||||
let keygen = tokio::process::Command::new("nvpn")
|
||||
.arg("keygen")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("nvpn keygen failed: {}", e))?;
|
||||
|
||||
if !keygen.status.success() {
|
||||
anyhow::bail!("nvpn keygen failed: {}", String::from_utf8_lossy(&keygen.stderr));
|
||||
}
|
||||
|
||||
let keygen_output = String::from_utf8_lossy(&keygen.stdout);
|
||||
let lines: Vec<&str> = keygen_output.lines().collect();
|
||||
|
||||
// Parse private and public keys from keygen output
|
||||
let (peer_private, peer_public) = if lines.len() >= 2 {
|
||||
(lines[0].trim().to_string(), lines[1].trim().to_string())
|
||||
} else {
|
||||
anyhow::bail!("Unexpected keygen output: {}", keygen_output);
|
||||
};
|
||||
|
||||
// Get server's public key from nvpn render-wg
|
||||
let render = tokio::process::Command::new("nvpn")
|
||||
.arg("render-wg")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("nvpn render-wg failed: {}", e))?;
|
||||
let render_output = String::from_utf8_lossy(&render.stdout);
|
||||
let server_privkey = render_output.lines()
|
||||
.find(|l| l.starts_with("PrivateKey"))
|
||||
.and_then(|l| l.split('=').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Derive server public key from private key
|
||||
let server_pubkey_cmd = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("echo '{}' | wg pubkey", server_privkey))
|
||||
.output()
|
||||
.await;
|
||||
let server_pubkey = server_pubkey_cmd
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Detect host IP for endpoint
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
let endpoint = format!("{}:51820", host_ip);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||
let peer_ip = format!("10.44.0.{}/32", peer_num);
|
||||
|
||||
// Build WireGuard config for the mobile device
|
||||
let wg_config = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n",
|
||||
peer_private, peer_ip, server_pubkey, endpoint
|
||||
);
|
||||
|
||||
// Generate QR code as SVG
|
||||
let qr = qrcode::QrCode::new(wg_config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
|
||||
// Save peer info
|
||||
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||
tokio::fs::create_dir_all(&peers_dir).await.ok();
|
||||
let peer_info = serde_json::json!({
|
||||
"name": name,
|
||||
"public_key": peer_public,
|
||||
"ip": peer_ip,
|
||||
"created": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(
|
||||
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
|
||||
serde_json::to_string_pretty(&peer_info)?,
|
||||
).await.ok();
|
||||
|
||||
info!("VPN peer created: {} ({})", name, peer_ip);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"peer_ip": peer_ip,
|
||||
"config": wg_config,
|
||||
"qr_svg": svg,
|
||||
"public_key": peer_public,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.list-peers — List configured VPN peers.
|
||||
pub(super) async fn handle_vpn_list_peers(&self) -> Result<serde_json::Value> {
|
||||
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||
let mut peers = Vec::new();
|
||||
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry.path().extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
|
||||
if let Ok(peer) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
peers.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
/// vpn.remove-peer — Remove a VPN peer by name.
|
||||
pub(super) async fn handle_vpn_remove_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params.get("name").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
|
||||
|
||||
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
|
||||
|
||||
if tokio::fs::remove_file(&peer_file).await.is_ok() {
|
||||
info!("VPN peer removed: {}", name);
|
||||
Ok(serde_json::json!({ "removed": true }))
|
||||
} else {
|
||||
anyhow::bail!("Peer '{}' not found", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Server store — computed server state and RPC action proxies
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useSyncStore } from './sync'
|
||||
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
||||
@@ -13,6 +13,45 @@ export const useServerStore = defineStore('server', () => {
|
||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
||||
const uninstallingApps = ref<Set<string>>(new Set())
|
||||
|
||||
// Watch WebSocket data for real install progress — runs globally, not just on Marketplace page
|
||||
watch(() => sync.packages, (packages) => {
|
||||
if (!packages) return
|
||||
for (const [appId, pkg] of Object.entries(packages)) {
|
||||
if ((pkg.state as string) === 'installing') {
|
||||
// Backend confirms it's installing — update or create tracking entry
|
||||
if (!installingApps.value.has(appId)) {
|
||||
installingApps.value.set(appId, {
|
||||
id: appId,
|
||||
title: pkg.manifest?.title || appId,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: 'Installing...',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
const progress = pkg['install-progress']
|
||||
if (progress) {
|
||||
const current = installingApps.value.get(appId)!
|
||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: 'downloading',
|
||||
progress: Math.min(pct, 95),
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
|
||||
})
|
||||
}
|
||||
} else if (installingApps.value.has(appId)) {
|
||||
const state = pkg.state as string
|
||||
// Only clear when app is fully running or definitively stopped — not during 'starting' transition
|
||||
if (state === 'running' || state === 'stopped' || state === 'exited') {
|
||||
installingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||
const existing = installingApps.value.get(appId)
|
||||
installingApps.value.set(appId, {
|
||||
|
||||
@@ -153,7 +153,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
@@ -193,10 +193,30 @@ const selectedCategory = ref('all')
|
||||
|
||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||
|
||||
// Merge real packages from store with web-only app bookmarks
|
||||
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages || {}
|
||||
return { ...WEB_ONLY_APPS, ...realPackages }
|
||||
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
||||
|
||||
// Inject placeholder entries for apps being installed that aren't in backend data yet
|
||||
for (const [appId, progress] of serverStore.installingApps) {
|
||||
if (!merged[appId]) {
|
||||
merged[appId] = {
|
||||
state: 'installing' as PackageState,
|
||||
manifest: {
|
||||
id: appId,
|
||||
title: progress.title,
|
||||
version: '',
|
||||
description: { short: progress.message, long: '' },
|
||||
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
|
||||
'support-site': '', 'marketing-site': '', 'donation-url': null,
|
||||
},
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
|
||||
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||
|
||||
@@ -108,7 +108,7 @@ let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -159,39 +159,8 @@ const categories = computed(() => [
|
||||
const installingApps = server.installingApps
|
||||
const maxAttempts = ref(60)
|
||||
|
||||
// Watch WebSocket data for real install progress from backend
|
||||
watch(() => store.packages, (packages) => {
|
||||
if (!packages) return
|
||||
for (const [appId, pkg] of Object.entries(packages)) {
|
||||
if ((pkg.state as string) === 'installing') {
|
||||
const progress = pkg['install-progress']
|
||||
if (!installingApps.has(appId)) {
|
||||
installingApps.set(appId, {
|
||||
id: appId,
|
||||
title: pkg.manifest?.title || appId,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: t('common.installing'),
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
if (progress) {
|
||||
const current = installingApps.get(appId)!
|
||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||
installingApps.set(appId, {
|
||||
...current,
|
||||
status: 'downloading',
|
||||
progress: Math.min(pct, 95),
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
||||
})
|
||||
}
|
||||
} else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') {
|
||||
installingApps.delete(appId)
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||
// so it works regardless of which page is active
|
||||
|
||||
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
|
||||
function selectCategory(id: string) {
|
||||
@@ -406,20 +375,28 @@ function startInstallPolling(appId: string, statusMessage: string) {
|
||||
if (!current) { clearTrackedInterval(interval); return }
|
||||
|
||||
const newAttempt = current.attempt + 1
|
||||
const state = getInstalledState(appId)
|
||||
|
||||
// Update message based on actual backend state
|
||||
let message = statusMessage
|
||||
if (state === 'starting') message = 'Starting application...'
|
||||
else if (state === 'running') message = 'Installation complete!'
|
||||
|
||||
installingApps.set(appId, {
|
||||
...current,
|
||||
attempt: newAttempt,
|
||||
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
||||
message: statusMessage
|
||||
message
|
||||
})
|
||||
|
||||
if (isInstalled(appId)) {
|
||||
// Only clear when fully running — server store watcher handles the actual delete
|
||||
if (state === 'running') {
|
||||
clearTrackedInterval(interval)
|
||||
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
||||
// Server store watcher will clear installingApps when it sees 'running'
|
||||
} else if (newAttempt >= maxAttempts.value) {
|
||||
clearTrackedInterval(interval)
|
||||
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
||||
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout — check My Apps' })
|
||||
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
@@ -108,15 +108,59 @@
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-white/80 text-sm">VPN</span>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-white/80 text-sm">VPN</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||
{{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="networkData.vpnConnected" class="mt-3 pt-3 border-t border-white/10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Connected Devices</span>
|
||||
<button @click="showAddDeviceModal = true" class="glass-button px-3 py-1 text-xs">Add Device</button>
|
||||
</div>
|
||||
<div v-if="vpnPeers.length" class="space-y-1">
|
||||
<div v-for="peer in vpnPeers" :key="peer.name" class="flex items-center justify-between text-xs py-1">
|
||||
<span class="text-white/70">{{ peer.name }}</span>
|
||||
<span class="text-white/40 font-mono">{{ peer.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/40">No devices connected</div>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||
{{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="showAddDeviceModal = false">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
||||
<button @click="showAddDeviceModal = false" class="p-1 rounded hover:bg-white/10 text-white/60"><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>
|
||||
<div v-if="!peerQrData">
|
||||
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
||||
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="peerQrData = null; newPeerName = ''" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" /></svg>
|
||||
@@ -318,6 +362,47 @@ async function loadNetworkData() {
|
||||
} catch { /* keep defaults */ } finally { networkLoading.value = false }
|
||||
}
|
||||
|
||||
// VPN peer management
|
||||
const showAddDeviceModal = ref(false)
|
||||
const newPeerName = ref('')
|
||||
const creatingPeer = ref(false)
|
||||
const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null)
|
||||
const peerError = ref('')
|
||||
const copiedConfig = ref(false)
|
||||
const vpnPeers = ref<{ name: string; ip: string }[]>([])
|
||||
|
||||
async function loadVpnPeers() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
|
||||
vpnPeers.value = res.peers || []
|
||||
} catch { /* no peers */ }
|
||||
}
|
||||
|
||||
async function createPeer() {
|
||||
if (!newPeerName.value.trim()) return
|
||||
creatingPeer.value = true
|
||||
peerError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({
|
||||
method: 'vpn.create-peer',
|
||||
params: { name: newPeerName.value.trim() },
|
||||
})
|
||||
peerQrData.value = res
|
||||
loadVpnPeers()
|
||||
} catch (e) {
|
||||
peerError.value = e instanceof Error ? e.message : 'Failed to create peer'
|
||||
} finally {
|
||||
creatingPeer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPeerConfig() {
|
||||
if (!peerQrData.value?.config) return
|
||||
try { await navigator.clipboard.writeText(peerQrData.value.config) } catch { /* fallback */ }
|
||||
copiedConfig.value = true
|
||||
setTimeout(() => { copiedConfig.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Network interfaces
|
||||
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||
@@ -489,7 +574,7 @@ async function createService(name: string, port: number | null) {
|
||||
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices() })
|
||||
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers() })
|
||||
watch(showWifiModal, (open) => { if (open) scanWifi() })
|
||||
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user