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:
Dorian
2026-04-07 19:44:00 +01:00
parent b0e5e8c00e
commit 4fc6c103ba
9 changed files with 319 additions and 297 deletions

View File

@@ -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, {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = '' } })