feat: VPN peer QR code UI, consolidate CI workflows
- 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:
parent
b0e5e8c00e
commit
4fc6c103ba
@ -60,16 +60,6 @@ jobs:
|
|||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
run: cd neode-ui && npx vitest run
|
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
|
- name: Include AIUI if available
|
||||||
run: |
|
run: |
|
||||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
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.status" => self.handle_vpn_status().await,
|
||||||
"vpn.configure" => self.handle_vpn_configure(params).await,
|
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||||
"vpn.disconnect" => self.handle_vpn_disconnect().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,
|
"remote.setup" => self.handle_remote_setup(params).await,
|
||||||
|
|
||||||
// Marketplace
|
// Marketplace
|
||||||
|
|||||||
@ -201,4 +201,149 @@ impl RpcHandler {
|
|||||||
info!("VPN disconnected");
|
info!("VPN disconnected");
|
||||||
Ok(serde_json::json!({ "disconnected": true }))
|
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
|
// Server store — computed server state and RPC action proxies
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useSyncStore } from './sync'
|
import { useSyncStore } from './sync'
|
||||||
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
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 installingApps = ref<Map<string, InstallProgress>>(new Map())
|
||||||
const uninstallingApps = ref<Set<string>>(new Set())
|
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 }) {
|
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||||
const existing = installingApps.value.get(appId)
|
const existing = installingApps.value.get(appId)
|
||||||
installingApps.value.set(appId, {
|
installingApps.value.set(appId, {
|
||||||
|
|||||||
@ -153,7 +153,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
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 AppCard from './apps/AppCard.vue'
|
||||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
@ -193,10 +193,30 @@ const selectedCategory = ref('all')
|
|||||||
|
|
||||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
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 packages = computed(() => {
|
||||||
const realPackages = store.packages || {}
|
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)
|
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||||
|
|||||||
@ -108,7 +108,7 @@ let marketplaceAnimationDone = false
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
@ -159,39 +159,8 @@ const categories = computed(() => [
|
|||||||
const installingApps = server.installingApps
|
const installingApps = server.installingApps
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
// Watch WebSocket data for real install progress from backend
|
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||||
watch(() => store.packages, (packages) => {
|
// so it works regardless of which page is active
|
||||||
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 })
|
|
||||||
|
|
||||||
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
|
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
|
||||||
function selectCategory(id: string) {
|
function selectCategory(id: string) {
|
||||||
@ -406,20 +375,28 @@ function startInstallPolling(appId: string, statusMessage: string) {
|
|||||||
if (!current) { clearTrackedInterval(interval); return }
|
if (!current) { clearTrackedInterval(interval); return }
|
||||||
|
|
||||||
const newAttempt = current.attempt + 1
|
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, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
attempt: newAttempt,
|
attempt: newAttempt,
|
||||||
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
||||||
message: statusMessage
|
message
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isInstalled(appId)) {
|
// Only clear when fully running — server store watcher handles the actual delete
|
||||||
|
if (state === 'running') {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
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) {
|
} else if (newAttempt >= maxAttempts.value) {
|
||||||
clearTrackedInterval(interval)
|
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)
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|||||||
@ -108,15 +108,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</span>
|
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<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>
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-white/80 text-sm">VPN</span>
|
<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>
|
</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>
|
||||||
|
|
||||||
|
<!-- 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">
|
<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">
|
<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>
|
<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 }
|
} 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
|
// Network interfaces
|
||||||
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
||||||
interface WifiNetwork { ssid: string; signal: number; security: 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 }
|
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(showWifiModal, (open) => { if (open) scanWifi() })
|
||||||
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user