Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<div v-if="pkg">
|
||||
<AppHeroSection
|
||||
:pkg="pkg"
|
||||
:app-id="appId"
|
||||
:package-key="packageKey"
|
||||
:can-launch="canLaunch"
|
||||
:is-web-only="isWebOnly"
|
||||
:pending-action="pendingAction"
|
||||
@launch="launchApp"
|
||||
@start="startApp"
|
||||
@stop="stopApp"
|
||||
@restart="restartApp"
|
||||
@uninstall="uninstallApp"
|
||||
@update="updateApp"
|
||||
@channels="router.push('/dashboard/apps/lnd/channels')"
|
||||
/>
|
||||
|
||||
<LndSeedBackup v-if="packageKey === 'lnd' && pkg.installed" />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<AppContentSection
|
||||
:pkg="pkg"
|
||||
:features="features"
|
||||
:needs-bitcoin-sync="needsBitcoinSync"
|
||||
:bitcoin-synced="bitcoinSynced"
|
||||
:bitcoin-sync-percent="bitcoinSyncPercent"
|
||||
:bitcoin-block-height="bitcoinBlockHeight"
|
||||
/>
|
||||
|
||||
<AppSidebar
|
||||
:pkg="pkg"
|
||||
:package-key="packageKey"
|
||||
:is-web-only="isWebOnly"
|
||||
:gateway-state="gatewayState"
|
||||
:interface-addresses="interfaceAddresses"
|
||||
:lan-url="lanUrl"
|
||||
:tor-url="torUrl"
|
||||
:show-tor-address="showTorAddress"
|
||||
:credentials="credentials"
|
||||
:credentials-loading="credentialsLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="pendingAction === 'uninstall'"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="confirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
import LndSeedBackup from './appDetails/LndSeedBackup.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||
import {
|
||||
WEB_ONLY_APP_URLS,
|
||||
PACKAGE_ALIASES,
|
||||
BITCOIN_DEPENDENT_APPS,
|
||||
resolvePackageKey,
|
||||
isRealOnionAddress,
|
||||
} from './appDetails/appDetailsData'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = route.params.id
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
|
||||
|
||||
const pkg = computed(() => {
|
||||
const routeId = appId.value
|
||||
const pkgKey = resolvePackageKey(routeId)
|
||||
if (store.packages[pkgKey]) return store.packages[pkgKey]
|
||||
if (store.packages[routeId]) return store.packages[routeId]
|
||||
const aliases = PACKAGE_ALIASES[routeId]
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (store.packages[alias]) return store.packages[alias]
|
||||
}
|
||||
}
|
||||
if (dummyApps[routeId]) return dummyApps[routeId]
|
||||
return null
|
||||
})
|
||||
|
||||
const interfaceAddresses = computed(() => {
|
||||
const main = pkg.value?.installed?.['interface-addresses']?.main
|
||||
if (!main) return null
|
||||
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
||||
return main
|
||||
})
|
||||
|
||||
const lanUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['lan-address']
|
||||
if (!addr) return '#'
|
||||
if (addr.includes('localhost')) return addr.replace('localhost', window.location.hostname)
|
||||
return addr
|
||||
})
|
||||
|
||||
const torUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['tor-address']
|
||||
if (!addr || !isRealOnionAddress(addr)) return ''
|
||||
return addr.startsWith('http') ? addr : `http://${addr}`
|
||||
})
|
||||
|
||||
const showTorAddress = computed(() => isRealOnionAddress(interfaceAddresses.value?.['tor-address']))
|
||||
|
||||
const packageKey = computed(() => resolvePackageKey(appId.value))
|
||||
|
||||
const gatewayState = computed(() => {
|
||||
const gw = store.packages['fedimint-gateway']
|
||||
return gw ? gw.state : 'not installed'
|
||||
})
|
||||
|
||||
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
|
||||
|
||||
// Keyed per app id (D-04): AppDetails is never instance-cached (no
|
||||
// KeepAlive), but the route's `:key="route.path"` (DashboardRouterView.vue)
|
||||
// means an id change always fully remounts this component, so the key can be
|
||||
// computed once at setup time rather than re-derived via a watch.
|
||||
const bitcoinSyncResource = useCachedResource<{ block_height: number; sync_progress: number }>({
|
||||
key: `app-details:bitcoin-sync:${appId.value}`,
|
||||
fetcher: (signal) => rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 5000,
|
||||
}),
|
||||
ttlMs: 30_000, // install/health-state-shaped data, per plan default
|
||||
persist: true, // public chain height/sync progress — no money amount or identity
|
||||
immediate: false, // kicked from onMounted, gated on needsBitcoinSync
|
||||
})
|
||||
const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100)
|
||||
const bitcoinBlockHeight = computed(() => bitcoinSyncResource.data.value?.block_height ?? 0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
|
||||
// Credential material — memory-only (persist: false), never written to
|
||||
// sessionStorage (D-08 / T-02-01).
|
||||
const credentialsResource = useCachedResource<AppCredentialsResponse | null>({
|
||||
key: `app-details:credentials:${appId.value}`,
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
return resolveAppCredentials(packageKey.value, result)
|
||||
},
|
||||
ttlMs: 30_000,
|
||||
persist: false,
|
||||
immediate: false,
|
||||
})
|
||||
const credentials = computed(() => credentialsResource.data.value ?? resolveAppCredentials(packageKey.value, null))
|
||||
const credentialsLoading = computed(() => credentialsResource.loadState.value === 'loading')
|
||||
|
||||
// refresh() is unconditional (force-fetch); only call it when the cached
|
||||
// entry is missing or past its TTL, so a repeat open inside the TTL paints
|
||||
// from cache with no new RPC.
|
||||
function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
if (bitcoinSyncResource.data.value === null || bitcoinSyncResource.isStale.value) {
|
||||
void bitcoinSyncResource.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
if (credentialsResource.data.value === null || credentialsResource.isStale.value) {
|
||||
void credentialsResource.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
// Both loaders are independent (bitcoin sync state vs. this app's
|
||||
// credentials) and were already fire-and-forget here before this plan — do
|
||||
// not "fix" this into an awaited chain, it is already effectively parallel.
|
||||
onMounted(() => {
|
||||
loadBitcoinSync()
|
||||
loadCredentials()
|
||||
})
|
||||
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
const uninstallModal = ref({ show: false, appTitle: '' })
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
const backButtonText = computed(() => {
|
||||
if (route.query.from === 'discover') return 'Back to Discover'
|
||||
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
||||
return t('appDetails.backToApps')
|
||||
})
|
||||
|
||||
const canLaunch = computed(() => {
|
||||
if (!pkg.value) return false
|
||||
if (isWebOnly.value) return true
|
||||
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
||||
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||
})
|
||||
|
||||
const features = computed(() => [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default'
|
||||
])
|
||||
|
||||
function goBack() {
|
||||
if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (route.query.from === 'marketplace') {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
}
|
||||
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
const id = appId.value
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebsitePackage(id, pkg.value)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (runtimeUrl) {
|
||||
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
|
||||
return
|
||||
}
|
||||
|
||||
// Container apps should launch through session routing so protocol/path
|
||||
// handling stays centralized in appSessionConfig.
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
|
||||
async function startApp() {
|
||||
pendingAction.value = 'start'
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp() {
|
||||
pendingAction.value = 'stop'
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
// Stopping the app can take its admin credentials offline — invalidate
|
||||
// rather than show a stale "healthy" credentials card (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
pendingAction.value = 'restart'
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
// A restart can rotate credentials/admin URLs — invalidate so the next
|
||||
// read is fresh rather than the pre-restart cache (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
pendingAction.value = 'update'
|
||||
try {
|
||||
await store.updatePackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function showUninstallModal() {
|
||||
if (!pkg.value) return
|
||||
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
||||
}
|
||||
|
||||
async function confirmUninstall(deleteAppData: boolean) {
|
||||
uninstallModal.value.show = false
|
||||
pendingAction.value = 'uninstall'
|
||||
try {
|
||||
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||
// Invalidate before navigating away — the app no longer exists, so its
|
||||
// cached credentials must not be replayed if this screen is reopened
|
||||
// before the TTL lapses (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallApp() {
|
||||
showUninstallModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||
<p class="text-white/70">
|
||||
Container registries this node pulls app images from. The primary is tried first; if it's
|
||||
slow or unreachable, the next one in the list is tried automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Registry list -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Registries</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
|
||||
@click="openAddRegistry"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Registries are tried in priority order on every app install. Changing the primary takes
|
||||
effect on the next install — existing containers keep running on whatever image they
|
||||
already pulled.
|
||||
</p>
|
||||
<ul v-if="registries.length" class="space-y-2">
|
||||
<li
|
||||
v-for="r in sortedRegistries"
|
||||
:key="r.url"
|
||||
class="p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
|
||||
<span
|
||||
v-if="r.priority === 0"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
|
||||
>PRIMARY</span>
|
||||
<span
|
||||
v-if="!r.tls_verify"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
|
||||
title="TLS verification disabled — HTTP or self-signed registry"
|
||||
>HTTP</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registryTests[r.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testRegistry(r)"
|
||||
>
|
||||
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="r.priority !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimary(r.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="registries.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove registry"
|
||||
@click="removeRegistry(r.url)"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
|
||||
class="mt-2 pt-2 border-t border-white/5 text-xs"
|
||||
>
|
||||
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable (HTTP {{ registryTests[r.url]?.status }})
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Add-registry modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="addingRegistry"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||
@click.self="cancelAddRegistry"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
|
||||
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
|
||||
<span class="font-mono text-white/80">192.0.2.10:3000/apps</span>. Registries are
|
||||
added to the end of the list; use "Make primary" to reorder.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitRegistry">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name</label>
|
||||
<input
|
||||
v-model="registryDraft.name"
|
||||
type="text"
|
||||
placeholder="My private registry"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
|
||||
<input
|
||||
v-model="registryDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="host:port/namespace"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
|
||||
<input
|
||||
v-model="registryDraft.tls_verify"
|
||||
type="checkbox"
|
||||
class="accent-orange-400"
|
||||
/>
|
||||
Verify TLS certificate (uncheck for HTTP or self-signed)
|
||||
</label>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddRegistry"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registrySaving || !registryDraft.url.trim()"
|
||||
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Registry {
|
||||
url: string
|
||||
name: string
|
||||
tls_verify: boolean
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface RegistryTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const registries = ref<Registry[]>([])
|
||||
const sortedRegistries = computed(() =>
|
||||
[...registries.value].sort((a, b) => a.priority - b.priority)
|
||||
)
|
||||
const registryTests = ref<Record<string, RegistryTestState>>({})
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const addingRegistry = ref(false)
|
||||
const registrySaving = ref(false)
|
||||
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadRegistries() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
|
||||
registries.value = res.registries
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('registry.list failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRegistry() {
|
||||
registryDraft.url = ''
|
||||
registryDraft.name = ''
|
||||
registryDraft.tls_verify = true
|
||||
addingRegistry.value = true
|
||||
}
|
||||
function cancelAddRegistry() {
|
||||
addingRegistry.value = false
|
||||
}
|
||||
|
||||
async function submitRegistry() {
|
||||
const url = registryDraft.url.trim()
|
||||
if (!url) return
|
||||
registrySaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.add',
|
||||
params: {
|
||||
url,
|
||||
name: registryDraft.name.trim() || url,
|
||||
tls_verify: registryDraft.tls_verify,
|
||||
},
|
||||
})
|
||||
registries.value = res.registries
|
||||
addingRegistry.value = false
|
||||
showStatus('Registry added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add registry failed: ${msg}`, true)
|
||||
} finally {
|
||||
registrySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRegistry(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.remove',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Registry removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.set-primary',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Primary registry updated. Next install will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function testRegistry(r: Registry) {
|
||||
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
url: string
|
||||
reachable: boolean
|
||||
status: number | null
|
||||
error?: string | null
|
||||
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: {
|
||||
testing: false,
|
||||
reachable: res.reachable,
|
||||
status: res.status,
|
||||
error: res.error ?? null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: { testing: false, reachable: false, error: msg },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadRegistries() })
|
||||
</script>
|
||||
@@ -0,0 +1,741 @@
|
||||
<template>
|
||||
<!-- The root stays in the layout as a rect placeholder; the session itself
|
||||
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
|
||||
subtree on panel<->overlay switches, and moving an iframe node reloads
|
||||
it — inline mode is now emulated with a fixed-position rect synced from
|
||||
this placeholder, so the iframe never moves in the DOM. -->
|
||||
<div class="app-session-root" ref="rootRef">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
:style="inlineRectStyle"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
ref="sessionRef"
|
||||
:class="panelClasses"
|
||||
@click.stop
|
||||
>
|
||||
<AppSessionHeader
|
||||
:app-title="appTitle"
|
||||
:is-refreshing="isRefreshing"
|
||||
:display-mode="displayMode"
|
||||
@go-back="iframeGoBack"
|
||||
@go-forward="iframeGoForward"
|
||||
@refresh="refresh"
|
||||
@open-new-tab="openNewTab"
|
||||
@close="closeSession"
|
||||
@set-mode="setMode"
|
||||
/>
|
||||
|
||||
<AppSessionFrame
|
||||
ref="frameRef"
|
||||
:app-url="appUrl"
|
||||
:app-id="appId"
|
||||
:app-title="appTitle"
|
||||
:app-icon="appIcon"
|
||||
:loading="loading"
|
||||
:iframe-blocked="iframeBlocked"
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:warming-up="warmingUp"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
/>
|
||||
|
||||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||||
<MobileGamepad
|
||||
v-if="isMobile && appId === 'botfights'"
|
||||
:iframe-ref="iframeRef ?? null"
|
||||
:player="1"
|
||||
@refresh="refresh"
|
||||
@openBrowser="openNewTab"
|
||||
@close="closeSession"
|
||||
/>
|
||||
<div v-else class="md:hidden app-session-mobile-bar">
|
||||
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Forward" @click="iframeGoForward">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Close" @click="closeSession">
|
||||
<svg 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>
|
||||
|
||||
<NostrIdentityPicker
|
||||
:show="showIdentityPicker"
|
||||
:app-name="appTitle"
|
||||
@select="identity.onIdentitySelected"
|
||||
@cancel="showIdentityPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||
import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||
import { PackageState } from '@/types/api'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
|
||||
pathProp?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
/** True when rendered inline via store (panel mode), false when route-based */
|
||||
const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
|
||||
const sessionRef = ref<HTMLElement | null>(null)
|
||||
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
||||
const loading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeBlocked = ref(false)
|
||||
const refreshKey = ref(0)
|
||||
const showIdentityPicker = ref(false)
|
||||
const autoRetryCount = ref(0)
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
// Display mode -- per-app user choice → per-app default → last global → panel
|
||||
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||
const appIcon = computed(() =>
|
||||
packageEntry.value
|
||||
? resolveAppIcon(appId.value, packageEntry.value)
|
||||
: `/assets/img/app-icons/${appId.value}.png`
|
||||
)
|
||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||
|
||||
// A container that is up but not yet answering its probe is STARTING, not
|
||||
// broken — bitcoind serves RPC error -28 for its whole warm-up and lnd is
|
||||
// unreachable until the wallet unlocks, so both spent that window reading as
|
||||
// a hard "App not reachable" failure. The retry machinery below already
|
||||
// tolerates it (6 × 10s); this only makes the headline tell the truth while
|
||||
// those retries are still in flight. Once they are exhausted, the failure is
|
||||
// real again and the copy reverts.
|
||||
const MAX_AUTO_RETRIES = 6
|
||||
const warmingUp = computed(() =>
|
||||
iframeBlocked.value &&
|
||||
!mustOpenNewTab.value &&
|
||||
!blockedReason.value &&
|
||||
autoRetryCount.value < MAX_AUTO_RETRIES &&
|
||||
(packageEntry.value?.state === PackageState.Running ||
|
||||
packageEntry.value?.state === PackageState.Starting ||
|
||||
packageEntry.value?.state === PackageState.Restarting ||
|
||||
packageEntry.value?.health === 'starting')
|
||||
)
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
||||
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||||
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||||
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||||
// In the demo, apps backed by a real external site that blocks iframing open
|
||||
// in a new tab rather than the in-app session frame. Demoable apps are served
|
||||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
const isElectrsApp = computed(() =>
|
||||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||||
)
|
||||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||||
useElectrsSync()
|
||||
const electrsSync = computed(() =>
|
||||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||||
)
|
||||
watch(
|
||||
isElectrsApp,
|
||||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||||
{ immediate: true }
|
||||
)
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
'jellyfin',
|
||||
'immich',
|
||||
'photoprism',
|
||||
'filebrowser',
|
||||
])
|
||||
|
||||
const appUrl = computed(() => {
|
||||
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
|
||||
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.replace(fallbackPath).catch(() => {})
|
||||
}
|
||||
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
function setMode(mode: DisplayMode) {
|
||||
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
displayMode.value = mode
|
||||
// Strictly per-app: the pick is remembered for THIS app only (no global
|
||||
// key — one app's mode must never change how another opens).
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||||
|
||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||
// the app keeps floating above the dashboard instead of owning the route.
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.push(fallbackPath).then(() => {
|
||||
launcher.panelAppId = id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Reactive classes based on display mode. The store-driven session honors the
|
||||
// selected display mode in place: panel renders inline beside the page,
|
||||
// overlay/fullscreen render above it — the underlying route never changes.
|
||||
// Mobile always uses the full overlay.
|
||||
const inlinePanelMode = computed(() =>
|
||||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||||
)
|
||||
|
||||
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
|
||||
// to the placeholder's box so "inline" looks identical to the old in-place
|
||||
// render while the iframe stays put in the DOM across mode switches.
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
let rectObserver: ResizeObserver | null = null
|
||||
|
||||
function syncInlineRect() {
|
||||
const el = rootRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
// A hidden/unmounted placeholder measures 0x0 — keep the last good rect.
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
}
|
||||
}
|
||||
|
||||
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||||
if (!inlinePanelMode.value) return undefined
|
||||
const r = inlineRect.value
|
||||
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||||
if (!r) {
|
||||
const hidden: Record<string, string> = { visibility: 'hidden' }
|
||||
return hidden
|
||||
}
|
||||
const style: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
top: `${r.top}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
zIndex: '100',
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
watch(inlinePanelMode, (on) => {
|
||||
if (on) void nextTick(syncInlineRect)
|
||||
})
|
||||
|
||||
const backdropClasses = computed(() => {
|
||||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
})
|
||||
|
||||
const panelClasses = computed(() => {
|
||||
const base = 'app-session-panel glass-card'
|
||||
if (inlinePanelMode.value) return `${base} app-session-inline`
|
||||
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
autoRetryCount.value = 0
|
||||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||
// Check if iframe actually loaded content (same-origin only)
|
||||
iframeCheckId = setTimeout(() => {
|
||||
try {
|
||||
const iframe = frameRef.value?.iframeRef
|
||||
const doc = iframe?.contentDocument
|
||||
if (doc) {
|
||||
const body = doc.body
|
||||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin -- can't check, assume OK
|
||||
}
|
||||
}, 1000)
|
||||
identity.onIframeLoadIdentity()
|
||||
}
|
||||
|
||||
function onError() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
iframeBlocked.value = true
|
||||
// Auto-retry up to 6 times (60s total) for apps that are still starting
|
||||
if (!mustOpenNewTab.value && autoRetryCount.value < MAX_AUTO_RETRIES) {
|
||||
autoRetryId = setTimeout(() => {
|
||||
autoRetryCount.value++
|
||||
refresh()
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
isRefreshing.value = true
|
||||
loading.value = true
|
||||
iframeBlocked.value = false
|
||||
refreshKey.value++
|
||||
startLoadTimeout()
|
||||
}
|
||||
|
||||
function startLoadTimeout() {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (loading.value) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
|
||||
}
|
||||
|
||||
function iframeGoForward() {
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
else closeSession()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||
displayMode.value = 'overlay'
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
||||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||||
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
|
||||
}
|
||||
|
||||
// Enter fullscreen on mount if mode is fullscreen
|
||||
watch(displayMode, (mode) => {
|
||||
if (mode !== 'fullscreen') return
|
||||
// The panel may teleport to <body> on this mode change — request fullscreen
|
||||
// after the DOM settles so we grab the element at its new location.
|
||||
void nextTick(() => {
|
||||
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
|
||||
// Open them directly instead of showing a "this app opens in a tab"
|
||||
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
|
||||
// or new tab (PWA). Then dismiss the (empty) session surface.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
if (isMobile.value) openInAppOrNewTab(appUrl.value)
|
||||
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('resize', updateIsMobile)
|
||||
window.addEventListener('resize', syncInlineRect)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
// Track the placeholder's box (sidebar collapse, layout shifts) for the
|
||||
// inline-mode fixed-position emulation. Sync before first paint so the
|
||||
// inline backdrop never flashes at the wrong rect.
|
||||
syncInlineRect()
|
||||
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
rectObserver = new ResizeObserver(syncInlineRect)
|
||||
rectObserver.observe(rootRef.value)
|
||||
}
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
startLoadTimeout()
|
||||
}
|
||||
if (displayMode.value === 'fullscreen') {
|
||||
requestAnimationFrame(() => {
|
||||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||||
})
|
||||
}
|
||||
if (screensaverSuppressedApps.has(appId.value)) {
|
||||
screensaverStore.suppress(screensaverReason.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (autoRetryId) clearTimeout(autoRetryId)
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('resize', updateIsMobile)
|
||||
window.removeEventListener('resize', syncInlineRect)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
rectObserver?.disconnect()
|
||||
rectObserver = null
|
||||
screensaverStore.resume(screensaverReason.value)
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Unscoped so children can use these classes */
|
||||
.app-session-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Inline panel mode -- fills content area, no blur, original layout */
|
||||
.app-session-backdrop-inline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-session-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-inline {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.app-session-inline {
|
||||
border-radius: 1rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay mode -- covers entire viewport including sidebar */
|
||||
.app-session-backdrop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-overlay {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-session-overlay {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-overlay {
|
||||
max-width: calc(100vw - 5rem);
|
||||
max-height: calc(100vh - 5rem);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.app-session-fullscreen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Shared */
|
||||
.app-session-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-session-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.app-session-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Active display-mode button in the header bar */
|
||||
.app-session-btn-active {
|
||||
color: #fb923c;
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
.app-session-btn-active:hover {
|
||||
color: #fb923c;
|
||||
background: rgba(251, 146, 60, 0.18);
|
||||
}
|
||||
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.content-fade-enter-from,
|
||||
.content-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-session-frame-scroll-host {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-inline {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-overlay,
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--visual-viewport-height, 100dvh);
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
background: black;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.app-session-backdrop-overlay {
|
||||
padding: 0;
|
||||
backdrop-filter: none;
|
||||
background: black;
|
||||
}
|
||||
.app-session-frame-safe {
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(var(--visual-viewport-height, 100dvh) - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile bottom browser bar — sized like the main tab bar.
|
||||
Uses !important-free display so Tailwind md:hidden can override. */
|
||||
@media (min-width: 768px) {
|
||||
.app-session-mobile-bar { display: none !important; }
|
||||
}
|
||||
.app-session-mobile-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2600;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||
/* Solid black, not translucent: the app iframe's theme colour bled
|
||||
through the bar and its safe-area strip on phones. */
|
||||
background: #000;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-session-inline .app-session-mobile-bar {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
.app-session-bar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.app-session-bar-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.app-session-bar-btn:active {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,908 @@
|
||||
<template>
|
||||
<div class="apps-view pb-6">
|
||||
<!-- Nav header -- tabs + categories + search -->
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
<div ref="appsHeaderRef" class="app-header-desktop items-center gap-4 relative">
|
||||
<div ref="appsPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && !collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in serviceCategoriesWithItems"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="apps-category-select">My Apps category</label>
|
||||
<select
|
||||
id="apps-category-select"
|
||||
v-model="selectedCategory"
|
||||
class="segmented-select-control"
|
||||
>
|
||||
<option
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
:value="category.id"
|
||||
>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="appsCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact: tabs for tablet, categories, search + sideload -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mobile-category-strip mb-3" aria-label="My Apps categories">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mobile-category-strip mb-3" aria-label="Services categories">
|
||||
<button
|
||||
v-for="category in serviceCategoriesWithItems"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="isLoadingApps" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Loading apps</h3>
|
||||
<p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Error -->
|
||||
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<div class="alert-error mb-4">{{ connectionError }}</div>
|
||||
<button
|
||||
@click="connectionError = ''; store.connectWebSocket()"
|
||||
class="glass-button px-6 py-3 rounded-lg font-medium"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container scanner still warming up -->
|
||||
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
|
||||
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-12 max-w-md mx-auto">
|
||||
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
|
||||
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
{{ t('apps.browseAppStore') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceMatches.length > 0" class="mb-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="discover-terminal-tag">app store</span>
|
||||
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="app in marketplaceMatches"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
|
||||
@click="openMarketplaceResult(app)"
|
||||
>
|
||||
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
|
||||
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-white truncate">{{ app.title }}</p>
|
||||
<p class="text-xs text-white/50 truncate">Available in App Store</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
|
||||
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Refreshing container state. Showing the last known app list until the scan finishes.</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: iPhone-style icon grid -->
|
||||
<div class="apps-icon-grid-mobile">
|
||||
<AppIconGrid
|
||||
:apps="filteredPackageEntries as [string, PackageDataEntry][]"
|
||||
@go-to-app="goToApp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Card grid -->
|
||||
<div class="apps-card-grid-desktop grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||
<AppCard
|
||||
v-for="([id, pkg], index) in filteredPackageEntries"
|
||||
:key="id"
|
||||
:id="id as string"
|
||||
:pkg="pkg"
|
||||
:index="index"
|
||||
:show-stagger="showStagger"
|
||||
:is-loading="!!actions.loadingActions.value[id as string]"
|
||||
:is-installing="serverStore.isInstalling(id as string)"
|
||||
:install-progress="serverStore.installingApps.get(id as string)"
|
||||
:is-uninstalling="actions.uninstallingApps.has(id as string)"
|
||||
@go-to-app="goToApp"
|
||||
@launch="launchApp"
|
||||
@start="actions.startApp"
|
||||
@stop="actions.stopApp"
|
||||
@restart="actions.restartApp"
|
||||
@update="updateApp"
|
||||
@show-uninstall="showUninstallModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="actions.uninstalling.value"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||
against every other modal in the app. BaseModal also brings the
|
||||
standard scroll contract, Esc/focus handling and body scroll lock. -->
|
||||
<BaseModal
|
||||
:show="credentialModal.show"
|
||||
:title="credentialModal.title"
|
||||
max-width="max-w-lg"
|
||||
z-index="z-[2700]"
|
||||
@close="closeCredentialModal"
|
||||
>
|
||||
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||
{{ credentialModal.description }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeSideload"
|
||||
>
|
||||
<form class="sideload-modal" @submit.prevent="submitSideload">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
|
||||
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">
|
||||
<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 class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="sideload-label">App ID</span>
|
||||
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Docker image</span>
|
||||
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="sideload-label">Title</span>
|
||||
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Port mapping</span>
|
||||
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Description</span>
|
||||
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, the Archipelago app registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
|
||||
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
|
||||
{{ sideloading ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actions.actionError.value }}</span>
|
||||
<button @click="actions.actionError.value = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// Module-level -- persists across component unmount/remount within same session
|
||||
let appsAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { useLastKnownPackages } from './apps/appPackageCache'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
buildServiceCategories, useServiceCategories,
|
||||
} from './apps/appsConfig'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
const sideloadForm = ref({
|
||||
id: '',
|
||||
image: '',
|
||||
title: '',
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<AppsTab>(
|
||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'services' : 'apps'
|
||||
)
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
activeTab.value = tab === 'websites' || tab === 'services' ? 'services' : 'apps'
|
||||
})
|
||||
|
||||
// Search (debounced)
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = ref('')
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(searchQuery, (val) => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
|
||||
})
|
||||
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
|
||||
|
||||
// Category filter (shared by My Apps and Services; reset when switching tabs so
|
||||
// an apps-category selection never carries into the Services sub-nav).
|
||||
const selectedCategory = ref('all')
|
||||
watch(activeTab, () => { selectedCategory.value = 'all' })
|
||||
|
||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
|
||||
|
||||
const livePackages = computed(() => store.packages || {})
|
||||
// Field missing from server data = not scanned yet (consistent with Discover/Marketplace)
|
||||
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||
// Escape hatch: never show "Checking containers…" forever — after a timeout,
|
||||
// fall through to the real (empty) state even if the scanned flag never arrives.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
const {
|
||||
packages: stablePackages,
|
||||
isUsingLastKnownPackages,
|
||||
} = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
||||
const packages = computed(() => {
|
||||
const realPackages = stablePackages.value
|
||||
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: '', 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 serviceCategoriesWithItems = useServiceCategories(packages, SERVICE_CATEGORIES)
|
||||
const appsHeaderRef = ref<HTMLElement | null>(null)
|
||||
const appsPrimaryRef = ref<HTMLElement | null>(null)
|
||||
const appsCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
appsHeaderRef,
|
||||
appsPrimaryRef,
|
||||
appsCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
const curatedApps = getCuratedAppList()
|
||||
const marketplaceMatches = computed(() => {
|
||||
const q = debouncedSearchQuery.value.trim().toLowerCase()
|
||||
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
|
||||
return curatedApps.filter(app => {
|
||||
if (isInstalledInMyApps(app.id)) return false
|
||||
return app.title?.toLowerCase().includes(q) ||
|
||||
app.id.toLowerCase().includes(q) ||
|
||||
app.author?.toLowerCase().includes(q) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
|
||||
}).slice(0, 6)
|
||||
})
|
||||
|
||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||
const isCheckingContainers = computed(() => (
|
||||
store.hasLoadedInitialData &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
!isUsingLastKnownPackages.value &&
|
||||
sortedPackageEntries.value.length === 0 &&
|
||||
!searchQuery.value &&
|
||||
!containersScanned.value
|
||||
))
|
||||
|
||||
// Connection error state
|
||||
const connectionError = ref('')
|
||||
let connectionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
// Entry-scoped guard: under a surviving instance the 15s "unable to connect"
|
||||
// timer would otherwise arm once on the first visit and never again, so its
|
||||
// diagnosis of a fresh entry would go stale. Re-armed on every activation
|
||||
// (clearing any prior handle first — idempotent, so two consecutive
|
||||
// activations never double-arm) and cleared on deactivation; the
|
||||
// onBeforeUnmount clear stays for the non-cached mount path.
|
||||
function armConnectionGuard() {
|
||||
if (connectionTimer) clearTimeout(connectionTimer)
|
||||
// A stale error from a previous visit must not persist into a fresh entry
|
||||
// — otherwise a since-reconnected node would keep showing "Unable to
|
||||
// connect" instantly (no fresh 15s grace period) once this instance
|
||||
// survives deactivation instead of fully remounting.
|
||||
connectionError.value = ''
|
||||
if (!store.isConnected) {
|
||||
connectionTimer = setTimeout(() => {
|
||||
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
|
||||
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
}
|
||||
onActivated(() => armConnectionGuard())
|
||||
|
||||
// Once-per-session: the intro stagger flag. Also arms the connection guard
|
||||
// directly here — onActivated is a no-op outside a <KeepAlive> boundary, so
|
||||
// a bare mount (a unit test, or any future non-KeepAlive usage) must not
|
||||
// silently skip it. armConnectionGuard() is idempotent, so the harmless
|
||||
// extra pass this causes on a KeepAlive-wrapped first mount costs nothing.
|
||||
onMounted(() => {
|
||||
appsAnimationDone = true
|
||||
armConnectionGuard()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (connectionTimer) clearTimeout(connectionTimer)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (connectionTimer) clearTimeout(connectionTimer)
|
||||
})
|
||||
|
||||
// Sorted entries: web-only first, then alphabetical by title
|
||||
const sortedPackageEntries = computed(() => {
|
||||
const entries = Object.entries(packages.value)
|
||||
const filtered = filterEntriesForTab(entries, activeTab.value, selectedCategory.value)
|
||||
return filtered.sort(([idA, a], [idB, b]) => {
|
||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
||||
if (aWeb !== bWeb) return aWeb - bWeb
|
||||
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPackageEntries = computed(() => {
|
||||
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
|
||||
const q = debouncedSearchQuery.value.toLowerCase()
|
||||
return sortedPackageEntries.value.filter(([id, pkg]) =>
|
||||
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
|
||||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
|
||||
id.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalledInMyApps(appId: string): boolean {
|
||||
if (appId in packages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some(alias => alias in packages.value) : false
|
||||
}
|
||||
|
||||
function openMarketplaceResult(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
|
||||
}
|
||||
|
||||
// Uninstall modal
|
||||
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
||||
|
||||
function showUninstallModal(id: string, pkg: PackageDataEntry) {
|
||||
uninstallModal.value = { show: true, appId: id, appTitle: pkg.manifest.title }
|
||||
}
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
async function onConfirmUninstall(deleteAppData: boolean) {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||
}
|
||||
|
||||
async function launchApp(id: string) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id)
|
||||
if (shown) return
|
||||
launchAppNow(id)
|
||||
}
|
||||
|
||||
function launchAppNow(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (pkg && webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
// Demo: demoable apps are served same-origin by the mock backend, so the
|
||||
// tab-launch list (real apps with framing headers) doesn't apply.
|
||||
if (!isMobile && pkg && opensInTab(id) && !(IS_DEMO && isDemoApp(id))) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
|
||||
// hostage for its full 5s timeout over the mesh (home-card launches skip this
|
||||
// gate entirely, which is why they always felt instant). First launch waits at
|
||||
// most LAUNCH_CRED_BUDGET_MS; the RPC keeps running in the background and its
|
||||
// answer is memoized, so every later launch of that app resolves instantly.
|
||||
const LAUNCH_CRED_BUDGET_MS = 1200
|
||||
const credentialsCache = new Map<string, AppCredentialsResponse | null>()
|
||||
|
||||
function fetchCredentials(id: string): Promise<AppCredentialsResponse | null> {
|
||||
return rpcClient
|
||||
.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
.then((r) => {
|
||||
credentialsCache.set(id, r)
|
||||
return r
|
||||
})
|
||||
.catch(() => {
|
||||
credentialsCache.set(id, null)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
const result = credentialsCache.has(id)
|
||||
? credentialsCache.get(id) ?? null
|
||||
: await Promise.race([
|
||||
fetchCredentials(id),
|
||||
// Budget exceeded → launch with the static fallback config; the
|
||||
// in-flight RPC still lands in the cache for next time.
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), LAUNCH_CRED_BUDGET_MS)),
|
||||
])
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function closeCredentialModal() {
|
||||
credentialModal.value.show = false
|
||||
}
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
closeCredentialModal()
|
||||
if (id) launchAppNow(id)
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
async function updateApp(id: string) {
|
||||
try {
|
||||
await serverStore.updatePackage(id)
|
||||
} catch (err) {
|
||||
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideload() {
|
||||
if (sideloading.value) return
|
||||
showSideload.value = false
|
||||
sideloadError.value = ''
|
||||
}
|
||||
|
||||
function inferPortMapping(image: string): string {
|
||||
const lower = image.toLowerCase()
|
||||
if (lower.includes('excalidraw')) return '3009:80'
|
||||
if (lower.includes('stirling')) return '3011:8080'
|
||||
if (lower.includes('freshrss')) return '3012:80'
|
||||
if (lower.includes('wallabag')) return '3013:80'
|
||||
if (lower.includes('hedgedoc')) return '3014:3000'
|
||||
if (lower.includes('cyberchef')) return '3015:80'
|
||||
if (lower.includes('mealie')) return '3016:9000'
|
||||
if (lower.includes('pairdrop')) return '3017:3000'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitSideload() {
|
||||
const id = sideloadForm.value.id.trim().toLowerCase()
|
||||
const image = sideloadForm.value.image.trim()
|
||||
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
|
||||
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
|
||||
return
|
||||
}
|
||||
if (!image || !image.includes('/')) {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
const validationError = validateSideloadRequest(id, port, store.packages)
|
||||
if (validationError) {
|
||||
sideloadError.value = validationError
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
containerConfig.title = title
|
||||
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
|
||||
if (port) containerConfig.ports = [port]
|
||||
try {
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Sideload queued...',
|
||||
attempt: 0,
|
||||
})
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id,
|
||||
dockerImage: image,
|
||||
version: 'sideload',
|
||||
containerConfig,
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
closeSideload()
|
||||
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
|
||||
} catch (err) {
|
||||
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: sideloadError.value,
|
||||
attempt: 0,
|
||||
})
|
||||
} finally {
|
||||
sideloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-icon-btn:hover,
|
||||
.sideload-icon-btn:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.sideload-icon-btn-mobile {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.sideload-modal {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
/* Bottom sheet on mobile (flush with the screen edge, so only the top
|
||||
corners round); the md: breakpoint below switches to a centered
|
||||
floating card, matching the wrapper's own items-end -> md:items-center
|
||||
layout switch, so it gets fully rounded corners like every other modal. */
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
.sideload-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-close-btn:hover,
|
||||
.sideload-close-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
.sideload-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.sideload-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" 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>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator while iframe loads. pointer-events:none on the
|
||||
wrapper (see <style>) so this never blocks clicks reaching the
|
||||
iframe underneath even while shown; the bounded timeout below
|
||||
(see aiuiLoadTimedOut) additionally guarantees it disappears
|
||||
outright regardless of backend/handshake state, so it can never
|
||||
wedge the UI permanently. -->
|
||||
<Transition name="fade">
|
||||
<div v-if="aiuiUrl && !aiuiConnected && !aiuiLoadTimedOut" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- AIUI iframe — on mobile, leave room for close bar + tab bar at bottom.
|
||||
No `sandbox` attribute: it was considered and rejected for this
|
||||
phase (AIUI-04, 13-RESEARCH.md Open Question 2). `allow-scripts`
|
||||
together with `allow-same-origin` is the well-known escape pattern,
|
||||
and dropping `allow-same-origin` moves AIUI to an opaque origin,
|
||||
breaking its storage and its origin-checked postMessage bridge — a
|
||||
change bigger than this phase budgeted. The enforced boundary
|
||||
instead is the /aiui/-scoped Content-Security-Policy (nginx) plus
|
||||
the node-side rate limit (G-B3, 13-12); the residual risk (a
|
||||
browser that ignores or partially enforces CSP) is named in
|
||||
13-AI-SPEC.md §6, not silently assumed away. -->
|
||||
<iframe
|
||||
v-if="aiuiUrl"
|
||||
ref="aiuiFrame"
|
||||
:src="aiuiUrl"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
allow="microphone"
|
||||
referrerpolicy="no-referrer"
|
||||
style="background: transparent"
|
||||
/>
|
||||
|
||||
<!-- Fallback when no AIUI URL configured -->
|
||||
<div v-else class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner">
|
||||
<div class="chat-placeholder-icon">
|
||||
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/30">
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 13-08 (D-11): the destructive-tool confirmation dialog — trusted
|
||||
chrome, mounted as a SIBLING of the iframe, never inside it. The
|
||||
component Teleports to body with a full-screen backdrop, so it
|
||||
covers the whole viewport including the area over the iframe, and
|
||||
no ancestor transform can trap its position: fixed. Its text is
|
||||
node-authored, fetched by the ContextBroker over the page's own
|
||||
RPC session — nothing the iframe sends can open or resolve it. -->
|
||||
<ToolConfirmModal
|
||||
:show="!!toolConfirm"
|
||||
:description="toolConfirm?.description ?? ''"
|
||||
@approve="resolveToolConfirm(true)"
|
||||
@deny="resolveToolConfirm(false)"
|
||||
@dismiss="dismissToolConfirm"
|
||||
/>
|
||||
|
||||
<!-- A tool the operator asked for was blocked by an ungranted
|
||||
category. Trusted chrome, and Teleported to body for the same
|
||||
reason ToolConfirmModal is: a transformed ancestor would trap
|
||||
position:fixed. This only OFFERS the settings screen — it never
|
||||
changes a grant itself, so nothing the iframe or the model says
|
||||
can widen permissions. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="permissionNeeded.length" class="chat-permission-offer" role="status">
|
||||
<p class="text-sm text-white/85">
|
||||
{{ t('chat.permissionNeeded', { categories: permissionNeededLabels }) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button class="chat-permission-btn" @click="openAISettings">
|
||||
{{ t('chat.openAISettings') }}
|
||||
</button>
|
||||
<button
|
||||
class="chat-permission-dismiss"
|
||||
:aria-label="t('common.dismiss')"
|
||||
@click="permissionNeeded = []"
|
||||
>
|
||||
<svg class="w-4 h-4" aria-hidden="true" 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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
|
||||
// loading overlay must never be able to wedge the UI permanently regardless
|
||||
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the
|
||||
// AI provider being unreachable must not leave the user staring at a
|
||||
// spinner forever with no way to interact with the panel underneath. This
|
||||
// timeout dismisses the overlay unconditionally after a bounded wait even if
|
||||
// 'ready' never arrives; it does not affect aiuiConnected itself (the
|
||||
// connection indicator dot still reflects the real state).
|
||||
const AIUI_LOAD_TIMEOUT_MS = 8000
|
||||
const aiuiLoadTimedOut = ref(false)
|
||||
let loadTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
// D-14 presentation flags (Phase 02 Plan 07, 02-AIUI-D14.md): AIUI opens
|
||||
// already expanded (chatExpanded) and, on a mobile viewport, on its chat view
|
||||
// rather than its context view (mobileChat). Both are static strings — never
|
||||
// derived from a reactive viewport read, connection state, or timestamp — so
|
||||
// the computed below has no reactive dependencies and its value never
|
||||
// changes after first evaluation, which is exactly what keeps the iframe
|
||||
// `src` byte-identical across re-renders, resizes, and deactivate/reactivate
|
||||
// cycles (the load-bearing constraint that lets the AIUI panel survive a tab
|
||||
// switch without reloading). AIUI decides how to apply mobileChat against its
|
||||
// own viewport rather than neode-ui's, per 02-AIUI-D14.md's D-14b rationale.
|
||||
const D14_FLAGS = 'chatExpanded=true&mobileChat=true'
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
|
||||
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
|
||||
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true&${D14_FLAGS}${demo}`
|
||||
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true&${D14_FLAGS}${demo}`
|
||||
return ''
|
||||
})
|
||||
|
||||
// ⌘K → "Talk to AIUI about it" hands the typed text over as `?ask=`.
|
||||
//
|
||||
// It is delivered by postMessage, NOT by adding a query param to `aiuiUrl`.
|
||||
// That is deliberate: the comment on D14_FLAGS above explains that aiuiUrl must
|
||||
// have no reactive dependencies so the iframe `src` stays byte-identical and
|
||||
// AIUI survives a tab switch. Threading `ask` through the URL would rebuild the
|
||||
// src on every question and reload AIUI, discarding the conversation — the
|
||||
// exact opposite of what this feature is for.
|
||||
//
|
||||
// The ask is queued rather than sent directly, because the common case is
|
||||
// arriving from ⌘K on a cold Chat tab where the iframe has not handshaked yet.
|
||||
// `ready` flushes it.
|
||||
const pendingAsk = ref('')
|
||||
|
||||
function flushAsk() {
|
||||
const text = pendingAsk.value
|
||||
if (!text || !aiuiConnected.value) return
|
||||
const frame = aiuiFrame.value
|
||||
if (!frame?.contentWindow || !aiuiUrl.value) return
|
||||
let targetOrigin: string
|
||||
try {
|
||||
targetOrigin = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
} catch { return }
|
||||
frame.contentWindow.postMessage({ type: 'chat:prefill', text }, targetOrigin)
|
||||
pendingAsk.value = ''
|
||||
// Drop ask/askedAt from the URL so a refresh or a back-nav does not re-ask.
|
||||
const { ask: _a, askedAt: _t, ...rest } = route.query
|
||||
router.replace({ path: route.path, query: rest })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.askedAt,
|
||||
() => {
|
||||
const ask = route.query.ask
|
||||
if (!ask) return
|
||||
pendingAsk.value = String(ask)
|
||||
flushAsk()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
// 13-08 (D-11): the pending destructive-tool confirmation the trusted
|
||||
// chrome is currently showing. Set ONLY from the ContextBroker's
|
||||
// aiui:tool-confirm-request CustomEvent, whose payload is node-fetched
|
||||
// over the page's own RPC session — never from anything the iframe posts.
|
||||
const toolConfirm = ref<{ reqId: string; description: string } | null>(null)
|
||||
|
||||
function onToolConfirmRequest(e: Event) {
|
||||
const detail = (e as CustomEvent).detail as { reqId?: string; description?: string }
|
||||
if (!detail?.reqId || typeof detail.description !== 'string') return
|
||||
toolConfirm.value = { reqId: detail.reqId, description: detail.description }
|
||||
}
|
||||
|
||||
function resolveToolConfirm(approved: boolean) {
|
||||
const current = toolConfirm.value
|
||||
toolConfirm.value = null
|
||||
if (!current) return
|
||||
// The decision travels back to the broker (and from there to the node
|
||||
// over the authenticated RPC session) — never through the iframe.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:tool-confirm-response', {
|
||||
detail: { reqId: current.reqId, approved },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function dismissToolConfirm() {
|
||||
// Closed without a decision: send nothing. The action stays pending on
|
||||
// the node until its own timeout declines it — never silently approved.
|
||||
toolConfirm.value = null
|
||||
}
|
||||
|
||||
function onToolConfirmExpired(e: Event) {
|
||||
// 13-08 on-device UAT: the node no longer holds this pending action
|
||||
// (timed out, or resolved elsewhere) — close the dialog rather than
|
||||
// leave the human an Approve button whose click can only be refused.
|
||||
const detail = (e as CustomEvent).detail as { reqId?: string }
|
||||
if (toolConfirm.value && detail?.reqId === toolConfirm.value.reqId) {
|
||||
toolConfirm.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the operator's question needed was refused because its category
|
||||
// is off. The node reports WHICH categories; we name them and offer the
|
||||
// screen that owns the toggles. Never flips a toggle here — the operator
|
||||
// decides, on the settings screen, in the trusted chrome.
|
||||
const permissionNeeded = ref<string[]>([])
|
||||
|
||||
const permissionNeededLabels = computed(() =>
|
||||
permissionNeeded.value
|
||||
.map((id) => AI_PERMISSION_CATEGORIES.find((c) => c.id === id)?.label ?? id)
|
||||
.join(', '),
|
||||
)
|
||||
|
||||
function onPermissionNeeded(e: Event) {
|
||||
const detail = (e as CustomEvent).detail as { categories?: unknown }
|
||||
const categories = Array.isArray(detail?.categories) ? detail.categories : []
|
||||
const known = categories.filter(
|
||||
(c): c is string => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some((k) => k.id === c),
|
||||
)
|
||||
if (known.length) permissionNeeded.value = known
|
||||
}
|
||||
|
||||
function openAISettings() {
|
||||
permissionNeeded.value = []
|
||||
router.push({ path: '/dashboard/settings', hash: '#ai-data-access' })
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
try {
|
||||
const expected = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
if (event.origin !== expected) return
|
||||
} catch { return }
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
// A ⌘K ask that arrived before the handshake is waiting — send it now.
|
||||
flushAsk()
|
||||
}
|
||||
}
|
||||
|
||||
// The window message listener and the ContextBroker are only-while-visible:
|
||||
// Chat is a main tab that survives a tab switch (KeepAlive), so both follow
|
||||
// activation rather than mount. Idempotent — remove/stop any existing
|
||||
// listener/broker before starting a new one, so two consecutive activations
|
||||
// (Vue fires onActivated on first mount too) never double-arm either.
|
||||
// `aiuiConnected` is set by a one-time 'ready' message from the iframe; once
|
||||
// the iframe survives deactivation that message will not be re-sent on
|
||||
// re-entry, so it must NOT be reset on deactivate.
|
||||
function armChatLive() {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.addEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
window.addEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (aiuiUrl.value) {
|
||||
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
|
||||
broker.start()
|
||||
}
|
||||
if (loadTimeout) clearTimeout(loadTimeout)
|
||||
loadTimeout = null
|
||||
if (aiuiUrl.value && !aiuiConnected.value) {
|
||||
loadTimeout = setTimeout(() => {
|
||||
aiuiLoadTimedOut.value = true
|
||||
loadTimeout = null
|
||||
}, AIUI_LOAD_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
onActivated(() => armChatLive())
|
||||
|
||||
onDeactivated(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
})
|
||||
|
||||
// onActivated is a no-op outside a <KeepAlive> boundary — call it directly
|
||||
// here too so a bare mount (a unit test, or any future non-KeepAlive usage)
|
||||
// still gets the listener and the AIUI ContextBroker. Idempotent, so the
|
||||
// redundant pass this causes on a KeepAlive-wrapped first mount is harmless.
|
||||
onMounted(() => armChatLive())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Teleported to body, so this is positioned against the viewport, not the
|
||||
chat panel. Sits above the iframe but below the confirm modal — a
|
||||
blocking decision must always win over a passive offer. */
|
||||
.chat-permission-offer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(1.25rem + var(--safe-bottom, 0px));
|
||||
z-index: 60;
|
||||
max-width: min(40rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.chat-permission-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: #fdba74;
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-btn:hover {
|
||||
background: rgba(251, 146, 60, 0.24);
|
||||
}
|
||||
|
||||
.chat-permission-dismiss {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-dismiss:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
/* Never let the loading state itself block interaction with the iframe
|
||||
underneath — it has no interactive content of its own, so there is
|
||||
nothing here that needs to capture clicks. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #fb923c;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="cloud-folder-container flex flex-col h-full">
|
||||
<!-- Desktop Back Button + Header -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<BackButton :label="backLabel" @click="goBack" />
|
||||
|
||||
<!-- Folder Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section?.iconBg || 'bg-white/10'">
|
||||
<svg class="w-7 h-7" :class="section?.iconColor || 'text-white/70'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in (section?.iconPaths || [])"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<h1 class="text-2xl font-bold text-white">{{ section?.name || 'Folder' }}</h1>
|
||||
<p class="text-sm text-white/50">{{ section?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="appRunning"
|
||||
@click="openExternal"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in New Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Installed -->
|
||||
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ section?.appLabel }} not running</h3>
|
||||
<p class="text-white/60 mb-4">Install {{ section?.appLabel }} from the App Store to manage your {{ section?.name?.toLowerCase() }}.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Store Error -->
|
||||
<div v-else-if="cloudStore.error" class="glass-card p-6 flex-1 flex flex-col items-center justify-center text-center">
|
||||
<div class="alert-error mb-4">{{ cloudStore.error }}</div>
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="cloudStore.refresh()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Native File Browser (for FileBrowser-backed sections) -->
|
||||
<div
|
||||
v-else-if="useNativeUI"
|
||||
class="flex-1 min-h-0 flex flex-col relative"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- Drag-and-drop overlay -->
|
||||
<div v-if="draggingOver" class="cloud-drop-overlay">
|
||||
<div class="cloud-drop-overlay-inner">
|
||||
<svg class="w-12 h-12 text-white/80 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-white/90">Drop files to upload</p>
|
||||
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/70">Uploading...</span>
|
||||
</div>
|
||||
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
|
||||
<span class="text-sm text-red-400">{{ uploadError }}</span>
|
||||
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
The transition name flips with navigation direction so descending
|
||||
zooms forward and going back up zooms in reverse — matching the
|
||||
cloud → folder route transition. Only the file content zooms; the
|
||||
header + breadcrumb nav above stay fixed in place. -->
|
||||
<Transition :name="folderTransition" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Audio player is now the global bottom bar (GlobalAudioPlayer in App.vue) -->
|
||||
</div>
|
||||
|
||||
<!-- Fallback iframe (for sections without native UI) -->
|
||||
<div v-else class="flex-1 min-h-0 rounded-xl overflow-hidden border border-white/10">
|
||||
<div v-if="!iframeLoaded" class="flex items-center justify-center h-full">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="w-8 h-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<p class="text-sm text-white/60">Loading {{ section?.appLabel }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="appRunning"
|
||||
:src="iframeUrl"
|
||||
class="w-full h-full border-0"
|
||||
:class="{ 'opacity-0': !iframeLoaded }"
|
||||
style="min-height: 500px"
|
||||
@load="iframeLoaded = true"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<ShareModal
|
||||
v-if="shareTarget"
|
||||
:filename="shareTarget.name"
|
||||
:filepath="shareTarget.path"
|
||||
:is-dir="shareTarget.isDir"
|
||||
@close="shareTarget = null"
|
||||
@saved="shareTarget = null"
|
||||
/>
|
||||
|
||||
<!-- Media Lightbox -->
|
||||
<MediaLightbox
|
||||
v-if="lightboxIndex !== null"
|
||||
:items="cloudStore.sortedItems"
|
||||
:start-index="lightboxIndex"
|
||||
:show="lightboxIndex !== null"
|
||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||
:stream-url="cloudStore.streamUrl"
|
||||
@close="lightboxIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const cloudStore = useCloudStore()
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Direction-aware folder zoom: descending into a subfolder plays the same
|
||||
// "depth-forward" feel as the cloud → folder route transition (new arrives from
|
||||
// depth, current zooms out toward the viewer); navigating back up plays its
|
||||
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
|
||||
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
|
||||
let prevFolderDepth = -1
|
||||
watch(() => cloudStore.currentPath, (path) => {
|
||||
const depth = path.split('/').filter(Boolean).length
|
||||
// First render (prevFolderDepth === -1) defaults to forward.
|
||||
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
|
||||
prevFolderDepth = depth
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
const packages = store.packages
|
||||
if (packages[appId]?.state === 'running') return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (packages[alias]?.state === 'running') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface ContentSection {
|
||||
name: string
|
||||
description: string
|
||||
appId: string
|
||||
appLabel: string
|
||||
iconPaths: string[]
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
iframeUrl: string
|
||||
externalUrl: string
|
||||
nativeUI: boolean
|
||||
initialPath: string
|
||||
}
|
||||
|
||||
const origin = computed(() => window.location.origin)
|
||||
|
||||
const sections: Record<string, () => ContentSection> = {
|
||||
photos: () => ({
|
||||
name: 'Photos & Videos',
|
||||
description: 'Auto-backup & browse your media',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
iconBg: 'bg-blue-500/15',
|
||||
iconColor: 'text-blue-400',
|
||||
iframeUrl: `${origin.value}/app/immich/photos`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Photos',
|
||||
}),
|
||||
music: () => ({
|
||||
name: 'Music',
|
||||
description: 'Your music collection',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
iconBg: 'bg-orange-500/15',
|
||||
iconColor: 'text-orange-400',
|
||||
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Songs`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Music',
|
||||
}),
|
||||
documents: () => ({
|
||||
name: 'Documents',
|
||||
description: 'Files, docs & spreadsheets',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
iconBg: 'bg-green-500/15',
|
||||
iconColor: 'text-green-400',
|
||||
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Documents`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Documents',
|
||||
}),
|
||||
files: () => ({
|
||||
name: 'All Files',
|
||||
description: 'Browse your server file system',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
||||
iconBg: 'bg-white/10',
|
||||
iconColor: 'text-white/70',
|
||||
iframeUrl: `${origin.value}/app/filebrowser/`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/',
|
||||
}),
|
||||
}
|
||||
|
||||
const section = computed(() => {
|
||||
const factory = sections[folderId.value]
|
||||
return factory ? factory() : null
|
||||
})
|
||||
|
||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
||||
// Whether we're at the section's root folder. Derived from the route (the URL
|
||||
// is the source of truth) rather than cloudStore.currentPath, which is async
|
||||
// and still holds the previous/blank path on first entry — that staleness is
|
||||
// what made entering e.g. "Photos and videos" wrongly show "Back to Parent
|
||||
// Folder" and break the back action.
|
||||
const atSectionRoot = computed(() =>
|
||||
!section.value || routeFolderPath.value === section.value.initialPath
|
||||
)
|
||||
const backLabel = computed(() => {
|
||||
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
||||
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
||||
})
|
||||
|
||||
// Initialize native file browser when entering a native-UI section.
|
||||
// No reset() here: navigate() serves the per-path cache instantly and
|
||||
// revalidates underneath — resetting wiped the listing and forced a
|
||||
// spinner on every folder entry.
|
||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||
if (native && sec) {
|
||||
const ok = await cloudStore.init()
|
||||
if (ok) {
|
||||
await cloudStore.navigate(path)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
function handlePreview(path: string) {
|
||||
const items = cloudStore.sortedItems
|
||||
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||
const clicked = items.find(item => item.path === path)
|
||||
if (clicked) {
|
||||
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||
void handlePlay(path, clicked.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MediaLightbox internally filters items to media only, so startIndex
|
||||
// must be the index within that filtered list
|
||||
const mediaItems = items.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
const cat = getFileCategory(ext, item.isDir)
|
||||
return cat === 'image' || cat === 'video' || cat === 'audio'
|
||||
})
|
||||
const idx = mediaItems.findIndex(item => item.path === path)
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function onDragOver() {
|
||||
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
|
||||
draggingOver.value = true
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
// Debounce to avoid flicker when dragging between child elements
|
||||
if (dragLeaveTimer) clearTimeout(dragLeaveTimer)
|
||||
dragLeaveTimer = setTimeout(() => { draggingOver.value = false }, 100)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
draggingOver.value = false
|
||||
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
|
||||
const dt = e.dataTransfer
|
||||
if (!dt?.files?.length) return
|
||||
handleUpload(Array.from(dt.files))
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
uploading.value = true
|
||||
uploadError.value = null
|
||||
try {
|
||||
for (const file of files) {
|
||||
await cloudStore.uploadFile(file)
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
await cloudStore.deleteItem(path)
|
||||
}
|
||||
|
||||
async function handlePlay(path: string, name: string) {
|
||||
const url = await cloudStore.streamUrl(path)
|
||||
audioPlayer.play(url, name)
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
if (section.value) {
|
||||
window.open(section.value.externalUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateCloudPath(path: string) {
|
||||
const target = normalizeCloudPath(path, section.value?.initialPath || '/')
|
||||
await router.push({
|
||||
name: 'cloud-folder',
|
||||
params: { folderId: folderId.value },
|
||||
query: target === section.value?.initialPath ? {} : { path: target },
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (useNativeUI.value && !atSectionRoot.value) {
|
||||
navigateCloudPath(parentCloudPath(routeFolderPath.value))
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Mirrors the
|
||||
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
|
||||
blur) so descending into a folder and going back up feel identical to the
|
||||
cloud ⇄ folder route change. -->
|
||||
<style>
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition:
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* Forward (into a deeper folder): new folder arrives from depth while the
|
||||
current one zooms out toward the viewer — matches depth-forward. */
|
||||
.cloud-zoom-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
.cloud-zoom-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back (up to a parent folder): the mirror — new folder shrinks in from the
|
||||
front while the current one recedes into depth — matches depth-back. */
|
||||
.cloud-zoom-back-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.cloud-zoom-back-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-forward-enter-from,
|
||||
.cloud-zoom-forward-leave-to,
|
||||
.cloud-zoom-back-enter-from,
|
||||
.cloud-zoom-back-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<BackButton :label="t('containerDetails.back')" @click="$router.back()" />
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as ContainerStateValue"
|
||||
:health="healthStatus as HealthStatusValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<div v-if="loading" key="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" key="error" class="glass-card p-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="container" key="content" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<!-- Single primary button: Start when stopped, Stop when running,
|
||||
transitional label + spinner while stopping/starting/restarting. -->
|
||||
<button
|
||||
:disabled="isPrimaryDisabled"
|
||||
@click="handlePrimary"
|
||||
:class="primaryButtonClass"
|
||||
class="px-6 py-3 rounded-lg font-medium text-white transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<svg v-if="isTransitional" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ primaryButtonLabel }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || isTransitional || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading || isTransitional"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
{{ t('containerDetails.loadingLogs') }}
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
{{ t('containerDetails.noLogs') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
type ContainerStateValue =
|
||||
| 'created'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'exited'
|
||||
| 'paused'
|
||||
| 'unknown'
|
||||
| 'stopping'
|
||||
| 'starting'
|
||||
| 'restarting'
|
||||
| 'installing'
|
||||
| 'updating'
|
||||
| 'removing'
|
||||
| 'installed'
|
||||
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
return appId.value
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<ContainerStatusData | null>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const healthStatus = ref<string>('unknown')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
|
||||
async function loadContainer() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthStatus() {
|
||||
await store.fetchHealthStatus()
|
||||
healthStatus.value = store.getHealthStatus(appId.value)
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
// Use the async container-restart RPC (returns immediately, backend
|
||||
// flips state to Restarting and spawns the stop+start sequence).
|
||||
await store.restartContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Single-button state helpers mirroring ContainerApps.vue — driven off the
|
||||
// backend state so transitional labels stay accurate across the 5–600s
|
||||
// graceful-stop window.
|
||||
const isTransitional = computed(() => {
|
||||
const s = container.value?.state
|
||||
return (
|
||||
s === 'stopping' ||
|
||||
s === 'starting' ||
|
||||
s === 'restarting' ||
|
||||
s === 'installing' ||
|
||||
s === 'updating' ||
|
||||
s === 'removing'
|
||||
)
|
||||
})
|
||||
|
||||
const isPrimaryDisabled = computed(() => actionLoading.value || isTransitional.value)
|
||||
|
||||
const primaryButtonLabel = computed(() => {
|
||||
const s = container.value?.state
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return t('containerDetails.stopContainer')
|
||||
case 'stopping':
|
||||
return 'Stopping…'
|
||||
case 'starting':
|
||||
return 'Starting…'
|
||||
case 'restarting':
|
||||
return 'Restarting…'
|
||||
case 'installing':
|
||||
return 'Installing…'
|
||||
case 'updating':
|
||||
return 'Updating…'
|
||||
case 'removing':
|
||||
return 'Removing…'
|
||||
default:
|
||||
return t('containerDetails.startContainer')
|
||||
}
|
||||
})
|
||||
|
||||
const primaryButtonClass = computed(() => {
|
||||
const s = container.value?.state
|
||||
if (s === 'running') {
|
||||
return 'glass-button hover:text-white text-white/90'
|
||||
}
|
||||
if (isTransitional.value) {
|
||||
return 'bg-yellow-700/40 text-yellow-200'
|
||||
}
|
||||
return 'bg-green-600 hover:bg-green-500'
|
||||
})
|
||||
|
||||
async function handlePrimary() {
|
||||
const s = container.value?.state
|
||||
if (s === 'running') {
|
||||
return handleStop()
|
||||
}
|
||||
if (!isTransitional.value) {
|
||||
return handleStart()
|
||||
}
|
||||
}
|
||||
|
||||
// Poll every 2s while a transition is in flight so the label updates
|
||||
// without needing a manual refresh.
|
||||
let transitionalPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
transitionalPollInterval = setInterval(async () => {
|
||||
if (isTransitional.value) {
|
||||
try {
|
||||
await loadContainer()
|
||||
} catch {
|
||||
// ignore transient poll errors
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (transitionalPollInterval) clearInterval(transitionalPollInterval)
|
||||
})
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/apps')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
|
||||
<p class="text-white/70">Manage your Archipelago applications</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State (initial load) -->
|
||||
<div v-if="store.loading && !hasAnyApps" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="store.error" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Bundled Apps -->
|
||||
<div
|
||||
v-for="app in bundledApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
|
||||
tabindex="0"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl">{{ app.icon }}</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{{ app.name }}</h3>
|
||||
<p class="text-sm text-white/60">{{ app.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="mb-4">
|
||||
<span
|
||||
:class="getStatusBadgeClass(app.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
>
|
||||
<!-- Loading spinner -->
|
||||
<svg
|
||||
v-if="store.isAppLoading(app.id)"
|
||||
class="w-3 h-3 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
v-else
|
||||
:class="getStatusDotClass(app.id)"
|
||||
class="w-2 h-2 rounded-full"
|
||||
></span>
|
||||
{{ getStatusText(app.id) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Port info -->
|
||||
<div class="text-sm text-white/50 mb-4">
|
||||
<span v-if="store.getAppState(app.id) === 'running'">
|
||||
Port{{ app.ports.length > 1 ? 's' : '' }}:
|
||||
{{ app.ports.map(p => p.host).join(', ') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ app.image.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Single primary button whose label + action depend on visual state.
|
||||
Transitional states (stopping/starting/restarting/installing/
|
||||
updating/removing) show a spinner and are disabled. -->
|
||||
<button
|
||||
:disabled="isPrimaryDisabled(app.id)"
|
||||
@click="handlePrimary(app)"
|
||||
:class="primaryButtonClass(app.id)"
|
||||
class="flex-1 px-4 py-2 rounded text-sm font-medium text-white transition-colors flex items-center justify-center gap-2 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="isTransitional(app.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ primaryButtonLabel(app.id) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Restart button: only visible when running, hidden during transitions -->
|
||||
<button
|
||||
v-if="store.getAppVisualState(app.id) === 'running'"
|
||||
@click="handleRestartApp(app.id)"
|
||||
:disabled="store.isAppLoading(app.id)"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white disabled:cursor-not-allowed transition-colors"
|
||||
:title="'Restart ' + app.name"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Launch button: only when running -->
|
||||
<button
|
||||
v-if="store.getAppVisualState(app.id) === 'running'"
|
||||
type="button"
|
||||
data-controller-launch-btn
|
||||
class="px-4 py-2 glass-button glass-button-warning rounded text-sm font-medium flex items-center gap-2"
|
||||
@click="launchApp(app)"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other containers (not bundled) -->
|
||||
<div
|
||||
v-for="container in otherContainers"
|
||||
:key="container.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl">📦</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{{ extractAppName(container.name) }}</h3>
|
||||
<p class="text-sm text-white/60">{{ container.image }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<ContainerStatus :state="container.state" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click="handleStartContainer(container.name)"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleStopContainer(container.name)"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (when no bundled apps - shouldn't happen) -->
|
||||
<div v-if="!hasAnyApps && !store.loading" class="glass-card p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No apps available</h3>
|
||||
<p class="text-white/60">Check your Archipelago installation</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useContainerStore, type BundledApp } from '@/stores/container'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const store = useContainerStore()
|
||||
const appLauncherStore = useAppLauncherStore()
|
||||
|
||||
// Use enriched bundled apps with runtime data (like lan_address)
|
||||
// Only show apps that actually have a container (hides pre-defined apps on unbundled installs)
|
||||
const bundledApps = computed(() => store.enrichedBundledApps.filter(
|
||||
app => store.getAppState(app.id) !== 'not-installed'
|
||||
))
|
||||
|
||||
// Get current host for launch URLs
|
||||
const currentHost = computed(() => window.location.hostname)
|
||||
|
||||
let startingPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
// Refresh every 10 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// When any bundled app is transitional (stopping/starting/restarting/etc),
|
||||
// poll every 2s so state updates flow through quickly.
|
||||
startingPollInterval = setInterval(async () => {
|
||||
const anyTransitional = bundledApps.value.some((app) => isTransitional(app.id))
|
||||
if (anyTransitional) {
|
||||
try {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (startingPollInterval) clearInterval(startingPollInterval)
|
||||
})
|
||||
|
||||
// Containers that aren't bundled apps
|
||||
const otherContainers = computed(() => {
|
||||
const bundledIds = bundledApps.value.map(a => a.id)
|
||||
return store.containers.filter(c => {
|
||||
const name = c.name.toLowerCase()
|
||||
return !bundledIds.some(id => name.includes(id))
|
||||
})
|
||||
})
|
||||
|
||||
const hasAnyApps = computed(() => bundledApps.value.length > 0 || store.containers.length > 0)
|
||||
|
||||
function extractAppName(containerName: string): string {
|
||||
return containerName
|
||||
.replace('archipelago-', '')
|
||||
.replace('-dev', '')
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'bg-green-500/20 text-green-400'
|
||||
case 'stopped':
|
||||
return 'bg-gray-500/20 text-gray-400'
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
case 'restarting':
|
||||
case 'installing':
|
||||
case 'updating':
|
||||
case 'removing':
|
||||
return 'bg-yellow-500/20 text-yellow-400'
|
||||
case 'not-installed':
|
||||
default:
|
||||
return 'bg-blue-500/20 text-blue-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDotClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'bg-green-400'
|
||||
case 'stopped':
|
||||
return 'bg-gray-400'
|
||||
case 'not-installed':
|
||||
return 'bg-blue-400'
|
||||
default:
|
||||
return 'bg-yellow-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'stopped':
|
||||
return 'Stopped'
|
||||
case 'not-installed':
|
||||
return 'Ready to Start'
|
||||
case 'starting':
|
||||
return 'Starting'
|
||||
case 'stopping':
|
||||
return 'Stopping'
|
||||
case 'restarting':
|
||||
return 'Restarting'
|
||||
case 'installing':
|
||||
return 'Installing'
|
||||
case 'updating':
|
||||
return 'Updating'
|
||||
case 'removing':
|
||||
return 'Removing'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function isTransitional(appId: string): boolean {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
return (
|
||||
vs === 'starting' ||
|
||||
vs === 'stopping' ||
|
||||
vs === 'restarting' ||
|
||||
vs === 'installing' ||
|
||||
vs === 'updating' ||
|
||||
vs === 'removing'
|
||||
)
|
||||
}
|
||||
|
||||
function isPrimaryDisabled(appId: string): boolean {
|
||||
return isTransitional(appId) || store.isAppLoading(appId)
|
||||
}
|
||||
|
||||
function primaryButtonLabel(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'Stop'
|
||||
case 'stopped':
|
||||
case 'not-installed':
|
||||
return 'Start'
|
||||
case 'starting':
|
||||
return 'Starting…'
|
||||
case 'stopping':
|
||||
return 'Stopping…'
|
||||
case 'restarting':
|
||||
return 'Restarting…'
|
||||
case 'installing':
|
||||
return 'Installing…'
|
||||
case 'updating':
|
||||
return 'Updating…'
|
||||
case 'removing':
|
||||
return 'Removing…'
|
||||
default:
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function primaryButtonClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
if (vs === 'running') {
|
||||
return 'glass-button hover:text-white text-white/90 disabled:opacity-60'
|
||||
}
|
||||
if (vs === 'stopped' || vs === 'not-installed') {
|
||||
return 'bg-green-600 hover:bg-green-500 disabled:bg-green-800'
|
||||
}
|
||||
// Transitional: muted appearance
|
||||
return 'bg-yellow-700/40 text-yellow-200 disabled:opacity-80'
|
||||
}
|
||||
|
||||
const backendPort = 5678
|
||||
|
||||
function getLaunchUrl(app: BundledApp): string {
|
||||
// Prefer lan_address from backend (for apps with custom UIs)
|
||||
if (app.lan_address) {
|
||||
// Replace localhost so Launch works when browsing from another machine (e.g. a LAN address)
|
||||
let url = app.lan_address.replace(/localhost/i, currentHost.value)
|
||||
// LND UI (and other app UIs) need backend URL for live data (logs, getinfo proxy)
|
||||
if (app.id === 'lnd') {
|
||||
const backend = `http://${currentHost.value}:${backendPort}`
|
||||
url += (url.includes('?') ? '&' : '?') + 'backend=' + encodeURIComponent(backend)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// Fallback to first configured port
|
||||
const port = app.ports[0]?.host
|
||||
if (!port) return '#'
|
||||
return `http://${currentHost.value}:${port}`
|
||||
}
|
||||
|
||||
function launchApp(app: BundledApp) {
|
||||
const url = getLaunchUrl(app)
|
||||
if (url === '#') return
|
||||
appLauncherStore.open({ url, title: app.name })
|
||||
}
|
||||
|
||||
async function handlePrimary(app: BundledApp) {
|
||||
const vs = store.getAppVisualState(app.id)
|
||||
if (vs === 'running') {
|
||||
return handleStopApp(app.id)
|
||||
}
|
||||
if (vs === 'stopped' || vs === 'not-installed') {
|
||||
return handleStartApp(app)
|
||||
}
|
||||
// Transitional — button should be disabled; ignore.
|
||||
}
|
||||
|
||||
async function handleStartApp(app: BundledApp) {
|
||||
try {
|
||||
// Route through container-start (async) rather than the legacy synchronous
|
||||
// bundled-app-start RPC so Stop/Start exercise the spawn_transitional path
|
||||
// and the UI sees Starting → Running transitions.
|
||||
await store.startContainer(app.id)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopApp(appId: string) {
|
||||
try {
|
||||
await store.stopContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestartApp(appId: string) {
|
||||
try {
|
||||
await store.restartContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to restart app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartContainer(name: string) {
|
||||
try {
|
||||
const appId = name.replace('archipelago-', '').replace('-dev', '')
|
||||
await store.startContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start container:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopContainer(name: string) {
|
||||
try {
|
||||
const appId = name.replace('archipelago-', '').replace('-dev', '')
|
||||
await store.stopContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop container:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Web5" @click="$router.push('/dashboard/web5')" />
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Credentials</h1>
|
||||
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Issue Credential Form -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Issue New Credential</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Issuer Identity <span class="text-red-400">*</span></label>
|
||||
<select v-model="issueForm.issuerId" class="credential-input w-full">
|
||||
<option value="" disabled>Select identity</option>
|
||||
<option v-for="id in identities" :key="id.id" :value="id.id">
|
||||
{{ id.name }} ({{ id.did?.slice(0, 24) }}...)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Credential Type</label>
|
||||
<input v-model="issueForm.type" type="text" placeholder="e.g. NodeOperator" class="credential-input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Subject DID <span class="text-red-400">*</span></label>
|
||||
<input v-model="issueForm.subjectDid" type="text" placeholder="did:key:z6Mk..." class="credential-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Claims (JSON)</label>
|
||||
<textarea v-model="issueForm.claimsJson" rows="3" placeholder='{"role": "admin", "level": "full"}' class="credential-input w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Expiration (optional)</label>
|
||||
<input v-model="issueForm.expiresAt" type="datetime-local" class="credential-input w-full" />
|
||||
</div>
|
||||
<button @click="issueCredential" :disabled="issuing || !issueForm.issuerId || !issueForm.subjectDid" class="glass-button px-6 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ issuing ? 'Issuing...' : 'Issue Credential' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credentials List -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Your Credentials</h2>
|
||||
<button @click="loadCredentials" :disabled="loadingCreds" class="glass-button glass-button-sm px-3 py-1.5 text-xs">
|
||||
{{ loadingCreds ? 'Loading...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingCreds && credentials.length === 0" class="text-white/50 text-sm py-8 text-center">
|
||||
Loading credentials...
|
||||
</div>
|
||||
<div v-else-if="credentials.length === 0" class="text-white/50 text-sm py-8 text-center">
|
||||
No credentials yet. Issue one above or receive one from a peer.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="loadingCreds" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing credentials...
|
||||
</div>
|
||||
<div
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
|
||||
@click="selectedCredential = cred"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ credentialTypeLabel(cred) }}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="cred.status === 'revoked' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'"
|
||||
>
|
||||
{{ cred.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-white/40 font-mono">{{ cred.id?.slice(0, 20) }}...</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/50 space-y-0.5">
|
||||
<p>Issuer: <span class="font-mono">{{ cred.issuer?.slice(0, 32) }}...</span></p>
|
||||
<p>Subject: <span class="font-mono">{{ cred.credentialSubject?.id?.slice(0, 32) }}...</span></p>
|
||||
<p>Issued: {{ formatDate(cred.issuanceDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verify Credential -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Verify Credential</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Credential ID</label>
|
||||
<input v-model="verifyId" type="text" placeholder="urn:uuid:..." class="credential-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="verifyCredential" :disabled="verifying || !verifyId" class="glass-button px-6 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ verifying ? 'Verifying...' : 'Verify' }}
|
||||
</button>
|
||||
<div v-if="verifyResult !== null" class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="verifyResult ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="verifyResult ? 'text-green-400' : 'text-red-400'">
|
||||
{{ verifyResult ? 'Valid' : 'Invalid' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credential Detail Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||
<button @click="selectedCredential = null" class="text-white/60 hover:text-white text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Type</p>
|
||||
<p class="text-white font-medium">{{ credentialTypeLabel(selectedCredential) }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">ID</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.id }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Issuer</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.issuer }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Subject</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.credentialSubject?.id }}</p>
|
||||
</div>
|
||||
<div v-if="selectedCredential.credentialSubject" class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Claims</p>
|
||||
<pre class="text-white/80 font-mono text-xs whitespace-pre-wrap">{{ formatClaims(selectedCredential.credentialSubject) }}</pre>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Issued</p>
|
||||
<p class="text-white/80 text-xs">{{ formatDate(selectedCredential.issuanceDate) }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Expires</p>
|
||||
<p class="text-white/80 text-xs">{{ selectedCredential.expirationDate ? formatDate(selectedCredential.expirationDate) : 'Never' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Proof</p>
|
||||
<p class="text-white/80 text-xs">{{ selectedCredential.proof?.type }} — {{ selectedCredential.proof?.proofPurpose }}</p>
|
||||
<p class="text-white/60 font-mono text-xs mt-1 break-all">{{ selectedCredential.proof?.proofValue }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Status</p>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="selectedCredential.status === 'revoked' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'"
|
||||
>
|
||||
{{ selectedCredential.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="copyCredentialJson" class="glass-button px-4 py-2 text-sm">
|
||||
{{ credCopied ? 'Copied!' : 'Copy JSON' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedCredential.status !== 'revoked'"
|
||||
@click="revokeSelected"
|
||||
:disabled="revoking"
|
||||
class="glass-button px-4 py-2 text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
{{ revoking ? 'Revoking...' : 'Revoke' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toast" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 px-6 py-3 rounded-xl text-sm font-medium shadow-lg"
|
||||
:class="toast.type === 'error' ? 'bg-red-500/90 text-white' : 'bg-green-500/90 text-white'"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
interface Credential {
|
||||
'@context'?: string[]
|
||||
id: string
|
||||
type?: string[]
|
||||
issuer: string
|
||||
credentialSubject?: { id: string; [key: string]: unknown }
|
||||
issuanceDate?: string
|
||||
expirationDate?: string | null
|
||||
proof?: {
|
||||
type: string
|
||||
created: string
|
||||
verificationMethod: string
|
||||
proofPurpose: string
|
||||
proofValue: string
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
// Cached: revisits paint identities/credentials instantly and revalidate
|
||||
// behind them (errors keep the last-known lists).
|
||||
const identitiesRes = useCachedResource<Identity[]>({
|
||||
key: 'credentials.identities',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<{ identities: Identity[] }>({
|
||||
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return result?.identities || []
|
||||
},
|
||||
persist: false, // identity records — identity payload (T-02-01)
|
||||
})
|
||||
const credentialsRes = useCachedResource<Credential[]>({
|
||||
key: 'credentials.list',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
||||
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return result?.credentials || []
|
||||
},
|
||||
persist: false, // credential material — identity payload (T-02-01)
|
||||
})
|
||||
const identities = computed(() => identitiesRes.data.value ?? [])
|
||||
const credentials = computed(() => credentialsRes.data.value ?? [])
|
||||
const loadingCreds = computed(() =>
|
||||
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
|
||||
const selectedCredential = ref<Credential | null>(null)
|
||||
const credCopied = ref(false)
|
||||
const revoking = ref(false)
|
||||
|
||||
// Issue form
|
||||
const issueForm = ref({
|
||||
issuerId: '',
|
||||
subjectDid: '',
|
||||
type: 'NodeOperator',
|
||||
claimsJson: '{}',
|
||||
expiresAt: '',
|
||||
})
|
||||
const issuing = ref(false)
|
||||
|
||||
// Verify
|
||||
const verifyId = ref('')
|
||||
const verifying = ref(false)
|
||||
const verifyResult = ref<boolean | null>(null)
|
||||
|
||||
// Toast
|
||||
const toast = ref<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
||||
toast.value = { message, type }
|
||||
setTimeout(() => { toast.value = null }, 3000)
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return 'N/A'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function credentialTypeLabel(cred: Credential): string {
|
||||
if (!cred.type || cred.type.length === 0) return 'Credential'
|
||||
// Return the most specific type (last non-VerifiableCredential type)
|
||||
const specific = cred.type.filter((t: string) => t !== 'VerifiableCredential')
|
||||
return specific.length > 0 ? (specific[specific.length - 1] ?? 'Credential') : 'VerifiableCredential'
|
||||
}
|
||||
|
||||
function formatClaims(subject: Record<string, unknown>): string {
|
||||
const claims = { ...subject }
|
||||
delete claims.id
|
||||
return JSON.stringify(claims, null, 2)
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
await credentialsRes.refresh()
|
||||
if (credentialsRes.error.value) {
|
||||
showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function issueCredential() {
|
||||
if (!issueForm.value.issuerId || !issueForm.value.subjectDid) {
|
||||
showToast('Issuer identity and subject DID are required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
let claims: Record<string, unknown>
|
||||
try {
|
||||
claims = JSON.parse(issueForm.value.claimsJson)
|
||||
} catch {
|
||||
showToast('Invalid JSON in claims field', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
issuing.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.issue-credential',
|
||||
params: {
|
||||
issuer_id: issueForm.value.issuerId,
|
||||
subject_did: issueForm.value.subjectDid,
|
||||
type: issueForm.value.type || 'VerifiableCredential',
|
||||
claims,
|
||||
expires_at: issueForm.value.expiresAt || undefined,
|
||||
},
|
||||
})
|
||||
showToast('Credential issued successfully')
|
||||
issueForm.value.subjectDid = ''
|
||||
issueForm.value.claimsJson = '{}'
|
||||
issueForm.value.expiresAt = ''
|
||||
await loadCredentials()
|
||||
} catch (e) {
|
||||
showToast(`Failed to issue credential: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
issuing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCredential() {
|
||||
if (!verifyId.value) {
|
||||
showToast('Enter a credential ID to verify', 'error')
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
verifyResult.value = null
|
||||
try {
|
||||
const result = await rpcClient.call<{ valid: boolean }>({
|
||||
method: 'identity.verify-credential',
|
||||
params: { id: verifyId.value },
|
||||
})
|
||||
verifyResult.value = result.valid
|
||||
} catch (e) {
|
||||
showToast(`Verification failed: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
verifyResult.value = false
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSelected() {
|
||||
if (!selectedCredential.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.revoke-credential',
|
||||
params: { id: selectedCredential.value.id },
|
||||
})
|
||||
showToast('Credential revoked')
|
||||
selectedCredential.value = null
|
||||
await loadCredentials()
|
||||
} catch (e) {
|
||||
showToast(`Failed to revoke: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCredentialJson() {
|
||||
if (!selectedCredential.value) return
|
||||
const json = JSON.stringify(selectedCredential.value, null, 2)
|
||||
try {
|
||||
await navigator.clipboard.writeText(json)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = json
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
credCopied.value = true
|
||||
setTimeout(() => { credCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Both resources fetch themselves on first use (skipping the fetch entirely
|
||||
// when the cached value is fresh).
|
||||
|
||||
defineExpose({ credentials, loadCredentials })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.credential-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.credential-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
select.credential-input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(255,255,255,0.5)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
select.credential-input option {
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
}
|
||||
textarea.credential-input {
|
||||
resize: vertical;
|
||||
min-height: 4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Skip to main content link for keyboard users -->
|
||||
<!-- Skip-to-content handled by controller nav sidebar→main transition -->
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||
<div
|
||||
ref="bgDefault"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="[
|
||||
{ 'bg-transitioning-out': showAltBackground },
|
||||
{ 'zoom-reveal-bg': showZoomIn }
|
||||
]"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<!-- Background - detail layer (only visible during app/marketplace detail 3D transition) -->
|
||||
<div
|
||||
ref="bgAlt"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="{ 'bg-transitioning-in': showAltBackground }"
|
||||
style="background-image: url(/assets/img/bg-intro-3.jpg)"
|
||||
/>
|
||||
<!-- Glitch overlays - trigger on background change -->
|
||||
<div
|
||||
class="bg-glitch-layer-1"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<div
|
||||
class="bg-glitch-layer-2"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<div
|
||||
class="bg-glitch-scan"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
/>
|
||||
<!-- Glitch overlays removed — only intro glitch plays (via isGlitching) -->
|
||||
</div>
|
||||
|
||||
<!-- Oomph accent - brief impact flash when dashboard loads -->
|
||||
<div
|
||||
v-if="showZoomIn"
|
||||
class="fixed inset-0 pointer-events-none z-[100] oomph-flash"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Reveal flashes and glitch overlay - enthralling entrance -->
|
||||
<div
|
||||
v-if="showZoomIn"
|
||||
class="fixed inset-0 pointer-events-none z-[99] reveal-flash-glitch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Background overlay. The web5/server backdrop is a much lighter image
|
||||
than the others, so it gets a heavier scrim for text contrast. -->
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
|
||||
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
<!-- Sidebar - Desktop Only -->
|
||||
<DashboardSidebar :show-zoom-in="showZoomIn" @logout="handleLogout" />
|
||||
|
||||
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
||||
<main
|
||||
id="main-content"
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
@touchstart.passive="onContentTouchStart"
|
||||
@touchend.passive="onContentTouchEnd"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Banners -->
|
||||
<ConnectionBanner />
|
||||
|
||||
<div class="perspective-container-wrapper glass-piece" :class="{ 'glass-throw-content': showZoomIn && !isHomeRoute }">
|
||||
<div class="perspective-container">
|
||||
<DashboardRouterView
|
||||
:mobile-tab-padding-top="mobileTabPaddingTop"
|
||||
:needs-mobile-back-button-space="needsMobileBackButtonSpace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
||||
<AppSession
|
||||
:app-id-prop="appLauncher.panelAppId"
|
||||
:path-prop="appLauncher.panelPath ?? undefined"
|
||||
@close="appLauncher.closePanel()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
||||
<DashboardMobileNav ref="mobileNavRef" :show-zoom-in="showZoomIn" />
|
||||
|
||||
<!-- Health Notifications Toast -->
|
||||
<HealthNotifications />
|
||||
|
||||
<!-- First-use companion intro overlay -->
|
||||
<CompanionIntroOverlay />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||
|
||||
import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
||||
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
||||
import DashboardRouterView from '@/views/dashboard/DashboardRouterView.vue'
|
||||
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
|
||||
useIbdFinishWatcher()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const showZoomIn = ref(false)
|
||||
const pendingTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function scheduledTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(fn, ms)
|
||||
pendingTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const showAltBackground = ref(false)
|
||||
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
||||
const isGlitching = ref(false)
|
||||
|
||||
const backgroundImage = computed(() => {
|
||||
const mapped = ROUTE_BACKGROUNDS[route.path]
|
||||
if (mapped) return mapped
|
||||
// Detail/sub pages inherit their parent tab's background so they stay
|
||||
// visually "inside" the section instead of snapping to the home backdrop.
|
||||
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
|
||||
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
|
||||
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
||||
return 'bg-home.webp'
|
||||
})
|
||||
|
||||
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens
|
||||
// while it's showing so light text keeps its contrast.
|
||||
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
||||
|
||||
const isDarkRoute = computed(() => {
|
||||
const p = route.path
|
||||
return p.includes('/dashboard/web5') ||
|
||||
p.includes('/dashboard/server') ||
|
||||
p.includes('/dashboard/settings') ||
|
||||
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
|
||||
p.includes('/dashboard/marketplace') ||
|
||||
p.includes('/dashboard/discover') ||
|
||||
p.includes('/dashboard/cloud')
|
||||
})
|
||||
|
||||
const showDarkOverlay = ref(isDarkRoute.value)
|
||||
let overlayTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(isDarkRoute, (dark) => {
|
||||
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
||||
if (dark) {
|
||||
showDarkOverlay.value = true
|
||||
} else {
|
||||
overlayTimer = scheduledTimeout(() => { showDarkOverlay.value = false }, 450)
|
||||
}
|
||||
})
|
||||
|
||||
const WEB5_DETAIL_ROUTES = ['/dashboard/server/federation', '/dashboard/monitoring', '/dashboard/fleet']
|
||||
const needsMobileBackButtonSpace = computed(() =>
|
||||
isDetailRoute(route.path) || WEB5_DETAIL_ROUTES.includes(route.path)
|
||||
)
|
||||
|
||||
const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null)
|
||||
|
||||
const mobileTabPaddingTop = computed(() => {
|
||||
return mobileNavRef.value?.mobileTabPaddingTop ?? 0
|
||||
})
|
||||
|
||||
function activateMainScroll() {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
|
||||
active.blur()
|
||||
document.getElementById('main-content')?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipe left/right to move between the mobile top tabs ──────────────────
|
||||
// Apps screen: My Apps ⇄ App Store ⇄ Services
|
||||
// Web5 screen: Web5 ⇄ Cloud ⇄ Network ⇄ Mesh
|
||||
// Only active while the matching tab strip is actually showing (mobile).
|
||||
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
|
||||
const APPS_TABS: TabTarget[] = [
|
||||
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
|
||||
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
|
||||
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
|
||||
]
|
||||
const NET_TABS: TabTarget[] = [
|
||||
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
|
||||
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
|
||||
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
|
||||
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
|
||||
]
|
||||
|
||||
function activeAppsKey(): string {
|
||||
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
|
||||
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
|
||||
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
|
||||
return ''
|
||||
}
|
||||
function activeNetKey(): string {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard/web5')) return 'web5'
|
||||
if (p.startsWith('/dashboard/cloud')) return 'cloud'
|
||||
if (p.startsWith('/dashboard/server')) return 'server'
|
||||
if (p.startsWith('/dashboard/mesh')) return 'mesh'
|
||||
return ''
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
let swipeSuppressed = false
|
||||
function onContentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
// Don't begin a tab swipe when the gesture starts on an app icon (let the icon
|
||||
// handle tap/long-press) or on a horizontally-scrollable category strip (let
|
||||
// it scroll its own chips). Swiping anywhere else still changes tabs.
|
||||
swipeSuppressed = !!(
|
||||
e.target instanceof Element &&
|
||||
e.target.closest('.app-icon-item, .mobile-category-strip')
|
||||
)
|
||||
touchStartX = t.clientX
|
||||
touchStartY = t.clientY
|
||||
touchStartTime = e.timeStamp
|
||||
}
|
||||
function onContentTouchEnd(e: TouchEvent) {
|
||||
if (swipeSuppressed) { swipeSuppressed = false; return }
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
const dx = t.clientX - touchStartX
|
||||
const dy = t.clientY - touchStartY
|
||||
const dt = e.timeStamp - touchStartTime
|
||||
// Clear horizontal flick: far enough, mostly sideways, and quick.
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
|
||||
|
||||
const nav = mobileNavRef.value
|
||||
if (!nav) return
|
||||
let tabs: TabTarget[] | null = null
|
||||
let activeKey = ''
|
||||
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
|
||||
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
|
||||
if (!tabs) return
|
||||
|
||||
const idx = tabs.findIndex(tb => tb.key === activeKey)
|
||||
if (idx < 0) return
|
||||
const next = idx + (dx < 0 ? 1 : -1) // swipe left → next tab, right → previous
|
||||
if (next < 0 || next >= tabs.length) return
|
||||
const target = tabs[next]
|
||||
if (!target) return
|
||||
router.push(target.to).catch(() => {})
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
showAltBackground.value = isAppDetails
|
||||
|
||||
if (isAppDetails && !wasAppDetails) {
|
||||
scheduledTimeout(() => {
|
||||
isGlitching.value = true
|
||||
scheduledTimeout(() => { isGlitching.value = false }, 375)
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.body.classList.add('dashboard-active')
|
||||
if (loginTransition.justCompletedOnboarding) {
|
||||
// Full glitchy reveal — only on the very first dashboard entry
|
||||
// right after onboarding (one-time event, persists in feel).
|
||||
// force=true: onboarding is already marked complete by now, so the
|
||||
// un-forced call would gate itself silent.
|
||||
playDashboardLoadOomph(true)
|
||||
showZoomIn.value = true
|
||||
loginTransition.setIntroCinematicPlaying(true)
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustCompletedOnboarding(false)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
const triggerRevealGlitch = () => {
|
||||
isGlitching.value = true
|
||||
scheduledTimeout(() => { isGlitching.value = false }, 380)
|
||||
}
|
||||
scheduledTimeout(triggerRevealGlitch, 500)
|
||||
scheduledTimeout(triggerRevealGlitch, 1200)
|
||||
scheduledTimeout(triggerRevealGlitch, 2000)
|
||||
scheduledTimeout(() => {
|
||||
showZoomIn.value = false
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
}, 8000)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 4000)
|
||||
} else if (loginTransition.justLoggedIn) {
|
||||
// Regular re-login — no zoom, no glitch. Just land on the
|
||||
// dashboard and kick off the welcome typing quickly.
|
||||
playDashboardLoadOomph()
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKioskShortcuts)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-active')
|
||||
// Timers are cleared below; make sure overlays waiting on the cinematic
|
||||
// aren't left blocked forever if we unmount mid-reveal.
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
window.removeEventListener('keydown', handleKioskShortcuts)
|
||||
for (const id of pendingTimers) clearTimeout(id)
|
||||
pendingTimers.length = 0
|
||||
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
||||
})
|
||||
|
||||
function isKioskMode(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function handleKioskShortcuts(e: KeyboardEvent) {
|
||||
if (!isKioskMode()) return
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
if (e.key === 'R' || e.key === 'r') {
|
||||
e.preventDefault()
|
||||
router.push('/recovery')
|
||||
} else if (e.key === 'H' || e.key === 'h') {
|
||||
e.preventDefault()
|
||||
router.push('/dashboard')
|
||||
} else if (e.key === 'Q' || e.key === 'q') {
|
||||
e.preventDefault()
|
||||
if (confirm('Reboot the server?')) {
|
||||
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await store.logout()
|
||||
} catch {
|
||||
/* proceed to login regardless */
|
||||
}
|
||||
router.push('/login').catch(() => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Styles extracted to dashboard/dashboard-styles.css -->
|
||||
@@ -0,0 +1,664 @@
|
||||
<template>
|
||||
<div class="discover-container">
|
||||
<!-- Navigation Bar (always at top) -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div ref="discoverHeaderRef" class="app-header-desktop mb-6 items-center gap-4 relative">
|
||||
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="discover-category-select">App Store category</label>
|
||||
<select
|
||||
id="discover-category-select"
|
||||
class="segmented-select-control"
|
||||
value="discover"
|
||||
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile: categories + search -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero + Featured + Banner (only when no search) -->
|
||||
<template v-if="!searchQuery">
|
||||
<DiscoverHero
|
||||
:total-apps="allApps.length"
|
||||
:installed-count="installedCount"
|
||||
/>
|
||||
|
||||
<FeaturedApps
|
||||
:featured-apps="featuredApps"
|
||||
:show-stagger="showStagger"
|
||||
:containers-scanned="containersScanned"
|
||||
:installing-apps="installingApps"
|
||||
:is-installed="isInstalled"
|
||||
:is-starting-up="isStartingUp"
|
||||
:get-app-tier="getAppTier"
|
||||
@view-details="viewAppDetails"
|
||||
@launch="launchInstalledApp"
|
||||
@install="handleInstall"
|
||||
/>
|
||||
|
||||
<!-- Featured App Banner (from catalog or hardcoded) -->
|
||||
<div
|
||||
v-if="featuredBanner"
|
||||
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
|
||||
>
|
||||
<img
|
||||
:src="featuredBanner.banner"
|
||||
:alt="featuredBanner.headline"
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="featured-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
|
||||
@click.stop="launchInstalledApp(featuredBannerApp)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Launch</button>
|
||||
<button
|
||||
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
|
||||
@click.stop="handleInstall(featuredBannerApp)"
|
||||
:disabled="installingApps.has(featuredBannerApp.id)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile companion app banner — opens the download/pairing modal -->
|
||||
<CompanionBanner />
|
||||
|
||||
<!-- Category Section Divider -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">all</span>
|
||||
<h2 class="text-xl font-bold text-white">Available to Install</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search results header -->
|
||||
<div v-else class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">search</span>
|
||||
<h2 class="text-xl font-bold text-white">Search Results</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
|
||||
<!-- Community Load Error -->
|
||||
<div v-if="communityError" class="alert-error mb-4">
|
||||
{{ communityError }}
|
||||
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
||||
</div>
|
||||
|
||||
<AppGrid
|
||||
:filtered-apps="filteredApps"
|
||||
:show-stagger="showStagger"
|
||||
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
|
||||
:containers-scanned="containersScanned"
|
||||
:installing-apps="installingApps"
|
||||
:is-installed="isInstalled"
|
||||
:is-starting-up="isStartingUp"
|
||||
:get-installed-state="getInstalledState"
|
||||
:get-app-tier="getAppTier"
|
||||
:is-loading="loadingCommunity || nostrLoading"
|
||||
:loading-message="nostrLoading ? 'Querying Nostr relays...' : 'Loading...'"
|
||||
:nostr-error="nostrError"
|
||||
:is-nostr-category="selectedCategory === 'nostr'"
|
||||
:search-query="searchQuery"
|
||||
@view-details="viewAppDetails"
|
||||
@launch="launchInstalledApp"
|
||||
@install="handleInstall"
|
||||
@retry-nostr="retryNostr"
|
||||
/>
|
||||
|
||||
<!-- Manifesto Footer (only when no search) -->
|
||||
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag text-orange-400/80">manifesto</span>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
|
||||
"Privacy is not about having something to hide. Privacy is about having the right to choose
|
||||
what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance.
|
||||
Every service you run on your own hardware is a vote for a future where individuals — not
|
||||
corporations — control their digital lives."
|
||||
</blockquote>
|
||||
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
</div>
|
||||
|
||||
<!-- First-install version chooser (Bitcoin Knots / Core) -->
|
||||
<InstallVersionModal
|
||||
:show="showInstallModal"
|
||||
:app-id="installModalApp?.id || ''"
|
||||
:app="installModalApp"
|
||||
@close="showInstallModal = false; installModalApp = null"
|
||||
@confirm="onInstallModalConfirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let discoverAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import CompanionBanner from './discover/CompanionBanner.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
|
||||
const showStagger = !discoverAnimationDone
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Community marketplace + Bitcoin prune-status — routed through the SAME
|
||||
// shared cache keys the 02-02 tracer introduced on Marketplace.vue
|
||||
// ('app-catalog', 'bitcoin.prune-status') rather than duplicating the
|
||||
// fetch (02-04). Both views populate the same cache entry, whichever one
|
||||
// happens to trigger a given fetch — the store doesn't care which caller's
|
||||
// fetcher ran, only the resulting cached value.
|
||||
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||
key: 'app-catalog',
|
||||
// Preserves this view's existing dynamic-catalog-first behavior (Marketplace's
|
||||
// own fetcher for this key is the simpler getCuratedAppList()-only form —
|
||||
// both are valid producers of the same shared cache entry).
|
||||
fetcher: async () => {
|
||||
const catalog = await fetchAppCatalog()
|
||||
if (catalog) {
|
||||
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
|
||||
return catalog.apps
|
||||
}
|
||||
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
|
||||
return getCuratedAppList()
|
||||
},
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
})
|
||||
// Featured banner payload lives on its own cache key (WR-01), deliberately
|
||||
// NOT derived from catalogResource's fetcher: 'app-catalog' is a shared key
|
||||
// with Marketplace.vue, whose simpler getCuratedAppList()-only fetcher can
|
||||
// win the in-flight dedup race and never populate the featured payload.
|
||||
// fetchAppCatalog() has its own internal memoization (1h TTL + localStorage
|
||||
// fallback), so this second subscription is normally a cache hit, not an
|
||||
// extra network round trip.
|
||||
const catalogFeatured = useCachedResource<CatalogFeatured | null>({
|
||||
key: 'app-catalog:featured',
|
||||
fetcher: async () => (await fetchAppCatalog())?.featured ?? null,
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
}).data
|
||||
const communityApps = computed(() => catalogResource.data.value ?? [])
|
||||
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
|
||||
// Keep-last-value error banner (D-07): a failed background refresh never
|
||||
// raises a toast, it only surfaces here — content stays on screen either way.
|
||||
const communityError = computed(() => catalogResource.error.value ?? '')
|
||||
function loadCommunityMarketplace() {
|
||||
return catalogResource.refresh()
|
||||
}
|
||||
|
||||
interface BitcoinStatusResponse {
|
||||
blockchain_info?: { pruned?: boolean }
|
||||
}
|
||||
const pruneStatusResource = useCachedResource<BitcoinStatusResponse | null>({
|
||||
key: 'bitcoin.prune-status',
|
||||
fetcher: async (signal) => {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal })
|
||||
if (!res.ok) throw new Error(`bitcoin-status responded ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
persist: true,
|
||||
})
|
||||
const bitcoinPruned = computed(() => pruneStatusResource.data.value?.blockchain_info?.pruned === true)
|
||||
function loadBitcoinPruneStatus() {
|
||||
return pruneStatusResource.refresh()
|
||||
}
|
||||
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
const discoverHeaderRef = ref<HTMLElement | null>(null)
|
||||
const discoverPrimaryRef = ref<HTMLElement | null>(null)
|
||||
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
discoverHeaderRef,
|
||||
discoverPrimaryRef,
|
||||
discoverCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation.
|
||||
// The store's watcher (stores/server.ts) handles install-progress updates
|
||||
// globally, so this view doesn't need its own watcher. Previously had a
|
||||
// local watcher that duplicated logic using byte counters only — it has
|
||||
// been removed in favour of the store's phase-aware mapping.
|
||||
const installingApps = serverStore.installingApps
|
||||
|
||||
// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core)
|
||||
const showInstallModal = ref(false)
|
||||
const installModalApp = ref<MarketplaceApp | null>(null)
|
||||
|
||||
function navigateToMarketplace(categoryId: string) {
|
||||
router.push({ name: 'marketplace', query: { category: categoryId } })
|
||||
}
|
||||
|
||||
function selectDiscoverCategory(categoryId: string) {
|
||||
if (categoryId === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
navigateToMarketplace(categoryId)
|
||||
}
|
||||
|
||||
// Nostr community marketplace state (community/catalog state is declared
|
||||
// above, alongside the shared catalogResource/pruneStatusResource)
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
const nostrLoading = ref(false)
|
||||
const nostrError = ref('')
|
||||
|
||||
async function loadNostrMarketplace() {
|
||||
if (nostrApps.value.length > 0 || nostrLoading.value) return
|
||||
nostrLoading.value = true
|
||||
nostrError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.marketplaceDiscover()
|
||||
nostrApps.value = res.apps.map(app => ({
|
||||
id: app.manifest.app_id,
|
||||
title: app.manifest.name,
|
||||
version: app.manifest.version,
|
||||
description: typeof app.manifest.description === 'string'
|
||||
? app.manifest.description
|
||||
: app.manifest.description,
|
||||
icon: app.manifest.icon_url || '',
|
||||
author: app.manifest.author.name,
|
||||
dockerImage: app.manifest.container.image,
|
||||
repoUrl: app.manifest.repo_url,
|
||||
category: app.manifest.category,
|
||||
source: 'nostr',
|
||||
trustScore: app.trust_score,
|
||||
trustTier: app.trust_tier,
|
||||
relayCount: app.relay_count,
|
||||
}))
|
||||
} catch (e) {
|
||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
if (import.meta.env.DEV) console.warn('Nostr marketplace discovery failed:', e)
|
||||
} finally {
|
||||
nostrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retryNostr() {
|
||||
nostrApps.value = []
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
|
||||
const installedPackages = computed(() => store.data?.['package-data'] || {})
|
||||
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||
// timeout, treat the scan as done so cards render their normal install state.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
|
||||
|
||||
const allApps = computed(() => {
|
||||
const local: (MarketplaceApp & { category: string; source: string })[] = []
|
||||
const community = communityApps.value.map(app => ({
|
||||
...app,
|
||||
category: categorizeCommunityApp(app),
|
||||
source: 'community'
|
||||
}))
|
||||
const base = [...local, ...community]
|
||||
|
||||
if (nostrApps.value.length > 0) {
|
||||
const existingIds = new Set(base.map(a => a.id))
|
||||
const nostrMerged = nostrApps.value
|
||||
.filter(app => !existingIds.has(app.id))
|
||||
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
|
||||
return [...base, ...nostrMerged]
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
app.title?.toLowerCase().includes(query) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(query)) ||
|
||||
(typeof app.description === 'object' && app.description?.short?.toLowerCase().includes(query)) ||
|
||||
app.id?.toLowerCase().includes(query) ||
|
||||
app.author?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
// Hide installed apps and web-only links (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && app.dockerImage)
|
||||
return apps
|
||||
})
|
||||
|
||||
const installedCount = computed(() => {
|
||||
return allApps.value.filter(app => isInstalled(app.id)).length
|
||||
})
|
||||
|
||||
// Featured banner — from catalog.json or first FEATURED_DEFINITIONS entry with banner
|
||||
const featuredBanner = computed(() => {
|
||||
if (catalogFeatured.value) return catalogFeatured.value
|
||||
const first = FEATURED_DEFINITIONS.find(f => f.banner)
|
||||
if (!first) return null
|
||||
const app = allApps.value.find(a => a.id === first.id)
|
||||
if (!app) return null
|
||||
return { id: first.id, banner: first.banner!, headline: app.title ?? first.id, description: first.desc, tag: first.tag }
|
||||
})
|
||||
|
||||
const featuredBannerApp = computed(() => {
|
||||
if (!featuredBanner.value) return null
|
||||
return allApps.value.find(a => a.id === featuredBanner.value!.id) ?? null
|
||||
})
|
||||
|
||||
const featuredApps = computed<FeaturedApp[]>(() => {
|
||||
return FEATURED_DEFINITIONS
|
||||
.map(f => {
|
||||
const app = allApps.value.find(a => a.id === f.id)
|
||||
if (!app) return null
|
||||
return { ...app, featuredDescription: f.desc, privacyTag: f.tag } as FeaturedApp
|
||||
})
|
||||
.filter((a): a is FeaturedApp => a !== null)
|
||||
})
|
||||
|
||||
|
||||
function isInstalled(appId: string): boolean {
|
||||
if (appId in installedPackages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
||||
}
|
||||
|
||||
function getInstalledState(appId: string): string | null {
|
||||
const pkg = installedPackages.value[appId]
|
||||
if (pkg) return pkg.state
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const a of aliases) {
|
||||
const aliasPkg = installedPackages.value[a]
|
||||
if (aliasPkg) return aliasPkg.state
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isStartingUp(appId: string): boolean {
|
||||
const state = getInstalledState(appId)
|
||||
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
|
||||
}
|
||||
|
||||
function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
||||
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
async function handleInstall(app: MarketplaceApp) {
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(app.id)
|
||||
if (info.supportsVersions && info.versions.length > 1) {
|
||||
installModalApp.value = app
|
||||
showInstallModal.value = true
|
||||
return
|
||||
}
|
||||
} catch { /* no catalog versions — fall through to direct install */ }
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (app.source === 'local') {
|
||||
installApp(app, versionOverride)
|
||||
} else {
|
||||
installCommunityApp(app, versionOverride)
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version)
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
try {
|
||||
if (isInstalled(app.id)) {
|
||||
router.push({ name: 'app-details', params: { id: app.id }, query: { from: 'discover' } })
|
||||
} else {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'discover' } })
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[Discover] Navigation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Timer management
|
||||
const activeTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function trackTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
const idx = activeTimers.indexOf(id)
|
||||
if (idx !== -1) activeTimers.splice(idx, 1)
|
||||
fn()
|
||||
}, ms)
|
||||
activeTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of activeTimers) clearTimeout(t)
|
||||
activeTimers.length = 0
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Queued…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message,
|
||||
attempt: 0,
|
||||
})
|
||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||
}
|
||||
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Once-per-session: the intro stagger flag. The catalog/prune-status loads
|
||||
// need no onMounted or onActivated call of their own — catalogResource and
|
||||
// pruneStatusResource are useCachedResource-backed with `immediate` at its
|
||||
// default (true), so each fetches itself on setup and revalidates itself,
|
||||
// staleness-gated, on every later reactivation via the composable's own
|
||||
// onActivated hook (same precedent as Marketplace.vue in 02-02).
|
||||
onMounted(() => {
|
||||
discoverAnimationDone = true
|
||||
})
|
||||
|
||||
// Exposed for tests only (mirrors Marketplace.vue's own defineExpose for the
|
||||
// same shared-key resources).
|
||||
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
|
||||
</script>
|
||||
@@ -0,0 +1,844 @@
|
||||
<template>
|
||||
<!-- Map view: no pb-6 — the .dashboard-scroll-panel:has(.node-map-stage)
|
||||
rules turn this view into a column that hands remaining height to the
|
||||
map, so bottom padding would just re-create the dead margin. -->
|
||||
<div :class="mapActive ? undefined : 'pb-6'">
|
||||
<FederationHeader
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@rotate="showRotateModal = true"
|
||||
/>
|
||||
|
||||
<RotateDidModal
|
||||
:visible="showRotateModal"
|
||||
:rotating="rotatingDid"
|
||||
:error="rotateError"
|
||||
:success="rotateSuccess"
|
||||
@close="showRotateModal = false; rotateError = ''; rotateSuccess = ''"
|
||||
@rotate="rotateDid"
|
||||
/>
|
||||
|
||||
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
|
||||
<!-- md:self-start: in map view the root is a flex column, and stretch
|
||||
alignment would otherwise pull the pill full-width on desktop -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start">
|
||||
<button
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.id"
|
||||
class="mode-switcher-btn"
|
||||
role="tab"
|
||||
:aria-selected="activeView === tab.id"
|
||||
:class="{ 'mode-switcher-btn-active': activeView === tab.id }"
|
||||
@click="setView(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
|
||||
vertical space belongs to the map (desktop keeps the header card) -->
|
||||
<DidCardMobile
|
||||
v-if="!mapActive"
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@rotate="showRotateModal = true"
|
||||
/>
|
||||
|
||||
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||
<NetworkMap3D
|
||||
:nodes="mapNodes"
|
||||
:links="mapLinks"
|
||||
:requests="mapRequests"
|
||||
@select="onMapSelect"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="activeView === 'list'">
|
||||
<QuickActions
|
||||
:generating-invite="generatingInvite"
|
||||
:invite-type="inviteType"
|
||||
:invite-code="inviteCode"
|
||||
:syncing="syncing"
|
||||
@generate-invite="handleGenerateInvite"
|
||||
@show-join="showJoinModal = true"
|
||||
@sync="syncAll"
|
||||
@clear-invite="inviteCode = ''"
|
||||
/>
|
||||
|
||||
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
|
||||
Renders inline so the Federation page is the single place a user
|
||||
manages everything related to peering. The toggle directly mutates
|
||||
the `nostr_discovery_enabled` config flag — backend defaults to OFF
|
||||
and nothing is published until the user explicitly turns it on. -->
|
||||
<div class="glass-card p-4 mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">Nostr discoverability</span>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
|
||||
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mt-1">
|
||||
When on, this node publishes a presence event (DID + npub only — never an onion)
|
||||
so other nodes can find you and request to peer. Inbound requests land in the
|
||||
panel below for your approval. Off by default.
|
||||
</p>
|
||||
<button
|
||||
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
|
||||
@click="showSigningInfo = !showSigningInfo"
|
||||
>
|
||||
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discoveryToggling"
|
||||
@click="toggleDiscovery"
|
||||
>
|
||||
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="!discoveryEnabled"
|
||||
@click="showDiscoverModal = true"
|
||||
>
|
||||
Discover Nodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signing details: shows WHO signs the presence event (always the
|
||||
node's own discovery key — deliberately not pickable, so a user's
|
||||
personal Web5/Nostr identities can never be burned for node
|
||||
discovery) and a human-readable view of WHAT gets signed. -->
|
||||
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
|
||||
<div>
|
||||
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
|
||||
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
|
||||
node discovery always signs with the node's own key, never a
|
||||
personal identity, so there is nothing to pick. -->
|
||||
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
|
||||
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Presence events are always signed with this node's dedicated discovery key.
|
||||
Your personal identities are never used for node discovery, so unlike the app
|
||||
sign-in overlay there is nothing to choose here.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed & published</label>
|
||||
<dl class="text-xs space-y-1.5">
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
|
||||
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
|
||||
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
|
||||
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
|
||||
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-white/40 mt-2">
|
||||
Every presence event carries a Schnorr signature (NIP-01) made with the key
|
||||
above — relays reject unsigned events, so anything you see via discovery was
|
||||
cryptographically signed by its node. Your onion address is never part of this
|
||||
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
|
||||
|
||||
<PendingRequestsPanel
|
||||
:requests="pendingRequests"
|
||||
:polling="pollingHandshake"
|
||||
:busy-id="pendingBusyId"
|
||||
@poll="pollHandshake"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
@cancel="cancelPending"
|
||||
/>
|
||||
|
||||
<NodeList
|
||||
:nodes="nodes"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:sync-results="syncResults"
|
||||
:dwn-sync-dot-class="dwnSyncDotClass"
|
||||
:cleaning-nodes="cleaningNodes"
|
||||
@select-node="selectedNode = $event"
|
||||
@clear-sync-results="syncResults = []"
|
||||
@cleanup-dead="cleanupDeadNodes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<NodeDetailModal
|
||||
:node="selectedNode"
|
||||
:dwn-sync-dot-class="dwnSyncDotClass"
|
||||
:dwn-sync-label="dwnSyncLabel"
|
||||
:dwn-message-count="String(dwnStatus?.message_count ?? '--')"
|
||||
:dwn-last-sync="dwnStatus?.last_sync ? timeAgo(dwnStatus.last_sync) : 'never'"
|
||||
:dwn-syncing="dwnSyncing"
|
||||
:deploying="deploying"
|
||||
:deploy-result="deployResult"
|
||||
:action-error="nodeActionError"
|
||||
@close="selectedNode = null; nodeActionError = ''"
|
||||
@change-trust="changeTrust"
|
||||
@remove-node="removeNode"
|
||||
@deploy-app="deployApp"
|
||||
@dwn-sync="triggerDwnSync"
|
||||
/>
|
||||
|
||||
<JoinModal
|
||||
:visible="showJoinModal"
|
||||
:joining="joining"
|
||||
:error="joinError"
|
||||
:success="joinSuccess"
|
||||
@close="showJoinModal = false"
|
||||
@join="joinFederation"
|
||||
/>
|
||||
|
||||
<DiscoverModal
|
||||
:visible="showDiscoverModal"
|
||||
:outbound-sent="pendingRequests"
|
||||
@close="showDiscoverModal = false"
|
||||
@sent="loadPendingRequests"
|
||||
/>
|
||||
|
||||
<PresenceSignModal
|
||||
:show="showPresenceSignModal"
|
||||
:server-name="appStore.serverName"
|
||||
:npub="nodeNpub"
|
||||
:did="selfDid"
|
||||
:version="appVersion"
|
||||
:busy="discoveryToggling"
|
||||
@confirm="confirmPresenceSign"
|
||||
@cancel="showPresenceSignModal = false"
|
||||
/>
|
||||
|
||||
<TrustPasswordModal
|
||||
:visible="showTrustPassword"
|
||||
:context="trustPasswordContext"
|
||||
:busy="trustPasswordBusy"
|
||||
:error="trustPasswordError"
|
||||
@confirm="submitTrustPassword"
|
||||
@close="closeTrustPassword"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
||||
import FederationHeader from './federation/FederationHeader.vue'
|
||||
import DidCardMobile from './federation/DidCardMobile.vue'
|
||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||
import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
import NodeDetailModal from './federation/NodeDetailModal.vue'
|
||||
import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import PresenceSignModal from './federation/PresenceSignModal.vue'
|
||||
import TrustPasswordModal from './federation/TrustPasswordModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, nodeNameFromDid, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
const syncStore = useSyncStore()
|
||||
|
||||
// Cached: revisits paint the node list instantly; the 5s poll and mutation
|
||||
// refreshes revalidate behind it. `loading` is initial-load only (background
|
||||
// refreshes keep content on screen — the old showLoader:false semantics).
|
||||
const nodesRes = useCachedResource<FederatedNode[]>({
|
||||
key: 'federation.nodes',
|
||||
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
|
||||
persist: false, // FederatedNode carries did/pubkey/onion — peer identity payload (T-02-01)
|
||||
})
|
||||
const nodes = computed(() => nodesRes.data.value ?? [])
|
||||
const loading = computed(() => nodesRes.loadState.value === 'loading')
|
||||
const error = ref('')
|
||||
/** Failure from an action taken inside NodeDetailModal (trust dropdown). Kept
|
||||
* separate from `error`, which renders in NodeList behind the modal. */
|
||||
const nodeActionError = ref('')
|
||||
const selectedNode = ref<FederatedNode | null>(null)
|
||||
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
||||
|
||||
const inviteCode = ref('')
|
||||
const generatingInvite = ref(false)
|
||||
|
||||
const showJoinModal = ref(false)
|
||||
const joining = ref(false)
|
||||
const joinError = ref('')
|
||||
const joinSuccess = ref(false)
|
||||
|
||||
const syncing = ref(false)
|
||||
const syncResults = ref<SyncResult[]>([])
|
||||
|
||||
const deploying = ref(false)
|
||||
const deployResult = ref('')
|
||||
|
||||
const viewTabs = [
|
||||
{ id: 'list', label: 'List View' },
|
||||
{ id: 'map', label: 'Network Map' },
|
||||
] as const
|
||||
|
||||
type ViewId = typeof viewTabs[number]['id']
|
||||
const activeView = ref<ViewId>(
|
||||
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
|
||||
)
|
||||
|
||||
function setView(id: ViewId) {
|
||||
activeView.value = id
|
||||
localStorage.setItem('federation-view', id)
|
||||
}
|
||||
|
||||
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
|
||||
|
||||
/** Map click-through: tapping a peer opens the same detail modal as the list
|
||||
* view. Tapping the self node is a no-op (its actions live in the header). */
|
||||
function onMapSelect(did: string) {
|
||||
const node = nodes.value.find(n => n.did === did)
|
||||
if (node) selectedNode.value = node
|
||||
}
|
||||
|
||||
/** Seeded from the cached DID so the map's centre node (and its links) exist
|
||||
* on the very first frame; the authoritative fetch in onMounted refreshes it
|
||||
* and re-caches. Without this the intro raced the RPC and often played with
|
||||
* no centre. */
|
||||
const selfDid = ref<string>((() => {
|
||||
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
|
||||
})())
|
||||
|
||||
const mapNodes = computed(() => {
|
||||
const result = []
|
||||
if (selfDid.value) {
|
||||
result.push({
|
||||
did: selfDid.value,
|
||||
label: appStore.serverName,
|
||||
trust_level: 'trusted' as const,
|
||||
online: true,
|
||||
app_count: 0,
|
||||
is_self: true,
|
||||
})
|
||||
}
|
||||
for (const node of nodes.value) {
|
||||
result.push({
|
||||
did: node.did,
|
||||
label: nodeName(node),
|
||||
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
|
||||
online: isOnlineCheck(node),
|
||||
app_count: node.last_state?.apps?.length ?? 0,
|
||||
is_self: false,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const mapLinks = computed(() => {
|
||||
if (!selfDid.value) return []
|
||||
return nodes.value.map(n => ({
|
||||
source: selfDid.value,
|
||||
target: n.did,
|
||||
}))
|
||||
})
|
||||
|
||||
/** Inbound pending requests for the map — blinking yellow nodes the user can
|
||||
* accept/reject in place (same RPCs as the pending panel). */
|
||||
const mapRequests = computed(() => pendingRequests.value
|
||||
.filter(r => !r.outbound && r.state === 'pending')
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}…`,
|
||||
message: r.message,
|
||||
})))
|
||||
|
||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
||||
key: 'federation.dwn-status',
|
||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
||||
persist: true, // sync status/counters only — no identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const dwnStatus = computed(() => dwnStatusRes.data.value)
|
||||
const dwnSyncing = ref(false)
|
||||
|
||||
const dwnSyncDotClass = computed(() => {
|
||||
if (!dwnStatus.value) return 'bg-white/30'
|
||||
switch (dwnStatus.value.sync_status) {
|
||||
case 'synced': return 'bg-green-400'
|
||||
case 'syncing': return 'bg-yellow-400 animate-pulse'
|
||||
case 'error': return 'bg-red-400'
|
||||
default: return 'bg-white/30'
|
||||
}
|
||||
})
|
||||
|
||||
const dwnSyncLabel = computed(() => {
|
||||
if (!dwnStatus.value) return 'Unknown'
|
||||
switch (dwnStatus.value.sync_status) {
|
||||
case 'synced': return 'Synced'
|
||||
case 'syncing': return 'Syncing...'
|
||||
case 'error': return 'Error'
|
||||
default: return dwnStatus.value.sync_status || 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
// DID rotation
|
||||
const showRotateModal = ref(false)
|
||||
const rotatingDid = ref(false)
|
||||
const rotateError = ref('')
|
||||
const rotateSuccess = ref('')
|
||||
|
||||
// Dead node cleanup
|
||||
const cleaningNodes = ref(false)
|
||||
|
||||
// Nostr discoverability + pending peer requests
|
||||
const discoveryEnabled = ref(false)
|
||||
const discoveryToggling = ref(false)
|
||||
const discoveryError = ref('')
|
||||
const showDiscoverModal = ref(false)
|
||||
// Signing-details disclosure: who signs the presence event (always the node's
|
||||
// own discovery key) and a human-readable view of the signed content.
|
||||
const showSigningInfo = ref(false)
|
||||
const showPresenceSignModal = ref(false)
|
||||
const nodeNpub = ref('')
|
||||
const appVersion = computed(() => syncStore.serverInfo?.version || '')
|
||||
|
||||
function truncateNpub(npub: string): string {
|
||||
if (npub.length <= 20) return npub
|
||||
return npub.slice(0, 12) + '...' + npub.slice(-6)
|
||||
}
|
||||
|
||||
async function loadNodeNpub() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
|
||||
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
|
||||
} catch {
|
||||
// Nostr key not provisioned yet — panel shows a placeholder
|
||||
}
|
||||
}
|
||||
const pendingRequests = ref<PendingPeerRequest[]>([])
|
||||
const pollingHandshake = ref(false)
|
||||
const pendingBusyId = ref<string | null>(null)
|
||||
|
||||
async function loadDiscoveryState() {
|
||||
try {
|
||||
const result = await rpcClient.nostrDiscoveryStatus()
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch {
|
||||
discoveryEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiscovery() {
|
||||
if (!discoveryEnabled.value) {
|
||||
// Enabling publishes a signed presence event — surface the signer overlay
|
||||
// first so the user sees exactly what gets signed and with which key.
|
||||
// Nothing is published until they confirm.
|
||||
if (!nodeNpub.value) loadNodeNpub()
|
||||
showPresenceSignModal.value = true
|
||||
return
|
||||
}
|
||||
// Disabling publishes nothing new — apply immediately.
|
||||
discoveryToggling.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.nostrSetDiscovery(false)
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
|
||||
} finally {
|
||||
discoveryToggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPresenceSign() {
|
||||
discoveryToggling.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.nostrSetDiscovery(true)
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
showPresenceSignModal.value = false
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to enable discoverability'
|
||||
} finally {
|
||||
discoveryToggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const result = await rpcClient.federationListPendingRequests()
|
||||
pendingRequests.value = result.requests
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests'
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHandshake() {
|
||||
pollingHandshake.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.handshakePoll()
|
||||
await loadPendingRequests()
|
||||
// If a poll applied a PeerInvite, the federation node list also changed.
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Poll failed'
|
||||
} finally {
|
||||
pollingHandshake.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approvePending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationApproveRequest(id)
|
||||
await loadPendingRequests()
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Approve failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationRejectRequest(id)
|
||||
await loadPendingRequests()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Reject failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
// Default notify=true from the rpc-client — the peer's inbound row
|
||||
// disappears from their UI so they don't have to wonder about a
|
||||
// stale handshake.
|
||||
await rpcClient.federationCancelRequest(id)
|
||||
await loadPendingRequests()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isOnlineCheck(node: FederatedNode): boolean {
|
||||
if (!node.last_seen) return false
|
||||
const lastSeen = new Date(node.last_seen).getTime()
|
||||
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
|
||||
return lastSeen > tenMinutesAgo
|
||||
}
|
||||
|
||||
/** Explicit reload (mutations, retry): surfaces a load failure in the error
|
||||
* banner. The background poll calls nodesRes.refresh() directly and stays
|
||||
* silent, like the old surfaceErrors:false path. */
|
||||
async function loadNodes() {
|
||||
await nodesRes.refresh()
|
||||
if (nodesRes.error.value) error.value = nodesRes.error.value
|
||||
else error.value = ''
|
||||
}
|
||||
|
||||
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
inviteType.value = type
|
||||
generateInvite()
|
||||
}
|
||||
|
||||
/** The backend is the only authority on whether a given change is an
|
||||
* escalation, so the UI never pre-judges: it attempts the call and prompts
|
||||
* only when the backend says a password is required. That keeps demotions —
|
||||
* and no-op re-sets of an already-Trusted peer — free of a pointless prompt
|
||||
* without the frontend having to duplicate the rule. */
|
||||
function isPasswordRequired(e: unknown): boolean {
|
||||
return e instanceof Error && e.message.includes('PASSWORD_REQUIRED')
|
||||
}
|
||||
|
||||
const showTrustPassword = ref(false)
|
||||
const trustPasswordContext = ref('')
|
||||
const trustPasswordBusy = ref(false)
|
||||
const trustPasswordError = ref('')
|
||||
let pendingTrustAction: ((password: string) => Promise<void>) | null = null
|
||||
|
||||
function promptForTrustPassword(context: string, action: (password: string) => Promise<void>) {
|
||||
trustPasswordContext.value = context
|
||||
trustPasswordError.value = ''
|
||||
pendingTrustAction = action
|
||||
showTrustPassword.value = true
|
||||
}
|
||||
|
||||
function closeTrustPassword() {
|
||||
showTrustPassword.value = false
|
||||
trustPasswordError.value = ''
|
||||
trustPasswordBusy.value = false
|
||||
pendingTrustAction = null
|
||||
}
|
||||
|
||||
async function submitTrustPassword(password: string) {
|
||||
if (!pendingTrustAction) return
|
||||
try {
|
||||
trustPasswordBusy.value = true
|
||||
trustPasswordError.value = ''
|
||||
await pendingTrustAction(password)
|
||||
closeTrustPassword()
|
||||
} catch (e) {
|
||||
// Keep the failure inside the modal so the operator can retry in place
|
||||
// rather than losing the pending action to the page-level banner.
|
||||
trustPasswordError.value = e instanceof Error ? e.message : 'Password verification failed'
|
||||
} finally {
|
||||
trustPasswordBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws so both the first attempt and the password retry can
|
||||
* route the error to the right place. */
|
||||
async function requestInvite(password?: string) {
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value, password)
|
||||
inviteCode.value = result.code
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
generatingInvite.value = true
|
||||
error.value = ''
|
||||
await requestInvite()
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
promptForTrustPassword(
|
||||
'This invite grants Trusted access to whoever redeems it — full read of this node\'s state, and the ability to deploy apps to it. Confirm with your node password.',
|
||||
requestInvite,
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinFederation(code: string) {
|
||||
try {
|
||||
joining.value = true
|
||||
joinError.value = ''
|
||||
joinSuccess.value = false
|
||||
await rpcClient.federationJoin(code)
|
||||
joinSuccess.value = true
|
||||
await loadNodes()
|
||||
setTimeout(() => { showJoinModal.value = false; joinSuccess.value = false }, 1500)
|
||||
} catch (e) {
|
||||
joinError.value = e instanceof Error ? e.message : 'Failed to join'
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
try {
|
||||
syncing.value = true
|
||||
error.value = ''
|
||||
syncResults.value = []
|
||||
const result = await rpcClient.call<{
|
||||
synced: number; failed: number;
|
||||
results: SyncResult[]
|
||||
}>({ method: 'federation.sync-state', timeout: 180000 })
|
||||
syncResults.value = result.results
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Sync failed — some peers may be unreachable over Tor'
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws; see `requestInvite`. */
|
||||
async function requestTrustChange(did: string, level: string, password?: string) {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted', password)
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrust(did: string, level: string) {
|
||||
nodeActionError.value = ''
|
||||
try {
|
||||
await requestTrustChange(did, level)
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
const name = nodeNameFromDid(did, nodes.value)
|
||||
promptForTrustPassword(
|
||||
`Granting ${name} Trusted lets it read this node's state and deploy apps to it. Confirm with your node password.`,
|
||||
(password) => requestTrustChange(did, level, password),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Show the failure INSIDE the open modal. `error` renders in NodeList,
|
||||
// which sits behind NodeDetailModal — so routing it there made a failed
|
||||
// trust change look like the dropdown simply doing nothing.
|
||||
const msg = e instanceof Error ? e.message : 'Failed to update trust level'
|
||||
if (selectedNode.value?.did === did) nodeActionError.value = msg
|
||||
else error.value = msg
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(did: string) {
|
||||
try {
|
||||
await rpcClient.federationRemoveNode(did)
|
||||
selectedNode.value = null
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove node'
|
||||
}
|
||||
}
|
||||
|
||||
async function deployApp(did: string, appId: string) {
|
||||
try {
|
||||
deploying.value = true
|
||||
deployResult.value = ''
|
||||
await rpcClient.federationDeployApp({ did, appId })
|
||||
deployResult.value = `Successfully deployed ${appId} to remote node`
|
||||
} catch (e) {
|
||||
deployResult.value = `Error: ${e instanceof Error ? e.message : 'Deploy failed'}`
|
||||
} finally {
|
||||
deploying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadDwnStatus() {
|
||||
return dwnStatusRes.refresh()
|
||||
}
|
||||
|
||||
async function triggerDwnSync() {
|
||||
try {
|
||||
dwnSyncing.value = true
|
||||
await rpcClient.call({ method: 'dwn.sync', timeout: 120000 })
|
||||
await loadDwnStatus()
|
||||
} catch {
|
||||
// Silently handle sync errors
|
||||
} finally {
|
||||
dwnSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDeadNodes() {
|
||||
cleaningNodes.value = true
|
||||
try {
|
||||
const deadNodes = nodes.value.filter(n => !isOnlineCheck(n) && (!n.last_seen || n.last_seen === 'never'))
|
||||
for (const node of deadNodes) {
|
||||
await rpcClient.federationRemoveNode(node.did)
|
||||
}
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Cleanup failed'
|
||||
} finally {
|
||||
cleaningNodes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateDid(password: string) {
|
||||
if (!password) return
|
||||
rotatingDid.value = true
|
||||
rotateError.value = ''
|
||||
rotateSuccess.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{
|
||||
old_did: string; new_did: string; proof_signature: string; proof_message: string
|
||||
}>({ method: 'node.rotate-did', params: { password } })
|
||||
|
||||
selfDid.value = result.new_did
|
||||
try { localStorage.setItem('neode_did', result.new_did) } catch { /* noop */ }
|
||||
rotateSuccess.value = `DID rotated. Notifying peers...`
|
||||
|
||||
const notify = await rpcClient.call<{ notified: number; failed: number }>({
|
||||
method: 'federation.notify-did-change',
|
||||
params: {
|
||||
old_did: result.old_did,
|
||||
new_did: result.new_did,
|
||||
proof_signature: result.proof_signature,
|
||||
proof_message: result.proof_message,
|
||||
},
|
||||
timeout: 120000,
|
||||
})
|
||||
rotateSuccess.value = `DID rotated successfully. ${notify.notified} peers notified${notify.failed > 0 ? `, ${notify.failed} failed` : ''}.`
|
||||
} catch (err: unknown) {
|
||||
rotateError.value = err instanceof Error ? err.message : 'Rotation failed'
|
||||
} finally {
|
||||
rotatingDid.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
loadNodeNpub()
|
||||
transportStore.fetchPeers()
|
||||
try {
|
||||
const result = await rpcClient.getNodeDid()
|
||||
selfDid.value = result.did
|
||||
try { localStorage.setItem('neode_did', result.did) } catch { /* private mode */ }
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
void nodesRes.refresh()
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6 mobile-scroll-pad">
|
||||
<BackButton label="Web5" desktop-margin="mb-6" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-center min-[1440px]:justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
|
||||
<p class="text-white/70">Beta Telemetry — monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact col-span-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap min-[1440px]:col-span-1 min-[1440px]:min-w-[220px]">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<div class="md:hidden mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
|
||||
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact mb-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
|
||||
<div class="glass-card p-8 max-w-md text-center">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Loading fleet data</h3>
|
||||
<p class="text-white/60 text-sm">Checking beta telemetry reports from connected nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="fleet.errorMessage.value" class="glass-card p-6 mb-6">
|
||||
<div class="alert-error rounded-lg mb-4">{{ fleet.errorMessage.value }}</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<FleetOverviewCards
|
||||
:node-count="fleet.nodes.value.length"
|
||||
:online-count="fleet.onlineCount.value"
|
||||
:offline-count="fleet.offlineCount.value"
|
||||
:fleet-health-pct="fleet.fleetHealthPct.value"
|
||||
:healthy-count="fleet.healthyCount.value"
|
||||
:avg-cpu="fleet.avgCpu.value"
|
||||
:avg-mem="fleet.avgMem.value"
|
||||
:avg-disk="fleet.avgDisk.value"
|
||||
/>
|
||||
|
||||
<div v-if="fleet.refreshing.value && fleet.nodes.value.length > 0" class="glass-card p-3 mb-4 text-sm text-white/60 flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 text-white/50" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing fleet telemetry...
|
||||
</div>
|
||||
|
||||
<FleetNodeGrid
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
:sort-by="fleet.sortBy.value"
|
||||
:selected-node-id="fleet.selectedNodeId.value"
|
||||
@update:sort-by="fleet.sortBy.value = $event"
|
||||
@select-node="fleet.selectNode"
|
||||
/>
|
||||
|
||||
<FleetAlerts
|
||||
:alerts="fleet.fleetAlerts.value"
|
||||
:alerts-loading="fleet.alertsLoading.value"
|
||||
/>
|
||||
|
||||
<FleetNodeDetail
|
||||
v-if="fleet.selectedNodeId.value && fleet.selectedNode.value"
|
||||
:node="fleet.selectedNode.value"
|
||||
:node-id="fleet.selectedNodeId.value"
|
||||
:history-loading="fleet.nodeHistoryLoading.value"
|
||||
:history-labels="fleet.nodeHistoryLabels.value"
|
||||
:cpu-datasets="fleet.nodeHistoryCpuDatasets.value"
|
||||
:mem-datasets="fleet.nodeHistoryMemDatasets.value"
|
||||
:disk-datasets="fleet.nodeHistoryDiskDatasets.value"
|
||||
:chart-width="fleet.chartWidth.value"
|
||||
@close="fleet.selectedNodeId.value = null"
|
||||
/>
|
||||
|
||||
<FleetContainerMatrix
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
:all-app-ids="fleet.allAppIds.value"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
{{ fleet.autoRefresh.value ? 'Auto-refreshing every 60s' : 'Auto-refresh paused' }}
|
||||
· Last updated {{ fleet.lastRefreshed.value ? timeAgo(fleet.lastRefreshed.value) : 'never' }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
|
||||
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
|
||||
import FleetAlerts from './fleet/FleetAlerts.vue'
|
||||
import FleetNodeDetail from './fleet/FleetNodeDetail.vue'
|
||||
import FleetContainerMatrix from './fleet/FleetContainerMatrix.vue'
|
||||
import { useFleetData, timeAgo } from './fleet/useFleetData'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const fleet = useFleetData()
|
||||
</script>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Goal wizard -->
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ goal.title }}</h1>
|
||||
<p class="text-white/70">{{ goal.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="overallStatus === 'completed' ? 'bg-green-400' : 'bg-orange-400'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sovereignty patience message for bitcoin-dependent goals -->
|
||||
<div
|
||||
v-if="showSyncMessage"
|
||||
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm leading-relaxed">
|
||||
{{ t('goalDetail.syncMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(step, idx) in goal.steps"
|
||||
:key="step.id"
|
||||
class="glass-card p-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="goal-step"
|
||||
:class="{
|
||||
'goal-step-completed': isStepCompleted(step),
|
||||
'goal-step-active': idx === activeStepIndex && overallStatus !== 'completed',
|
||||
'goal-step-pending': idx > activeStepIndex && !isStepCompleted(step),
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Step indicator -->
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<div v-if="isStepCompleted(step)" class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else-if="idx === activeStepIndex && isInstalling" class="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-orange-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span class="text-xs text-white/40 font-medium">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App icon -->
|
||||
<img
|
||||
v-if="stepIconUrl(step)"
|
||||
:src="stepIconUrl(step)"
|
||||
:alt="step.title"
|
||||
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
|
||||
<p class="text-sm text-white/55 leading-relaxed">{{ step.description }}</p>
|
||||
|
||||
<!-- Action button for active step -->
|
||||
<div v-if="idx === activeStepIndex && overallStatus !== 'completed'" class="mt-4">
|
||||
<button
|
||||
v-if="step.action === 'install' && step.appId && !isAppInstalled(step.appId)"
|
||||
@click="installApp(step)"
|
||||
:disabled="isInstalling"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
|
||||
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
|
||||
<div v-else-if="step.action === 'fund'" class="space-y-3">
|
||||
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<span class="text-xs text-white/75">Bitcoin is syncing — funding unlocks when it finishes</span>
|
||||
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
|
||||
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
|
||||
</div>
|
||||
<p class="text-xs text-white/50">
|
||||
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
|
||||
<span v-else>Estimating time remaining…</span>
|
||||
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-white/60">On-chain wallet balance</span>
|
||||
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
|
||||
{{ walletOnchainSats.toLocaleString() }} sats
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="showFundModal = true"
|
||||
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Fund Wallet
|
||||
</button>
|
||||
<button
|
||||
@click="completeFundStep(step)"
|
||||
:disabled="walletOnchainSats <= 0"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@click="completeVerifyStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.checkAndContinue') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.iveDoneThis') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
|
||||
>
|
||||
{{ t('goalDetail.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completion -->
|
||||
<div v-if="overallStatus === 'completed'" class="glass-card p-8 mt-6 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 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>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<button
|
||||
v-if="completionCta"
|
||||
@click="openCompletionTarget"
|
||||
class="glass-button rounded-lg px-6 py-3 font-medium"
|
||||
>
|
||||
{{ completionCta.label }}
|
||||
</button>
|
||||
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
|
||||
<ReceiveBitcoinModal
|
||||
:show="showFundModal"
|
||||
note="Fund your Lightning channel: minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||
auto-generate
|
||||
@close="showFundModal = false"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepRouteOverride } from './goals/goalStepActions'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
bitcoinSyncPercent,
|
||||
bitcoinBlockHeight,
|
||||
bitcoinSyncEtaText,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
lnd: '/assets/img/app-icons/lnd.png',
|
||||
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
|
||||
immich: '/assets/img/app-icons/immich.png',
|
||||
nextcloud: '/assets/img/app-icons/nextcloud.webp',
|
||||
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
electrs: '/assets/img/app-icons/electrumx.png',
|
||||
electrumx: '/assets/img/app-icons/electrumx.png',
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
if (step.icon) return step.icon
|
||||
if (!step.appId) return undefined
|
||||
return APP_ICON_MAP[step.appId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the completion card sends the user: the app they just set up, not the
|
||||
* generic services list. `launchAppId` opens via the app launcher (iframe apps
|
||||
* overlay on top of the current screen; X-Frame-Options apps open a tab).
|
||||
*/
|
||||
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
|
||||
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
|
||||
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
|
||||
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
|
||||
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
|
||||
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
|
||||
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
|
||||
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
|
||||
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const goalStore = useGoalStore()
|
||||
|
||||
const goalId = computed(() => route.params.goalId as string)
|
||||
const goal = computed(() => getGoalById(goalId.value))
|
||||
|
||||
const isInstalling = ref(false)
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
const overallStatus = computed(() => goalStore.getGoalStatus(goalId.value))
|
||||
|
||||
const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
// Only install steps auto-tick from package state — manual steps (fund the
|
||||
// wallet, open a channel, configure) must be walked through.
|
||||
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
}
|
||||
return completed
|
||||
})
|
||||
|
||||
const activeStepIndex = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
const steps = goal.value.steps
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
if (step && !completedSteps.value.has(step.id)) return i
|
||||
}
|
||||
return steps.length - 1
|
||||
})
|
||||
|
||||
const currentStepDisplay = computed(() => Math.min(activeStepIndex.value + 1, goal.value?.steps.length || 1))
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
return Math.round((completedSteps.value.size / goal.value.steps.length) * 100)
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (overallStatus.value === 'completed') return t('goalDetail.completed')
|
||||
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
|
||||
return t('goalDetail.notStarted')
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'goal-status-badge-completed'
|
||||
if (overallStatus.value === 'in-progress') return 'goal-status-badge-in-progress'
|
||||
return 'goal-status-badge-not-started'
|
||||
})
|
||||
|
||||
const showSyncMessage = computed(() => {
|
||||
if (!goal.value) return false
|
||||
const hasBitcoin = goal.value.requiredApps.includes('bitcoin-knots')
|
||||
const bitcoinNotRunning = !isAppRunning('bitcoin-knots')
|
||||
return hasBitcoin && bitcoinNotRunning && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
function isStepCompleted(step: GoalStep): boolean {
|
||||
return completedSteps.value.has(step.id)
|
||||
}
|
||||
|
||||
/** App ID aliases — backend may register under variant names */
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
||||
}
|
||||
|
||||
function matchesAppId(pkgId: string, appId: string): boolean {
|
||||
if (pkgId === appId) return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
return aliases ? aliases.includes(pkgId) : false
|
||||
}
|
||||
|
||||
function isAppInstalled(appId: string): boolean {
|
||||
return Object.keys(appStore.packages).some((pkgId) => matchesAppId(pkgId, appId))
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
return Object.entries(appStore.packages).some(
|
||||
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
|
||||
)
|
||||
}
|
||||
|
||||
async function installApp(step: GoalStep) {
|
||||
if (!step.appId) return
|
||||
isInstalling.value = true
|
||||
|
||||
ensureGoalStarted()
|
||||
|
||||
try {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
} catch (err) {
|
||||
showActionError(`Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
isInstalling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
const override = goalStepRouteOverride(step)
|
||||
if (override) {
|
||||
// Internal screens (channels, web5, settings) — tag where we came from so
|
||||
// their back button returns to this wizard.
|
||||
router.push({ path: override, query: { from: 'goal', goal: goalId.value } })
|
||||
} else if (step.appId) {
|
||||
// Launch the app itself: iframe apps overlay on top of the wizard,
|
||||
// tab-only apps open a tab (mobile: the in-app browser) — the app
|
||||
// launcher handles every case.
|
||||
useAppLauncherStore().openSession(step.appId)
|
||||
}
|
||||
}
|
||||
|
||||
function completeVerifyStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function completeInfoStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function ensureGoalStarted() {
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// The goal cards live on Home's Setup tab — return there, not the dashboard.
|
||||
router.push({ path: '/dashboard', query: { tab: 'setup' } })
|
||||
}
|
||||
|
||||
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
|
||||
|
||||
const showFundModal = ref(false)
|
||||
const walletOnchainSats = ref(0)
|
||||
|
||||
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
|
||||
const fundStepActive = computed(() => {
|
||||
if (!goal.value || !hasFundStep.value) return false
|
||||
const active = goal.value.steps[activeStepIndex.value]
|
||||
return active?.action === 'fund' && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
let releaseSync: (() => void) | null = null
|
||||
let balanceTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshWalletBalance() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
|
||||
walletOnchainSats.value = res.balance_sats || 0
|
||||
} catch { /* LND not up yet — balance stays at last known value */ }
|
||||
}
|
||||
|
||||
watch(fundStepActive, (active) => {
|
||||
if (active) {
|
||||
if (!releaseSync) releaseSync = acquireBitcoinSync()
|
||||
void refreshWalletBalance()
|
||||
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
|
||||
} else {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Refresh the balance right after the receive modal closes — the user may
|
||||
// have just sent funds.
|
||||
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
})
|
||||
|
||||
function completeFundStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
// ── Completion CTA: go to the app you just set up ────────────────────────────
|
||||
|
||||
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
|
||||
|
||||
function openCompletionTarget() {
|
||||
const cta = completionCta.value
|
||||
if (!cta) return
|
||||
if (cta.launchAppId) {
|
||||
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
|
||||
// (BTCPay, Nextcloud…) open in a new tab.
|
||||
useAppLauncherStore().openSession(cta.launchAppId)
|
||||
} else if (cta.route) {
|
||||
router.push(cta.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,850 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
|
||||
<div class="min-h-[4.5rem]">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
|
||||
{{ line1Text }}<span v-if="showCaretLine1" class="typing-caret"></span>
|
||||
</h1>
|
||||
<RefreshIndicator :state="homeRefreshIndicatorState" label="Refreshing wallet balance" />
|
||||
</div>
|
||||
<p class="text-white/80">
|
||||
{{ line2Text }}<span v-if="showCaretLine2" class="typing-caret"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Desktop: tabs inline with header -->
|
||||
<div
|
||||
v-if="!uiMode.isChat"
|
||||
role="tablist"
|
||||
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update notification banner -->
|
||||
<div
|
||||
v-if="updateAvailable && !updateDismissed"
|
||||
role="alert"
|
||||
class="mb-6 glass-card p-4 flex items-center justify-between gap-4 border-l-4 border-orange-400 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-6 h-6 text-orange-400 shrink-0" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ t('home.updateAvailable', { version: updateVersion }) }}</p>
|
||||
<p v-if="updateChangelog" class="text-xs text-white/60 truncate">{{ updateChangelog }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ t('home.updateNow') }}</RouterLink>
|
||||
<button @click="dismissUpdate" aria-label="Dismiss update notification" class="text-white/40 hover:text-white/80 transition-colors p-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" 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>
|
||||
|
||||
<!-- Tab bar + content (all non-chat modes) -->
|
||||
<template v-if="!uiMode.isChat">
|
||||
<!-- Mobile: full-width tabs -->
|
||||
<div
|
||||
role="tablist"
|
||||
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Setup tab -->
|
||||
<EasyHome v-if="homeTab === 'setup'" :show="!showWelcomeBlock || animateCards" :animate="animateCards" />
|
||||
|
||||
<!-- Dashboard tab: overview cards -->
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<!-- My Apps Overview -->
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable order-1 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 0">
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.myApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.myAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/apps" :aria-label="t('home.goToApps')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
||||
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
|
||||
<div class="w-14 h-14 rounded-xl overflow-hidden transition-all group-hover:-translate-y-1 group-hover:shadow-lg flex items-center justify-center">
|
||||
<img :src="app.icon" :alt="app.name" class="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
||||
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageApps') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable order-3 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 1">
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.cloud') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.cloudDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/cloud" :aria-label="t('home.goToCloud')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg"><p class="text-xs text-white/60 mb-1">{{ t('home.storageUsed') }}</p><p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p></div>
|
||||
<div class="p-4 bg-white/5 rounded-lg"><p class="text-xs text-white/60 mb-1">{{ t('home.folders') }}</p><p class="text-2xl font-bold text-white">{{ cloudFolderDisplay }}</p></div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.viewFolders') }}</RouterLink>
|
||||
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.uploadFiles') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Overview -->
|
||||
<HomeWalletCard
|
||||
class="order-2 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:wallet-connected="walletConnected"
|
||||
:wallet-onchain="walletOnchain"
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
:wallet-fedimint="walletFedimint"
|
||||
:wallet-ark="walletArk"
|
||||
:wallet-transactions="walletTransactions"
|
||||
:is-dev="isDev"
|
||||
@show-scan="showScanModal = true"
|
||||
@show-send="showSendModal = true"
|
||||
@show-receive="showReceiveModal = true"
|
||||
@show-transactions="showTransactionsModal = true"
|
||||
@show-wallet-settings="showWalletSettingsModal = true"
|
||||
@faucet="devFaucet"
|
||||
@open-in-mempool="openInMempool"
|
||||
/>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable order-4 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.network') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.networkDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/server" :aria-label="t('home.goToNetwork')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="torConnected ? 'bg-purple-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Tor</span></div>
|
||||
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnDotClass"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnTextClass">{{ vpnStatusLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="bitcoinDotClass"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="bitcoinTextClass">{{ bitcoinSyncDisplay }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
|
||||
<span class="text-sm font-medium" :class="fipsTextClass">{{ fipsStatusLabel }}</span>
|
||||
</div>
|
||||
<div v-if="homeStatus.tollgateStatus" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="tollgateDotClass"></div><span class="text-sm text-white/80">TollGate</span></div>
|
||||
<span class="text-sm font-medium" :class="tollgateTextClass">{{ tollgateStatusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageNetwork') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Store Recommendations -->
|
||||
<div
|
||||
v-if="homeRecommendedApps.length > 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable lg:col-span-2 order-5 lg:order-none"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 4"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
|
||||
<button
|
||||
v-for="app in homeRecommendedApps"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
||||
@click="viewRecommendedApp(app)"
|
||||
>
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title || app.id"
|
||||
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
||||
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start Goals -->
|
||||
<div
|
||||
v-if="showQuickStart"
|
||||
class="home-card lg:col-span-2 transition-opacity duration-300 order-6 lg:order-none"
|
||||
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
style="--card-stagger: 5"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner px-6 py-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-2 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||
</div>
|
||||
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" 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 class="grid grid-cols-1 gap-3 mt-auto">
|
||||
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
|
||||
<span>{{ goal.title }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Stats -->
|
||||
<HomeSystemCard
|
||||
class="order-7 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:loaded="systemStatsLoaded"
|
||||
:stats="systemStats"
|
||||
:uptime-display="systemUptimeDisplay"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Chat Mode -->
|
||||
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Modals -->
|
||||
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WalletScanModal from '@/components/WalletScanModal.vue'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||
import WalletSettingsModal from '@/components/WalletSettingsModal.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import { PackageState } from '../types/api'
|
||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import EasyHome from '@/components/EasyHome.vue'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getAppUsage } from '@/utils/appUsage'
|
||||
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
||||
import HomeWalletCard from './home/HomeWalletCard.vue'
|
||||
import HomeSystemCard from './home/HomeSystemCard.vue'
|
||||
import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const uiMode = useUIModeStore()
|
||||
const isDev = import.meta.env.DEV
|
||||
// ?tab=setup lands on the Setup tab (e.g. "Back to Goals" from a goal wizard)
|
||||
const homeTab = ref<'dashboard' | 'setup'>(route.query.tab === 'setup' ? 'setup' : 'dashboard')
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'setup') homeTab.value = 'setup'
|
||||
else if (tab === 'dashboard') homeTab.value = 'dashboard'
|
||||
})
|
||||
const topGoals = GOALS.slice(0, 3)
|
||||
|
||||
const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
|
||||
const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
|
||||
const QUICK_START_RESHOW_LOGINS = 5
|
||||
|
||||
const store = useAppStore()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const LINE1 = t('home.title')
|
||||
const LINE2 = t('home.subtitle')
|
||||
const MS_PER_CHAR = 55
|
||||
|
||||
const displayLine1 = ref('')
|
||||
const displayLine2 = ref('')
|
||||
const showCaretLine1 = ref(false)
|
||||
const showCaretLine2 = ref(false)
|
||||
const showWelcomeBlock = ref(false)
|
||||
const hasTypedWelcome = ref(false)
|
||||
const animateCards = ref(false)
|
||||
let typingInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const line1Text = computed(() => showWelcomeBlock.value ? displayLine1.value : LINE1)
|
||||
const line2Text = computed(() => showWelcomeBlock.value ? displayLine2.value : LINE2)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typingInterval) clearInterval(typingInterval)
|
||||
disarmLiveDataPolling()
|
||||
})
|
||||
|
||||
watch(() => loginTransition.pendingWelcomeTyping, (pending) => { if (pending) showWelcomeBlock.value = true })
|
||||
|
||||
watch(() => loginTransition.startWelcomeTyping, (shouldStart) => {
|
||||
if (!shouldStart || hasTypedWelcome.value) return
|
||||
hasTypedWelcome.value = true; showWelcomeBlock.value = true
|
||||
displayLine1.value = ''; displayLine2.value = ''
|
||||
showCaretLine1.value = true; showCaretLine2.value = false
|
||||
playTypingSound(); animateCards.value = true
|
||||
let i = 0
|
||||
typingInterval = setInterval(() => {
|
||||
if (i < LINE1.length) { displayLine1.value = LINE1.slice(0, i + 1); i++ }
|
||||
else if (i < LINE1.length + LINE2.length) { showCaretLine1.value = false; showCaretLine2.value = true; displayLine2.value = LINE2.slice(0, i - LINE1.length + 1); i++ }
|
||||
else { if (typingInterval) clearInterval(typingInterval); typingInterval = null; showCaretLine2.value = false; loginTransition.setStartWelcomeTyping(false) }
|
||||
}, MS_PER_CHAR)
|
||||
}, { immediate: true })
|
||||
|
||||
const packages = computed(() => store.packages)
|
||||
const appCount = computed(() => Object.keys(packages.value || {}).length)
|
||||
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
|
||||
|
||||
const quickLaunchApps = computed(() => {
|
||||
const usage = getAppUsage()
|
||||
return Object.entries(packages.value || {})
|
||||
.filter(([id, pkg]) => !isServicePackage(id, pkg) && !isWebsitePackage(id, pkg))
|
||||
.map(([id, pkg]) => ({
|
||||
id,
|
||||
name: pkg.manifest?.title || id,
|
||||
icon: resolveAppIcon(id, pkg),
|
||||
state: pkg.state,
|
||||
usage: usage[id]?.count || 0,
|
||||
lastLaunchedAt: usage[id]?.lastLaunchedAt || 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.usage !== a.usage) return b.usage - a.usage
|
||||
if (b.lastLaunchedAt !== a.lastLaunchedAt) return b.lastLaunchedAt - a.lastLaunchedAt
|
||||
if ((b.state === PackageState.Running ? 1 : 0) !== (a.state === PackageState.Running ? 1 : 0)) {
|
||||
return (b.state === PackageState.Running ? 1 : 0) - (a.state === PackageState.Running ? 1 : 0)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppList(), packages.value, 3))
|
||||
|
||||
function viewRecommendedApp(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
|
||||
}
|
||||
|
||||
function marketplaceDescription(app: MarketplaceApp) {
|
||||
if (typeof app.description === 'string') return app.description
|
||||
return app.description?.short || app.description?.long || ''
|
||||
}
|
||||
|
||||
// Network card data
|
||||
// Tor liveness, NOT "was an onion ever provisioned". This used to return
|
||||
// `!!server-info['tor-address']`, but that address is read from the
|
||||
// hidden-service hostname file on disk and outlives the daemon — so this card
|
||||
// showed "Connected" on three fleet nodes whose Tor had been dead for days
|
||||
// (2026-08-09), which is precisely why nobody noticed. `tor-running` is a live
|
||||
// probe of 127.0.0.1:9050 performed server-side.
|
||||
const torConnected = computed(() => store.data?.['server-info']?.['tor-running'] === true)
|
||||
const vpnConnected = computed(() => homeStatus.vpnStatus.connected === true || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const vpnDotClass = computed(() => {
|
||||
if (vpnConnected.value) return 'bg-orange-400'
|
||||
return homeStatus.vpnKnown ? 'bg-white/40' : 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const vpnTextClass = computed(() => vpnConnected.value ? 'text-orange-400' : (homeStatus.vpnKnown ? 'text-white/40' : 'text-white/50'))
|
||||
const vpnStatusLabel = computed(() => {
|
||||
if (vpnConnected.value) return homeStatus.vpnStatus.provider || 'WireGuard'
|
||||
if (!homeStatus.vpnKnown) return 'Checking…'
|
||||
return 'Not configured'
|
||||
})
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
if (s.anchor_connected === false) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s) return homeStatus.fipsLoadState === 'loading' ? 'Checking…' : '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability in the label so the
|
||||
// Home and Server rows flip in sync with the FIPS card.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const tollgateDotClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
return s.enabled ? 'bg-green-400' : 'bg-yellow-400'
|
||||
})
|
||||
const tollgateTextClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
return s.enabled ? 'text-green-400' : 'text-yellow-400'
|
||||
})
|
||||
const tollgateStatusLabel = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s) return homeStatus.tollgateLoadState === 'loading' ? 'Checking…' : 'Not configured'
|
||||
if (!s.installed) return 'Not installed'
|
||||
return s.enabled ? 'Enabled' : 'Disabled'
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…'
|
||||
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
|
||||
if (homeStatus.stats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (homeStatus.stats.bitcoinSyncPercent < 0.01 && homeStatus.stats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${homeStatus.stats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
})
|
||||
const bitcoinDotClass = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === true) return 'bg-orange-400'
|
||||
if (homeStatus.stats.bitcoinAvailable === false) return 'bg-white/40'
|
||||
return 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const bitcoinTextClass = computed(() => homeStatus.stats.bitcoinAvailable ? 'text-orange-400' : (homeStatus.stats.bitcoinAvailable === null ? 'text-white/50' : 'text-white/40'))
|
||||
|
||||
// Quick Start
|
||||
const quickStartDismissed = ref(false)
|
||||
const allQuickStartAppsInstalled = computed(() => QUICK_START_APPS.every((appId) => Object.keys(packages.value).includes(appId)))
|
||||
const showQuickStart = computed(() => !allQuickStartAppsInstalled.value && !quickStartDismissed.value)
|
||||
|
||||
function loadQuickStartState() {
|
||||
try { const raw = localStorage.getItem(QUICK_START_KEY); if (!raw) { quickStartDismissed.value = false; return }; const data = JSON.parse(raw); if (!data.dismissed) { quickStartDismissed.value = false; return }; const loginCount = (data.loginCount || 0) + 1; localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount })); quickStartDismissed.value = loginCount % QUICK_START_RESHOW_LOGINS !== 0 } catch { quickStartDismissed.value = false }
|
||||
}
|
||||
function dismissQuickStart() { quickStartDismissed.value = true; try { localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount: 0 })) } catch { /* ignore */ } }
|
||||
loadQuickStartState()
|
||||
|
||||
// Update notification
|
||||
const updateAvailable = ref(false); const updateDismissed = ref(false); const updateVersion = ref(''); const updateChangelog = ref('')
|
||||
async function checkUpdateStatus() {
|
||||
try { const res = await rpcClient.call<{ update_available: boolean }>({ method: 'update.status', dedup: true }); updateAvailable.value = res.update_available } catch { /* unavailable */ }
|
||||
if (updateAvailable.value) { try { const detail = await rpcClient.call<{ update: { version: string; changelog: string[] } | null }>({ method: 'update.check', dedup: true }); if (detail.update) { updateVersion.value = detail.update.version; updateChangelog.value = detail.update.changelog.slice(0, 2).join('; ') } } catch { /* unavailable */ } }
|
||||
}
|
||||
async function dismissUpdate() { updateDismissed.value = true; try { await rpcClient.call({ method: 'update.dismiss' }) } catch { /* ignore */ } }
|
||||
|
||||
// Cloud data
|
||||
const cloudStorageUsed = ref<number | null>(null); const cloudFolderCount = ref<number | null>(null)
|
||||
function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const val = bytes / Math.pow(1024, i); return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}` }
|
||||
const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? formatBytes(cloudStorageUsed.value) : '...')
|
||||
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
|
||||
|
||||
// 02-06: system stats, update status and cloud storage usage on keyed
|
||||
// useCachedResource entries (side-effect-only, per the Mesh.vue/02-05
|
||||
// pattern — each fetcher wraps an existing function that already sets its
|
||||
// own reactive refs, resolving to a sentinel timestamp rather than the real
|
||||
// payload). homeStatus.refresh() is a Pinia store action, so — mirroring
|
||||
// 02-05's finding that a store's own defineStore(id, setup) runs in a bare
|
||||
// effectScope where onActivated() silently no-ops — the useCachedResource
|
||||
// wrapper lives here in Home.vue, not inside stores/homeStatus.ts.
|
||||
//
|
||||
// Web5.vue's own two resources (web5.networking-profits, web5.lnd-info)
|
||||
// were read and are NOT shared here: web5.networking-profits is an
|
||||
// unrelated dataset (routing/content-sale profit totals), and
|
||||
// web5.lnd-info now declares persist: false explicitly (fixed, CR-01) so its
|
||||
// independent refresh cycle can never leak balance data to sessionStorage —
|
||||
// sharing that key would still either corrupt Web5.vue's differently-shaped
|
||||
// entry.data (a real number here vs. its typed balance object there) or
|
||||
// require Home to also opt into persist: false for no benefit.
|
||||
// Home's own wallet fetch is also a strictly broader 7-call composite
|
||||
// (lnd.getinfo + ecash/fedimint/ark balances + 3 histories), not the same
|
||||
// single-call dataset. See 02-06-SUMMARY.md for the full finding.
|
||||
const systemStatsRes = useCachedResource<number>({
|
||||
key: 'home.system-stats',
|
||||
immediate: false,
|
||||
ttlMs: 10_000, // matches the pre-existing 10s poll cadence
|
||||
persist: true, // aggregate system/bitcoin/vpn/fips/tollgate status, no identity payload
|
||||
fetcher: async () => { await loadSystemStats(); return Date.now() },
|
||||
})
|
||||
const updateStatusRes = useCachedResource<number>({
|
||||
key: 'home.update-status',
|
||||
immediate: false,
|
||||
ttlMs: 300_000, // near-static — an available update doesn't appear/disappear quickly
|
||||
persist: true,
|
||||
fetcher: async () => { await checkUpdateStatus(); return Date.now() },
|
||||
})
|
||||
const cloudUsageRes = useCachedResource<number>({
|
||||
key: 'home.cloud-usage',
|
||||
immediate: false,
|
||||
ttlMs: 30_000, // default tier
|
||||
persist: true,
|
||||
fetcher: async () => {
|
||||
try {
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
cloudStorageUsed.value = usage.totalSize
|
||||
cloudFolderCount.value = usage.folderCount
|
||||
} catch { /* not running */ }
|
||||
return Date.now()
|
||||
},
|
||||
})
|
||||
const homeCachedGroups = [systemStatsRes, updateStatusRes, cloudUsageRes]
|
||||
function refreshHomeGroupIfStale(res: typeof systemStatsRes): Promise<void> {
|
||||
if (res.entry.data === null || res.isStale.value) return res.refresh()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Wallet is the exception (T-02-13): a money figure must never be presented
|
||||
// as current without a visible re-check, so this resource revalidates
|
||||
// UNCONDITIONALLY on every activation rather than TTL-gated — never via
|
||||
// refreshHomeGroupIfStale above. persist:false — the fetcher wraps
|
||||
// loadWeb5Status(), which sets the wallet balance/transaction refs directly
|
||||
// as a side effect; hydrateWalletSnapshot() (localStorage, a separate
|
||||
// mechanism) is the view's own last-known-figures path and is untouched by
|
||||
// this resource.
|
||||
const walletStatusRes = useCachedResource<number>({
|
||||
key: 'home.wallet-status',
|
||||
immediate: false,
|
||||
persist: false,
|
||||
fetcher: async () => { await loadWeb5Status(); return Date.now() },
|
||||
})
|
||||
|
||||
// Only-while-visible side effects (system/update/storage cache revalidation,
|
||||
// wallet poll, wallet websocket push) are armed on every activation
|
||||
// (including the first mount — Vue fires onActivated on first mount too)
|
||||
// and torn down on deactivation, so an off-screen Home costs nothing and a
|
||||
// re-entered Home revalidates the liveness-critical wallet figures
|
||||
// immediately rather than waiting out the 10s/30s intervals (T-02-13).
|
||||
// Idempotent: any existing handle is cleared before a new one is armed, so
|
||||
// two consecutive activations never double-arm.
|
||||
function armLiveDataPolling() {
|
||||
void Promise.allSettled(homeCachedGroups.map(refreshHomeGroupIfStale))
|
||||
if (systemStatsInterval) clearInterval(systemStatsInterval)
|
||||
systemStatsInterval = setInterval(() => void systemStatsRes.refresh(), 10000)
|
||||
|
||||
// Poll wallet balances/transactions like Web5.vue does — without this a
|
||||
// pending on-chain receive (or a fresh instant payment) only shows up
|
||||
// after a manual wallet action or a remount. Unconditional refresh, not
|
||||
// staleness-gated (T-02-13) — see walletStatusRes above.
|
||||
void walletStatusRes.refresh()
|
||||
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = setInterval(() => void walletStatusRes.refresh(), 30000)
|
||||
|
||||
// Real-time wallet push (2026-07-22): the backend streams LND transaction
|
||||
// events and nudges /ws/db the moment a tx hits the mempool. Any push =
|
||||
// something changed → refetch the wallet (debounced; the RPC is cheap).
|
||||
// This is what makes an incoming 0-conf tx appear in seconds instead of
|
||||
// waiting out the 30s poll above.
|
||||
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
|
||||
unsubscribeWs = wsClient.subscribe(() => {
|
||||
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
|
||||
wsWalletDebounce = setTimeout(() => { void walletStatusRes.refresh() }, 800)
|
||||
})
|
||||
}
|
||||
|
||||
// Drives the header RefreshIndicator (D-05/T-02-13): visible whenever the
|
||||
// wallet resource is revalidating, so a resumed tab never presents a
|
||||
// paused-poll balance as current without a visible signal.
|
||||
const homeRefreshIndicatorState = computed(() => walletStatusRes.loadState.value)
|
||||
|
||||
function disarmLiveDataPolling() {
|
||||
if (systemStatsInterval) { clearInterval(systemStatsInterval); systemStatsInterval = null }
|
||||
if (walletRefreshInterval) { clearInterval(walletRefreshInterval); walletRefreshInterval = null }
|
||||
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
|
||||
if (wsWalletDebounce) { clearTimeout(wsWalletDebounce); wsWalletDebounce = null }
|
||||
}
|
||||
|
||||
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
|
||||
// component's first mount. Unlike a plain interval re-arm, armLiveDataPolling()
|
||||
// also fires the wallet's 7-call Promise.allSettled immediately — firing it
|
||||
// twice back-to-back on every fresh session start is real, avoidable waste,
|
||||
// not a harmless no-op. This flag lets onMounted's call count as the first
|
||||
// activation's arm; onActivated only re-arms on a genuine later reactivation.
|
||||
let homeFreshMount = true
|
||||
onActivated(() => {
|
||||
if (homeFreshMount) { homeFreshMount = false; return }
|
||||
armLiveDataPolling()
|
||||
})
|
||||
onDeactivated(() => { disarmLiveDataPolling() })
|
||||
|
||||
onMounted(() => {
|
||||
// Once-per-session: paint last-known wallet figures BEFORE any network
|
||||
// round-trip. Not polled, so it stays here rather than moving to
|
||||
// onActivated — a resumed tab doesn't need it re-run.
|
||||
hydrateWalletSnapshot()
|
||||
|
||||
// 02-06: update status and cloud storage usage are now every-entry,
|
||||
// TTL-gated cached resources (see homeCachedGroups above) — armed
|
||||
// concurrently (Promise.allSettled, no sequential await chain) alongside
|
||||
// system stats and the wallet from the single armLiveDataPolling() call
|
||||
// below, rather than a separate trailing-await block here.
|
||||
//
|
||||
// Also arm here directly: onActivated is a no-op outside a <KeepAlive>
|
||||
// boundary (Vue only auto-fires it on first mount for a component that
|
||||
// already has a KeepAlive ancestor), so a bare mount — a unit test, or any
|
||||
// future non-KeepAlive usage — must not silently skip every poll/
|
||||
// subscription this view owns.
|
||||
armLiveDataPolling()
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
const showScanModal = ref(false); const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
|
||||
|
||||
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
||||
|
||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
||||
let walletInfoFailures = 0
|
||||
const walletArk = ref(0)
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
// Overlay the local Mempool app when it's running; otherwise route through
|
||||
// the external-explorer consent flow (pruned nodes can't run Mempool).
|
||||
const txExplorer = useTxExplorer()
|
||||
function openInMempool(txHash: string) { txExplorer.openTx(txHash) }
|
||||
|
||||
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
|
||||
// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used
|
||||
// elsewhere in this file, so it's mapped into that shape below rather than
|
||||
// widening WalletTransaction itself with a pile of ecash-only fields.
|
||||
interface EcashTransaction {
|
||||
id: string
|
||||
tx_type: 'send' | 'receive'
|
||||
amount_sats: number
|
||||
timestamp: string
|
||||
description: string
|
||||
mint_url: string
|
||||
peer: string
|
||||
kind: 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
|
||||
return {
|
||||
tx_hash: tx.id,
|
||||
amount_sats: tx.amount_sats,
|
||||
direction: tx.tx_type === 'receive' ? 'incoming' : 'outgoing',
|
||||
num_confirmations: 1,
|
||||
time_stamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
|
||||
total_fees: 0,
|
||||
dest_addresses: [],
|
||||
label: tx.description,
|
||||
block_height: 0,
|
||||
kind: tx.kind,
|
||||
}
|
||||
}
|
||||
|
||||
// Last-known wallet snapshot, hydrated before ANY network round-trip so the
|
||||
// card paints real figures instantly (app-launch-speed doctrine: over the
|
||||
// mesh every serialized RPC costs a full RTT — never make the user watch it).
|
||||
const WALLET_SNAPSHOT_KEY = 'archy-wallet-snapshot-v1'
|
||||
|
||||
function hydrateWalletSnapshot() {
|
||||
try {
|
||||
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
|
||||
if (!raw) return
|
||||
const s = JSON.parse(raw)
|
||||
walletOnchain.value = s.onchain ?? 0
|
||||
walletLightning.value = s.lightning ?? 0
|
||||
walletEcash.value = s.ecash ?? 0
|
||||
walletFedimint.value = s.fedimint ?? 0
|
||||
walletArk.value = s.ark ?? 0
|
||||
walletConnected.value = s.connected === true
|
||||
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
|
||||
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
|
||||
}
|
||||
|
||||
function persistWalletSnapshot() {
|
||||
try {
|
||||
localStorage.setItem(WALLET_SNAPSHOT_KEY, JSON.stringify({
|
||||
onchain: walletOnchain.value,
|
||||
lightning: walletLightning.value,
|
||||
ecash: walletEcash.value,
|
||||
fedimint: walletFedimint.value,
|
||||
ark: walletArk.value,
|
||||
connected: walletConnected.value,
|
||||
// Enough for the Transactions modal's first paint; refresh replaces it.
|
||||
transactions: walletTransactions.value.slice(0, 50),
|
||||
}))
|
||||
} catch { /* storage full — snapshot is best-effort */ }
|
||||
}
|
||||
|
||||
async function loadWeb5Status() {
|
||||
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||
// there is a balance"). On failure keep the last-known value — the refs start
|
||||
// from the persisted snapshot, so 0 only ever shows on a genuinely fresh node.
|
||||
//
|
||||
// All seven calls are independent — fire them TOGETHER. Serialized, this
|
||||
// block cost 7 × (mesh RTT + backend time); parallel it costs one slowest
|
||||
// call, which is what makes the card feel like an app launch.
|
||||
const balances = Promise.allSettled([
|
||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
||||
.catch(() => {
|
||||
// A single slow poll must NOT flip the card to "disconnected" and
|
||||
// hide balances the user already knows — busy nodes routinely blow
|
||||
// the 5s budget mid-payment or during IO storms (a test node user
|
||||
// report: balances vanished while a payment settled). Only call it
|
||||
// disconnected after three consecutive failures (~30s of silence).
|
||||
walletInfoFailures += 1
|
||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
||||
}),
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000, dedup: true })
|
||||
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000, dedup: true })
|
||||
.then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000, dedup: true })
|
||||
.then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
])
|
||||
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
|
||||
// already unifies both) so Cashu/Fedimint receives appear in the modal.
|
||||
const histories = Promise.allSettled([
|
||||
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000, dedup: true })
|
||||
.then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]),
|
||||
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000, dedup: true })
|
||||
.then(res => res.transactions || []).catch(() => [] as WalletTransaction[]),
|
||||
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000, dedup: true })
|
||||
.then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]),
|
||||
]).then((results) => {
|
||||
const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
|
||||
// Keep last-known list when every history call failed this round.
|
||||
if (merged.length > 0 || results.some(r => r.status === 'fulfilled')) {
|
||||
walletTransactions.value = merged.sort((a, b) => b.time_stamp - a.time_stamp)
|
||||
}
|
||||
})
|
||||
await Promise.allSettled([balances, histories])
|
||||
persistWalletSnapshot()
|
||||
}
|
||||
|
||||
// System stats
|
||||
const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
bitcoinStale: homeStatus.bitcoinStale,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
let unsubscribeWs: (() => void) | null = null
|
||||
let wsWalletDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadSystemStats() {
|
||||
await homeStatus.refresh(packages.value)
|
||||
}
|
||||
|
||||
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
|
||||
|
||||
defineExpose({
|
||||
loadWeb5Status,
|
||||
homeRefreshIndicatorState,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.typing-caret::after { content: ''; display: inline-block; width: 3px; height: 1.1em; background: #fbbf24; margin-left: 2px; vertical-align: text-bottom; animation: caret-blink 0.7s step-end infinite; }
|
||||
@keyframes caret-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Home card styles — unscoped so they reach child components (HomeWalletCard, HomeSystemCard) */
|
||||
.grid > .home-card { min-height: 280px; }
|
||||
.home-card-shell { background-color: rgba(0, 0, 0, 0.65); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border-radius: 1rem; overflow: hidden; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); border: 1px solid transparent; height: 100%; }
|
||||
.home-card-animate .home-card-shell { animation: card-fly-in 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; animation-delay: calc(var(--card-stagger) * 0.18s); opacity: 0; transform: translateY(50px) scale(0.92); }
|
||||
@keyframes card-fly-in { 0% { opacity: 0; transform: translateY(50px) scale(0.92); border-color: transparent; } 75% { opacity: 1; transform: translateY(0) scale(1); border-color: transparent; } 100% { opacity: 1; transform: translateY(0) scale(1); border-color: rgba(255, 255, 255, 0.18); } }
|
||||
.home-card-inner { overflow: hidden; opacity: 0; }
|
||||
.home-card-animate .home-card-inner { animation: inner-draw 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; animation-delay: calc(var(--card-stagger) * 0.18s + 0.9s); }
|
||||
@keyframes inner-draw { 0% { opacity: 0; clip-path: inset(0 100% 0 0); } 15% { opacity: 1; } 100% { opacity: 1; clip-path: inset(0 0 0 0); } }
|
||||
.home-card-text { overflow: hidden; }
|
||||
.home-card-stats { overflow: hidden; }
|
||||
.home-card-btn { opacity: 0; transform: scale(0.5); border-color: transparent; min-height: 44px; padding-top: 10px; padding-bottom: 10px; }
|
||||
.home-card-animate .home-card-btn { animation: btn-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation-delay: calc(var(--card-stagger) * 0.18s + 1.5s); }
|
||||
@keyframes btn-pop { 0% { opacity: 0; transform: scale(0.5); border-color: transparent; } 85% { opacity: 1; transform: scale(1); border-color: transparent; } 100% { opacity: 1; transform: scale(1); border-color: rgba(255, 255, 255, 0.18); } }
|
||||
.home-card:not(.home-card-animate) .home-card-inner, .home-card:not(.home-card-animate) .home-card-btn { opacity: 1; animation: none; clip-path: none; transform: none; border-color: rgba(255, 255, 255, 0.18); }
|
||||
.home-card:not(.home-card-animate) .home-card-shell { border-color: rgba(255, 255, 255, 0.18); }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black flex items-center justify-center p-6">
|
||||
<div class="glass-card p-8 w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ t('kioskRecovery.title') }}</h1>
|
||||
<p class="text-sm text-white/50">{{ t('kioskRecovery.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Server IP -->
|
||||
<div class="bg-white/5 rounded-lg p-4 mb-4">
|
||||
<div class="text-xs text-white/50 mb-1">{{ t('kioskRecovery.serverAddress') }}</div>
|
||||
<div class="text-lg font-mono text-white font-medium">{{ serverIp || t('common.loading') }}</div>
|
||||
<div v-if="serverIp" class="text-xs text-white/40 mt-1">{{ t('kioskRecovery.webUi', { address: serverIp }) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="serverIp" class="bg-white/5 rounded-lg p-4 mb-4 flex flex-col items-center">
|
||||
<div class="text-xs text-white/50 mb-2">{{ t('kioskRecovery.scanForMobile') }}</div>
|
||||
<div class="bg-white p-3 rounded-lg inline-block">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="w-32 h-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.backend') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="backendHealthy ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="backendHealthy ? 'text-green-400' : 'text-red-400'">
|
||||
{{ backendHealthy ? t('common.healthy') : t('kioskRecovery.unreachable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.containers') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ containerCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('monitoring.diskUsage') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ diskUsage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button @click="goToLogin" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('kioskRecovery.goToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-xs text-white/30">{{ t('kioskRecovery.lastChecked', { time: lastChecked }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const serverIp = ref('')
|
||||
const backendHealthy = ref(false)
|
||||
const containerCount = ref('—')
|
||||
const diskUsage = ref('—')
|
||||
const lastChecked = ref('—')
|
||||
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (!serverIp.value) return ''
|
||||
const url = `http://${serverIp.value}`
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=128x128&data=${encodeURIComponent(url)}`
|
||||
})
|
||||
|
||||
async function refreshDiagnostics() {
|
||||
lastChecked.value = new Date().toLocaleTimeString()
|
||||
|
||||
// Detect server IP from window location
|
||||
serverIp.value = window.location.hostname !== 'localhost'
|
||||
? window.location.hostname
|
||||
: '127.0.0.1'
|
||||
|
||||
// Check backend health
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(5000) })
|
||||
backendHealthy.value = res.ok
|
||||
} catch {
|
||||
backendHealthy.value = false
|
||||
}
|
||||
|
||||
// Get system stats (unauthenticated won't work for RPC, but try health)
|
||||
if (backendHealthy.value) {
|
||||
try {
|
||||
const statsRes = await fetch('/rpc/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'system.stats' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await statsRes.json()
|
||||
if (data.result) {
|
||||
const disk = data.result.disk
|
||||
if (disk) {
|
||||
const usedPct = ((disk.used / disk.total) * 100).toFixed(0)
|
||||
diskUsage.value = `${usedPct}% used`
|
||||
}
|
||||
containerCount.value = String(data.result.containers?.running ?? '—')
|
||||
}
|
||||
} catch {
|
||||
// Stats require auth — show defaults
|
||||
containerCount.value = '—'
|
||||
diskUsage.value = '—'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshDiagnostics()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative z-10 login-fly-perspective">
|
||||
<div class="w-full max-w-md relative z-20">
|
||||
<!-- Login Card - flies towards user on success -->
|
||||
<div
|
||||
class="glass-card p-8 pt-20 relative login-card overflow-visible"
|
||||
:class="{ 'login-fly-towards': whooshAway }"
|
||||
>
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||
<div class="logo-gradient-border w-20 h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
<span v-if="isCheckingSetup"> </span>
|
||||
<span v-else-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
|
||||
<span v-else>{{ t('login.title') }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Server Startup Progress -->
|
||||
<div v-if="!serverReady" class="mb-6" role="status" aria-live="polite">
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<svg class="animate-spin h-4 w-4 text-orange-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-white/60">{{ t('login.serverStarting') }}</span>
|
||||
</div>
|
||||
<div class="startup-progress-track">
|
||||
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Checking setup state -->
|
||||
<div v-if="isCheckingSetup" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Setup Mode: Password Setup -->
|
||||
<template v-else-if="isSetupMode && !isSetup">
|
||||
<div class="mb-4 p-4 bg-white/5 border border-white/10 rounded-lg text-white/80 text-sm">
|
||||
<p class="mb-2">Create a password to secure your Archipelago node.</p>
|
||||
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="setup-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||
@input="error = null"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="setup-confirm-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.confirmPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
ref="confirmPasswordInputRef"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keydown.enter="handleSetupWithSound"
|
||||
@input="error = null"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="{ 'opacity-60': !password || password.length < 8 || password !== confirmPassword }"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.settingUp') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- TOTP Verification Step -->
|
||||
<template v-else-if="requiresTotp">
|
||||
<div class="mb-6 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p class="text-white/80 text-sm mb-1">{{ t('login.twoFactorTitle') }}</p>
|
||||
<p class="text-white/50 text-xs">{{ t('login.totpInstruction') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
ref="totpInputRef"
|
||||
v-model="totpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
data-controller-no-submit
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@keyup.enter="handleTotpVerify"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleTotpVerify"
|
||||
:disabled="loading || !totpCode"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.verifyButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.verifying') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="useBackupCode = !useBackupCode; totpCode = ''"
|
||||
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
|
||||
>
|
||||
{{ useBackupCode ? t('login.useAuthCode') : t('login.useBackupCode') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Normal Login Mode -->
|
||||
<template v-else>
|
||||
<!-- Demo credential hint -->
|
||||
<div v-if="isDemo" class="mb-4 p-3 bg-orange-500/15 border border-orange-400/30 rounded-lg text-orange-100 text-sm text-center">
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All auth inputs opt out of controller-nav's Enter→click-next-button
|
||||
pattern (data-controller-no-submit): they submit via their own Enter
|
||||
handlers, and while the submit button is still disabled the "next
|
||||
focusable" is Replay Intro — the companion's auto-login injects
|
||||
Enter before Vue re-enables the button, which replayed the intro
|
||||
in a loop on every app connect. -->
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keydown.enter="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.loginButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.loggingIn') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-6 text-center text-sm text-white/40">
|
||||
{{ t('login.recoveryNote') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay Intro / Restart Onboarding - Bottom of Page -->
|
||||
<div class="mt-8 text-center flex items-center justify-center gap-4">
|
||||
<button
|
||||
@click="replayIntro"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<template v-if="!isDemo">
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="confirmingRestartOnboarding ? 'text-orange-400 hover:text-orange-300' : 'text-white/50 hover:text-white/70'"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : (confirmingRestartOnboarding ? t('login.restartConfirm') : t('login.onboarding')) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isLocalRedirect } from '../router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
import { IS_DEMO, DEMO_PASSWORD, clearDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const currentRoute = useRoute()
|
||||
|
||||
/** After login, redirect to the intended page or default to home */
|
||||
const loginRedirectTo = computed(() => {
|
||||
const redirect = currentRoute.query.redirect as string
|
||||
if (redirect && isLocalRedirect(redirect)) return redirect
|
||||
return '/dashboard'
|
||||
})
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const isDemo = IS_DEMO
|
||||
const password = ref(IS_DEMO ? DEMO_PASSWORD : '')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSetup = ref(false)
|
||||
const whooshAway = ref(false)
|
||||
const requiresTotp = ref(false)
|
||||
const totpCode = ref('')
|
||||
const useBackupCode = ref(false)
|
||||
const totpInputRef = ref<HTMLInputElement | null>(null)
|
||||
const confirmPasswordInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Server startup state
|
||||
const serverReady = ref(false)
|
||||
const serverChecking = ref(true)
|
||||
const startupProgress = ref(0)
|
||||
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Whether we're in setup mode (no password created yet)
|
||||
const isSetupMode = ref(false)
|
||||
|
||||
// Whether we're still checking the setup state (prevents flash of wrong form)
|
||||
const isCheckingSetup = ref(true)
|
||||
|
||||
// Whether the login form should be disabled (server not ready)
|
||||
const formDisabled = computed(() => !serverReady.value)
|
||||
|
||||
async function checkServerHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
// Any HTTP response from backend (200, 401, 403, etc.) means it's up
|
||||
// Only 502/503 from nginx means backend isn't running yet
|
||||
return response.status !== 502 && response.status !== 503
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function pollServerStartup(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Animate progress slowly while waiting
|
||||
startupProgressInterval = setInterval(() => {
|
||||
if (startupProgress.value < 90) {
|
||||
startupProgress.value += Math.random() * 8 + 2
|
||||
if (startupProgress.value > 90) startupProgress.value = 90
|
||||
}
|
||||
}, 600)
|
||||
|
||||
const poll = async () => {
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
startupProgress.value = 100
|
||||
// Brief pause to show 100% before revealing form
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Retry in 2s
|
||||
startupPollTimer = setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
let unlockHandler: (() => void) | null = null
|
||||
|
||||
function removeUnlockListeners() {
|
||||
if (unlockHandler) {
|
||||
document.removeEventListener('click', unlockHandler)
|
||||
document.removeEventListener('touchstart', unlockHandler)
|
||||
document.removeEventListener('keydown', unlockHandler)
|
||||
unlockHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeUnlockListeners()
|
||||
if (startupPollTimer) clearTimeout(startupPollTimer)
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
|
||||
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
|
||||
unlockHandler = () => {
|
||||
if (!fromSplash) {
|
||||
resumeAudioContext()
|
||||
startSynthwave()
|
||||
}
|
||||
removeUnlockListeners()
|
||||
}
|
||||
document.addEventListener('click', unlockHandler, { once: true })
|
||||
document.addEventListener('touchstart', unlockHandler, { once: true })
|
||||
document.addEventListener('keydown', unlockHandler, { once: true })
|
||||
|
||||
// Check server health first
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
} else {
|
||||
// Server not ready — start polling with progress bar
|
||||
await pollServerStartup()
|
||||
}
|
||||
|
||||
// Check if password has been set up — show setup form if not
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
isSetup.value = Boolean(result)
|
||||
isSetupMode.value = !isSetup.value
|
||||
} catch {
|
||||
isSetup.value = false
|
||||
isSetupMode.value = true
|
||||
} finally {
|
||||
isCheckingSetup.value = false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function handleSetupWithSound() {
|
||||
if (!loading.value && password.value && password.value === confirmPassword.value) {
|
||||
playPop()
|
||||
}
|
||||
handleSetup()
|
||||
}
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = t('login.errorMinLength')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = t('login.errorMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'auth.setup',
|
||||
params: { password: password.value.trim() }
|
||||
})
|
||||
|
||||
await store.login(password.value.trim())
|
||||
// Verify session cookie works before navigating (prevents connection lost on first login)
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
|
||||
} catch {
|
||||
error.value = 'Setup succeeded but session could not be established. Try refreshing.'
|
||||
store.logout()
|
||||
return
|
||||
}
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
// First password setup counts as the first login (video → static rotation).
|
||||
try { localStorage.setItem('neode_first_login_done', '1') } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || t('login.errorSetupFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoginWithSound() {
|
||||
if (!loading.value && password.value) {
|
||||
playPop()
|
||||
}
|
||||
handleLogin()
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await store.login(password.value.trim())
|
||||
if (result?.requires_totp) {
|
||||
requiresTotp.value = true
|
||||
loading.value = false
|
||||
// Focus the TOTP input after DOM update
|
||||
setTimeout(() => totpInputRef.value?.focus(), 100)
|
||||
return
|
||||
}
|
||||
// Verify session cookie works before navigating (prevents login loop on LAN)
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
|
||||
} catch {
|
||||
error.value = 'Login succeeded but session could not be established. Try clearing cookies and refreshing.'
|
||||
store.logout()
|
||||
return
|
||||
}
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || t('login.errorLoginFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!totpCode.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (useBackupCode.value) {
|
||||
await rpcClient.loginBackup(totpCode.value)
|
||||
} else {
|
||||
await rpcClient.loginTotp(totpCode.value)
|
||||
}
|
||||
await store.completeLoginAfterTotp()
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/expired|too many/i.test(msg)) {
|
||||
// Session expired, go back to password step
|
||||
requiresTotp.value = false
|
||||
totpCode.value = ''
|
||||
error.value = msg
|
||||
} else {
|
||||
error.value = msg || t('login.errorInvalidCode')
|
||||
}
|
||||
totpCode.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
||||
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
||||
* regular password login (e.g. the demo). Subsequent logins stay
|
||||
* deliberately low-key (justLoggedIn only).
|
||||
* Also stamps neode_first_login_done: the login page keeps the intro VIDEO
|
||||
* background until someone has logged in once (OnboardingWrapper reads it
|
||||
* to pick video vs the rotating static backgrounds). */
|
||||
function consumeOnboardingFinale() {
|
||||
try {
|
||||
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
||||
sessionStorage.removeItem('archy_onboarding_finale')
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
}
|
||||
localStorage.setItem('neode_first_login_done', '1')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Demo: also clear the per-day gate so the intro plays again now.
|
||||
if (IS_DEMO) clearDemoIntroSeen()
|
||||
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
|
||||
// what keeps fresh browsers on already-onboarded nodes from replaying it) —
|
||||
// this explicit one-shot flag tells it the replay is deliberate.
|
||||
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
|
||||
// Navigate to root to trigger splash screen
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const isResettingOnboarding = ref(false)
|
||||
const confirmingRestartOnboarding = ref(false)
|
||||
let confirmRestartTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function restartOnboarding() {
|
||||
if (isResettingOnboarding.value) return
|
||||
// First click arms a confirmation state; only a second explicit click restarts.
|
||||
if (!confirmingRestartOnboarding.value) {
|
||||
confirmingRestartOnboarding.value = true
|
||||
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||
confirmRestartTimer = setTimeout(() => {
|
||||
confirmingRestartOnboarding.value = false
|
||||
confirmRestartTimer = null
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
if (confirmRestartTimer) {
|
||||
clearTimeout(confirmRestartTimer)
|
||||
confirmRestartTimer = null
|
||||
}
|
||||
confirmingRestartOnboarding.value = false
|
||||
isResettingOnboarding.value = true
|
||||
// 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(() => {
|
||||
window.location.href = '/onboarding/intro'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Server startup progress bar */
|
||||
.startup-progress-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.startup-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease-out;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
/* Perspective for 3D fly effect */
|
||||
.login-fly-perspective {
|
||||
perspective: 1200px;
|
||||
perspective-origin: center center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
overflow: visible !important;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Fly towards user - card zooms forward as it transitions out */
|
||||
.login-fly-towards {
|
||||
animation: login-fly-towards 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes login-fly-towards {
|
||||
0% {
|
||||
transform: translateZ(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
60% {
|
||||
transform: translateZ(180px) scale(1.4);
|
||||
opacity: 0.95;
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateZ(400px) scale(2);
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,629 @@
|
||||
<template>
|
||||
<div class="marketplace-container">
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div ref="marketplaceHeaderRef" class="app-header-desktop mb-4 items-center gap-4 relative">
|
||||
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
|
||||
>
|
||||
{{ section.name }}
|
||||
<span v-if="section.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="marketplace-category-select">App Store category</label>
|
||||
<select
|
||||
id="marketplace-category-select"
|
||||
:value="selectedCategory"
|
||||
class="segmented-select-control"
|
||||
@change="selectAppStoreSection(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}{{ section.id === 'nostr' && nostrApps.length > 0 ? ` +${nostrApps.length}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="marketplaceCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === section.id }"
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Apps Section -->
|
||||
<div class="pb-8">
|
||||
<!-- Community Load Error -->
|
||||
<div v-if="communityError" class="alert-error mb-4">
|
||||
{{ communityError }}
|
||||
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template v-if="(loadingCommunity || nostrLoading) && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-6 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<MarketplaceAppCard
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:index="index"
|
||||
:stagger="showStagger"
|
||||
:installed="isInstalled(app.id)"
|
||||
:installing="installingApps.has(app.id)"
|
||||
:install-progress="installingApps.get(app.id)"
|
||||
:installed-state="getInstalledState(app.id)"
|
||||
:starting-up="isStartingUp(app.id)"
|
||||
:containers-scanned="containersScanned"
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@launch="launchInstalledApp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
|
||||
<div v-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
INSTALLED_ALIASES,
|
||||
getAppTier,
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const server = useServerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showStagger = !marketplaceAnimationDone
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Category state — read initial value from query param (set by Discover page navigation)
|
||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
// 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) {
|
||||
selectedCategory.value = id
|
||||
const query = id === 'all' ? {} : { category: id }
|
||||
router.replace({ name: 'marketplace', query }).catch(() => {})
|
||||
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAppStoreSection(id: string) {
|
||||
if (id === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
selectCategory(id)
|
||||
}
|
||||
|
||||
watch(() => route.query.category, (category) => {
|
||||
const next = typeof category === 'string' && category ? category : 'all'
|
||||
selectedCategory.value = next
|
||||
if (next === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
})
|
||||
|
||||
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
|
||||
// TTL) behind a shared key so Discover.vue's identical loader picks up the
|
||||
// same cache entry without its own conversion (plan 02-04). Non-sensitive
|
||||
// and small, so it persists across reloads.
|
||||
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||
key: 'app-catalog',
|
||||
fetcher: async () => getCuratedAppList(),
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
})
|
||||
const communityApps = computed(() => catalogResource.data.value ?? [])
|
||||
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
|
||||
// Keep-last-value error banner (D-07): a failed background refresh never
|
||||
// raises a toast, it only surfaces here — content stays on screen either way.
|
||||
const communityError = computed(() => catalogResource.error.value ?? '')
|
||||
|
||||
interface BitcoinStatusResponse {
|
||||
blockchain_info?: { pruned?: boolean }
|
||||
}
|
||||
|
||||
// Prune status — the default TTL (30s) matches the rest of the app; also
|
||||
// non-sensitive and small, so it persists too.
|
||||
const pruneStatusResource = useCachedResource<BitcoinStatusResponse | null>({
|
||||
key: 'bitcoin.prune-status',
|
||||
fetcher: async (signal) => {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal })
|
||||
if (!res.ok) throw new Error(`bitcoin-status responded ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
persist: true,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = computed(() => pruneStatusResource.data.value?.blockchain_info?.pruned === true)
|
||||
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
|
||||
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
|
||||
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
marketplaceHeaderRef,
|
||||
marketplacePrimaryRef,
|
||||
marketplaceCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
// Nostr community marketplace state
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
const nostrLoading = ref(false)
|
||||
const nostrError = ref('')
|
||||
|
||||
async function loadNostrMarketplace() {
|
||||
if (nostrApps.value.length > 0 || nostrLoading.value) return
|
||||
nostrLoading.value = true
|
||||
nostrError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.marketplaceDiscover()
|
||||
nostrApps.value = res.apps.map(app => ({
|
||||
id: app.manifest.app_id,
|
||||
title: app.manifest.name,
|
||||
version: app.manifest.version,
|
||||
description: typeof app.manifest.description === 'string'
|
||||
? app.manifest.description
|
||||
: app.manifest.description,
|
||||
icon: app.manifest.icon_url || '',
|
||||
author: app.manifest.author.name,
|
||||
dockerImage: app.manifest.container.image,
|
||||
repoUrl: app.manifest.repo_url,
|
||||
category: app.manifest.category,
|
||||
source: 'nostr',
|
||||
trustScore: app.trust_score,
|
||||
trustTier: app.trust_tier,
|
||||
relayCount: app.relay_count,
|
||||
// Default to `missing` rather than leaving it undefined: a node running
|
||||
// an older backend returns no field at all, and "we couldn't check" must
|
||||
// never render as "signed".
|
||||
signature: app.signature ?? { status: 'missing' as const },
|
||||
authorDid: app.manifest.author.did,
|
||||
}))
|
||||
} catch (e) {
|
||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
if (import.meta.env.DEV) console.warn('Nostr marketplace discovery failed:', e)
|
||||
} finally {
|
||||
nostrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const installedPackages = computed(() => {
|
||||
return store.data?.['package-data'] || {}
|
||||
})
|
||||
|
||||
const containersScannedRaw = computed(() => {
|
||||
return store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false
|
||||
})
|
||||
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||
// timeout, treat the scan as done so cards render their normal install state.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
|
||||
// Combine curated apps with Nostr relay-discovered apps
|
||||
const allApps = computed(() => {
|
||||
const local: (MarketplaceApp & { category: string; source: string })[] = []
|
||||
|
||||
const community = communityApps.value.map(app => {
|
||||
const category = categorizeCommunityApp(app)
|
||||
return { ...app, category, source: 'community' }
|
||||
})
|
||||
|
||||
const base = [...local, ...community]
|
||||
|
||||
if (nostrApps.value.length > 0) {
|
||||
const existingIds = new Set(base.map(a => a.id))
|
||||
const nostrMerged = nostrApps.value
|
||||
.filter(app => !existingIds.has(app.id))
|
||||
.map(app => {
|
||||
const category = app.category || categorizeCommunityApp(app)
|
||||
return { ...app, category, source: 'nostr' }
|
||||
})
|
||||
return [...base, ...nostrMerged]
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
app.title?.toLowerCase().includes(query) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(query)) ||
|
||||
(typeof app.description === 'object' && app.description?.short?.toLowerCase().includes(query)) ||
|
||||
app.id?.toLowerCase().includes(query) ||
|
||||
app.author?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Hide installed, installing, and web-only apps (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id) && app.dockerImage)
|
||||
|
||||
return apps
|
||||
})
|
||||
|
||||
function isInstalled(appId: string): boolean {
|
||||
if (appId in installedPackages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
||||
}
|
||||
|
||||
function getInstalledState(appId: string): string | null {
|
||||
const pkg = installedPackages.value[appId]
|
||||
if (pkg) return pkg.state
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const a of aliases) {
|
||||
const aliasPkg = installedPackages.value[a]
|
||||
if (aliasPkg) return aliasPkg.state
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isStartingUp(appId: string): boolean {
|
||||
const state = getInstalledState(appId)
|
||||
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
// onMounted fires exactly once for the lifetime of this kept-alive instance
|
||||
// (D-01/plan 02-01). marketplaceAnimationDone is genuinely once-per-session
|
||||
// intro state, so it stays here. The catalog and prune-status loads used to
|
||||
// live here too, fetching fresh on every remount; they're now cache-gated
|
||||
// useCachedResource entries whose own onActivated revalidates them on every
|
||||
// tab re-entry (staleness-checked, so a quick revisit issues no RPC) — they
|
||||
// need no per-view mount/activation hook of their own.
|
||||
onMounted(() => {
|
||||
marketplaceAnimationDone = true
|
||||
})
|
||||
|
||||
/** Force-refresh the Bitcoin prune-status cache entry (deduped with any
|
||||
* in-flight refresh, per useCachedResource). Exposed for tests. */
|
||||
function loadBitcoinPruneStatus() {
|
||||
return pruneStatusResource.refresh()
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
/** Force-refresh the app-catalog cache entry (used by the Retry button and
|
||||
* exposed for tests). */
|
||||
function loadCommunityMarketplace() {
|
||||
return catalogResource.refresh()
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] Navigating to app detail:', app)
|
||||
|
||||
try {
|
||||
if (isInstalled(app.id)) {
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] App is installed, navigating to app details page')
|
||||
router.push({ name: 'app-details', params: { id: app.id } })
|
||||
} else {
|
||||
setCurrentApp(app)
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] App data stored in composable')
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id } })
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[Marketplace] Navigation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function trackTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
const idx = activeTimers.indexOf(id)
|
||||
if (idx !== -1) activeTimers.splice(idx, 1)
|
||||
fn()
|
||||
}, ms)
|
||||
activeTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of activeTimers) clearTimeout(t)
|
||||
activeTimers.length = 0
|
||||
})
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
server.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Queued…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
|
||||
server.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message,
|
||||
attempt: 0,
|
||||
})
|
||||
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, url: installUrl, version: app.version },
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exposed for tests only (mirrors Cloud.vue's `defineExpose({ loadPeers })`):
|
||||
// lets a test trigger a background refresh directly without waiting on TTL
|
||||
// staleness or a real KeepAlive round-trip.
|
||||
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar styling for apps section */
|
||||
.marketplace-container ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.marketplace-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 255px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,727 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- App Details -->
|
||||
<div v-else-if="app" key="content">
|
||||
<!-- Compact Hero Section -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info (grows to fill space) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-sm mb-2">{{ shortDescription }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<select
|
||||
v-if="!isInstalled && installVersions.length > 1"
|
||||
v-model="selectedInstallVersion"
|
||||
:disabled="installing"
|
||||
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||
class="rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||
>
|
||||
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="!isInstalled"
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Top: Icon + Info -->
|
||||
<div class="grid grid-cols-[80px_1fr] gap-4 mb-4">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ shortDescription }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install-time version selector (multi-version apps) -->
|
||||
<select
|
||||
v-if="!isInstalled && installVersions.length > 1"
|
||||
v-model="selectedInstallVersion"
|
||||
:disabled="installing"
|
||||
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||
class="w-full mb-2 rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||
>
|
||||
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Bottom: Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Mobile) -->
|
||||
<div v-if="installError" class="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Desktop) -->
|
||||
<div v-if="installError" class="hidden md:block mt-4 p-4 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
|
||||
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
|
||||
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ longDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
|
||||
</div>
|
||||
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ app.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
|
||||
</div>
|
||||
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Package</span>
|
||||
<span class="text-white font-medium text-xs">.s9pk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- App Dependencies -->
|
||||
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
|
||||
<div
|
||||
v-for="dep in dependencies"
|
||||
:key="dep.id"
|
||||
class="flex items-center gap-3 py-2 border-b border-white/10"
|
||||
>
|
||||
<!-- Status indicator -->
|
||||
<svg v-if="dep.status === 'running'" class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="dep.status === 'stopped'" class="w-5 h-5 text-yellow-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
|
||||
<p class="text-white/50 text-xs">
|
||||
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Install missing dependencies button -->
|
||||
<button
|
||||
v-if="dependencies.some(d => d.status === 'missing')"
|
||||
@click="installDependencies"
|
||||
:disabled="installingDeps"
|
||||
class="glass-button w-full mt-3 px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg v-if="installingDeps" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="py-2 border-b border-white/10">
|
||||
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card (no GitHub - repo link removed per product) -->
|
||||
<div v-if="app.manifestUrl" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
:href="app.manifestUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.downloadPackage') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else key="not-found" class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import type { PackageVersionsResponse } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { handleImageError } from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const app = ref<MarketplaceAppInfo | null>(null)
|
||||
const installing = ref(false)
|
||||
const installingDeps = ref(false)
|
||||
const installError = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const bitcoinPruned = ref(false)
|
||||
|
||||
// Multi-version support: install-time version choice. Populated from the signed
|
||||
// catalog for apps that offer multiple versions (e.g. Bitcoin Core / Knots).
|
||||
// Hidden when an app offers only one version — install stays one-click.
|
||||
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||
const selectedInstallVersion = ref('')
|
||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Keyed per marketplace app id (D-04). Catalog version metadata is
|
||||
// near-static, so this can take a longer TTL than the 30s default; it holds
|
||||
// nothing credential/DID/wallet/tx-history-shaped so persist:true is safe
|
||||
// (explicit, not defaulted — CR-01 follow-up removed the implicit default).
|
||||
// Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's
|
||||
// captured RPC calls that isn't confounded by the Home-tab-transit artifact —
|
||||
// getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune
|
||||
// check below is a plain fetch(), not an rpcClient call.
|
||||
const versionsResource = useCachedResource<PackageVersionsResponse>({
|
||||
key: `app-details:versions:${appId.value}`,
|
||||
fetcher: (signal) => rpcClient.call<PackageVersionsResponse>({
|
||||
method: 'package.versions',
|
||||
params: { id: appId.value },
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 15000,
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
persist: true, // package version list — public catalog metadata, no identity/money
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
// Web-only apps (no container, just a URL) — always treated as "installed"
|
||||
const isWebOnly = computed(() => {
|
||||
return !!(app.value?.webUrl && !app.value?.dockerImage)
|
||||
})
|
||||
|
||||
// Check if app is already installed
|
||||
const isInstalled = computed(() => {
|
||||
if (isWebOnly.value) return true
|
||||
return !!store.packages[appId.value]
|
||||
})
|
||||
|
||||
// Extract descriptions with safety checks
|
||||
const shortDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.short || desc.long || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const longDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.long || desc.short || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
|
||||
|
||||
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
|
||||
// Placeholder features
|
||||
const features = computed(() => {
|
||||
return [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default',
|
||||
'Open source'
|
||||
]
|
||||
})
|
||||
|
||||
/** App dependency definitions */
|
||||
const R = 'source.archipelago-foundation.org/lfg2025'
|
||||
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
|
||||
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'mempool': [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` },
|
||||
{ id: 'electrumx', title: 'ElectrumX', dockerImage: `${R}/electrumx:v1.18.0` },
|
||||
],
|
||||
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
}
|
||||
|
||||
/** Check dependency status against installed packages */
|
||||
const dependencies = computed(() => {
|
||||
if (!app.value) return []
|
||||
const deps = APP_DEPENDENCIES[app.value.id]
|
||||
if (!deps) return []
|
||||
return deps.map(dep => {
|
||||
const pkg = store.packages[dep.id]
|
||||
let status: 'running' | 'stopped' | 'missing' = 'missing'
|
||||
if (pkg) {
|
||||
status = pkg.state === 'running' ? 'running' : 'stopped'
|
||||
}
|
||||
return { ...dep, status }
|
||||
})
|
||||
})
|
||||
|
||||
const installBlockedReason = computed(() => {
|
||||
const id = app.value?.id
|
||||
if (!bitcoinPruned.value || !id) return ''
|
||||
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
// Demo: only demoable apps can be installed; the rest show "Not available in demo".
|
||||
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (import.meta.env.DEV) console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
|
||||
|
||||
try {
|
||||
const loadedApp = getCurrentApp()
|
||||
|
||||
if (loadedApp && loadedApp.id === appId.value) {
|
||||
app.value = loadedApp
|
||||
loading.value = false
|
||||
} else {
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
void loadInstallVersions()
|
||||
})
|
||||
|
||||
// Fetch the catalog's selectable versions so the install panel can offer a
|
||||
// choice (latest pre-selected). Best-effort: on any failure the selector stays
|
||||
// hidden and install proceeds at the catalog default. Only refetches when the
|
||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||
// paints from cache with no new RPC.
|
||||
async function loadInstallVersions() {
|
||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
await versionsResource.refresh()
|
||||
}
|
||||
const info = versionsResource.data.value
|
||||
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
||||
installVersions.value = []
|
||||
return
|
||||
}
|
||||
installVersions.value = info.versions
|
||||
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
|
||||
}
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
if (route.query.from === 'home') {
|
||||
router.push('/dashboard').catch(() => {})
|
||||
} else if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
} else {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function goToInstalledApp() {
|
||||
// Web-only apps: launch directly via appLauncher
|
||||
if (isWebOnly.value && app.value?.webUrl) {
|
||||
useAppLauncherStore().open({
|
||||
url: app.value.webUrl,
|
||||
title: app.value.title || appId.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: `/dashboard/apps/${appId.value}`,
|
||||
query: { from: 'marketplace' }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function installDependencies() {
|
||||
if (installingDeps.value) return
|
||||
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
|
||||
if (!missingDeps.length) return
|
||||
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
|
||||
installError.value = electrumxArchiveWarning
|
||||
toast.error(electrumxArchiveWarning)
|
||||
return
|
||||
}
|
||||
|
||||
installingDeps.value = true
|
||||
installError.value = null
|
||||
|
||||
try {
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrumx)
|
||||
for (const dep of missingDeps) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: dep.id,
|
||||
dockerImage: dep.dockerImage,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
// Wait for package to register before installing next
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
} finally {
|
||||
installingDeps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (installBlockedReason.value) {
|
||||
installError.value = installBlockedReason.value
|
||||
toast.error(installBlockedReason.value)
|
||||
return
|
||||
}
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
installError.value = null
|
||||
|
||||
// Multi-version: a runner-chosen version (when offered) overrides the default.
|
||||
const chosenVersion = (installVersions.value.length > 1 && selectedInstallVersion.value)
|
||||
? selectedInstallVersion.value
|
||||
: app.value.version
|
||||
|
||||
try {
|
||||
if (app.value.dockerImage) {
|
||||
// Docker-based app installation
|
||||
const installParams: Record<string, unknown> = {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: chosenVersion,
|
||||
}
|
||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 600000,
|
||||
})
|
||||
} else {
|
||||
// Package-based installation
|
||||
const installUrl = app.value.url || app.value.manifestUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: chosenVersion,
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait a moment for the package to be registered
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,567 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6">
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="router.push(backTarget)" />
|
||||
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('monitoring.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('monitoring.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('csv')">
|
||||
{{ t('monitoring.exportCsv') }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('json')">
|
||||
{{ t('monitoring.exportJson') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ liveSystem.cpu_percent.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ liveSystem.load_avg_1.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.mem_used_bytes) }} / {{ formatBytes(liveSystem.mem_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(liveSystem.net_rx_bytes) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(liveSystem.net_tx_bytes) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.cpuUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="cpuDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.memoryUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="memDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.networkIo') }}</h3>
|
||||
<LineChart
|
||||
:datasets="netDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.rpcLatency') }}</h3>
|
||||
<LineChart
|
||||
:datasets="latencyDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert History -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.alertHistory') }}</h3>
|
||||
<button
|
||||
class="glass-button text-xs px-3 py-1"
|
||||
@click="showAlertConfig = !showAlertConfig"
|
||||
>
|
||||
{{ showAlertConfig ? t('monitoring.hideConfig') : t('common.configure') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alert Rule Configuration -->
|
||||
<div v-if="showAlertConfig" class="mb-4 space-y-2">
|
||||
<div
|
||||
v-for="rule in alertRules"
|
||||
:key="rule.kind"
|
||||
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<ToggleSwitch :model-value="rule.enabled" @update:model-value="toggleAlertRule(rule.kind, $event)" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
|
||||
<p class="text-xs text-white/40">{{ rule.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="rule.threshold"
|
||||
class="monitoring-threshold-input"
|
||||
@change="updateThreshold(rule.kind, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span class="text-xs text-white/40">{{ ruleUnit(rule.kind) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fired Alerts List -->
|
||||
<div v-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
|
||||
{{ t('monitoring.noAlerts') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg"
|
||||
:class="{ 'opacity-50': alert.acknowledged }"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||
:class="alertDotColor(alert.kind)"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white">{{ alert.message }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatAlertTime(alert.timestamp) }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!alert.acknowledged"
|
||||
class="text-xs text-white/40 hover:text-white/70 flex-shrink-0"
|
||||
@click="acknowledgeAlert(alert.id)"
|
||||
>
|
||||
{{ t('common.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Resource Breakdown -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">{{ t('monitoring.containerResources') }}</h3>
|
||||
<div v-if="!containers.length" class="text-white/40 text-sm py-4 text-center">
|
||||
{{ t('monitoring.noContainerMetrics') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="c in containers"
|
||||
:key="c.name"
|
||||
class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white truncate">{{ c.name }}</p>
|
||||
<div class="flex gap-4 mt-1 text-xs text-white/50">
|
||||
<span>CPU: {{ c.cpu_percent.toFixed(1) }}%</span>
|
||||
<span>Mem: {{ formatBytes(c.mem_used_bytes) }}</span>
|
||||
<span>Net: {{ formatBytes(c.net_rx_bytes) }} / {{ formatBytes(c.net_tx_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitoring-bar-container">
|
||||
<div
|
||||
class="monitoring-bar-fill"
|
||||
:style="{ width: Math.min(c.cpu_percent, 100) + '%' }"
|
||||
:class="c.cpu_percent > 80 ? 'monitoring-bar-danger' : c.cpu_percent > 50 ? 'monitoring-bar-warn' : 'monitoring-bar-ok'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Timeline -->
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.systemHealth') }}</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> {{ t('common.healthy') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> {{ t('common.elevated') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> {{ t('common.critical') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-0.5 h-6">
|
||||
<div
|
||||
v-for="(entry, idx) in healthTimeline"
|
||||
:key="idx"
|
||||
class="flex-1 rounded-sm transition-colors"
|
||||
:class="healthColor(entry)"
|
||||
:title="entry.label"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1 text-xs text-white/30">
|
||||
<span>{{ historyMinutesAgo }}m ago</span>
|
||||
<span>Now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
{{ t('monitoring.refreshFooter') }} · {{ t('monitoring.wsConnections', { count: current?.ws_connections ?? 0 }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
net_rx_bytes: number
|
||||
net_tx_bytes: number
|
||||
load_avg_1: number
|
||||
load_avg_5: number
|
||||
load_avg_15: number
|
||||
}
|
||||
|
||||
interface ContainerMetrics {
|
||||
name: string
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_limit_bytes: number
|
||||
net_rx_bytes: number
|
||||
net_tx_bytes: number
|
||||
block_read_bytes: number
|
||||
block_write_bytes: number
|
||||
}
|
||||
|
||||
interface MetricSnapshot {
|
||||
timestamp: number
|
||||
system: SystemMetrics
|
||||
containers: ContainerMetrics[]
|
||||
rpc_latency_ms: number
|
||||
ws_connections: number
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
resolution: string
|
||||
count: number
|
||||
data: MetricSnapshot[]
|
||||
}
|
||||
|
||||
interface AlertRule {
|
||||
kind: string
|
||||
threshold: number
|
||||
enabled: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FiredAlert {
|
||||
id: string
|
||||
kind: string
|
||||
message: string
|
||||
value: number
|
||||
threshold: number
|
||||
timestamp: number
|
||||
acknowledged: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Monitoring is reachable from Web5 (the monitoring link) AND from the Home
|
||||
// "System" card (which passes ?from=home). The back button follows the entry
|
||||
// point so it returns where the user came from instead of always Web5.
|
||||
const cameFromHome = computed(() => route.query.from === 'home')
|
||||
const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboard/web5'))
|
||||
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
// Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
|
||||
// poll revalidates behind them; errors keep the last-known values.
|
||||
const currentRes = useCachedResource<MetricSnapshot>({
|
||||
key: 'monitoring.current',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
if (!data || !('system' in data)) throw new Error('metrics not ready')
|
||||
return data
|
||||
},
|
||||
persist: true, // system/container/rpc metrics — no identity/money payload
|
||||
})
|
||||
const historyRes = useCachedResource<MetricSnapshot[]>({
|
||||
key: 'monitoring.history.minute60',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<HistoryResponse>({
|
||||
method: 'monitoring.history', params: { resolution: 'minute', count: 60 },
|
||||
signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return data?.data ?? []
|
||||
},
|
||||
persist: true, // historical metric snapshots — no identity/money payload
|
||||
})
|
||||
const alertsRes = useCachedResource<FiredAlert[]>({
|
||||
key: 'monitoring.alerts',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
||||
method: 'monitoring.alerts', params: { count: 50 }, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return (data?.alerts ?? []).reverse()
|
||||
},
|
||||
persist: true, // alert metadata (kind/message/threshold) — no identity/money payload
|
||||
})
|
||||
const alertRulesRes = useCachedResource<AlertRule[]>({
|
||||
key: 'monitoring.alert-rules',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
||||
method: 'monitoring.alert-rules', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return data?.rules ?? []
|
||||
},
|
||||
persist: true, // static alert-rule config — no identity/money payload
|
||||
})
|
||||
const current = computed(() => currentRes.data.value)
|
||||
const history = computed(() => historyRes.data.value ?? [])
|
||||
const containers = computed<ContainerMetrics[]>(() => currentRes.data.value?.containers ?? [])
|
||||
const alerts = computed(() => alertsRes.data.value ?? [])
|
||||
const alertRules = computed(() => alertRulesRes.data.value ?? [])
|
||||
const showAlertConfig = ref(false)
|
||||
const chartWidth = ref(380)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const liveSystem = computed<SystemMetrics>(() => ({
|
||||
cpu_percent: homeStatus.stats.cpuPercent,
|
||||
mem_used_bytes: homeStatus.stats.memUsed,
|
||||
mem_total_bytes: homeStatus.stats.memTotal,
|
||||
disk_used_bytes: homeStatus.stats.diskUsed,
|
||||
disk_total_bytes: homeStatus.stats.diskTotal,
|
||||
net_rx_bytes: current.value?.system.net_rx_bytes ?? 0,
|
||||
net_tx_bytes: current.value?.system.net_tx_bytes ?? 0,
|
||||
load_avg_1: homeStatus.stats.loadAvg1,
|
||||
load_avg_5: homeStatus.stats.loadAvg5,
|
||||
load_avg_15: homeStatus.stats.loadAvg15,
|
||||
}))
|
||||
|
||||
const memPercent = computed(() => {
|
||||
if (!liveSystem.value.mem_total_bytes) return '--'
|
||||
return ((liveSystem.value.mem_used_bytes / liveSystem.value.mem_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const diskPercent = computed(() => {
|
||||
if (!liveSystem.value.disk_total_bytes) return '--'
|
||||
return ((liveSystem.value.disk_used_bytes / liveSystem.value.disk_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const historyMinutesAgo = computed(() => history.value.length || 60)
|
||||
|
||||
const timeLabels = computed(() => {
|
||||
if (!history.value.length) return []
|
||||
return history.value.map((s) => {
|
||||
const d = new Date(s.timestamp * 1000)
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
})
|
||||
})
|
||||
|
||||
const cpuDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'CPU',
|
||||
data: history.value.map((s) => s.system.cpu_percent),
|
||||
color: '#fb923c',
|
||||
}])
|
||||
|
||||
const memDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'Memory',
|
||||
data: history.value.map((s) =>
|
||||
s.system.mem_total_bytes > 0
|
||||
? (s.system.mem_used_bytes / s.system.mem_total_bytes) * 100
|
||||
: 0,
|
||||
),
|
||||
color: '#3b82f6',
|
||||
}])
|
||||
|
||||
const netDatasets = computed<ChartDataset[]>(() => [
|
||||
{
|
||||
label: 'RX',
|
||||
data: history.value.map((s) => s.system.net_rx_bytes),
|
||||
color: '#4ade80',
|
||||
},
|
||||
{
|
||||
label: 'TX',
|
||||
data: history.value.map((s) => s.system.net_tx_bytes),
|
||||
color: '#f59e0b',
|
||||
},
|
||||
])
|
||||
|
||||
const latencyDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'Latency',
|
||||
data: history.value.map((s) => s.rpc_latency_ms),
|
||||
color: '#a78bfa',
|
||||
}])
|
||||
|
||||
interface HealthEntry {
|
||||
cpu: number
|
||||
mem: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const healthTimeline = computed<HealthEntry[]>(() => {
|
||||
if (!history.value.length) {
|
||||
return Array.from({ length: 60 }, () => ({ cpu: 0, mem: 0, label: 'No data' }))
|
||||
}
|
||||
return history.value.map((s) => {
|
||||
const memPct = s.system.mem_total_bytes > 0
|
||||
? (s.system.mem_used_bytes / s.system.mem_total_bytes) * 100
|
||||
: 0
|
||||
const d = new Date(s.timestamp * 1000)
|
||||
const time = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
return {
|
||||
cpu: s.system.cpu_percent,
|
||||
mem: memPct,
|
||||
label: `${time} — CPU: ${s.system.cpu_percent.toFixed(1)}%, Mem: ${memPct.toFixed(1)}%`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function healthColor(entry: HealthEntry): string {
|
||||
if (entry.cpu > 90 || entry.mem > 90) return 'bg-red-400/60'
|
||||
if (entry.cpu > 70 || entry.mem > 70) return 'bg-orange-400/40'
|
||||
return 'bg-green-400/30'
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
function ruleLabel(kind: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
disk_usage: t('monitoring.diskUsage'),
|
||||
ram_usage: t('monitoring.ramUsage'),
|
||||
container_crash: t('monitoring.containerCrash'),
|
||||
backend_error_spike: t('monitoring.rpcLatencySpike'),
|
||||
ssl_cert_expiry: t('monitoring.sslCertExpiry'),
|
||||
}
|
||||
return labels[kind] ?? kind
|
||||
}
|
||||
|
||||
function ruleUnit(kind: string): string {
|
||||
const units: Record<string, string> = {
|
||||
disk_usage: '%',
|
||||
ram_usage: '%',
|
||||
container_crash: '',
|
||||
backend_error_spike: 'ms',
|
||||
ssl_cert_expiry: 'days',
|
||||
}
|
||||
return units[kind] ?? ''
|
||||
}
|
||||
|
||||
function alertDotColor(kind: string): string {
|
||||
if (kind === 'container_crash' || kind === 'ssl_cert_expiry') return 'bg-red-400'
|
||||
if (kind === 'disk_usage' || kind === 'ram_usage') return 'bg-orange-400'
|
||||
return 'bg-yellow-400'
|
||||
}
|
||||
|
||||
function formatAlertTime(timestamp: number): string {
|
||||
const d = new Date(timestamp * 1000)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
async function exportMetrics(format: 'csv' | 'json') {
|
||||
try {
|
||||
const data = await rpcClient.call<{ csv?: string; data?: unknown[]; count: number }>({
|
||||
method: 'monitoring.export',
|
||||
params: { format, resolution: 'minute', count: 1440 },
|
||||
})
|
||||
let blob: Blob
|
||||
let filename: string
|
||||
if (format === 'csv' && data?.csv) {
|
||||
blob = new Blob([data.csv], { type: 'text/csv' })
|
||||
filename = `archipelago-metrics-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
} else {
|
||||
blob = new Blob([JSON.stringify(data?.data ?? [], null, 2)], { type: 'application/json' })
|
||||
filename = `archipelago-metrics-${new Date().toISOString().slice(0, 10)}.json`
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
if (import.meta.env.DEV) console.warn('Failed to export metrics')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAlertRule(kind: string, enabled: boolean) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
|
||||
await alertRulesRes.refresh()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function updateThreshold(kind: string, value: string) {
|
||||
const threshold = parseFloat(value)
|
||||
if (isNaN(threshold) || threshold <= 0) return
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
|
||||
await alertRulesRes.refresh()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeAlert(id: string) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
|
||||
await alertsRes.refresh()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function updateChartWidth() {
|
||||
const container = document.querySelector('.glass-card')
|
||||
if (container) {
|
||||
chartWidth.value = Math.max(container.clientWidth - 40, 200)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
|
||||
// The cached resources fetch themselves on first use; the poll keeps the
|
||||
// live view fresh (refreshes dedup in the store).
|
||||
void homeStatus.refreshSystemStats()
|
||||
pollTimer = setInterval(() => {
|
||||
void homeStatus.refreshSystemStats()
|
||||
void currentRes.refresh()
|
||||
void historyRes.refresh()
|
||||
void alertsRes.refresh()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="glass-card px-8 py-10 text-center max-w-md">
|
||||
<h1 class="text-6xl font-bold text-white/30 mb-4">404</h1>
|
||||
<p class="text-lg text-white/70 mb-6">Page not found</p>
|
||||
<RouterLink to="/dashboard" class="glass-button inline-block px-6 py-3">
|
||||
Back to Dashboard
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Backup Your Identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Create a secure backup of your identity. Set a passphrase and download your encrypted backup file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
|
||||
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<!-- Passphrase Input -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div class="text-left w-full">
|
||||
<label class="block text-xs sm:text-sm font-semibold text-white/80 mb-2 sm:mb-3 uppercase tracking-wide">
|
||||
Backup Passphrase
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<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>
|
||||
<input
|
||||
ref="passphraseInput"
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
placeholder="Enter a strong passphrase"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 pl-12 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/60 mt-2 sm:mt-3">
|
||||
Keep this passphrase safe. You'll need it to restore your identity from backup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
@click="downloadBackup"
|
||||
:disabled="!passphrase || isDownloading"
|
||||
class="path-action-button path-action-button--continue w-full"
|
||||
>
|
||||
<span v-if="!isDownloading && !downloaded">Backup to Continue</span>
|
||||
<span v-else-if="isDownloading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center gap-2">
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Downloaded
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="downloaded" class="text-center">
|
||||
<p class="text-sm text-white/70">
|
||||
Backup saved as <span class="font-mono text-white/90">archipelago-did-backup.json</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!downloaded"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const passphraseInput = ref<HTMLInputElement | null>(null)
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const passphrase = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
passphraseInput.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const isDownloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
async function downloadBackup() {
|
||||
if (!passphrase.value) return
|
||||
|
||||
isDownloading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const backupData = await rpcClient.createBackup(passphrase.value)
|
||||
|
||||
const json = JSON.stringify(backupData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
// Use a visible link appended to DOM for better mobile compatibility
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-did-backup.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Delay cleanup so mobile browsers can start the download
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
downloaded.value = true
|
||||
localStorage.setItem('neode_backup_created', '1')
|
||||
// Focus Continue button after backup completes
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 100)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Failed to create backup. Please try again.'
|
||||
}
|
||||
} finally {
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/verify').catch(() => {})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header (before DID is retrieved) -->
|
||||
<div v-if="!generatedDid" class="text-center flex-shrink-0">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-3 sm:mb-6 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your node's identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-4 sm:mb-6">
|
||||
Your node has a Decentralized Identifier (DID) for secure, passwordless authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Waiting for server / Generating state -->
|
||||
<div v-if="!generatedDid && (isGenerating || waitingForServer)" class="text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1–3 minutes after first boot</p>
|
||||
<p v-if="!waitingForServer" class="text-lg text-white/80">Generating your identity key...</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated DID Display -->
|
||||
<div v-if="generatedDid" class="w-full max-w-[600px] space-y-4">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your node's decentralized identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID Display Card -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
|
||||
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
|
||||
{{ generatedDid }}
|
||||
</p>
|
||||
<button
|
||||
@click="copyDid"
|
||||
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
|
||||
:title="didCopied ? 'Copied!' : 'Copy DID'"
|
||||
>
|
||||
<svg v-if="!didCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mb-3">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Nostr ID -->
|
||||
<div v-if="nostrNpub" class="text-left mt-4">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
|
||||
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
|
||||
{{ nostrNpub }}
|
||||
</p>
|
||||
<button
|
||||
@click="copyNpub"
|
||||
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
|
||||
:title="npubCopied ? 'Copied!' : 'Copy npub'"
|
||||
>
|
||||
<svg v-if="!npubCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const generatedDid = ref<string>('')
|
||||
const nostrNpub = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const didCopied = ref(false)
|
||||
const npubCopied = ref(false)
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
function storeDidState(did: string, pubkey: string) {
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: `${did}#key-1`, pubkey }))
|
||||
}
|
||||
|
||||
async function fetchDid() {
|
||||
if (!waitingForServer.value) {
|
||||
isGenerating.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
stopTimers()
|
||||
generatedDid.value = did
|
||||
storeDidState(did, pubkey)
|
||||
isGenerating.value = false
|
||||
waitingForServer.value = false
|
||||
|
||||
// Fetch Nostr npub in parallel (non-blocking)
|
||||
rpcClient.getNostrPubkey().then(({ nostr_npub }) => {
|
||||
if (nostr_npub) {
|
||||
nostrNpub.value = nostr_npub
|
||||
localStorage.setItem('neode_nostr_npub', nostr_npub)
|
||||
}
|
||||
}).catch(() => { /* Nostr key may not exist yet */ })
|
||||
|
||||
} catch {
|
||||
isGenerating.value = false
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(fetchDid, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(generatedDid, (did) => {
|
||||
if (did) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const cached = localStorage.getItem('neode_did')
|
||||
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
||||
if (cachedNpub) nostrNpub.value = cachedNpub
|
||||
if (cached && !cached.includes('...')) {
|
||||
generatedDid.value = cached
|
||||
} else {
|
||||
fetchDid()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
stopTimers()
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
|
||||
function copyDid() {
|
||||
if (!generatedDid.value) return
|
||||
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function copyNpub() {
|
||||
if (!nostrNpub.value) return
|
||||
navigator.clipboard.writeText(nostrNpub.value).catch(() => {})
|
||||
npubCopied.value = true
|
||||
setTimeout(() => { npubCopied.value = false }, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Success Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 text-center space-y-4 sm:space-y-6 px-3 sm:px-4 py-4 sm:py-6">
|
||||
<!-- Success Icon -->
|
||||
<div class="flex justify-center mb-4 sm:mb-6">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6 sm:mb-8">
|
||||
Your sovereign identity is ready. You can now log in and start your journey as a noderunner.
|
||||
</p>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4 mb-6 sm:mb-8 max-w-[700px] mx-auto">
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" 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>
|
||||
<h3 class="text-sm font-semibold text-white/90">Sovereign Identity</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Encrypted Backup</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Ready to Use</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FIPS activation status (non-blocking — onboarding never waits on it) -->
|
||||
<div v-if="fipsLabel" class="flex items-center justify-center gap-2 mb-4 text-xs sm:text-sm">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="fipsReady ? 'bg-green-400' : 'bg-orange-400 animate-pulse'"
|
||||
></span>
|
||||
<span class="text-white/60">{{ fipsLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Set Password Button -->
|
||||
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
|
||||
<button
|
||||
ref="setPasswordButton"
|
||||
@click="goToLogin"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Set Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const setPasswordButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
// FIPS auto-activates in a detached task after the seed is written, so it can
|
||||
// lag a few seconds behind onboarding on slow hardware. Surface a gentle status
|
||||
// so the encrypted-transport bring-up reads as "in progress", never "stuck".
|
||||
// This is purely informational — it never blocks moving on to set a password.
|
||||
const fipsLabel = ref('')
|
||||
const fipsReady = ref(false)
|
||||
let fipsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let fipsTries = 0
|
||||
|
||||
async function pollFips() {
|
||||
try {
|
||||
const s = await rpcClient.call<{ key_present?: boolean; service_active?: boolean }>({
|
||||
method: 'fips.status',
|
||||
timeout: 8000,
|
||||
})
|
||||
// Only relevant once a seed-derived FIPS key exists; otherwise stay silent.
|
||||
if (s.key_present) {
|
||||
if (s.service_active) {
|
||||
fipsReady.value = true
|
||||
fipsLabel.value = 'Private connection ready'
|
||||
return // settled — stop polling
|
||||
}
|
||||
fipsLabel.value = 'Securing your private connection…'
|
||||
}
|
||||
} catch {
|
||||
// Backend still booting — ignore and keep polling.
|
||||
}
|
||||
fipsTries += 1
|
||||
if (fipsTries < 20) {
|
||||
fipsTimer = setTimeout(pollFips, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
setPasswordButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
pollFips()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fipsTimer) clearTimeout(fipsTimer)
|
||||
})
|
||||
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
// The login that follows the wizard gets the full dashboard entrance
|
||||
// (zoom + oomph) even when it's a regular password login (e.g. the demo) —
|
||||
// Login.vue consumes this flag on success.
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 sm:mb-4 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Name your identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-4 sm:mb-6">
|
||||
Give your first identity a name and choose how you'll use it. You can create more identities later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
|
||||
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
|
||||
<!-- Name Input -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
|
||||
<input
|
||||
ref="nameInput"
|
||||
v-model="identityName"
|
||||
type="text"
|
||||
placeholder="Personal"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Purpose Selection -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Purpose</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="p in purposes"
|
||||
:key="p.value"
|
||||
@click="playNavSound('action'); selectedPurpose = p.value"
|
||||
class="px-4 py-3 rounded-lg border text-left transition-all"
|
||||
:class="selectedPurpose === p.value
|
||||
? 'bg-white/15 border-white/30 text-white'
|
||||
: 'bg-black/20 border-white/10 text-white/60 hover:bg-white/10 hover:text-white/80'"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0" :class="p.color">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium text-sm">{{ p.label }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 ml-7">{{ p.desc }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / Server starting -->
|
||||
<div v-if="serverStarting" class="text-center mb-4 px-3">
|
||||
<p class="text-orange-400/80 text-sm">Server is still starting up. Your identity will be saved once it's ready.</p>
|
||||
</div>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-3">
|
||||
<button
|
||||
@click="createIdentity"
|
||||
:disabled="isCreating"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="isCreating">Creating...</span>
|
||||
<span v-else>Continue</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const identityName = ref('Personal')
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
nameInput.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const selectedPurpose = ref('personal')
|
||||
const isCreating = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
const purposes = [
|
||||
{ value: 'personal', label: 'Personal', desc: 'Everyday use', color: 'bg-blue-500/30 text-blue-400' },
|
||||
{ value: 'business', label: 'Business', desc: 'Professional', color: 'bg-orange-500/30 text-orange-400' },
|
||||
{ value: 'anonymous', label: 'Anonymous', desc: 'Private', color: 'bg-purple-500/30 text-purple-400' },
|
||||
]
|
||||
|
||||
function isServerStartingError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return /502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)
|
||||
}
|
||||
|
||||
async function createIdentity() {
|
||||
isCreating.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.create',
|
||||
params: {
|
||||
name: identityName.value || 'Personal',
|
||||
purpose: selectedPurpose.value
|
||||
}
|
||||
})
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
} catch (err) {
|
||||
if (isServerStartingError(err)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to create identity'
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-4 sm:p-6">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="glass-card p-8 pt-16 sm:p-12 sm:pt-20 text-center relative overflow-visible onb-card">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-8 sm:-top-10 left-0 right-0 flex justify-center z-10 onb-logo">
|
||||
<div class="logo-gradient-border w-16 h-16 sm:w-20 sm:h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-white mb-3 sm:mb-4 onb-title">
|
||||
Welcome to Archipelago
|
||||
</h1>
|
||||
|
||||
<p class="text-base sm:text-xl text-white/80 mb-8 sm:mb-12 max-w-2xl mx-auto onb-tagline">
|
||||
Your personal server for a sovereign digital life
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref="ctaButton"
|
||||
@click="goToOptions"
|
||||
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
|
||||
>
|
||||
{{ isDemo ? 'Enter the demo →' : 'Unlock your sovereignty →' }}
|
||||
</button>
|
||||
|
||||
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||
<template v-if="!isDemo">
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||
const isDemo = IS_DEMO
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||
setTimeout(() => {
|
||||
ctaButton.value?.focus({ preventScroll: true })
|
||||
}, 2100)
|
||||
})
|
||||
|
||||
/** Any exit from the intro INTO login is the end of the cinematic — hand the
|
||||
* login the one-shot finale flag so the dashboard plays its full first-entry
|
||||
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
|
||||
* full wizard; these are the shortcut exits that used to skip it (which is
|
||||
* why the demo never got the big entrance). */
|
||||
function armOnboardingFinale() {
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function goToOptions() {
|
||||
playNavSound('action')
|
||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||
// to login, which is prefilled with the demo password.
|
||||
if (isDemo) {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
function goToRestore() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-card {
|
||||
opacity: 0;
|
||||
animation: onb-card-in 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s forwards;
|
||||
}
|
||||
.onb-logo {
|
||||
opacity: 0;
|
||||
animation: onb-scale-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s forwards;
|
||||
}
|
||||
.onb-title {
|
||||
opacity: 0;
|
||||
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.7s forwards;
|
||||
}
|
||||
.onb-tagline {
|
||||
opacity: 0;
|
||||
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1.0s forwards;
|
||||
}
|
||||
.onb-cta {
|
||||
opacity: 0;
|
||||
animation: onb-fade-in 0.6s ease 1.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes onb-card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes onb-scale-in {
|
||||
from { opacity: 0; transform: scale(0.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes onb-slide-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes onb-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[1200px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<div class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<div class="logo-gradient-border inline-block mb-4 sm:mb-6">
|
||||
<img
|
||||
src="/assets/icon/favico-black-v2.svg"
|
||||
alt="Archipelago"
|
||||
class="w-16 h-16 sm:w-20 sm:h-20"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-white mb-2 sm:mb-4">Choose Your Setup</h1>
|
||||
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-6 px-3 sm:px-4 max-w-[760px] mx-auto w-full">
|
||||
<!-- Fresh Start -->
|
||||
<button
|
||||
@click="selectOption('fresh')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'fresh' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Fresh Start</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Set up a new server from scratch
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Restore from Seed -->
|
||||
<button
|
||||
@click="selectOption('restore')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'restore' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore from Seed</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-4 sm:pt-6">
|
||||
<button
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
selected.value = 'fresh'
|
||||
})
|
||||
|
||||
function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
playNavSound('action')
|
||||
if (selected.value === 'restore') {
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
} else {
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6 relative">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[1200px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 md:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">Your Node, Your Possibilities</h1>
|
||||
<p class="text-xs md:text-sm text-white/75 leading-relaxed">Archipelago gives you the tools to build your sovereign digital life. All of these capabilities are available from your dashboard.</p>
|
||||
</div>
|
||||
|
||||
<!-- Options Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4 flex-shrink-0 mb-4 md:mb-6 px-3 sm:px-4">
|
||||
<!-- Self Sovereignty -->
|
||||
<div class="path-option-card">
|
||||
<div class="icon-wrapper transition-all duration-300">
|
||||
<svg class="w-10 h-10 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white/96 mb-1.5">Self Sovereignty</h3>
|
||||
<p class="text-sm text-white/75 leading-snug">
|
||||
Data, files, ownership, property of my data estate. Own, manage, edit, and even sell your personal data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Community Commerce -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Community Commerce</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Self contained and owned ecommerce system built on bitcoin and mesh networks. Trade freely without intermediaries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sovereign Projects -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign Projects</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Logistics and project management self owned with privacy control. Collaborate without surveillance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data Transmitter -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Data Transmitter</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Assist the new sovereign net with relay points and networking where you get paid for your value.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hoster -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Hoster</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Host services and content, archives, and more to others for micro bitcoin payments. Earn while you serve.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sovereign AI -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign AI</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Run AI models locally on your hardware. No cloud surveillance, complete privacy, full control over your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-4 sm:pt-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your Recovery Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Write down these 24 words in order. They are the only way to recover your node.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div ref="contentWrapper" class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
|
||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error (genuine failure — server-starting hiccups retry silently) -->
|
||||
<div v-if="errorMessage" class="text-center">
|
||||
<p class="text-red-400 text-sm mb-3">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="generateSeed"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<!-- Words / QR tabs — words first; QR for wallets that import by scan -->
|
||||
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
class="bg-black/60 rounded-lg px-2.5 py-1 sm:py-1.5 border border-white/10"
|
||||
>
|
||||
<span class="text-white/40 text-sm font-mono mr-1">{{ i + 1 }}.</span>
|
||||
<span class="text-white/95 text-[1.05rem] font-mono">{{ word }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-2 py-2">
|
||||
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
||||
{{ qrFormat === 'seedqr'
|
||||
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
|
||||
: 'Plain text words — for wallets that read the phrase as text.' }}
|
||||
Treat this code exactly like the words themselves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label ref="confirmLabel" class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
|
||||
/>
|
||||
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="onb-cue-fade">
|
||||
<div v-if="showScrollCue" class="sticky bottom-0 inset-x-0 h-16 flex items-end justify-center pointer-events-none">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 mb-2 pointer-events-auto inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 text-white/75 text-xs"
|
||||
aria-label="Scroll down to the confirmation checkbox"
|
||||
@click="revealConfirm"
|
||||
>
|
||||
<span>One more step below</span>
|
||||
<svg class="onb-cue-chevron w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!confirmed"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
|
||||
// Bottom scroll cue (UIFIX-03) — on short viewports the confirmation
|
||||
// checkbox sits below the fold inside the scrolling region while the
|
||||
// Continue button stays pinned, disabled, in the fixed footer below. The
|
||||
// cue is a pure wayfinding affordance: it only appears when the scroll
|
||||
// region actually has more content below the fold than fits, and it never
|
||||
// touches `confirmed` or the Continue button itself.
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const contentWrapper = ref<HTMLElement | null>(null)
|
||||
const confirmLabel = ref<HTMLElement | null>(null)
|
||||
const showScrollCue = ref(false)
|
||||
let cueResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updateScrollCue() {
|
||||
const container = scrollContainer.value
|
||||
const label = confirmLabel.value
|
||||
if (!container || !label || loading.value || words.value.length === 0 || confirmed.value) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
const hasOverflow = container.scrollHeight > container.clientHeight
|
||||
if (!hasOverflow) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
// Viewport-relative rects rather than offsetTop/offsetHeight — offsetTop is
|
||||
// relative to the nearest *positioned* ancestor (here, the outer card,
|
||||
// which carries `relative` for its own z-index stacking), not necessarily
|
||||
// this scroll container, so it cannot be trusted to measure "below the
|
||||
// scroll region's visible bottom".
|
||||
const labelBottom = label.getBoundingClientRect().bottom
|
||||
const containerBottom = container.getBoundingClientRect().bottom
|
||||
showScrollCue.value = labelBottom > containerBottom
|
||||
}
|
||||
|
||||
// Wayfinding only — this must never set `confirmed`, never focus/enable the
|
||||
// Continue button, and never call proceed().
|
||||
function revealConfirm() {
|
||||
confirmLabel.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// Words / QR code view of the seed — words are the default first view.
|
||||
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||
// text stays available for wallets that read the phrase as text.
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
|
||||
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderSeedQr() {
|
||||
await nextTick()
|
||||
if (!seedQrCanvas.value || words.value.length === 0) return
|
||||
try {
|
||||
let payload = words.value.join(' ')
|
||||
if (qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(words.value)
|
||||
if (digits) payload = digits
|
||||
else qrFormat.value = 'text' // non-BIP39 word — only text is honest
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
// Transient errors mean the backend is still booting (slow first boot) — we
|
||||
// retry these silently. Anything else is a genuine failure the user should see.
|
||||
function isServerStartingError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return /502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)
|
||||
}
|
||||
|
||||
async function generateSeed() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// seed.generate is idempotent server-side, so retries are safe. Use a
|
||||
// longer timeout than the default 15s — key derivation + disk writes can be
|
||||
// slow on first boot, and we don't want a spurious abort to look like a
|
||||
// failure.
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate', timeout: 30000 })
|
||||
stopTimers()
|
||||
words.value = res.words
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
} catch (err) {
|
||||
if (isServerStartingError(err)) {
|
||||
// Backend not ready yet — keep waiting, retry silently. `loading` stays
|
||||
// true through the whole retry loop: dropping it here unmounts the lock
|
||||
// icon and status text for the 4s between attempts, which reads as the
|
||||
// screen flashing in and out (reported on a live install test).
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
} else {
|
||||
// Genuine failure — stop the silent loop and surface it with a manual retry.
|
||||
stopTimers()
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
const raw = err instanceof Error ? err.message : 'Failed to generate seed'
|
||||
// The backend's provisioned-guard refusal is precise but written for
|
||||
// developers ("authenticated system.factory-reset"). Operators hit it
|
||||
// when a node that already has an identity lands on this screen —
|
||||
// translate it into what they can actually do about it.
|
||||
errorMessage.value = raw.startsWith('Not supported: this node is already provisioned')
|
||||
? 'This node already has an identity, so a new seed cannot be created. Sign in normally — or to start this node over, run a factory reset from Settings first.'
|
||||
: raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(confirmed, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
updateScrollCue()
|
||||
})
|
||||
|
||||
// Words arrive asynchronously (RPC or restored from sessionStorage) and the
|
||||
// word grid changes height when the words/QR tabs switch — both can flip the
|
||||
// scroll region from non-overflowing to overflowing, so re-measure whenever
|
||||
// either happens.
|
||||
watch(words, () => { nextTick(updateScrollCue) })
|
||||
watch(loading, () => { nextTick(updateScrollCue) })
|
||||
|
||||
onMounted(() => {
|
||||
// Restore previously generated seed if navigating back (don't regenerate)
|
||||
const saved = sessionStorage.getItem('_seed_words')
|
||||
let restored = false
|
||||
const setup = () => {
|
||||
scrollContainer.value?.addEventListener('scroll', updateScrollCue)
|
||||
window.addEventListener('resize', updateScrollCue)
|
||||
if (contentWrapper.value && typeof ResizeObserver !== 'undefined') {
|
||||
cueResizeObserver = new ResizeObserver(() => updateScrollCue())
|
||||
cueResizeObserver.observe(contentWrapper.value)
|
||||
}
|
||||
nextTick(updateScrollCue)
|
||||
}
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length === 24) {
|
||||
words.value = parsed
|
||||
restored = true
|
||||
}
|
||||
} catch { /* regenerate */ }
|
||||
}
|
||||
setup()
|
||||
if (!restored) generateSeed()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
scrollContainer.value?.removeEventListener('scroll', updateScrollCue)
|
||||
window.removeEventListener('resize', updateScrollCue)
|
||||
cueResizeObserver?.disconnect()
|
||||
cueResizeObserver = null
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
|
||||
router.push('/onboarding/seed-verify').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Bottom scroll cue (UIFIX-03) — chevron bob + fade transition. */
|
||||
.onb-cue-chevron {
|
||||
animation: onb-cue-bob 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-cue-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
.onb-cue-fade-enter-active,
|
||||
.onb-cue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.onb-cue-fade-enter-from,
|
||||
.onb-cue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-cue-chevron { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Restore from Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Enter your 24-word recovery seed to restore your node identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
|
||||
<!-- Restore Success -->
|
||||
<div v-if="restored" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Identity restored successfully
|
||||
</p>
|
||||
</div>
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredDid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="restoredNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Input Grid -->
|
||||
<div v-if="!restored" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<div v-for="i in 24" :key="i" class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 text-[1rem] font-mono pointer-events-none">{{ i }}.</span>
|
||||
<input
|
||||
:ref="el => { if (el) wordInputs[i - 1] = el as HTMLInputElement }"
|
||||
v-model="seedWords[i - 1]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-[1.2rem] text-white/95 font-mono placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all"
|
||||
:placeholder="`word ${i}`"
|
||||
@keydown.enter="i < 24 ? wordInputs[i]?.focus() : restore()"
|
||||
@input="onWordInput(i - 1)"
|
||||
@paste="i === 1 ? onPaste($event) : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2 text-center">
|
||||
Paste all 24 words into the first field to auto-fill
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!restored"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!restored"
|
||||
@click="restore"
|
||||
:disabled="isRestoring || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isRestoring" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Restoring...
|
||||
</span>
|
||||
<span v-else>Restore Identity</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const wordInputs = ref<HTMLInputElement[]>([])
|
||||
const seedWords = ref<string[]>(Array(24).fill(''))
|
||||
const restored = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
const restoredDid = ref('')
|
||||
const restoredNpub = ref('')
|
||||
|
||||
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function onWordInput(index: number) {
|
||||
seedWords.value[index] = (seedWords.value[index] ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')?.trim()
|
||||
if (!text) return
|
||||
|
||||
const pastedWords = text.split(/\s+/)
|
||||
if (pastedWords.length === 24) {
|
||||
event.preventDefault()
|
||||
for (let i = 0; i < 24; i++) {
|
||||
seedWords.value[i] = (pastedWords[i] ?? '').toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!allFilled.value) return
|
||||
isRestoring.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
|
||||
try {
|
||||
const words = seedWords.value.map(w => w.trim().toLowerCase())
|
||||
const res = await rpcClient.call<{ did: string; nostr_npub: string; restored: boolean }>({
|
||||
method: 'seed.restore',
|
||||
params: { words },
|
||||
})
|
||||
|
||||
if (res.restored) {
|
||||
restored.value = true
|
||||
restoredDid.value = res.did
|
||||
restoredNpub.value = res.nostr_npub || ''
|
||||
if (res.did) localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Restore failed. Check your seed words and try again.'
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header (hidden after verification) -->
|
||||
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Confirm you wrote down your seed correctly by entering the requested words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 px-6 sm:px-8 py-4">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px] pt-2">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Seed verified successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5 mb-3">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your DID</h3>
|
||||
<button @click="copyText(did)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ did }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr npub -->
|
||||
<div v-if="nostrNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<button @click="copyText(nostrNpub)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ nostrNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Word Input Fields -->
|
||||
<div v-if="!verified" class="w-full max-w-[600px] space-y-3 sm:space-y-4">
|
||||
<div
|
||||
v-for="(idx, i) in challengeIndices"
|
||||
:key="idx"
|
||||
>
|
||||
<label class="block text-xs font-semibold text-white/80 mb-1.5 uppercase tracking-wide text-left">
|
||||
Word #{{ idx + 1 }}
|
||||
</label>
|
||||
<input
|
||||
:ref="el => { if (el) inputRefs[i] = el as HTMLInputElement }"
|
||||
v-model="answers[i]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:placeholder="`Enter word #${idx + 1}`"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white/95 placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all font-mono text-[1.2rem]"
|
||||
@keydown.enter.prevent="i < challengeIndices.length - 1 ? inputRefs[i + 1]?.focus() : verify()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!verified"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!verified"
|
||||
@click="verify"
|
||||
type="button"
|
||||
:disabled="isVerifying || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isVerifying">Verifying...</span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="verified"
|
||||
@click="downloadIdentity"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</span>
|
||||
<button
|
||||
v-if="verified"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const inputRefs = ref<HTMLInputElement[]>([])
|
||||
const words = ref<string[]>([])
|
||||
const challengeIndices = ref<number[]>([])
|
||||
const answers = ref<string[]>(['', '', '', ''])
|
||||
const verified = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const did = ref('')
|
||||
const nostrNpub = ref('')
|
||||
const copiedField = ref('')
|
||||
|
||||
const allFilled = computed(() => answers.value.every(a => a.trim().length > 0))
|
||||
|
||||
function pickRandomIndices(count: number, max: number): number[] {
|
||||
const indices = new Set<number>()
|
||||
while (indices.size < count) {
|
||||
indices.add(Math.floor(Math.random() * max))
|
||||
}
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = sessionStorage.getItem('_seed_words')
|
||||
if (!stored) {
|
||||
router.replace('/onboarding/seed').catch(() => {})
|
||||
return
|
||||
}
|
||||
words.value = JSON.parse(stored)
|
||||
|
||||
// Restore challenge indices so going back and returning asks the same words
|
||||
const savedIndices = sessionStorage.getItem('_seed_challenge_indices')
|
||||
if (savedIndices) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedIndices)
|
||||
if (Array.isArray(parsed) && parsed.length === 4) {
|
||||
challengeIndices.value = parsed
|
||||
} else {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
} catch {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
} else {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
sessionStorage.setItem('_seed_challenge_indices', JSON.stringify(challengeIndices.value))
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
copiedField.value = text === did.value ? 'did' : 'npub'
|
||||
setTimeout(() => { copiedField.value = '' }, 2000)
|
||||
}
|
||||
|
||||
function downloadIdentity() {
|
||||
const data = {
|
||||
did: did.value,
|
||||
nostr_npub: nostrNpub.value || undefined,
|
||||
created: new Date().toISOString(),
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-identity.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url) }, 1000)
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (!allFilled.value) return
|
||||
isVerifying.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const correct = challengeIndices.value.every(
|
||||
(wordIdx, i) => (answers.value[i] ?? '').trim().toLowerCase() === (words.value[wordIdx] ?? '')
|
||||
)
|
||||
|
||||
if (!correct) {
|
||||
isVerifying.value = false
|
||||
errorMessage.value = 'One or more words are incorrect. Please check your written seed and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ verified: boolean; did: string; nostr_npub: string }>({
|
||||
method: 'seed.verify',
|
||||
params: { words: words.value, indices: challengeIndices.value },
|
||||
})
|
||||
|
||||
if (res.verified) {
|
||||
verified.value = true
|
||||
did.value = res.did
|
||||
nostrNpub.value = res.nostr_npub || ''
|
||||
localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
sessionStorage.removeItem('_seed_words')
|
||||
sessionStorage.removeItem('_seed_challenge_indices')
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
} else {
|
||||
errorMessage.value = 'Verification failed. Please try again.'
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
// Words already matched the local copy, so a network/server hiccup here is
|
||||
// transient — the backend just needs a moment. Don't alarm the user; let
|
||||
// them tap Verify again.
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)) {
|
||||
errorMessage.value = 'Server is still starting. Please tap Verify again in a moment.'
|
||||
} else {
|
||||
errorMessage.value = msg || 'Verification failed'
|
||||
}
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header -->
|
||||
<div v-if="!verified" class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Sign a challenge to verify your decentralized identity is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<!-- Sign Button (if not verified yet) -->
|
||||
<button
|
||||
ref="signButton"
|
||||
v-if="!verified"
|
||||
@click="signChallenge"
|
||||
:disabled="isSigning"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="!isSigning">Sign Challenge</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your identity has been successfully verified and is ready to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Signature Display -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Signature</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs break-all leading-relaxed">
|
||||
{{ signature }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="finishButton"
|
||||
v-if="verified"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Finish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const signButton = ref<HTMLButtonElement | null>(null)
|
||||
const finishButton = ref<HTMLButtonElement | null>(null)
|
||||
const verified = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
signButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const isSigning = ref(false)
|
||||
const signature = ref('')
|
||||
const currentChallenge = ref('')
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
/** Generate a cryptographically random challenge (32 bytes, base64) */
|
||||
function generateChallenge(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
async function signChallenge() {
|
||||
isSigning.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
currentChallenge.value = generateChallenge()
|
||||
const { signature: sig } = await rpcClient.signChallenge(currentChallenge.value)
|
||||
signature.value = sig
|
||||
isSigning.value = false
|
||||
|
||||
// Auto-verify the signature using identity.verify
|
||||
const did = localStorage.getItem('neode_did')
|
||||
if (did) {
|
||||
const result = await rpcClient.call({
|
||||
method: 'identity.verify',
|
||||
params: { did, message: currentChallenge.value, signature: sig },
|
||||
}) as { valid: boolean }
|
||||
verified.value = result.valid !== false
|
||||
} else {
|
||||
verified.value = true
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => finishButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
const isRetryable = /502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)
|
||||
if (!isRetryable || attempt === 2) {
|
||||
if (isRetryable) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Failed to sign challenge. Please try again.'
|
||||
}
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
isSigning.value = false
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
playNavSound('action')
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch {
|
||||
/* localStorage fallback ensures we can proceed */
|
||||
}
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="onb-viewport relative overflow-hidden">
|
||||
<!-- Background layers with 3D perspective and zoom effect -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Video background for intro/login routes (smooth transition from splash) -->
|
||||
<video
|
||||
v-if="useVideoBackground"
|
||||
ref="videoElement"
|
||||
class="bg-layer"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-intro.jpg"
|
||||
style="width: 100%; height: 100%; object-fit: cover; object-position: center; position: absolute; inset: 0; transform: scale(1); transition: none;"
|
||||
@pause.prevent="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Login: static background + archipelago-style glitch (no zoom) -->
|
||||
<template v-else-if="isLoginRoute">
|
||||
<div
|
||||
class="bg-layer bg-login-static bg-fullwidth"
|
||||
:style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }"
|
||||
/>
|
||||
<!-- Archipelago-style glitch overlays - continuous every 5s -->
|
||||
<div class="login-glitch-layer login-glitch-1" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
|
||||
<div class="login-glitch-layer login-glitch-2" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
|
||||
<div class="login-glitch-scan" />
|
||||
</template>
|
||||
|
||||
<!-- Static image background for other routes (with zoom on transition) -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-layer bg-zoom"
|
||||
:class="{ 'bg-zoom-in': isTransitioning }"
|
||||
:style="{ backgroundImage: `url('/assets/img/${currentBackground}')` }"
|
||||
:key="currentBackground"
|
||||
></div>
|
||||
|
||||
<!-- Glitch overlay layer - only for non-video, non-login background changes -->
|
||||
<div v-show="isGlitching && !useVideoBackground && !isLoginRoute" class="bg-glitch-layer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content with 3D transitions -->
|
||||
<div class="perspective-container-wrapper">
|
||||
<div class="perspective-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="transitionName">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
|
||||
|
||||
const route = useRoute()
|
||||
const currentBackground = ref('bg-intro.jpg')
|
||||
const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const transitionName = ref('depth-forward')
|
||||
|
||||
// Ordered onboarding steps for direction detection
|
||||
const onboardingOrder = [
|
||||
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
|
||||
'/onboarding/seed', '/onboarding/seed-verify', '/onboarding/seed-restore',
|
||||
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
|
||||
'/onboarding/verify', '/onboarding/done', '/login'
|
||||
]
|
||||
|
||||
// Routes that should use video background (smooth transition from splash, loops through login)
|
||||
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||||
|
||||
// Login uses video when coming from splash, or static + glitch when direct
|
||||
const isLoginRoute = computed(() => route.path === '/login')
|
||||
|
||||
|
||||
// Check if current route should use video background.
|
||||
// The FIRST login (nobody has logged in / set a password yet) keeps the VIDEO
|
||||
// running so the splash → login handoff is one continuous shot; only after a
|
||||
// successful login does /login switch to the rotating static backgrounds.
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const hasLoggedInBefore = () => {
|
||||
try {
|
||||
return localStorage.getItem('neode_first_login_done') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const useVideoBackground = computed(() => {
|
||||
if (!videoBackgroundRoutes.includes(route.path)) return false
|
||||
if (route.path === '/login' && hasLoggedInBefore()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Map each route to a specific background image
|
||||
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
|
||||
const routeBackgrounds: Record<string, string> = {
|
||||
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
|
||||
'/onboarding/options': 'bg-intro-4.webp',
|
||||
'/onboarding/path': 'bg-intro-3.jpg',
|
||||
'/onboarding/seed': 'bg-intro-1.webp',
|
||||
'/onboarding/seed-verify': 'bg-intro-1.webp',
|
||||
'/onboarding/seed-restore': 'bg-intro-1.webp',
|
||||
'/onboarding/did': 'bg-intro-4.webp',
|
||||
'/onboarding/identity': 'bg-intro-1.webp',
|
||||
'/onboarding/backup': 'bg-intro-6.webp',
|
||||
'/onboarding/verify': 'bg-intro-2.jpg',
|
||||
'/onboarding/done': 'bg-intro-1.webp',
|
||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||
}
|
||||
|
||||
// Until the user has logged in (or set their password) once, the login
|
||||
// background stays PINNED to the same still the intro video ends on — the
|
||||
// video → login handoff only reads as one continuous shot when both show the
|
||||
// identical frame. From the second login onward the lock screen rotates
|
||||
// through bg-intro-1..6 for variety (counter persisted in localStorage).
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const LOGIN_BACKGROUNDS = [
|
||||
'bg-intro-1.webp',
|
||||
'bg-intro-2.jpg',
|
||||
'bg-intro-3.jpg',
|
||||
'bg-intro-4.webp',
|
||||
'bg-intro-5.webp',
|
||||
'bg-intro-6.webp',
|
||||
]
|
||||
function pickNextLoginBackground(): string {
|
||||
try {
|
||||
if (localStorage.getItem('neode_first_login_done') !== '1') return 'bg-intro.jpg'
|
||||
const raw = localStorage.getItem('neode_login_bg_idx')
|
||||
const prev = raw !== null ? parseInt(raw, 10) : -1
|
||||
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
||||
localStorage.setItem('neode_login_bg_idx', String(next))
|
||||
return LOGIN_BACKGROUNDS[next]!
|
||||
} catch {
|
||||
return 'bg-intro.jpg'
|
||||
}
|
||||
}
|
||||
const loginBackground = ref(pickNextLoginBackground())
|
||||
watch(() => route.path, (p) => {
|
||||
if (p === '/login') loginBackground.value = pickNextLoginBackground()
|
||||
})
|
||||
|
||||
// Restore video time from splash screen for seamless transition
|
||||
function restoreVideoTime() {
|
||||
if (videoElement.value && useVideoBackground.value) {
|
||||
const savedTime = sessionStorage.getItem('video_intro_currentTime')
|
||||
const wasPlaying = sessionStorage.getItem('video_intro_wasPlaying') === 'true'
|
||||
const savedPlaybackRate = sessionStorage.getItem('video_intro_playbackRate')
|
||||
|
||||
if (savedTime) {
|
||||
const time = parseFloat(savedTime)
|
||||
const playbackRate = savedPlaybackRate ? parseFloat(savedPlaybackRate) : 1.0
|
||||
|
||||
const setVideoTime = () => {
|
||||
if (videoElement.value) {
|
||||
// Set playback rate first for smooth playback
|
||||
videoElement.value.playbackRate = playbackRate
|
||||
// Set time with slight offset to ensure smooth transition (avoid frame boundary issues)
|
||||
videoElement.value.currentTime = Math.max(0, time - 0.05)
|
||||
|
||||
// If video was playing, ensure it continues playing immediately
|
||||
if (wasPlaying) {
|
||||
requestAnimationFrame(() => ensureVideoPlaying())
|
||||
}
|
||||
|
||||
// Clean up session storage after successful restore
|
||||
sessionStorage.removeItem('video_intro_currentTime')
|
||||
sessionStorage.removeItem('video_intro_wasPlaying')
|
||||
sessionStorage.removeItem('video_intro_playbackRate')
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement.value.readyState >= 2) {
|
||||
// Video can play through current position, set time immediately
|
||||
setVideoTime()
|
||||
} else if (videoElement.value.readyState >= 1) {
|
||||
// Video metadata loaded, set time immediately
|
||||
setVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
setVideoTime()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}
|
||||
videoElement.value.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video play with retry - hardened for reliability
|
||||
function ensureVideoPlaying(retries = 3): void {
|
||||
const vid = videoElement.value
|
||||
if (!vid || !useVideoBackground.value) return
|
||||
if (!vid.paused) return
|
||||
vid.play()
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
if (retries > 0) setTimeout(() => ensureVideoPlaying(retries - 1), 300)
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure video plays when route uses video background
|
||||
watch([useVideoBackground, route], ([useVideo]) => {
|
||||
if (useVideo && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition first
|
||||
restoreVideoTime()
|
||||
// Then ensure it's playing - use double RAF for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value && videoElement.value.paused) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Also ensure video plays on mount if route uses video
|
||||
onMounted(() => {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition
|
||||
restoreVideoTime()
|
||||
// Use double RAF for smoother playback start
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for video element to restore time when it becomes available
|
||||
watch(videoElement, (element) => {
|
||||
if (element && useVideoBackground.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
// Try to restore immediately if metadata already loaded
|
||||
if (element.readyState >= 2) {
|
||||
restoreVideoTime()
|
||||
} else if (element.readyState >= 1) {
|
||||
restoreVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
restoreVideoTime()
|
||||
element.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
element.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch route changes for background swaps, zoom, glitch, and transition direction
|
||||
watch(() => route.path, (newPath, oldPath) => {
|
||||
// Determine slide direction based on route order
|
||||
const oldIdx = onboardingOrder.indexOf(oldPath || '')
|
||||
const newIdx = onboardingOrder.indexOf(newPath)
|
||||
if (oldIdx >= 0 && newIdx >= 0) {
|
||||
transitionName.value = newIdx >= oldIdx ? 'slide-left' : 'slide-right'
|
||||
} else {
|
||||
transitionName.value = 'depth-forward'
|
||||
}
|
||||
const newBg = routeBackgrounds[newPath]
|
||||
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
|
||||
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
|
||||
|
||||
// If both old and new routes use video, don't restart video - keep it playing seamlessly
|
||||
if (oldUsesVideo && newUsesVideo && videoElement.value) {
|
||||
// Video continues seamlessly, just ensure it's playing
|
||||
if (videoElement.value.paused) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
// No glitch effect, no zoom, no transitions for video-to-video
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
return // Skip background change logic for video-to-video transitions
|
||||
}
|
||||
|
||||
// If transitioning from video to non-video or vice versa, no glitch, no zoom (smooth transition)
|
||||
if (oldUsesVideo || newUsesVideo) {
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
|
||||
if (newPath === '/login') {
|
||||
currentBackground.value = 'bg-intro-1.webp'
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Only update if we have a defined background for this route and it's different
|
||||
if (newBg && newBg !== currentBackground.value) {
|
||||
// Trigger zoom animation ONLY for non-video routes (never for video)
|
||||
if (!newUsesVideo && !oldUsesVideo) {
|
||||
isTransitioning.value = true
|
||||
|
||||
// Change background
|
||||
currentBackground.value = newBg
|
||||
|
||||
// Only trigger glitch for non-video background changes
|
||||
setTimeout(() => {
|
||||
isGlitching.value = true
|
||||
setTimeout(() => {
|
||||
isGlitching.value = false
|
||||
}, 500) // Match glitch duration
|
||||
|
||||
// Reset zoom after glitch
|
||||
isTransitioning.value = false
|
||||
}, 1500 + 50) // Wait for 3D transition (1500ms) + small delay - matches splash timing
|
||||
} else {
|
||||
// Smooth transition for video routes - no glitch, no zoom, no effects at all
|
||||
currentBackground.value = newBg
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent video from pausing during transitions
|
||||
function handleVideoPause(event: Event) {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
event.preventDefault()
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video ended - restart immediately for seamless loop
|
||||
function handleVideoEnded() {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
videoElement.value.currentTime = 0
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Update body class to disable global glitch effects ONLY for video backgrounds
|
||||
// This class is ONLY added on /onboarding/intro (login uses its own glitch)
|
||||
// All other routes will have glitch effects enabled (normal behavior)
|
||||
watch(useVideoBackground, (usesVideo) => {
|
||||
if (usesVideo) {
|
||||
// Add class ONLY on video background screens (/onboarding/intro, /login)
|
||||
// This disables glitch effects ONLY on these screens
|
||||
document.body.classList.add('video-background-active')
|
||||
} else {
|
||||
// Remove class on all other screens to re-enable glitch effects
|
||||
document.body.classList.remove('video-background-active')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize background on mount based on current route
|
||||
onMounted(() => {
|
||||
const bg = routeBackgrounds[route.path]
|
||||
if (bg) {
|
||||
currentBackground.value = bg
|
||||
}
|
||||
|
||||
if (useVideoBackground.value) {
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
document.body.classList.add('video-background-active')
|
||||
const unlock = () => {
|
||||
resumeAudioContext()
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
}
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
document.removeEventListener('keydown', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
document.addEventListener('keydown', unlock, { once: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed viewport container — locks to screen on mobile, no bounce/overflow */
|
||||
.onb-viewport {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Perspective container for 3D depth effect */
|
||||
.perspective-container {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View wrapper - allows smooth transitions with absolute positioning */
|
||||
.view-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 2advanced-style: fluid depth transitions */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-1200px) scale(0.6);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.depth-forward-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(500px) scale(1.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Horizontal slide transitions (direction-aware onboarding steps) */
|
||||
.slide-left-enter-active.view-wrapper,
|
||||
.slide-left-leave-active.view-wrapper,
|
||||
.slide-right-enter-active.view-wrapper,
|
||||
.slide-right-leave-active.view-wrapper {
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
}
|
||||
.slide-left-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
.slide-right-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
.slide-right-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
}
|
||||
|
||||
/* Background zoom - 2advanced fluid */
|
||||
.bg-zoom {
|
||||
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.bg-zoom-in {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Subtle 3D tilt - 2advanced layered depth */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-1200px) scale(0.6) rotateX(6deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(500px) scale(1.25) rotateX(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Background 3D container */
|
||||
.bg-perspective-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
perspective: 1000px;
|
||||
perspective-origin: 50% 50%;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
min-width: 100vw;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* Full width background on every screen */
|
||||
.bg-fullwidth {
|
||||
min-width: 100vw;
|
||||
width: 100vw;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Video background styling - video element itself has bg-layer class */
|
||||
.bg-layer video,
|
||||
video.bg-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.bg-static {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
}
|
||||
|
||||
/* Login: static background - just there, no zoom */
|
||||
.bg-login-static {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Archipelago-style glitch overlays for login - continuous every 5s */
|
||||
.login-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-glitch-1 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(22deg) saturate(1.35);
|
||||
animation: login-glitch-shift 5s steps(10, end) infinite;
|
||||
}
|
||||
|
||||
.login-glitch-2 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(-30deg) saturate(1.45);
|
||||
animation: login-glitch-shift-2 5s steps(9, end) infinite;
|
||||
}
|
||||
|
||||
.login-glitch-scan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0) 60%),
|
||||
repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 2px, rgba(0,0,0,0) 2px 4px),
|
||||
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.35) 100%);
|
||||
opacity: 0;
|
||||
animation: login-glitch-scan 5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes login-glitch-shift {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.22; }
|
||||
84% { transform: translate(6px,-2px); clip-path: inset(8% 0 70% 0); }
|
||||
86% { transform: translate(-5px,2px); clip-path: inset(42% 0 40% 0); }
|
||||
88% { transform: translate(3px,0); clip-path: inset(68% 0 10% 0); }
|
||||
91% { transform: translate(-4px,3px); clip-path: inset(18% 0 60% 0); }
|
||||
93% { transform: translate(5px,-3px); clip-path: inset(55% 0 20% 0); }
|
||||
95% { transform: translate(-3px,1px); clip-path: inset(10% 0 80% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes login-glitch-shift-2 {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.18; }
|
||||
84% { transform: translate(-6px,2px); clip-path: inset(12% 0 65% 0); }
|
||||
86% { transform: translate(5px,-1px) skewX(0.6deg); clip-path: inset(36% 0 42% 0); }
|
||||
89% { transform: translate(-3px,2px); clip-path: inset(72% 0 8% 0); }
|
||||
92% { transform: translate(4px,-3px); clip-path: inset(22% 0 58% 0); }
|
||||
95% { transform: translate(-4px,1px); clip-path: inset(50% 0 26% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes login-glitch-scan {
|
||||
0%, 82% { opacity: 0; transform: translateY(-20%); }
|
||||
84% { opacity: 0.4; }
|
||||
90% { opacity: 0.28; }
|
||||
100% { opacity: 0; transform: translateY(115%); }
|
||||
}
|
||||
|
||||
/* Glitch overlay layer */
|
||||
.bg-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0;
|
||||
animation: bg-glitch-flash 500ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-flash {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
10% {
|
||||
opacity: 0.3;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.4;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
40% {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Kiosk (software compositor): flatten THIS screen's background stack.
|
||||
Same failure mode the dashboard kiosk fix addressed — perspective +
|
||||
preserve-3d layers and mix-blend-mode overlays fail to repaint under
|
||||
Chromium software compositing, so the black body fill shows through.
|
||||
The dashboard-only overrides never reached this scoped stack, which is
|
||||
why the kiosk login/onboarding background still went black. Keep 2D
|
||||
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
|
||||
glitch overlays. */
|
||||
/* The full selector must live inside :global() — with `:global(html.kiosk-mode)
|
||||
.bg-layer` the SFC compiler drops the descendant part, emitting bare
|
||||
`html.kiosk-mode { display: none !important }` rules that blank the whole
|
||||
document on kiosk (the v1.7.104 white-screen). */
|
||||
:global(html.kiosk-mode .bg-perspective-container),
|
||||
:global(html.kiosk-mode .perspective-container) {
|
||||
perspective: none !important;
|
||||
}
|
||||
/* Scoped to the onboarding viewport: a bare `.view-wrapper` match also killed
|
||||
the dashboard's route transitions on kiosk (transform:none !important beats
|
||||
the transition classes), freezing every tab change. The dashboard has its
|
||||
own kiosk-safe 2D transition overrides in dashboard-styles.css. */
|
||||
:global(html.kiosk-mode .bg-layer),
|
||||
:global(html.kiosk-mode .onb-viewport .view-wrapper) {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
will-change: auto !important;
|
||||
filter: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode .login-glitch-layer),
|
||||
:global(html.kiosk-mode .login-glitch-scan) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<BootScreen :visible="showBootScreen" @ready="onServerReady" />
|
||||
<div v-if="!showBootScreen" class="min-h-screen flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
|
||||
<svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
/**
|
||||
* Public demo: every fresh boot of the app (first visit OR browser refresh at
|
||||
* the root) replays the intro for the full effect. In-session SPA navigation
|
||||
* never re-triggers it — this only runs when the app boots at '/'.
|
||||
*/
|
||||
function demoRoute() {
|
||||
log('demoRoute', { dest: '/onboarding/intro' })
|
||||
router.replace('/onboarding/intro').catch(() => {})
|
||||
}
|
||||
|
||||
function log(msg: string, data?: unknown) {
|
||||
const ts = new Date().toISOString()
|
||||
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
|
||||
console.log(entry)
|
||||
const prev = sessionStorage.getItem('archipelago_boot_log') || ''
|
||||
sessionStorage.setItem('archipelago_boot_log', prev + entry + '\n')
|
||||
}
|
||||
|
||||
async function quickHealthCheck(timeoutMs = 2000): Promise<boolean> {
|
||||
try {
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), timeoutMs)
|
||||
const res = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: ac.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
const ok = res.status !== 502 && res.status !== 503
|
||||
log('healthCheck', { status: res.status, ok })
|
||||
return ok
|
||||
} catch (e) {
|
||||
log('healthCheck FAILED', { error: String(e) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOnboarded(): Promise<boolean> {
|
||||
// No hard timeout here. isOnboardingComplete() already retries with
|
||||
// backoff (see useOnboarding.ts). A 3s Promise.race that resolves to
|
||||
// `false` on timeout was previously the main cause of the intro
|
||||
// flashing on already-onboarded nodes after browser-clear / reboot /
|
||||
// update: if the backend was slow to warm up, we'd force a 'false'
|
||||
// and route the user back through the setup wizard.
|
||||
try {
|
||||
const result = await isOnboardingComplete()
|
||||
log('checkOnboarded', { result })
|
||||
return result
|
||||
} catch (e) {
|
||||
const fallback = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
log('checkOnboarded ERROR, localStorage fallback', { error: String(e), fallback })
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedToApp() {
|
||||
if (IS_DEMO) {
|
||||
// Companion in-app demo skips the intro (acts as intro-already-seen) and
|
||||
// lands on /login; browser demo unaffected. No log() here — it writes
|
||||
// sessionStorage, and the skip path must write nothing.
|
||||
if (isCompanionApp()) {
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
log('proceedToApp devMode', { devMode })
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const onboarded = await checkOnboarded()
|
||||
const dest = onboarded ? '/login' : '/onboarding/intro'
|
||||
log('proceedToApp navigating', { onboarded, dest })
|
||||
router.replace(dest).catch(() => {})
|
||||
}
|
||||
|
||||
function onServerReady() {
|
||||
if (import.meta.env.DEV) console.log('[RootRedirect] onServerReady — setting flag and reloading')
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Do NOT clear neode_onboarding_complete here — that flag must persist
|
||||
// across boot screen reloads so completed onboarding isn't lost.
|
||||
sessionStorage.setItem('archipelago_from_boot', '1')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
log('mounted', { devMode, from_boot: sessionStorage.getItem('archipelago_from_boot'), from_splash: sessionStorage.getItem('archipelago_from_splash') })
|
||||
|
||||
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
|
||||
log('from_boot=1, deferring to SplashScreen')
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
|
||||
log('from_splash=1, proceedToApp')
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
log('devMode shortcut', { devMode })
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (devMode === 'boot') {
|
||||
log('devMode=boot, showing boot screen')
|
||||
showBootScreen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// First-boot backends can be up but slow (image loads, first-run podman
|
||||
// work), so a single 2s echo timing out used to flash the BootScreen and
|
||||
// then hard-reload seconds later ("loads, refreshes, loads again" on
|
||||
// kiosk). Give it a second, more patient attempt before concluding the
|
||||
// server is down.
|
||||
let isUp = await quickHealthCheck()
|
||||
if (!isUp) {
|
||||
log('healthCheck retry with longer timeout')
|
||||
isUp = await quickHealthCheck(6000)
|
||||
}
|
||||
log('production flow', { isUp })
|
||||
|
||||
if (isUp) {
|
||||
// Demo: per-day intro gate instead of server-side onboarding state.
|
||||
if (IS_DEMO) {
|
||||
// Companion in-app demo skips the intro; browser demo unaffected.
|
||||
// No log() — the skip path must write nothing to storage.
|
||||
if (isCompanionApp()) {
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const onboarded = await checkOnboarded()
|
||||
if (onboarded) {
|
||||
log('server up + onboarded → proceedToApp')
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
log('server up + NOT onboarded → onboarding intro')
|
||||
router.replace('/onboarding/intro').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// Server not ready. The full BootScreen is meant for a genuine
|
||||
// cold-start (fresh install), not for the brief blip during an
|
||||
// OTA update where the backend restarts. If onboarding has already
|
||||
// completed we just keep the spinner and retry until the server
|
||||
// responds again.
|
||||
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
if (wasOnboardedBefore) {
|
||||
log('server down + onboarded → polling without boot screen')
|
||||
let retries = 0
|
||||
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
|
||||
const poll = setInterval(async () => {
|
||||
retries++
|
||||
if (await quickHealthCheck()) {
|
||||
clearInterval(poll)
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
clearInterval(poll)
|
||||
log('server still down after retries → falling back to boot screen')
|
||||
showBootScreen.value = true
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
showBootScreen.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root-redirect-fade {
|
||||
animation: root-fade-in 0.3s ease 0.5s forwards;
|
||||
}
|
||||
@keyframes root-fade-in {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<AccountSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppSession from '../AppSession.vue'
|
||||
|
||||
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(() => Promise.resolve()),
|
||||
mockPush: vi.fn(() => Promise.resolve()),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockSuppress: vi.fn(),
|
||||
mockResume: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: { appId: 'gitea' },
|
||||
query: { returnTo: '/dashboard/apps' },
|
||||
fullPath: '/dashboard/apps/session/gitea',
|
||||
}),
|
||||
useRouter: () => ({ replace: mockReplace, push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ panelAppId: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ data: { 'package-data': {} } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/screensaver', () => ({
|
||||
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useAppIdentity', () => ({
|
||||
useAppIdentity: () => ({
|
||||
onIdentitySelected: vi.fn(),
|
||||
onIframeLoadIdentity: vi.fn(),
|
||||
handleIdentityRequest: vi.fn(),
|
||||
getStoredIdentity: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useNostrBridge', () => ({
|
||||
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
describe('AppSession mobile new-tab apps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
|
||||
const wrapper = mount(AppSession, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
AppSessionHeader: true,
|
||||
NostrIdentityPicker: true,
|
||||
MobileGamepad: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
|
||||
// (no native bridge in the test) and dismiss the empty session — no
|
||||
// "this app opens in a tab" interstitial.
|
||||
expect(mockWindowOpen).toHaveBeenCalled()
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Cloud from '../Cloud.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePeer() {
|
||||
return {
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer Alpha',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-06-10T10:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cloud peer list', () => {
|
||||
it('keeps peer nodes visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
|
||||
|
||||
const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
const pending = deferred<{ nodes: [] }>()
|
||||
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import Credentials from '../Credentials.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCredential(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: ['VerifiableCredential', 'NodeOperator'],
|
||||
issuer: 'did:key:issuer',
|
||||
credentialSubject: { id: 'did:key:subject' },
|
||||
issuanceDate: '2026-06-10T10:00:00Z',
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Credentials', () => {
|
||||
it('keeps credentials visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
|
||||
if (request.method === 'identity.list-credentials') {
|
||||
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const wrapper = mount(Credentials, {
|
||||
global: {
|
||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
||||
plugins: [createPinia()],
|
||||
mocks: {
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
|
||||
const pending = deferred<{ credentials: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list-credentials') return pending.promise
|
||||
return Promise.resolve({ identities: [] })
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).toContain('Refreshing credentials...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).not.toContain('Refreshing credentials...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Marketplace from '../Marketplace.vue'
|
||||
|
||||
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
||||
// mounting a view directly with its heavier deps mocked at the module
|
||||
// boundary) — kept in its own file rather than keepAliveTabs.test.ts because
|
||||
// vi.mock('vue-router', ...) is hoisted file-wide and would otherwise
|
||||
// clobber that file's real createRouter/createMemoryHistory imports used by
|
||||
// the DashboardRouterView tests (Rule 3 auto-fix).
|
||||
const routerPushMock = vi.fn()
|
||||
const toastErrorMock = vi.fn()
|
||||
const toastInfoMock = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: routerPushMock, replace: vi.fn().mockResolvedValue(undefined) }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ data: {}, hasLoadedInitialData: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/server', () => ({
|
||||
useServerStore: () => ({
|
||||
installingApps: new Map(),
|
||||
setInstallProgress: vi.fn(),
|
||||
clearInstallProgress: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ openSession: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
useMarketplaceApp: () => ({ setCurrentApp: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
routerPushMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastInfoMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps the curated catalog on screen and raises no toast when the prune-status background refresh rejects', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ blockchain_info: { pruned: false } }),
|
||||
}))
|
||||
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
|
||||
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('node unreachable'))
|
||||
await (wrapper.vm as unknown as { loadBitcoinPruneStatus: () => Promise<void> }).loadBitcoinPruneStatus()
|
||||
await flushPromises()
|
||||
|
||||
// Prior catalog content is still rendered — a background refresh failure
|
||||
// on an unrelated resource never wipes the view.
|
||||
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OnboardingOptions from '../OnboardingOptions.vue'
|
||||
|
||||
const push = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('OnboardingOptions', () => {
|
||||
it('shows only usable setup paths', () => {
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
expect(wrapper.text()).toContain('Fresh Start')
|
||||
expect(wrapper.text()).toContain('Restore from Seed')
|
||||
expect(wrapper.text()).not.toContain('Connect Existing')
|
||||
expect(wrapper.text()).not.toContain('Coming Soon')
|
||||
})
|
||||
|
||||
it('continues to fresh seed generation by default', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed')
|
||||
})
|
||||
|
||||
it('routes restore choice to seed restore', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
|
||||
expect(restoreButton).toBeDefined()
|
||||
|
||||
await restoreButton!.trigger('click')
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OnboardingSeedGenerate from '../OnboardingSeedGenerate.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const WORDS = Array.from({ length: 24 }, (_, i) => `word${i + 1}`)
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(() => Promise.resolve()) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Set the scroll region's and confirmation label's geometry directly — jsdom
|
||||
// has no layout engine, so scrollHeight/clientHeight/scrollTop are normally 0
|
||||
// and getBoundingClientRect() always returns an all-zero rect, so both must
|
||||
// be driven explicitly.
|
||||
function setGeometry(
|
||||
container: HTMLElement,
|
||||
label: HTMLElement,
|
||||
opts: { scrollHeight: number; clientHeight: number; scrollTop: number; containerBottom: number; labelBottom: number },
|
||||
) {
|
||||
Object.defineProperty(container, 'scrollHeight', { value: opts.scrollHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: opts.clientHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true })
|
||||
container.getBoundingClientRect = () => ({ bottom: opts.containerBottom } as DOMRect)
|
||||
label.getBoundingClientRect = () => ({ bottom: opts.labelBottom } as DOMRect)
|
||||
}
|
||||
|
||||
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
async function mountWithWords() {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ words: WORDS })
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('renders no cue when the scroll region reports no overflow', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 400,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('renders the cue when there is overflow and the tickbox is below the fold', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
})
|
||||
|
||||
it('removes the cue once scrolling brings the tickbox into view', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
// Scroll down: the container's own viewport rect doesn't move, but its
|
||||
// scrolled content does — the label's viewport-relative bottom shifts up
|
||||
// by the scroll delta, bringing it inside the visible window.
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 400,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('activating the cue scrolls the tickbox into view and never touches confirmed', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cueButton = wrapper.findAll('button').find((b) => b.text().includes('One more step below'))
|
||||
expect(cueButton).toBeDefined()
|
||||
|
||||
await cueButton!.trigger('click')
|
||||
|
||||
expect(label.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
|
||||
const checkbox = wrapper.get('input[type="checkbox"]').element as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('never shows the cue while loading, regardless of overflow', async () => {
|
||||
let resolveCall: (v: { words: string[] }) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockReturnValue(new Promise((resolve) => { resolveCall = resolve }))
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
expect(wrapper.text()).toContain('Generating your seed phrase')
|
||||
|
||||
resolveCall({ words: WORDS })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('removes the cue once the tickbox is ticked', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCatalogItem() {
|
||||
return {
|
||||
id: 'file-1',
|
||||
filename: 'notes.txt',
|
||||
mime_type: 'text/plain',
|
||||
size_bytes: 128,
|
||||
description: '',
|
||||
access: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
describe('PeerFiles', () => {
|
||||
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
|
||||
nodes: [{
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer',
|
||||
trust_level: 'trusted',
|
||||
}],
|
||||
} as never)
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
|
||||
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: 'peer.onion' },
|
||||
global: {
|
||||
// The shared peer-browse cache lives in the Pinia resources store.
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
|
||||
const pending = deferred<{ items: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('Refreshing peer files...')
|
||||
expect(wrapper.text()).not.toContain('Connecting via Tor')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer files...')
|
||||
})
|
||||
|
||||
it('opens the full-screen lightbox when a FREE image card is clicked', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
|
||||
nodes: [{
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer',
|
||||
trust_level: 'trusted',
|
||||
}],
|
||||
} as never)
|
||||
const freeImage = {
|
||||
id: 'photo-1',
|
||||
filename: 'sunset.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 1024,
|
||||
description: '',
|
||||
access: 'free',
|
||||
}
|
||||
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
|
||||
if (req.method === 'content.browse-peer') return { items: [freeImage] }
|
||||
if (req.method === 'content.owned-list') return { items: [] }
|
||||
return {}
|
||||
}) as never)
|
||||
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: 'peer.onion' },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('sunset.jpg')
|
||||
|
||||
// The free image card click was previously a no-op (the old ternary fell
|
||||
// through to `undefined` for non-playable free items).
|
||||
await wrapper.find('.aspect-video').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const viewerImg = wrapper
|
||||
.findAll('img')
|
||||
.find(img => (img.attributes('src') || '').includes('/api/peer-content/'))
|
||||
expect(viewerImg).toBeTruthy()
|
||||
expect(viewerImg!.attributes('src')).toContain('photo-1')
|
||||
expect(wrapper.text()).toContain('Free · shared by peer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import Server from '../Server.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
dnsStatus: vi.fn(),
|
||||
diskStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||
return mount(Server, {
|
||||
global: {
|
||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
QuickActionsCard: true,
|
||||
TorServicesCard: options.renderTorServices ? false : true,
|
||||
ServerModals: true,
|
||||
FipsNetworkCard: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Server network refresh states', () => {
|
||||
it('keeps network overview visible while refresh is pending', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [{}, {}] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
|
||||
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
|
||||
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
expect(wrapper.text()).toContain('Refreshing network...')
|
||||
|
||||
pendingDiagnostics.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
})
|
||||
|
||||
it('keeps network interfaces visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({
|
||||
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.0.2.10'] }],
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: false })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.0.2.10')
|
||||
|
||||
const pendingInterfaces = deferred<{ interfaces: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.0.2.10')
|
||||
expect(wrapper.text()).toContain('Refreshing interfaces...')
|
||||
|
||||
pendingInterfaces.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.0.2.10')
|
||||
})
|
||||
|
||||
it('keeps Tor services visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({
|
||||
services: [{
|
||||
name: 'filebrowser',
|
||||
local_port: 8080,
|
||||
onion_address: 'filebrowser123456789.onion',
|
||||
enabled: true,
|
||||
unauthenticated: false,
|
||||
protocol: false,
|
||||
}],
|
||||
tor_running: true,
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer({ renderTorServices: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
|
||||
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') return pendingTor.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
expect(wrapper.text()).toContain('Refreshing Tor services...')
|
||||
|
||||
pendingTor.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* THE FIPS / TOR TRANSPORT PILLS ARE A PERMANENT, USER-REQUESTED FEATURE.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Dorian asked for these pills explicitly ("really helpful") and asked that no
|
||||
* future cleanup or refactor ever remove them. They are the one place the app
|
||||
* tells a user whether their file arrived over the fast encrypted mesh (FIPS)
|
||||
* or over Tor — a security signal, not decoration. Requirement UIFIX-01.
|
||||
*
|
||||
* IF A TEST IN THIS FILE FAILS, THE MOST LIKELY CAUSE IS THAT SOMEONE REMOVED
|
||||
* OR RENAMED A TRANSPORT PILL — not that the test went stale. Put the pill
|
||||
* back. If a pill genuinely has to move, move the assertion with it; do not
|
||||
* delete the assertion.
|
||||
*
|
||||
* Each `it()` below is keyed to ONE specific render site so that deleting the
|
||||
* pill from that site, and only that site, fails. A pill somewhere else in the
|
||||
* app does not satisfy these assertions.
|
||||
*
|
||||
* Render sites pinned here (audited 2026-08-02 at 390×740 and 320×640):
|
||||
* S1 Cloud.vue — peer card badge row, Folders tab (renders a pill)
|
||||
* S2 Cloud.vue — Peer Files aggregated rows (no pill, by decision)
|
||||
* S3 Cloud.vue — Paid Files rows (no pill, by decision)
|
||||
* S4 PeerFiles.vue — header, desktop copy + mobile copy (renders a pill)
|
||||
* S5 PeerFiles.vue — per-file card body (no pill, by decision)
|
||||
*
|
||||
* The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a
|
||||
* bug: transport is measured PER PEER PER BROWSE, never per file, so a
|
||||
* per-file pill would claim a reading the app never took. The reasoning is
|
||||
* If you deliberately add a per-file pill, update that decision record and
|
||||
* this test together.
|
||||
*/
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import Cloud from '../Cloud.vue'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const PEER_ONION = 'peeraaaa1111bbbb2222cccc3333dddd4444eeee.onion'
|
||||
|
||||
function makePeer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
did: 'did:key:peerAlpha',
|
||||
pubkey: 'peer',
|
||||
onion: PEER_ONION,
|
||||
name: 'Peer Alpha',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-06-10T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'file-1',
|
||||
filename: 'notes.txt',
|
||||
mime_type: 'text/plain',
|
||||
size_bytes: 128,
|
||||
description: '',
|
||||
access: 'free',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount Cloud.vue with one peer whose last browse resolved over `transport`.
|
||||
* Pass `transport: null` for the "we have not observed a transport" edge. */
|
||||
async function mountCloud(transport: string | null, peerOverrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer(peerOverrides)] } as never)
|
||||
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
|
||||
if (req.method === 'content.browse-peer') {
|
||||
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
|
||||
}
|
||||
if (req.method === 'content.owned-list') {
|
||||
return {
|
||||
items: [{
|
||||
onion: PEER_ONION,
|
||||
content_id: 'file-1',
|
||||
filename: 'paid-track.mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
size_bytes: 4096,
|
||||
paid_sats: 2500,
|
||||
purchased_at: '2026-07-30T10:00:00Z',
|
||||
}],
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}) as never)
|
||||
|
||||
const wrapper = mount(Cloud, {
|
||||
global: { plugins: [createPinia()], stubs: { Teleport: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function mountPeerFiles(transport: string | null) {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer()] } as never)
|
||||
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
|
||||
if (req.method === 'content.browse-peer') {
|
||||
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
|
||||
}
|
||||
if (req.method === 'content.owned-list') return { items: [] }
|
||||
return {}
|
||||
}) as never)
|
||||
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: PEER_ONION },
|
||||
global: { plugins: [createPinia()], stubs: { Teleport: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** The Cloud.vue peer-card badge pill renders "<TRANSPORT> · <n>s" — a shape
|
||||
* no other pill in either view produces, which is what keys these assertions
|
||||
* to site S1 specifically. */
|
||||
function cloudPeerCardPill(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('span').find(s => /^(FIPS|TOR|MESH|LAN)\s·\s[\d.]+s$/.test(s.text().trim()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// The peer-browse cache is a persist:true key — it snapshots into
|
||||
// sessionStorage, which outlives a per-test `createPinia()`. Without this,
|
||||
// the transport from an earlier test leaks into the next one and the
|
||||
// unknown-transport case would "pass" against a stale FIPS reading.
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
// ── S1: Cloud.vue peer card badge row ───────────────────────────────────────
|
||||
describe('S1 — Cloud.vue peer card transport pill (Folders tab)', () => {
|
||||
it('renders the FIPS pill on the peer card when the last browse used FIPS', async () => {
|
||||
const wrapper = await mountCloud('fips')
|
||||
const pill = cloudPeerCardPill(wrapper)
|
||||
|
||||
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
|
||||
expect(pill!.text()).toMatch(/^FIPS · [\d.]+s$/)
|
||||
// FIPS reads as the good/fast path — emerald, matching the canonical palette.
|
||||
expect(pill!.classes().join(' ')).toContain('bg-emerald-500/15')
|
||||
expect(pill!.attributes('title')).toContain('FIPS')
|
||||
})
|
||||
|
||||
it('renders the TOR pill, in the slow-path colour, when the last browse used Tor', async () => {
|
||||
const wrapper = await mountCloud('tor')
|
||||
const pill = cloudPeerCardPill(wrapper)
|
||||
|
||||
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
|
||||
expect(pill!.text()).toMatch(/^TOR · [\d.]+s$/)
|
||||
// Tor is the slow fallback — amber, visibly different from FIPS.
|
||||
expect(pill!.classes().join(' ')).toContain('bg-amber-500/15')
|
||||
expect(pill!.attributes('title')).toContain('TOR')
|
||||
})
|
||||
|
||||
it('fabricates no pill when no transport has been observed, and keeps the not-known treatment', async () => {
|
||||
const wrapper = await mountCloud(null)
|
||||
|
||||
// T-01-78: never claim a transport the app has not actually measured.
|
||||
expect(cloudPeerCardPill(wrapper)).toBeUndefined()
|
||||
expect(wrapper.text()).not.toMatch(/\bFIPS\b/)
|
||||
// The existing "we don't know yet" treatment stays.
|
||||
expect(wrapper.text()).toContain('Peer Node')
|
||||
})
|
||||
|
||||
// UIFIX-01 mobile half. The badge row has no horizontal give at 320px: with a
|
||||
// longer trust label and no wrapping, flexbox compresses the transport badge
|
||||
// until its OWN text breaks mid-label ("TOR ·" / "120.0s") — measured in a
|
||||
// real browser at 320×640. flex-wrap makes the badge drop to a second line
|
||||
// intact instead, and shrink-0 stops it being squeezed on the way there.
|
||||
it('lets the badge row wrap and keeps the transport pill unsqueezed (mobile legibility)', async () => {
|
||||
const wrapper = await mountCloud('tor')
|
||||
const pill = cloudPeerCardPill(wrapper)
|
||||
expect(pill).toBeTruthy()
|
||||
|
||||
expect(pill!.classes(), 'transport pill must not be compressible').toContain('shrink-0')
|
||||
|
||||
const row = pill!.element.parentElement as HTMLElement
|
||||
expect(row, 'transport pill has no parent row').toBeTruthy()
|
||||
expect(
|
||||
Array.from(row.classList),
|
||||
'the peer-card badge row must wrap, or the pill text breaks mid-label at 320px',
|
||||
).toContain('flex-wrap')
|
||||
})
|
||||
})
|
||||
|
||||
// ── S2/S3: Cloud.vue file lists carry no per-file transport pill (decision) ──
|
||||
describe('S2/S3 — Cloud.vue file rows carry no per-file transport pill (recorded decision)', () => {
|
||||
it('shows no transport pill on the aggregated Peer Files rows or the Paid Files rows', async () => {
|
||||
const wrapper = await mountCloud('fips')
|
||||
const vm = wrapper.vm as unknown as { activeTab: string }
|
||||
|
||||
for (const tab of ['peers', 'paid']) {
|
||||
vm.activeTab = tab
|
||||
await flushPromises()
|
||||
|
||||
// Transport is a per-peer, per-browse reading. These rows list files —
|
||||
// from many peers at once in the Peer Files case, and from the local
|
||||
// purchase cache (no live transport at all) in the Paid Files case.
|
||||
// A pill here would assert a per-file measurement that was never taken.
|
||||
expect(
|
||||
cloudPeerCardPill(wrapper),
|
||||
`a transport pill appeared on the "${tab}" rows — see the header of this file`,
|
||||
).toBeUndefined()
|
||||
expect(wrapper.text(), `"${tab}" rows must not label files with a transport`).not.toMatch(/\bFIPS\b/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── S4: PeerFiles.vue header, desktop copy AND mobile copy ──────────────────
|
||||
describe('S4 — PeerFiles.vue header transport pill', () => {
|
||||
it('renders the pill in the desktop title block', async () => {
|
||||
const wrapper = await mountPeerFiles('fips')
|
||||
|
||||
const desktopBlock = wrapper.findAll('div').find(d => {
|
||||
const c = d.classes()
|
||||
return c.includes('hidden') && c.includes('md:block')
|
||||
})
|
||||
expect(desktopBlock, 'PeerFiles desktop title block is missing').toBeTruthy()
|
||||
|
||||
const pill = desktopBlock!.findAll('span').find(s => s.text().trim() === 'FIPS')
|
||||
expect(pill, 'desktop header transport pill is missing — see the header of this file').toBeTruthy()
|
||||
// Canonical mapping (PeerFiles.vue transportPill), not a duplicated table.
|
||||
expect(pill!.classes().join(' ')).toContain('bg-green-500/20')
|
||||
expect(pill!.attributes('title')).toContain('FIPS')
|
||||
})
|
||||
|
||||
it('renders a separate mobile copy of the pill, because the desktop title block is hidden on a phone', async () => {
|
||||
const wrapper = await mountPeerFiles('fips')
|
||||
|
||||
// This is the UIFIX-01 mobile half for this site: the title block that
|
||||
// carries the desktop pill is `hidden md:block`, so without this copy a
|
||||
// phone user would see no transport at all on the peer's file page.
|
||||
const mobilePill = wrapper.findAll('span').find(s =>
|
||||
s.classes().includes('md:hidden') && s.text().trim() === 'FIPS',
|
||||
)
|
||||
expect(
|
||||
mobilePill,
|
||||
'the md:hidden mobile transport pill is missing — a phone would show no transport here',
|
||||
).toBeTruthy()
|
||||
expect(mobilePill!.classes().join(' ')).toContain('bg-green-500/20')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fips', 'FIPS', 'bg-green-500/20'],
|
||||
['mesh', 'Mesh', 'bg-green-500/20'],
|
||||
['lan', 'LAN', 'bg-blue-500/20'],
|
||||
['tor', 'Tor', 'bg-amber-500/20'],
|
||||
])('maps transport %s to the canonical label %s and its canonical colour', async (transport, label, colour) => {
|
||||
const wrapper = await mountPeerFiles(transport)
|
||||
|
||||
const pills = wrapper.findAll('span').filter(s => s.text().trim() === label)
|
||||
// One desktop copy + one mobile copy, both from the same canonical mapping.
|
||||
expect(pills.length, `expected the ${label} pill in both the desktop and mobile header copies`).toBe(2)
|
||||
for (const p of pills) expect(p.classes().join(' ')).toContain(colour)
|
||||
})
|
||||
|
||||
it('fabricates no pill for an unobserved transport, in either the desktop or the mobile copy', async () => {
|
||||
const wrapper = await mountPeerFiles(null)
|
||||
|
||||
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
|
||||
expect(
|
||||
wrapper.findAll('span').some(s => s.text().trim() === label),
|
||||
`PeerFiles fabricated a "${label}" pill with no observed transport`,
|
||||
).toBe(false)
|
||||
}
|
||||
// The file list itself is unaffected — only the transport claim is absent.
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
})
|
||||
})
|
||||
|
||||
// ── S5: PeerFiles.vue per-file cards carry no transport pill (decision) ─────
|
||||
describe('S5 — PeerFiles.vue per-file cards carry no transport pill (recorded decision)', () => {
|
||||
it('labels file cards with access only, leaving transport to the single peer-level pill', async () => {
|
||||
const wrapper = await mountPeerFiles('tor')
|
||||
|
||||
// Every file on this page came from the same peer over the same transport,
|
||||
// so the header pill already states it once. Repeating it per card would
|
||||
// add no information and would crowd the row at 320px.
|
||||
const cardBody = wrapper.findAll('div').find(d => {
|
||||
const c = d.classes()
|
||||
return c.includes('p-4') && c.includes('flex') && c.includes('mt-auto')
|
||||
})
|
||||
expect(cardBody, 'PeerFiles per-file card body is missing').toBeTruthy()
|
||||
|
||||
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
|
||||
expect(
|
||||
cardBody!.findAll('span').some(s => s.text().trim() === label),
|
||||
`a transport pill appeared on a per-file card — see the header of this file`,
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
// 02-07: the AIUI embed URL must be byte-stable across re-renders, a
|
||||
// simulated viewport resize, and a KeepAlive deactivate/reactivate cycle —
|
||||
// any runtime-varying input would change the iframe `src` and force a full
|
||||
// AIUI reload on the next tab switch, giving back the entire benefit 02-04
|
||||
// established for the Chat tab. Also covers the two D-14 presentation
|
||||
// flags (chatExpanded, mobileChat), origin validation being unchanged, and
|
||||
// aiuiConnected surviving deactivation (AIUI's 'ready' message is not
|
||||
// re-sent on re-entry).
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Chat from '../Chat.vue'
|
||||
|
||||
const routerBackMock = vi.fn()
|
||||
const routerPushMock = vi.fn()
|
||||
const routerReplaceMock = vi.fn()
|
||||
|
||||
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
|
||||
// handoff, and route.path when it strips those params back off. Kept empty by
|
||||
// default so the byte-stability assertions below see no ask in play.
|
||||
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
|
||||
useRoute: () => routeMock,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
// IS_DEMO is a build-time constant in the real module; mock it so these
|
||||
// tests exercise the plain VITE_AIUI_URL branch deterministically.
|
||||
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false }))
|
||||
|
||||
// ContextBroker pulls in several Pinia stores (app/container/aiPermissions)
|
||||
// unrelated to this test's concern (URL stability + origin validation) —
|
||||
// mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's
|
||||
// convention for isolating a view from its heavier dependencies.
|
||||
vi.mock('@/services/contextBroker', () => ({
|
||||
ContextBroker: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
/** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */
|
||||
function mountChatInKeepAlive() {
|
||||
const show = ref(true)
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(KeepAlive, null, {
|
||||
default: () => (show.value ? h(Chat) : h('div', 'other-tab')),
|
||||
})
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Host)
|
||||
return { wrapper, show }
|
||||
}
|
||||
|
||||
function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
|
||||
return wrapper.find('iframe').attributes('src')
|
||||
}
|
||||
|
||||
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
routeMock.query = {}
|
||||
routerReplaceMock.mockClear()
|
||||
})
|
||||
|
||||
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const src = iframeSrc(wrapper)
|
||||
expect(src).toBeTruthy()
|
||||
expect(src).toContain('embedded=true')
|
||||
expect(src).toContain('hideClose=true')
|
||||
expect(src).toContain('chatExpanded=true')
|
||||
expect(src).toContain('mobileChat=true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
|
||||
// by postMessage: putting it in the URL would give aiuiUrl a reactive
|
||||
// dependency and reload AIUI on every question, which is precisely the
|
||||
// byte-stability property the rest of this file exists to protect.
|
||||
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
|
||||
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const before = iframeSrc(wrapper)
|
||||
expect(before).not.toContain('ask=')
|
||||
|
||||
const frame = wrapper.find('iframe').element as HTMLIFrameElement
|
||||
const post = vi.fn()
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
|
||||
'http://localhost:5173',
|
||||
)
|
||||
// src must be byte-identical after the ask round-trip
|
||||
expect(iframeSrc(wrapper)).toBe(before)
|
||||
// and the params are stripped so a refresh does not silently re-ask
|
||||
expect(routerReplaceMock).toHaveBeenCalled()
|
||||
const replaceArg = routerReplaceMock.mock.calls[0]![0]
|
||||
expect(replaceArg.query.ask).toBeUndefined()
|
||||
expect(replaceArg.query.askedAt).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not post a prefill when there is no ask in the route', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const frame = wrapper.find('iframe').element as HTMLIFrameElement
|
||||
const post = vi.fn()
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const before = iframeSrc(wrapper)
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const after = iframeSrc(wrapper)
|
||||
expect(after).toBe(before)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('is string-equal before and after a deactivate/reactivate cycle', async () => {
|
||||
const { wrapper, show } = mountChatInKeepAlive()
|
||||
const before = iframeSrc(wrapper)
|
||||
|
||||
show.value = false
|
||||
await wrapper.vm.$nextTick()
|
||||
show.value = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const after = iframeSrc(wrapper)
|
||||
expect(after).toBe(before)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not set aiuiConnected for a message from a foreign origin', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://evil.example',
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
// aiuiConnected stays false: the loading overlay is still shown and the
|
||||
// connected indicator (title="chat.aiuiConnected") is absent.
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(true)
|
||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
|
||||
const { wrapper, show } = mountChatInKeepAlive()
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://localhost:5173',
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(false)
|
||||
|
||||
show.value = false
|
||||
await wrapper.vm.$nextTick()
|
||||
show.value = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
|
||||
// No second 'ready' message is sent on reactivation — aiuiConnected must
|
||||
// not have been reset to false by the deactivate/reactivate cycle.
|
||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Belt-and-suspenders backstop added after live testing found the overlay
|
||||
// could wedge the UI when the 'ready' handshake never arrives (a real bug,
|
||||
// separately fixed at its root cause in AIUI's archyBridge.ts) — this
|
||||
// proves the archy side never depends on that fix alone.
|
||||
it('dismisses the loading overlay after a bounded timeout even if no ready message ever arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7999)
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(false)
|
||||
// The connection indicator must NOT falsely report connected — the
|
||||
// timeout only dismisses the blocking overlay, it does not fabricate
|
||||
// a successful handshake.
|
||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not dismiss the loading overlay before the timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
|
||||
|
||||
describe('cloudPath helpers', () => {
|
||||
it('normalizes query paths', () => {
|
||||
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
|
||||
})
|
||||
|
||||
it('walks to the parent folder without leaving root', () => {
|
||||
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
|
||||
expect(parentCloudPath('/Photos')).toBe('/')
|
||||
expect(parentCloudPath('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Chromium/Brave mis-rasterise `backdrop-filter` inside the dashboard's
|
||||
* animated perspective/scroll containers. style.css already neutralises it
|
||||
* for the shared glass classes, but that list is hand-maintained: a component
|
||||
* that declares its own `backdrop-filter` in a local <style> block is simply
|
||||
* not covered, and nothing fails.
|
||||
*
|
||||
* That is exactly how the 2026-08-03 seam shipped. `.home-card-shell` carried
|
||||
* `backdrop-filter: blur(18px)` in Home.vue and was missing from the list, so
|
||||
* a hover repaint left a vertical line where the refreshed backdrop met the
|
||||
* stale one — visible in both dashboard cards at the same screen x, and
|
||||
* absent in the gap between them.
|
||||
*
|
||||
* This test makes the omission fail loudly instead of shipping as a glitch
|
||||
* nobody can reproduce on demand.
|
||||
*/
|
||||
|
||||
const root = resolve(__dirname, '../../..')
|
||||
const styleCss = readFileSync(resolve(root, 'src/style.css'), 'utf8')
|
||||
|
||||
/** The selector list that disables backdrop-filter on the dashboard. */
|
||||
function dashboardMitigationBlock(): string {
|
||||
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
|
||||
expect(start, 'dashboard backdrop-filter mitigation block not found').toBeGreaterThan(-1)
|
||||
const end = styleCss.indexOf('}', start)
|
||||
return styleCss.slice(start, end)
|
||||
}
|
||||
|
||||
/** Class selectors that declare a non-none backdrop-filter in a .vue file. */
|
||||
function blurredClassesIn(relPath: string): string[] {
|
||||
const src = readFileSync(resolve(root, relPath), 'utf8')
|
||||
const found = new Set<string>()
|
||||
// Match `.some-class { ... backdrop-filter: <not none> ... }` on one line,
|
||||
// which is how these single-line rules are written in this codebase.
|
||||
const ruleRe = /(\.[a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = ruleRe.exec(src)) !== null) {
|
||||
const selector = m[1]
|
||||
const body = m[2]
|
||||
if (!selector || !body) continue
|
||||
const decl = /(?:^|[;{\s])backdrop-filter\s*:\s*([^;]+)/.exec(body)
|
||||
if (decl?.[1] && decl[1].trim() !== 'none') found.add(selector)
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
describe('dashboard backdrop-filter mitigation', () => {
|
||||
it('covers every backdrop-filter surface Home.vue defines itself', () => {
|
||||
const block = dashboardMitigationBlock()
|
||||
const uncovered = blurredClassesIn('src/views/Home.vue').filter(
|
||||
(sel) => !block.includes(`.dashboard-scroll-panel ${sel},`),
|
||||
)
|
||||
expect(
|
||||
uncovered,
|
||||
`these Home.vue classes declare backdrop-filter but are not in the ` +
|
||||
`body.dashboard-active .dashboard-scroll-panel mitigation list in style.css, ` +
|
||||
`so Chromium will leave repaint seams across the dashboard cards`,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('still lists the shared glass classes', () => {
|
||||
// Guards against someone "cleaning up" the list and silently reopening
|
||||
// the original black-rectangle corruption this block was written for.
|
||||
const block = dashboardMitigationBlock()
|
||||
for (const sel of ['.glass-card', '.glass-button', '.home-card-shell']) {
|
||||
expect(block).toContain(`.dashboard-scroll-panel ${sel},`)
|
||||
}
|
||||
})
|
||||
|
||||
it('the mitigation actually disables the filter', () => {
|
||||
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
|
||||
const body = styleCss.slice(styleCss.indexOf('{', start), styleCss.indexOf('}', start))
|
||||
expect(body).toContain('backdrop-filter: none')
|
||||
expect(body).toContain('-webkit-backdrop-filter: none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
// 02-06 Task 2: caches Home's system stats, update status and cloud storage
|
||||
// usage behind keyed useCachedResource entries (every-entry, TTL-gated), and
|
||||
// wraps the existing loadWeb5Status() wallet fetch in its own resource that
|
||||
// revalidates UNCONDITIONALLY on every activation (T-02-13) — a money figure
|
||||
// must never be presented as current without a visible re-check. Web5.vue's
|
||||
// own two resources (web5.networking-profits, web5.lnd-info) were read and
|
||||
// are NOT shared here — see 02-06-SUMMARY.md for the finding.
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Home from '../Home.vue'
|
||||
import HomeWalletCard from '../home/HomeWalletCard.vue'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: { subscribe: vi.fn(() => vi.fn()) },
|
||||
}))
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
getUsage: vi.fn().mockResolvedValue({ totalSize: 1024, folderCount: 2, fileCount: 5 }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Records every rpcClient.call({method}) invocation. wallet/system/update
|
||||
// calls all route through call(); nothing Home.vue uses goes through a
|
||||
// separate convenience method (unlike Server.vue's vpnStatus/dnsStatus).
|
||||
async function defaultRpcCallImpl({ method }: { method: string }) {
|
||||
switch (method) {
|
||||
case 'system.stats':
|
||||
return { cpu_usage_percent: 10, mem_used_bytes: 100, mem_total_bytes: 200, disk_used_bytes: 1, disk_total_bytes: 2, uptime_secs: 60 }
|
||||
case 'bitcoin.getinfo':
|
||||
return { block_height: 100, sync_progress: 1 }
|
||||
case 'fips.status':
|
||||
return { installed: false, service_active: false, key_present: false }
|
||||
case 'openwrt.get-status':
|
||||
return { tollgate: { installed: false } }
|
||||
case 'update.status':
|
||||
return { update_available: false }
|
||||
case 'lnd.getinfo':
|
||||
return { balance_sats: 5000, channel_balance_sats: 2500, synced_to_chain: true }
|
||||
case 'wallet.ecash-balance':
|
||||
return { balance_sats: 100 }
|
||||
case 'wallet.fedimint-balance':
|
||||
return { balance_sats: 0 }
|
||||
case 'wallet.ark-balance':
|
||||
return { spendable_sats: 0 }
|
||||
case 'lnd.gettransactions':
|
||||
return { transactions: [], incoming_pending_count: 0 }
|
||||
case 'lnd.lightning-history':
|
||||
return { transactions: [] }
|
||||
case 'wallet.ecash-history':
|
||||
return { transactions: [] }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const rpcCallMock = vi.fn(defaultRpcCallImpl)
|
||||
|
||||
const vpnStatusMock = vi.fn(async () => ({
|
||||
connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||
|
||||
function mountHomeHost() {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Home, { key: 'home' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
return mount(Host, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
HomeSystemCard: true,
|
||||
EasyHome: true,
|
||||
WalletScanModal: true,
|
||||
SendBitcoinModal: true,
|
||||
ReceiveBitcoinModal: true,
|
||||
TransactionsModal: true,
|
||||
WalletSettingsModal: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function callCountFor(method: string): number {
|
||||
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
|
||||
}
|
||||
|
||||
async function toggleTab(wrapper: ReturnType<typeof mountHomeHost>, show: boolean) {
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
// loadWeb5Status()'s nested Promise.allSettled chains (balances + histories,
|
||||
// each wrapping several rpcClient.call().then() hops) need more than one
|
||||
// macrotask boundary to fully settle under fake timers — a single
|
||||
// flushPromises() left the wallet resource's loadState at 'loading' in
|
||||
// practice. Two calls reliably drain it.
|
||||
async function settle() {
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
rpcCallMock.mockClear()
|
||||
// Guard against a leaking permanent mockImplementation() override from a
|
||||
// prior test (e.g. the never-resolving-promise case below) — mockClear()
|
||||
// only clears call history, not a permanently swapped implementation.
|
||||
rpcCallMock.mockImplementation(defaultRpcCallImpl)
|
||||
vpnStatusMock.mockClear()
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
} catch { /* unavailable in some envs */ }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Home tab cache (Task 2): system/update/storage groups + wallet freshness', () => {
|
||||
it('reactivating inside the TTL issues zero new RPCs for the system, update and storage-usage groups', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const before = {
|
||||
stats: callCountFor('system.stats'),
|
||||
btc: callCountFor('bitcoin.getinfo'),
|
||||
fips: callCountFor('fips.status'),
|
||||
tollgate: callCountFor('openwrt.get-status'),
|
||||
vpn: vpnStatusMock.mock.calls.length,
|
||||
update: callCountFor('update.status'),
|
||||
usage: vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length,
|
||||
}
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
|
||||
await toggleTab(wrapper, true)
|
||||
await settle()
|
||||
|
||||
expect(callCountFor('system.stats')).toBe(before.stats)
|
||||
expect(callCountFor('bitcoin.getinfo')).toBe(before.btc)
|
||||
expect(callCountFor('fips.status')).toBe(before.fips)
|
||||
expect(callCountFor('openwrt.get-status')).toBe(before.tollgate)
|
||||
expect(vpnStatusMock.mock.calls.length).toBe(before.vpn)
|
||||
expect(callCountFor('update.status')).toBe(before.update)
|
||||
expect(vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length).toBe(before.usage)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating always triggers a wallet revalidation even when the TTL has not lapsed', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const lndCallsAtMount = callCountFor('lnd.getinfo')
|
||||
expect(lndCallsAtMount).toBeGreaterThan(0)
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(1000) // nowhere near stale by any TTL definition
|
||||
await toggleTab(wrapper, true)
|
||||
await settle()
|
||||
|
||||
// Unconditional — fires again regardless of staleness (T-02-13).
|
||||
expect(callCountFor('lnd.getinfo')).toBe(lndCallsAtMount + 1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('the previously rendered wallet figure stays in the DOM throughout a wallet revalidation', async () => {
|
||||
const wrapper = mount(Home, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
HomeSystemCard: true,
|
||||
EasyHome: true,
|
||||
WalletScanModal: true,
|
||||
SendBitcoinModal: true,
|
||||
ReceiveBitcoinModal: true,
|
||||
TransactionsModal: true,
|
||||
WalletSettingsModal: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await settle()
|
||||
|
||||
const walletCardBefore = wrapper.findComponent(HomeWalletCard)
|
||||
expect(walletCardBefore.exists()).toBe(true)
|
||||
expect(walletCardBefore.props('walletOnchain')).toBe(5000)
|
||||
|
||||
// Trigger a fresh revalidation directly (mirrors what reactivation does)
|
||||
// and assert the card's props are still populated with the prior figure
|
||||
// before the new fetch resolves — never blanked/reset to 0 mid-flight.
|
||||
let resolveLnd!: (v: unknown) => void
|
||||
rpcCallMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLnd = resolve as (v: unknown) => void }) as ReturnType<typeof defaultRpcCallImpl>)
|
||||
const pending = (wrapper.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const walletCardDuring = wrapper.findComponent(HomeWalletCard)
|
||||
expect(walletCardDuring.props('walletOnchain')).toBe(5000)
|
||||
|
||||
resolveLnd({ balance_sats: 6000, channel_balance_sats: 2500, synced_to_chain: true })
|
||||
await pending
|
||||
await settle()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await toggleTab(wrapper, true)
|
||||
await settle()
|
||||
|
||||
const readSnapshot = (key: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(`resource:${key}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
expect(readSnapshot('home.wallet-status')).toBeNull()
|
||||
// Non-sensitive groups may persist.
|
||||
expect(readSnapshot('home.system-stats')).not.toBeNull()
|
||||
expect(readSnapshot('home.update-status')).not.toBeNull()
|
||||
expect(readSnapshot('home.cloud-usage')).not.toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hydrateWalletSnapshot still paints last-known figures before any network round-trip', async () => {
|
||||
localStorage.setItem('archy-wallet-snapshot-v1', JSON.stringify({
|
||||
onchain: 42000, lightning: 1000, ecash: 0, fedimint: 0, ark: 0, connected: true, transactions: [],
|
||||
}))
|
||||
// Defer every RPC indefinitely so nothing resolves before the assertion.
|
||||
rpcCallMock.mockImplementation(() => new Promise(() => { /* never resolves */ }))
|
||||
|
||||
const wrapper = mount(Home, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
HomeSystemCard: true,
|
||||
EasyHome: true,
|
||||
WalletScanModal: true,
|
||||
SendBitcoinModal: true,
|
||||
ReceiveBitcoinModal: true,
|
||||
TransactionsModal: true,
|
||||
WalletSettingsModal: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const walletCard = wrapper.findComponent(HomeWalletCard)
|
||||
expect(walletCard.props('walletOnchain')).toBe(42000)
|
||||
expect(walletCard.props('walletLightning')).toBe(1000)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('the websocket wallet-push path (02-04) is still present and still triggers a wallet refresh', async () => {
|
||||
const { wsClient } = await import('@/api/websocket')
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
|
||||
expect(vi.mocked(wsClient.subscribe)).toHaveBeenCalled()
|
||||
const subscribeCalls = vi.mocked(wsClient.subscribe).mock.calls
|
||||
const pushHandler = subscribeCalls[0]?.[0] as (() => void) | undefined
|
||||
expect(pushHandler).toBeDefined()
|
||||
const lndCallsBefore = callCountFor('lnd.getinfo')
|
||||
|
||||
pushHandler?.()
|
||||
vi.advanceTimersByTime(800) // the debounce window
|
||||
await settle()
|
||||
|
||||
expect(callCountFor('lnd.getinfo')).toBe(lndCallsBefore + 1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders RefreshIndicator bound to the wallet resource\'s loadState', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
|
||||
const indicator = wrapper.findComponent(RefreshIndicator)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.props('state')).toBe('ready')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await toggleTab(wrapper, true)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
|
||||
|
||||
await settle()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every fetcher backing the wallet composite call passes dedup:true', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
|
||||
const walletMethods = [
|
||||
'lnd.getinfo', 'wallet.ecash-balance', 'wallet.fedimint-balance', 'wallet.ark-balance',
|
||||
'lnd.gettransactions', 'lnd.lightning-history', 'wallet.ecash-history',
|
||||
]
|
||||
const dedupFlags = rpcCallMock.mock.calls
|
||||
.filter(([opts]) => walletMethods.includes((opts as { method: string }).method))
|
||||
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
|
||||
expect(dedupFlags.length).toBeGreaterThanOrEqual(walletMethods.length)
|
||||
expect(dedupFlags.every(Boolean)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
login: vi.fn(),
|
||||
call: vi.fn(),
|
||||
isOnboardingComplete: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoginSounds', () => ({
|
||||
ensureContext: vi.fn(),
|
||||
playLoopStart: vi.fn(),
|
||||
startSynthwave: vi.fn(),
|
||||
stopSynthwave: vi.fn(),
|
||||
playPop: vi.fn(),
|
||||
playLoginSuccessWhoosh: vi.fn(),
|
||||
playTypingSound: vi.fn(),
|
||||
playDashboardLoadOomph: vi.fn(),
|
||||
getContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useOnboarding', () => ({
|
||||
isOnboardingComplete: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AnimatedLogo.vue', () => ({
|
||||
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
|
||||
}))
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), onError: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
|
||||
createWebHistory: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub fetch for server health check
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: { message: 'ping' } }),
|
||||
}))
|
||||
|
||||
import Login from '../Login.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
login: {
|
||||
title: 'Welcome Back',
|
||||
setupTitle: 'Create Password',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
loginButton: 'Login',
|
||||
setupButton: 'Create Password',
|
||||
serverStarting: 'Starting server...',
|
||||
errorMinLength: 'Password must be at least 8 characters',
|
||||
errorMismatch: 'Passwords do not match',
|
||||
errorIncorrect: 'Incorrect password',
|
||||
errorNetwork: 'Unable to reach server',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('Login View', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
pushMock.mockResolvedValue(undefined)
|
||||
// Mock health check so Login renders the form (not "Starting server...")
|
||||
mockedRpc.call.mockImplementation(async (opts: any) => {
|
||||
if (opts.method === 'server.echo') return { message: 'pong' }
|
||||
if (opts.method === 'auth.isSetup') return { isSetup: true }
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
function mountLogin() {
|
||||
return shallowMount(Login, {
|
||||
global: {
|
||||
plugins: [createPinia(), i18n],
|
||||
stubs: {
|
||||
AnimatedLogo: defineComponent({ render: () => h('div') }),
|
||||
Transition: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('renders login page', () => {
|
||||
const wrapper = mountLogin()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('contains a password input', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
const input = wrapper.find('input[type="password"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows title text', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Welcome Back')
|
||||
})
|
||||
|
||||
it('has a login button', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
|
||||
expect(loginBtn).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error for empty password submission', async () => {
|
||||
const wrapper = mountLogin()
|
||||
// Find and submit the form
|
||||
const form = wrapper.find('form')
|
||||
if (form.exists()) {
|
||||
await form.trigger('submit')
|
||||
} else {
|
||||
// Try clicking submit button
|
||||
const btn = wrapper.findAll('button').find(b =>
|
||||
b.text().includes('Login') || b.text().includes('Create')
|
||||
)
|
||||
if (btn) await btn.trigger('click')
|
||||
}
|
||||
// No assertion on specific error text — login requires password
|
||||
})
|
||||
|
||||
it('calls rpcClient.login on form submission with password', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
|
||||
// Set password
|
||||
const input = wrapper.find('input[type="password"]')
|
||||
if (input.exists()) {
|
||||
await input.setValue('testpassword123')
|
||||
}
|
||||
|
||||
// Submit
|
||||
const form = wrapper.find('form')
|
||||
if (form.exists()) {
|
||||
await form.trigger('submit')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,353 @@
|
||||
// 02-05: caches the Mesh tab's six uncached fetch groups behind
|
||||
// useCachedResource entries, and bounds the Leaflet map's lifecycle across
|
||||
// Mesh.vue's activate/deactivate cycle (established in 02-04).
|
||||
//
|
||||
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree; the only D3 force simulation belongs to
|
||||
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// file therefore only covers the six cached fetch groups (Task 1) and the
|
||||
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
||||
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Mesh from '../Mesh.vue'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: { subscribe: vi.fn(() => vi.fn()) },
|
||||
}))
|
||||
|
||||
function meshStatusPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
device_type: 'meshcore',
|
||||
device_path: null,
|
||||
device_connected: true,
|
||||
firmware_version: null,
|
||||
self_node_id: 1,
|
||||
self_advert_name: 'Self',
|
||||
peer_count: 0,
|
||||
channel_name: 'Public',
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Records every rpcClient.call({method}) invocation so tests can assert per-
|
||||
// group call counts and start-order without depending on real network I/O.
|
||||
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
|
||||
switch (method) {
|
||||
case 'mesh.status':
|
||||
return meshStatusPayload()
|
||||
case 'mesh.peers':
|
||||
return { peers: [], count: 0 }
|
||||
case 'mesh.messages':
|
||||
return { messages: [], count: 0 }
|
||||
case 'mesh.deadman-status':
|
||||
return { enabled: false }
|
||||
case 'mesh.block-headers':
|
||||
return { headers: [], latest_height: 0, count: 0 }
|
||||
case 'transport.status':
|
||||
return { transports: [], mesh_only: false, peer_count: 0 }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
|
||||
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
|
||||
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:z6Mkself' }),
|
||||
meshContactsList: vi.fn().mockResolvedValue({ contacts: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||
|
||||
function mountMeshHost() {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
return mount(Host, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
AnimatedLogo: true,
|
||||
MeshMap: true,
|
||||
MeshBitcoinPanel: true,
|
||||
MeshDeadmanPanel: true,
|
||||
MeshDevicePanel: true,
|
||||
MeshAssistantPanel: true,
|
||||
HopVizModal: true,
|
||||
MediaLightbox: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function callCountFor(method: string): number {
|
||||
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
|
||||
}
|
||||
|
||||
function convenienceCallCounts() {
|
||||
return {
|
||||
federationListNodes: vi.mocked(rpcClient.federationListNodes).mock.calls.length,
|
||||
getTorAddress: vi.mocked(rpcClient.getTorAddress).mock.calls.length,
|
||||
getNodeDid: vi.mocked(rpcClient.getNodeDid).mock.calls.length,
|
||||
meshContactsList: vi.mocked(rpcClient.meshContactsList).mock.calls.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTab(wrapper: ReturnType<typeof mountMeshHost>, show: boolean) {
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
rpcCallMock.mockClear()
|
||||
vi.mocked(rpcClient.federationListNodes).mockClear()
|
||||
vi.mocked(rpcClient.getTorAddress).mockClear()
|
||||
vi.mocked(rpcClient.getNodeDid).mockClear()
|
||||
vi.mocked(rpcClient.meshContactsList).mockClear()
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch { /* unavailable in some envs */ }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Mesh tab cache (Task 1): six fetch groups', () => {
|
||||
it('a cold load fires all six groups concurrently — every RPC has already started before any microtask resolves', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
|
||||
// Synchronously (no await, no flushPromises yet) every one of the six
|
||||
// groups' underlying calls must already have fired: armMeshLive() runs
|
||||
// Promise.allSettled(meshCachedGroups.map(refreshMeshGroupIfStale)),
|
||||
// and none of the intermediate layers (refreshMeshGroupIfStale ->
|
||||
// store.refresh -> fetcher -> mesh.refreshAll/refreshFederationNodes/etc
|
||||
// -> rpcClient.call) awaits anything before reaching the RPC call — a
|
||||
// serialized chain (awaiting one group before starting the next) could
|
||||
// not possibly have reached all six yet at this point.
|
||||
expect(callCountFor('mesh.status')).toBe(1)
|
||||
expect(callCountFor('mesh.peers')).toBe(1)
|
||||
expect(callCountFor('mesh.messages')).toBe(1)
|
||||
expect(callCountFor('mesh.deadman-status')).toBe(1)
|
||||
expect(callCountFor('mesh.block-headers')).toBe(1)
|
||||
expect(callCountFor('transport.status')).toBe(1)
|
||||
expect(convenienceCallCounts()).toEqual({
|
||||
federationListNodes: 1, getTorAddress: 1, getNodeDid: 1, meshContactsList: 1,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating inside every group\'s TTL issues zero additional RPCs across all six groups', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
const before = {
|
||||
status: callCountFor('mesh.status'),
|
||||
peers: callCountFor('mesh.peers'),
|
||||
messages: callCountFor('mesh.messages'),
|
||||
deadman: callCountFor('mesh.deadman-status'),
|
||||
headers: callCountFor('mesh.block-headers'),
|
||||
transport: callCountFor('transport.status'),
|
||||
...convenienceCallCounts(),
|
||||
}
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
expect(callCountFor('mesh.status')).toBe(before.status)
|
||||
expect(callCountFor('mesh.peers')).toBe(before.peers)
|
||||
expect(callCountFor('mesh.messages')).toBe(before.messages)
|
||||
expect(callCountFor('mesh.deadman-status')).toBe(before.deadman)
|
||||
expect(callCountFor('mesh.block-headers')).toBe(before.headers)
|
||||
expect(callCountFor('transport.status')).toBe(before.transport)
|
||||
expect(convenienceCallCounts()).toEqual({
|
||||
federationListNodes: before.federationListNodes,
|
||||
getTorAddress: before.getTorAddress,
|
||||
getNodeDid: before.getNodeDid,
|
||||
meshContactsList: before.meshContactsList,
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating past the short (10s) TTL revalidates the fast-moving groups while the near-static identity groups stay cached', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
const statusCallsAtMount = callCountFor('mesh.status')
|
||||
const transportCallsAtMount = callCountFor('transport.status')
|
||||
const onionCallsAtMount = vi.mocked(rpcClient.getTorAddress).mock.calls.length
|
||||
const didCallsAtMount = vi.mocked(rpcClient.getNodeDid).mock.calls.length
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000) // past the 10s reachability/transport TTL, well under the 300s identity TTL
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
// Peer/status/transport (reachability-class, 10s TTL) revalidate exactly once.
|
||||
expect(callCountFor('mesh.status')).toBe(statusCallsAtMount + 1)
|
||||
expect(callCountFor('transport.status')).toBe(transportCallsAtMount + 1)
|
||||
// This node's own onion/DID (300s TTL, near-static identity) are still fresh.
|
||||
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBe(onionCallsAtMount)
|
||||
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBe(didCallsAtMount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('the previous graph/peer data stays rendered while a stale group revalidates (sticky-ready, no blank frame)', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000)
|
||||
await toggleTab(wrapper, true)
|
||||
// Before the revalidation resolves, prior content must still be present
|
||||
// (sticky-ready never regresses to a blank/loading state).
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('one group rejecting leaves the other five unaffected and the fan-out still completes (deep-link/outbox callback runs)', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const meshStore = useMeshStore()
|
||||
const rejectingRefreshAll = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
// Bypass the store's own internal try/catch entirely so a genuine
|
||||
// rejection reaches useCachedResource's fetcher wrapper.
|
||||
meshStore.refreshAll = rejectingRefreshAll
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
AnimatedLogo: true, MeshMap: true, MeshBitcoinPanel: true, MeshDeadmanPanel: true,
|
||||
MeshDevicePanel: true, MeshAssistantPanel: true, HopVizModal: true, MediaLightbox: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(rejectingRefreshAll).toHaveBeenCalled()
|
||||
// The other five groups still ran to completion.
|
||||
expect(callCountFor('transport.status')).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.federationListNodes).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.meshContactsList).mock.calls.length).toBeGreaterThan(0)
|
||||
// The .then() callback after the fan-out (refreshOutboxCount -> mesh.outbox) still ran.
|
||||
expect(callCountFor('mesh.outbox')).toBeGreaterThan(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every group carrying peer or self identity data declares persist:false; only the non-identity transport status may persist', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
const readSnapshot = (key: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(`resource:${key}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
expect(readSnapshot('mesh.refresh-all')).toBeNull() // carries mesh.peers (DIDs/pubkeys)
|
||||
expect(readSnapshot('mesh.federation-nodes')).toBeNull() // DID/pubkey/onion
|
||||
expect(readSnapshot('mesh.self-onion')).toBeNull() // this node's own onion
|
||||
expect(readSnapshot('mesh.self-did')).toBeNull() // this node's own DID
|
||||
expect(readSnapshot('mesh.contacts')).toBeNull() // contact records/aliases
|
||||
expect(readSnapshot('mesh.transport-status')).not.toBeNull() // non-identity aggregate
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every fetcher backing the six cache groups is registered with dedup:true', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
// Restrict to the six cache groups' own methods — other rpcClient.call
|
||||
// traffic incidental to the fan-out (e.g. mesh.outbox, refreshed from the
|
||||
// post-fan-out .then()) isn't one of the cached groups and is out of
|
||||
// scope for this assertion.
|
||||
const cachedMethods = [
|
||||
'mesh.status', 'mesh.peers', 'mesh.messages', 'mesh.deadman-status',
|
||||
'mesh.block-headers', 'transport.status',
|
||||
]
|
||||
const dedupFlags = rpcCallMock.mock.calls
|
||||
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
|
||||
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
|
||||
expect(dedupFlags.length).toBe(cachedMethods.length)
|
||||
expect(dedupFlags.every(Boolean)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders RefreshIndicator wired to whether any of the six groups is refreshing', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
const indicator = wrapper.findComponent(RefreshIndicator)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
// Idle once everything has settled.
|
||||
expect(indicator.props('state')).toBe('ready')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000)
|
||||
await toggleTab(wrapper, true)
|
||||
// Immediately after reactivation (before the revalidation resolves) the
|
||||
// indicator must be visible — peer reachability must never present a
|
||||
// frozen state as current without a visible refresh signal (T-02-13).
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
// Per-item keyed useCachedResource conversions for secondary screens (D-04,
|
||||
// plan 02-03). Pins: instant repeat-open from cache, no new RPC inside the
|
||||
// TTL, per-item key isolation (rendered content, not just call counts),
|
||||
// TTL-lapse revalidation, and keep-last-value on a rejected background
|
||||
// refresh (D-07).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, type Pinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppDetails from '../AppDetails.vue'
|
||||
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
|
||||
import OpenWrtGateway from '../server/OpenWrtGateway.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
getPackageVersions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
let currentRouteParams: Record<string, string> = {}
|
||||
let currentRouteQuery: Record<string, string> = {}
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }),
|
||||
useRoute: () => ({ params: currentRouteParams, query: currentRouteQuery }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
let currentPackages: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
packages: currentPackages,
|
||||
startPackage: vi.fn().mockResolvedValue(undefined),
|
||||
stopPackage: vi.fn().mockResolvedValue(undefined),
|
||||
restartPackage: vi.fn().mockResolvedValue(undefined),
|
||||
updatePackage: vi.fn().mockResolvedValue(undefined),
|
||||
uninstallPackage: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
useMarketplaceApp: () => ({ getCurrentApp: () => currentMarketplaceApp }),
|
||||
}))
|
||||
|
||||
let currentMarketplaceApp: Record<string, unknown> | null = null
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePkg(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
manifest: { id: 'pkg', version: '1.0.0', title: 'Test App' },
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
installed: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// A single Pinia instance is shared across the unmount/remount pairs within
|
||||
// one test — matching production, where the resources store's in-memory
|
||||
// entries Map is a singleton that survives navigating away from and back to
|
||||
// a secondary screen (only a full page reload, or clearAll() on logout,
|
||||
// resets it). Creating a fresh Pinia per mount would wipe persist:false
|
||||
// entries (credentials) between "visits" and falsely fail the cache-hit
|
||||
// assertions below.
|
||||
function mountAppDetails(id: string, packages: Record<string, unknown>, pinia: Pinia) {
|
||||
currentRouteParams = { id }
|
||||
currentRouteQuery = {}
|
||||
currentPackages = packages
|
||||
return mount(AppDetails, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { AppHeroSection: true, AppContentSection: true, LndSeedBackup: true, AppsUninstallModal: true },
|
||||
mocks: { $ver: (v: string) => v },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppDetails.vue — per-item cached resources (bitcoin sync + credentials)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
|
||||
id: 'x', supportsVersions: false, default: null, installedVersion: null,
|
||||
pinnedVersion: null, autoUpdate: false, versions: [],
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function credentialsHandler(byId: Record<string, { label: string; value: string }>) {
|
||||
return vi.fn((req: { method: string; params?: { app_id?: string } }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
const appId = req.params?.app_id ?? ''
|
||||
const cred = byId[appId]
|
||||
return Promise.resolve(cred ? { credentials: [cred] } : { credentials: [] })
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
}
|
||||
|
||||
it('repeat mount for the same app id inside the TTL issues exactly one credentials fetch total', async () => {
|
||||
const pinia = createPinia()
|
||||
const handler = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
|
||||
vi.mocked(rpcClient.call).mockImplementation(handler as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
|
||||
expect(credCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('mounting for app id alpha then beta issues two credentials fetches and never renders alpha data for beta', async () => {
|
||||
const pinia = createPinia()
|
||||
const handler = credentialsHandler({
|
||||
'app-alpha': { label: 'Password', value: 'alpha-secret' },
|
||||
'app-beta': { label: 'Password', value: 'beta-secret' },
|
||||
})
|
||||
vi.mocked(rpcClient.call).mockImplementation(handler as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w1.text()).toContain('alpha-secret')
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountAppDetails('app-beta', { 'app-beta': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w2.text()).toContain('beta-secret')
|
||||
expect(w2.text()).not.toContain('alpha-secret')
|
||||
w2.unmount()
|
||||
|
||||
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
|
||||
expect(credCalls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
|
||||
const pinia = createPinia()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
let credentialCallCount = 0
|
||||
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
credentialCallCount++
|
||||
return Promise.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
vi.advanceTimersByTime(31_000) // past the 30s TTL
|
||||
|
||||
// A deferred second fetch lets us observe the cached value on-screen
|
||||
// before the TTL-triggered revalidate resolves.
|
||||
const stalled = deferred<{ credentials: { label: string; value: string }[] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
credentialCallCount++
|
||||
return stalled.promise
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await w2.vm.$nextTick()
|
||||
// Cached value renders before the (TTL-triggered) revalidate resolves.
|
||||
expect(w2.text()).toContain('alpha-secret')
|
||||
|
||||
stalled.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(credentialCallCount).toBe(2)
|
||||
})
|
||||
|
||||
it('a rejected background refresh keeps the previously rendered credentials on screen', async () => {
|
||||
const pinia = createPinia()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const good = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
|
||||
vi.mocked(rpcClient.call).mockImplementation(good as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w1.text()).toContain('alpha-secret')
|
||||
w1.unmount()
|
||||
|
||||
vi.advanceTimersByTime(31_000)
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') return Promise.reject(new Error('offline'))
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
// Keep-last-value (D-07): the in-memory cache still shows the previous
|
||||
// credential even though the revalidate failed.
|
||||
expect(w2.text()).toContain('alpha-secret')
|
||||
w2.unmount()
|
||||
})
|
||||
|
||||
it('bitcoin-sync and credentials fetchers for a single mount are issued concurrently, not one after another', async () => {
|
||||
const pinia = createPinia()
|
||||
const bitcoinDeferred = deferred<{ block_height: number; sync_progress: number }>()
|
||||
const credsDeferred = deferred<{ credentials: { label: string; value: string }[] }>()
|
||||
const calledMethods: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
calledMethods.push(req.method)
|
||||
if (req.method === 'bitcoin.getinfo') return bitcoinDeferred.promise
|
||||
if (req.method === 'package.credentials') return credsDeferred.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
// 'mempool-electrs' is in BITCOIN_DEPENDENT_APPS and maps to itself via
|
||||
// ROUTE_TO_PACKAGE_KEY, so this id exercises the bitcoin-sync resource.
|
||||
const w = mountAppDetails('mempool-electrs', { 'mempool-electrs': makePkg() }, pinia)
|
||||
await w.vm.$nextTick()
|
||||
|
||||
// Both fetchers must already be in flight before either resolves —
|
||||
// proof neither loader awaited the other.
|
||||
expect(calledMethods).toContain('bitcoin.getinfo')
|
||||
expect(calledMethods).toContain('package.credentials')
|
||||
|
||||
bitcoinDeferred.resolve({ block_height: 800000, sync_progress: 1 })
|
||||
credsDeferred.resolve({ credentials: [] })
|
||||
await flushPromises()
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
currentRouteQuery = {}
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountMarketplaceDetails(id: string, app: Record<string, unknown> | null) {
|
||||
currentRouteParams = { id }
|
||||
currentRouteQuery = {}
|
||||
currentMarketplaceApp = app
|
||||
return mount(MarketplaceAppDetails, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
mocks: { $ver: (v: string) => v },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function makeApp(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
title: `App ${id}`,
|
||||
description: 'desc',
|
||||
version: '1.0.0',
|
||||
dockerImage: `registry/${id}:latest`,
|
||||
screenshots: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('repeat mount for the same marketplace app id inside the TTL issues exactly one package.versions fetch', async () => {
|
||||
const calls: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
calls.push(req.method)
|
||||
if (req.method === 'package.versions') {
|
||||
return Promise.resolve({ id: 'demo-app', supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(calls.filter((m) => m === 'package.versions').length).toBe(1)
|
||||
})
|
||||
|
||||
it('mounting for marketplace app id alpha then beta each issue their own versions fetch (distinct per-item keys)', async () => {
|
||||
const requestedIds: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string; params?: { id?: string } }) => {
|
||||
if (req.method === 'package.versions') {
|
||||
requestedIds.push(req.params?.id ?? '')
|
||||
return Promise.resolve({ id: req.params?.id, supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountMarketplaceDetails('mkt-alpha', makeApp('mkt-alpha'))
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountMarketplaceDetails('mkt-beta', makeApp('mkt-beta'))
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenWrtGateway.vue — cached router status (no item id: one gateway per node)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function makeStatus(hostname: string) {
|
||||
return {
|
||||
host: '192.168.1.1',
|
||||
hostname,
|
||||
uptime_secs: 100,
|
||||
release: {},
|
||||
tollgate: { installed: false },
|
||||
wifi_interfaces: [],
|
||||
wan: { configured: false, ssid: '', assoc_ssid: '', encryption: '', ip: '', internet: false, radio0_disabled: false, sta_iface: '', sta_state: '' },
|
||||
}
|
||||
}
|
||||
|
||||
function mountGateway(pinia: Pinia) {
|
||||
return mount(OpenWrtGateway, {
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
}
|
||||
|
||||
it('repeat mount inside the TTL issues exactly one openwrt.get-status fetch total', async () => {
|
||||
const pinia = createPinia()
|
||||
let calls = 0
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'openwrt.get-status') {
|
||||
calls++
|
||||
return Promise.resolve(makeStatus('router-1'))
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountGateway(pinia)
|
||||
await flushPromises()
|
||||
expect(w1.text()).toContain('router-1')
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountGateway(pinia)
|
||||
await flushPromises()
|
||||
expect(w2.text()).toContain('router-1')
|
||||
w2.unmount()
|
||||
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
|
||||
const pinia = createPinia()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
let calls = 0
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'openwrt.get-status') {
|
||||
calls++
|
||||
return Promise.resolve(makeStatus('router-1'))
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountGateway(pinia)
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
vi.advanceTimersByTime(31_000)
|
||||
|
||||
const w2 = mountGateway(pinia)
|
||||
await w2.vm.$nextTick()
|
||||
expect(w2.text()).toContain('router-1')
|
||||
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
// 02-06 Task 1: caches the Server tab's seven load groups (network summary,
|
||||
// FIPS summary, VPN peers, interfaces, Tor services — shared by
|
||||
// checkTorStatus and loadTorServices — and disk status) behind keyed
|
||||
// useCachedResource entries, with per-group TTL/persist decisions and the
|
||||
// cold-load fan-out kept concurrent (RESEARCH A3 settled: none of the seven
|
||||
// loaders consumes another's result — see 02-06-SUMMARY.md).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Server from '../Server.vue'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
// Records every rpcClient.call({method}) invocation so tests can assert
|
||||
// per-group call counts and concurrency without depending on real network
|
||||
// I/O. vpnStatus/dnsStatus/diskStatus are separate convenience methods on
|
||||
// rpcClient (not routed through call()), so they're mocked independently.
|
||||
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
|
||||
switch (method) {
|
||||
case 'network.diagnostics':
|
||||
return { tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' }
|
||||
case 'router.list-forwards':
|
||||
return { forwards: [] }
|
||||
case 'network.list-interfaces':
|
||||
return { interfaces: [] }
|
||||
case 'tor.list-services':
|
||||
return { services: [], tor_running: false }
|
||||
case 'vpn.list-peers':
|
||||
return { peers: [] }
|
||||
case 'fips.status':
|
||||
return { installed: false, service_active: false, key_present: false }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const vpnStatusMock = vi.fn(async () => ({
|
||||
connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24',
|
||||
peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: true, configured_provider: 'wireguard',
|
||||
}))
|
||||
const dnsStatusMock = vi.fn(async () => ({
|
||||
provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [],
|
||||
}))
|
||||
const diskStatusMock = vi.fn(async () => ({
|
||||
used_bytes: 0, total_bytes: 0, free_bytes: 0, used_percent: 0, level: 'ok' as const,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
dnsStatus: (...args: unknown[]) => (dnsStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
diskStatus: (...args: unknown[]) => (diskStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||
|
||||
function mountServerHost() {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Server, { key: 'server' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
return mount(Host, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
QuickActionsCard: true,
|
||||
TorServicesCard: true,
|
||||
ServerModals: true,
|
||||
FipsNetworkCard: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function callCountFor(method: string): number {
|
||||
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
|
||||
}
|
||||
|
||||
async function toggleTab(wrapper: ReturnType<typeof mountServerHost>, show: boolean) {
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
rpcCallMock.mockClear()
|
||||
vpnStatusMock.mockClear()
|
||||
dnsStatusMock.mockClear()
|
||||
diskStatusMock.mockClear()
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch { /* unavailable in some envs */ }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Server tab cache (Task 1): seven load groups', () => {
|
||||
it('a cold load fires all seven groups concurrently — every RPC has already started before any microtask resolves', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
|
||||
// Synchronous check (no await yet): armServerEntryEffects/onMounted call
|
||||
// all seven loaders without awaiting one before starting the next — a
|
||||
// serialized chain could not have reached all seven RPCs yet here.
|
||||
expect(callCountFor('network.diagnostics')).toBe(1)
|
||||
expect(callCountFor('router.list-forwards')).toBe(1)
|
||||
// vpnStatus fires twice on a fresh mount: once for network-summary's own
|
||||
// fetch, once for the VPN poll's immediate first tick (armVpnPoll, a
|
||||
// deliberate every-activation effect independent of this TTL cache).
|
||||
expect(vpnStatusMock).toHaveBeenCalledTimes(2)
|
||||
expect(dnsStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(callCountFor('network.list-interfaces')).toBe(1)
|
||||
expect(callCountFor('tor.list-services')).toBe(1)
|
||||
expect(callCountFor('vpn.list-peers')).toBe(1)
|
||||
expect(callCountFor('fips.status')).toBe(1)
|
||||
expect(diskStatusMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating inside every group\'s TTL issues zero additional RPCs across all seven groups', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
await flushPromises()
|
||||
const before = {
|
||||
diag: callCountFor('network.diagnostics'),
|
||||
fwd: callCountFor('router.list-forwards'),
|
||||
iface: callCountFor('network.list-interfaces'),
|
||||
tor: callCountFor('tor.list-services'),
|
||||
vpnPeers: callCountFor('vpn.list-peers'),
|
||||
fips: callCountFor('fips.status'),
|
||||
vpnStatus: vpnStatusMock.mock.calls.length,
|
||||
dns: dnsStatusMock.mock.calls.length,
|
||||
disk: diskStatusMock.mock.calls.length,
|
||||
}
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
expect(callCountFor('network.diagnostics')).toBe(before.diag)
|
||||
expect(callCountFor('router.list-forwards')).toBe(before.fwd)
|
||||
expect(callCountFor('network.list-interfaces')).toBe(before.iface)
|
||||
expect(callCountFor('tor.list-services')).toBe(before.tor)
|
||||
expect(callCountFor('vpn.list-peers')).toBe(before.vpnPeers)
|
||||
expect(callCountFor('fips.status')).toBe(before.fips)
|
||||
// dnsStatus is purely network-summary's own TTL-gated call — untouched.
|
||||
expect(dnsStatusMock.mock.calls.length).toBe(before.dns)
|
||||
// vpnStatus is the one exception: armVpnPoll's immediate first tick on
|
||||
// reactivation fires regardless of network-summary's own TTL (a
|
||||
// deliberate every-activation VPN-IP freshness effect from 02-04, not
|
||||
// something this task's TTL/persist conversion changes).
|
||||
expect(vpnStatusMock.mock.calls.length).toBe(before.vpnStatus + 1)
|
||||
expect(diskStatusMock.mock.calls.length).toBe(before.disk)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating past the short (10s) TTL revalidates the fast groups while the longer-TTL FIPS/Tor/VPN-peer groups stay cached', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
await flushPromises()
|
||||
const diagAtMount = callCountFor('network.diagnostics')
|
||||
const ifaceAtMount = callCountFor('network.list-interfaces')
|
||||
const diskAtMount = diskStatusMock.mock.calls.length
|
||||
const fipsAtMount = callCountFor('fips.status')
|
||||
const torAtMount = callCountFor('tor.list-services')
|
||||
const vpnPeersAtMount = callCountFor('vpn.list-peers')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
// Past the 10s fast tier, well under the 30s Tor/VPN-peer tier and the
|
||||
// 60s FIPS tier.
|
||||
vi.advanceTimersByTime(12000)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
expect(callCountFor('network.diagnostics')).toBe(diagAtMount + 1)
|
||||
expect(callCountFor('network.list-interfaces')).toBe(ifaceAtMount + 1)
|
||||
expect(diskStatusMock.mock.calls.length).toBe(diskAtMount + 1)
|
||||
// FIPS (60s), Tor services (30s) and VPN peers (30s) are still fresh.
|
||||
expect(callCountFor('fips.status')).toBe(fipsAtMount)
|
||||
expect(callCountFor('tor.list-services')).toBe(torAtMount)
|
||||
expect(callCountFor('vpn.list-peers')).toBe(vpnPeersAtMount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('a rejected refresh in one group leaves the other six unaffected and keeps that group\'s last known data rendered', async () => {
|
||||
// Mounted directly (no KeepAlive host) so the exposed loadNetworkData()
|
||||
// method is reachable, matching ServerNetworkRefresh.test.ts's convention.
|
||||
const wrapper = mount(Server, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: { QuickActionsCard: true, TorServicesCard: true, ServerModals: true, FipsNetworkCard: true },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
|
||||
rpcCallMock.mockImplementationOnce(async ({ method }: { method: string }) => {
|
||||
if (method === 'network.diagnostics') throw new Error('offline')
|
||||
return {}
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
||||
await flushPromises()
|
||||
|
||||
// Prior network data stays rendered (keep-last-value on error, D-07).
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
// Other groups' own data is untouched by the rejection.
|
||||
expect(diskStatusMock).toHaveBeenCalled()
|
||||
expect(callCountFor('fips.status')).toBeGreaterThan(0)
|
||||
expect(callCountFor('vpn.list-peers')).toBeGreaterThan(0)
|
||||
expect(callCountFor('network.list-interfaces')).toBeGreaterThan(0)
|
||||
expect(callCountFor('tor.list-services')).toBeGreaterThan(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('groups carrying VPN peer identity, Tor onion addresses, or this node\'s own FIPS identity key declare persist:false; non-identity groups may persist', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
await flushPromises()
|
||||
|
||||
const readSnapshot = (key: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(`resource:${key}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity)
|
||||
expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address
|
||||
expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status
|
||||
// CR-01 follow-up correction: `server.fips-summary` is shared with
|
||||
// FipsNetworkCard.vue, whose fuller FipsStatus type shows the real
|
||||
// `fips.status` response also carries `npub` — this node's own FIPS
|
||||
// identity public key — so this key must be persist:false too (T-02-01),
|
||||
// not persist:true as originally assumed when only the narrower
|
||||
// installed/service_active/key_present fields were considered.
|
||||
expect(readSnapshot('server.fips-summary')).toBeNull()
|
||||
expect(readSnapshot('server.interfaces')).not.toBeNull()
|
||||
expect(readSnapshot('server.disk-status')).not.toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every fetcher backing the seven load groups is registered with dedup:true', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
await flushPromises()
|
||||
|
||||
const cachedMethods = [
|
||||
'network.diagnostics', 'router.list-forwards', 'network.list-interfaces',
|
||||
'tor.list-services', 'vpn.list-peers', 'fips.status',
|
||||
]
|
||||
const dedupFlags = rpcCallMock.mock.calls
|
||||
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
|
||||
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
|
||||
expect(dedupFlags.length).toBe(cachedMethods.length)
|
||||
expect(dedupFlags.every(Boolean)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders RefreshIndicator wired to whether any of the seven groups is refreshing', async () => {
|
||||
const wrapper = mountServerHost()
|
||||
await flushPromises()
|
||||
|
||||
const indicator = wrapper.findComponent(RefreshIndicator)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.props('state')).toBe('ready')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(12000)
|
||||
await toggleTab(wrapper, true)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Settings from '../Settings.vue'
|
||||
|
||||
describe('Settings View', () => {
|
||||
it('renders AccountSection and SystemSection', () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = shallowMount(Settings)
|
||||
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
|
||||
<p class="text-white/70 mt-2 leading-relaxed">
|
||||
Some features may be unavailable until Bitcoin finishes syncing.
|
||||
Wallet connections and block data require a fully synced node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-orange-400 transition-all duration-500"
|
||||
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm text-white/55 mt-2">
|
||||
{{ bitcoinSyncPercent.toFixed(1) }}% synced — Block {{ bitcoinBlockHeight.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features (if available) -->
|
||||
<div v-if="features.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppScreenshot } from '@/types/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
features: string[]
|
||||
needsBitcoinSync: boolean
|
||||
bitcoinSynced: boolean
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
}>()
|
||||
|
||||
const screenshots = computed(() => {
|
||||
const manifestScreenshots = props.pkg.manifest?.screenshots
|
||||
const staticScreenshots = props.pkg['static-files']?.screenshots
|
||||
return normalizeScreenshots(Array.isArray(staticScreenshots) ? staticScreenshots : manifestScreenshots)
|
||||
})
|
||||
|
||||
function normalizeScreenshots(items: AppScreenshot[] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start md:items-center gap-4 md:gap-6">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="app-detail-icon archy-app-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-xs md:text-sm mb-2 line-clamp-2 md:line-clamp-none">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 md:px-2.5 md:py-1 rounded-lg text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ $ver(pkg.manifest.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
Updating...
|
||||
</span>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`top-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
|
||||
<template v-if="pkg.state === 'updating'">
|
||||
<span
|
||||
class="col-span-2 mobile-card-action bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
Updating...
|
||||
</span>
|
||||
</template>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`bottom-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="[
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
action.class,
|
||||
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||
]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: PackageDataEntry
|
||||
appId: string
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
|
||||
|
||||
const emit = defineEmits<{
|
||||
launch: []
|
||||
start: []
|
||||
stop: []
|
||||
restart: []
|
||||
uninstall: []
|
||||
update: []
|
||||
channels: []
|
||||
}>()
|
||||
|
||||
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
|
||||
|
||||
const actionItems = computed(() => {
|
||||
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
|
||||
|
||||
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
|
||||
actions.push({
|
||||
key: 'update',
|
||||
emit: 'update',
|
||||
label: props.pendingAction === 'update' ? 'Updating...' : `Update to ${displayVersion(props.pkg['available-update'])}`,
|
||||
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||
full: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.packageKey === 'lnd') {
|
||||
actions.push({
|
||||
key: 'channels',
|
||||
emit: 'channels',
|
||||
label: t('appDetails.channels'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.canLaunch) {
|
||||
actions.push({
|
||||
key: 'launch',
|
||||
emit: 'launch',
|
||||
label: t('common.launch'),
|
||||
class: 'glass-button font-semibold',
|
||||
})
|
||||
}
|
||||
|
||||
if (!props.isWebOnly) {
|
||||
if (props.pkg.state === 'stopped' || props.pkg.state === 'exited') {
|
||||
actions.push({
|
||||
key: 'start',
|
||||
emit: 'start',
|
||||
label: props.pendingAction === 'start' ? 'Starting...' : props.pkg.state === 'exited' ? 'Restart' : t('common.start'),
|
||||
class: props.pkg.state === 'exited' ? 'glass-button glass-button-danger' : 'glass-button glass-button-success',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.pkg.state === 'running') {
|
||||
actions.push({
|
||||
key: 'stop',
|
||||
emit: 'stop',
|
||||
label: props.pendingAction === 'stop' ? 'Stopping...' : t('common.stop'),
|
||||
class: 'glass-button text-yellow-200 border-yellow-500/30 hover:bg-yellow-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'restart',
|
||||
emit: 'restart',
|
||||
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
|
||||
actions.push({
|
||||
key: 'uninstall',
|
||||
emit: 'uninstall',
|
||||
label: props.pendingAction === 'uninstall' ? 'Uninstalling...' : t('common.uninstall'),
|
||||
class: 'glass-button text-red-300 border-red-500/30 hover:bg-red-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
function emitAction(action: ActionEmit) {
|
||||
switch (action) {
|
||||
case 'launch':
|
||||
emit('launch')
|
||||
break
|
||||
case 'start':
|
||||
emit('start')
|
||||
break
|
||||
case 'stop':
|
||||
emit('stop')
|
||||
break
|
||||
case 'restart':
|
||||
emit('restart')
|
||||
break
|
||||
case 'uninstall':
|
||||
emit('uninstall')
|
||||
break
|
||||
case 'update':
|
||||
emit('update')
|
||||
break
|
||||
case 'channels':
|
||||
emit('channels')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
if (!target.src.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-detail-actions-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-detail-action-btn {
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.app-detail-actions-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<div class="text-right">
|
||||
<span class="text-white font-medium">{{ $ver(pkg.manifest.version) }}</span>
|
||||
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
|
||||
{{ $ver(pkg['available-update']) }} available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium">App</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version & Updates Card (multi-version apps: Bitcoin Core / Knots).
|
||||
Lets a runner switch versions, pin, and opt into auto-update. See
|
||||
docs/bitcoin-multi-version-design.md §3 Phase 3. -->
|
||||
<div v-if="versionInfo && versionInfo.supportsVersions && versionInfo.versions.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.versionUpdates') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-white/60 text-sm">{{ t('appDetails.runningVersion') }}</span>
|
||||
<span class="text-white font-medium">{{ versionInfo.installedVersion || $ver(pkg.manifest.version) }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">{{ t('appDetails.selectVersion') }}</label>
|
||||
<select
|
||||
v-model="selectedVersion"
|
||||
:disabled="versionBusy"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-orange-400/60"
|
||||
>
|
||||
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span class="text-white/80 text-sm">{{ t('appDetails.autoUpdateLatest') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoUpdate"
|
||||
:disabled="versionBusy || isPinned"
|
||||
class="h-4 w-4 accent-orange-500"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="isPinned" class="text-white/40 text-xs -mt-2">{{ t('appDetails.autoUpdatePinnedNote') }}</p>
|
||||
|
||||
<!-- Downgrade confirmation -->
|
||||
<div v-if="downgradeWarning" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
|
||||
<p class="text-orange-200 text-xs leading-relaxed">⚠️ {{ downgradeWarning }}</p>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-orange-500/80 hover:bg-orange-500 text-white" :disabled="versionBusy" @click="applyVersionConfig(true)">
|
||||
{{ t('appDetails.confirmDowngrade') }}
|
||||
</button>
|
||||
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-white/10 hover:bg-white/20 text-white" :disabled="versionBusy" @click="cancelDowngrade">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="w-full glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-medium py-2"
|
||||
:disabled="versionBusy || !versionDirty"
|
||||
@click="applyVersionConfig(false)"
|
||||
>
|
||||
{{ versionBusy ? t('appDetails.applyingVersion') : t('appDetails.applyVersion') }}
|
||||
</button>
|
||||
<p v-if="versionError" class="text-red-300 text-xs">{{ versionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fedimint Services Card -->
|
||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 py-2 border-b border-white/10">
|
||||
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-orange-300 hover:text-orange-200 text-sm break-all"
|
||||
>
|
||||
{{ interfaceAddresses['lan-address'] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showTorAddress" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div v-if="setupInstructions" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-3">{{ t('appDetails.setupInstructions') }}</h3>
|
||||
<p class="text-sm text-white/70 leading-relaxed whitespace-pre-line">
|
||||
{{ setupInstructions }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="credentialsLoading || credentials?.credentials?.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
||||
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div v-if="credentialsLoading" class="space-y-3" aria-live="polite">
|
||||
<div class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="h-3 w-28 rounded bg-white/10 mb-3"></div>
|
||||
<div class="h-4 w-full rounded bg-white/10"></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">Loading credentials...</p>
|
||||
</div>
|
||||
<div v-else-if="credentials" class="space-y-3">
|
||||
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyCredential(cred.label, cred.value)">
|
||||
{{ copiedCredential === cred.label ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card (hidden for web-only apps) -->
|
||||
<div v-if="!isWebOnly" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-orange-300 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card -->
|
||||
<div v-if="links.length > 0" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.kind"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-orange-300 hover:text-orange-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
v-if="link.kind === 'website'"
|
||||
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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.kind === 'source'"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
const copiedCredential = ref('')
|
||||
|
||||
async function copyCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
copiedCredential.value = label
|
||||
setTimeout(() => {
|
||||
if (copiedCredential.value === label) copiedCredential.value = ''
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
packageKey: string
|
||||
isWebOnly: boolean
|
||||
gatewayState: string
|
||||
interfaceAddresses: { 'tor-address': string; 'lan-address': string | null } | null
|
||||
lanUrl: string
|
||||
torUrl: string
|
||||
showTorAddress: boolean
|
||||
credentials: AppCredentialsResponse | null
|
||||
credentialsLoading: boolean
|
||||
}>()
|
||||
|
||||
type LinkKind = 'website' | 'source' | 'documentation'
|
||||
|
||||
interface SidebarLink {
|
||||
kind: LinkKind
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: unknown): string {
|
||||
const url = typeof value === 'string' ? value.trim() : ''
|
||||
if (!url || url === '#') return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return ''
|
||||
}
|
||||
|
||||
const links = computed<SidebarLink[]>(() => {
|
||||
const manifest = props.pkg.manifest || {}
|
||||
const website = normalizeHttpUrl(manifest.website || manifest['marketing-site'])
|
||||
const source = normalizeHttpUrl(manifest['upstream-repo'] || manifest['wrapper-repo'])
|
||||
const documentation = normalizeHttpUrl(manifest['support-site'])
|
||||
|
||||
return [
|
||||
website ? { kind: 'website' as const, label: t('appDetails.website'), url: website } : null,
|
||||
source ? { kind: 'source' as const, label: t('appDetails.sourceCode'), url: source } : null,
|
||||
documentation ? { kind: 'documentation' as const, label: t('appDetails.documentation'), url: documentation } : null,
|
||||
].filter((link): link is SidebarLink => link !== null)
|
||||
})
|
||||
|
||||
const setupInstructions = computed(() => {
|
||||
const raw = props.pkg['static-files']?.instructions
|
||||
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
||||
return instructions ? instructions : ''
|
||||
})
|
||||
|
||||
// ---- Version & Updates (multi-version support) -----------------------------
|
||||
const versionInfo = ref<PackageVersionsResponse | null>(null)
|
||||
const selectedVersion = ref('')
|
||||
const autoUpdate = ref(false)
|
||||
const versionBusy = ref(false)
|
||||
const versionError = ref('')
|
||||
const downgradeWarning = ref('')
|
||||
|
||||
const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
|
||||
// "Apply" is enabled when the runner changed the version or the toggle.
|
||||
const versionDirty = computed(() => {
|
||||
const info = versionInfo.value
|
||||
if (!info) return false
|
||||
return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate
|
||||
})
|
||||
|
||||
// Option label: the floating "latest" entry reads as a sentence (no "v"
|
||||
// prefix); every concrete version is normalized via $ver + status suffixes.
|
||||
function versionOptionLabel(v: CatalogVersionInfo): string {
|
||||
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
|
||||
let label = displayVersion(v.version)
|
||||
if (v.deprecated) label += ' (deprecated)'
|
||||
if (v.eol) label += ` · EOL ${v.eol}`
|
||||
return label
|
||||
}
|
||||
|
||||
// Resolve the dropdown's current value and GUARANTEE it matches a real option,
|
||||
// otherwise the native <select> renders blank. Prefer the pin, then the catalog
|
||||
// default, then the running version — but fall back to the default/first option
|
||||
// when none of those is actually in the list (e.g. a stale installedVersion the
|
||||
// catalog no longer carries, which is what left the control blank).
|
||||
function pickSelection(info: PackageVersionsResponse): string {
|
||||
const options = info.versions.map((v) => v.version)
|
||||
const preferred = info.pinnedVersion || info.default || info.installedVersion || ''
|
||||
if (preferred && options.includes(preferred)) return preferred
|
||||
return info.default && options.includes(info.default)
|
||||
? info.default
|
||||
: info.versions[0]?.version || ''
|
||||
}
|
||||
|
||||
async function loadVersions(appId: string) {
|
||||
versionInfo.value = null
|
||||
versionError.value = ''
|
||||
downgradeWarning.value = ''
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(appId)
|
||||
if (!info.supportsVersions || !info.versions.length) return
|
||||
versionInfo.value = info
|
||||
selectedVersion.value = pickSelection(info)
|
||||
autoUpdate.value = info.autoUpdate
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyVersionConfig(confirm: boolean) {
|
||||
if (!versionInfo.value) return
|
||||
versionBusy.value = true
|
||||
versionError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.setPackageConfig(versionInfo.value.id, {
|
||||
version: selectedVersion.value,
|
||||
autoUpdate: autoUpdate.value,
|
||||
confirm,
|
||||
})
|
||||
if (res.status === 'confirm_required') {
|
||||
downgradeWarning.value = res.warning || t('appDetails.downgradeGeneric')
|
||||
return
|
||||
}
|
||||
downgradeWarning.value = ''
|
||||
// Refresh so the card reflects the new pin / running version.
|
||||
await loadVersions(versionInfo.value.id)
|
||||
} catch (err: unknown) {
|
||||
versionError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
versionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDowngrade() {
|
||||
downgradeWarning.value = ''
|
||||
// Reset the dropdown to the current selection.
|
||||
const info = versionInfo.value
|
||||
if (info) selectedVersion.value = pickSelection(info)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.packageKey,
|
||||
(key) => {
|
||||
if (key) void loadVersions(key)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||
// The backend captures the aezeed at wallet-init time; until the user
|
||||
// confirms writing it down (`acknowledged`), the card renders as a
|
||||
// prominent first-launch prompt.
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const available = ref(false)
|
||||
const acknowledged = ref(true)
|
||||
const statusLoaded = ref(false)
|
||||
|
||||
async function loadStatus(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
|
||||
method: 'lnd.seed-backup-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
available.value = !!res.available
|
||||
acknowledged.value = !!res.acknowledged
|
||||
statusLoaded.value = true
|
||||
return true
|
||||
} catch {
|
||||
// Leave statusLoaded as-is; a one-off RPC blip must not permanently
|
||||
// hide the card (the global banner may have just promised it's here).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Retry a few times — on fresh installs the backend can still be warming
|
||||
// up when the user lands here straight from the backup banner.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
if (await loadStatus()) break
|
||||
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
|
||||
}
|
||||
// Deep link from the "Back up your Lightning seed" banner: open the
|
||||
// reveal flow directly so the click visibly does something.
|
||||
if (route.query['seed-backup'] === '1') {
|
||||
router.replace({ query: { ...route.query, 'seed-backup': undefined } }).catch(() => {})
|
||||
if (statusLoaded.value && available.value) openReveal()
|
||||
}
|
||||
})
|
||||
|
||||
// Reveal modal — re-auth gated (password + 2FA when enabled), same UX as
|
||||
// the recovery-phrase reveal in Settings → Backup.
|
||||
const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
const revealCode = ref('')
|
||||
const revealing = ref(false)
|
||||
const revealError = ref('')
|
||||
const revealedWords = ref<string[]>([])
|
||||
const wordsHidden = ref(true)
|
||||
const wordsCopied = ref(false)
|
||||
const acking = ref(false)
|
||||
|
||||
function openReveal() {
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealError.value = ''
|
||||
revealedWords.value = []
|
||||
wordsHidden.value = true
|
||||
showRevealModal.value = true
|
||||
}
|
||||
|
||||
async function submitReveal() {
|
||||
if (revealing.value || !revealPassword.value) return
|
||||
revealing.value = true
|
||||
revealError.value = ''
|
||||
try {
|
||||
const params: Record<string, string> = { password: revealPassword.value }
|
||||
if (revealCode.value) params.code = revealCode.value
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'lnd.seed-reveal', params })
|
||||
revealedWords.value = res.words || []
|
||||
wordsHidden.value = true
|
||||
} catch (e: unknown) {
|
||||
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the Lightning seed'
|
||||
} finally {
|
||||
revealing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedWords.value.join(' '))
|
||||
wordsCopied.value = true
|
||||
setTimeout(() => { wordsCopied.value = false }, 2000)
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
|
||||
async function confirmBackedUp() {
|
||||
if (acking.value) return
|
||||
acking.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'lnd.seed-backup-ack' })
|
||||
acknowledged.value = true
|
||||
closeReveal()
|
||||
} catch { /* keep the modal open; user can retry */ } finally {
|
||||
acking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="statusLoaded && available" class="glass-card px-6 py-6 mb-6" :class="!acknowledged ? 'border border-orange-400/40' : ''">
|
||||
<div v-if="!acknowledged" class="flex items-center gap-2 mb-3 text-orange-300 text-sm font-medium" role="alert">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
|
||||
</svg>
|
||||
Your Lightning seed hasn't been backed up yet
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning wallet seed</h2>
|
||||
<p class="text-sm text-white/60">
|
||||
View the 24-word recovery seed for this node's Lightning wallet. You'll need to
|
||||
confirm your password (and 2FA code, if enabled). Write it down and store it
|
||||
offline — anyone with these words controls your Lightning funds.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="!acknowledged ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||
@click="openReveal"
|
||||
>{{ acknowledged ? 'Reveal' : 'Back up now' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-lnd-seed-title">
|
||||
<h3 id="reveal-lnd-seed-title" class="text-lg font-semibold text-white mb-1">Reveal Lightning seed</h3>
|
||||
|
||||
<template v-if="revealedWords.length === 0">
|
||||
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word Lightning seed.</p>
|
||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Password</label>
|
||||
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
|
||||
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
|
||||
</div>
|
||||
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
{{ revealing ? 'Verifying…' : 'Reveal' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SeedRevealPanel :words="revealedWords" aezeed />
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
{{ acking ? 'Saving…' : "I've backed it up" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppContentSection from '../AppContentSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
about: 'About {name}',
|
||||
features: 'Features',
|
||||
screenshots: 'Screenshots',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app details',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountContent(pkg: PackageDataEntry) {
|
||||
return mount(AppContentSection, {
|
||||
props: {
|
||||
pkg,
|
||||
features: [],
|
||||
needsBitcoinSync: false,
|
||||
bitcoinSynced: true,
|
||||
bitcoinSyncPercent: 100,
|
||||
bitcoinBlockHeight: 100,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppContentSection', () => {
|
||||
it('does not show screenshot placeholders when no screenshots exist', () => {
|
||||
const wrapper = mountContent(packageData())
|
||||
|
||||
expect(wrapper.text()).not.toContain('Screenshots')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders real screenshot metadata when provided', () => {
|
||||
const wrapper = mountContent(packageData({
|
||||
manifest: {
|
||||
...packageData().manifest,
|
||||
screenshots: [
|
||||
'/assets/screenshots/example-dashboard.png',
|
||||
{ src: '/assets/screenshots/example-settings.png', alt: 'Settings screen' },
|
||||
' ',
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
|
||||
expect(wrapper.text()).toContain('Screenshots')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0]?.attributes('src')).toBe('/assets/screenshots/example-dashboard.png')
|
||||
expect(images[0]?.attributes('alt')).toBe('Example screenshot 1')
|
||||
expect(images[1]?.attributes('src')).toBe('/assets/screenshots/example-settings.png')
|
||||
expect(images[1]?.attributes('alt')).toBe('Settings screen')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppHeroSection from '../AppHeroSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
common: {
|
||||
launch: 'Launch',
|
||||
restart: 'Restart',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
uninstall: 'Uninstall',
|
||||
},
|
||||
appDetails: {
|
||||
channels: 'Channels',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountHero(props: Partial<InstanceType<typeof AppHeroSection>['$props']> = {}) {
|
||||
return mount(AppHeroSection, {
|
||||
props: {
|
||||
pkg: packageData(),
|
||||
appId: 'example',
|
||||
packageKey: 'example',
|
||||
canLaunch: true,
|
||||
isWebOnly: false,
|
||||
pendingAction: null,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppHeroSection', () => {
|
||||
it('disables app controls while a container action is running', () => {
|
||||
const wrapper = mountHero({ pendingAction: 'restart' })
|
||||
|
||||
expect(wrapper.text()).toContain('Restarting...')
|
||||
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('labels update progress and disables update controls', () => {
|
||||
const wrapper = mountHero({
|
||||
pendingAction: 'update',
|
||||
pkg: packageData({ 'available-update': '1.1.0' }),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Updating...')
|
||||
const updateButtons = wrapper.findAll('button').filter(button => button.text().includes('Updating...'))
|
||||
expect(updateButtons.length).toBeGreaterThan(0)
|
||||
expect(updateButtons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppSidebar from '../AppSidebar.vue'
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
function mountSidebar(manifestOverrides: Record<string, unknown>, propOverrides: Record<string, unknown> = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
access: 'Access',
|
||||
documentation: 'Documentation',
|
||||
gateway: 'Gateway',
|
||||
information: 'Information',
|
||||
lan: 'LAN',
|
||||
links: 'Links',
|
||||
ram: 'RAM',
|
||||
ramDesc: 'Memory required',
|
||||
requirements: 'Requirements',
|
||||
setupInstructions: 'Setup Instructions',
|
||||
requiresTor: 'Requires Tor',
|
||||
services: 'Services',
|
||||
sourceCode: 'Source Code',
|
||||
storage: 'Storage',
|
||||
storageDesc: 'Storage required',
|
||||
tor: 'Tor',
|
||||
website: 'Website',
|
||||
},
|
||||
common: {
|
||||
category: 'Category',
|
||||
developer: 'Developer',
|
||||
license: 'License',
|
||||
status: 'Status',
|
||||
version: 'Version',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(AppSidebar, {
|
||||
props: {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
...manifestOverrides,
|
||||
},
|
||||
'static-files': { license: '', instructions: 'Follow step 1.\nThen step 2.', icon: '' },
|
||||
},
|
||||
packageKey: 'example',
|
||||
isWebOnly: false,
|
||||
gatewayState: 'stopped',
|
||||
interfaceAddresses: null,
|
||||
lanUrl: '',
|
||||
torUrl: '',
|
||||
showTorAddress: false,
|
||||
credentials: null,
|
||||
credentialsLoading: false,
|
||||
...propOverrides,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
it('renders manifest links with real URLs', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: 'https://example.com',
|
||||
'upstream-repo': 'https://github.com/example/app',
|
||||
'support-site': 'https://docs.example.com',
|
||||
})
|
||||
|
||||
const hrefs = wrapper.findAll('a').map(anchor => anchor.attributes('href'))
|
||||
|
||||
expect(hrefs).toEqual([
|
||||
'https://example.com',
|
||||
'https://github.com/example/app',
|
||||
'https://docs.example.com',
|
||||
])
|
||||
expect(wrapper.text()).toContain('Website')
|
||||
expect(wrapper.text()).toContain('Source Code')
|
||||
expect(wrapper.text()).toContain('Documentation')
|
||||
})
|
||||
|
||||
it('does not render dead placeholder links', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: '#',
|
||||
'upstream-repo': '',
|
||||
'support-site': undefined,
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('a')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('Links')
|
||||
})
|
||||
|
||||
it('shows a credentials loading state before credentials arrive', () => {
|
||||
const wrapper = mountSidebar({}, { credentialsLoading: true })
|
||||
|
||||
expect(wrapper.text()).toContain('Credentials')
|
||||
expect(wrapper.text()).toContain('Loading credentials...')
|
||||
})
|
||||
|
||||
it('renders setup instructions when provided', () => {
|
||||
const wrapper = mountSidebar({})
|
||||
|
||||
expect(wrapper.text()).toContain('Setup Instructions')
|
||||
expect(wrapper.text()).toContain('Follow step 1.')
|
||||
expect(wrapper.text()).toContain('Then step 2.')
|
||||
})
|
||||
|
||||
it('hides setup instructions when empty', () => {
|
||||
const wrapper = mountSidebar({}, {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
},
|
||||
'static-files': { license: '', instructions: ' ', icon: '' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Setup Instructions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AppDetails data: URL maps, route-to-package mappings, aliases, and status helpers.
|
||||
* Extracted from AppDetails.vue to keep the view under 500 lines.
|
||||
*/
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
/** Web-only app detection (no container -- external websites) */
|
||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
'btcpay-server': 'btcpay-server',
|
||||
fedimint: 'fedimint',
|
||||
'fedimint-gateway': 'fedimint-gateway',
|
||||
lnd: 'lnd',
|
||||
'lnd-ui': 'lnd',
|
||||
bitcoin: 'bitcoin-knots',
|
||||
'bitcoin-knots': 'bitcoin-knots',
|
||||
homeassistant: 'homeassistant',
|
||||
'home-assistant': 'homeassistant',
|
||||
grafana: 'grafana',
|
||||
searxng: 'searxng',
|
||||
ollama: 'ollama',
|
||||
nextcloud: 'nextcloud',
|
||||
vaultwarden: 'vaultwarden',
|
||||
jellyfin: 'jellyfin',
|
||||
photoprism: 'photoprism',
|
||||
immich: 'immich',
|
||||
filebrowser: 'filebrowser',
|
||||
'nginx-proxy-manager': 'nginx-proxy-manager',
|
||||
'gitea': 'gitea',
|
||||
portainer: 'portainer',
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
netbird: 'netbird',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
export const PACKAGE_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
}
|
||||
|
||||
export function resolvePackageKey(routeId: string): string {
|
||||
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
|
||||
}
|
||||
|
||||
/** Apps that depend on Bitcoin being synced */
|
||||
export const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver']
|
||||
|
||||
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
|
||||
export function isRealOnionAddress(addr: string | undefined): boolean {
|
||||
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200 border border-green-500/30'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
case PackageState.Exited:
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-500/20 text-red-200 border border-red-500/30'
|
||||
: 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusDotClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-400'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-400'
|
||||
case PackageState.Exited:
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-400 animate-pulse'
|
||||
: 'bg-gray-400'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-400 animate-pulse'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-400 animate-pulse'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-400 animate-pulse'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Updating) return 'updating...'
|
||||
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||
if (state === PackageState.Exited) {
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
|
||||
<Transition name="content-fade">
|
||||
<!-- Suppressed while the ElectrumX sync overlay below is showing — both
|
||||
conditions can be true at once during launch (generic loader fires
|
||||
first, then sync status arrives), and the sync screen is strictly
|
||||
more informative, so it takes precedence instead of the two
|
||||
rendering on top of each other. -->
|
||||
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
|
||||
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
|
||||
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
ElectrumX is building its index from the blockchain. The UI opens
|
||||
automatically once it's ready — you can keep using the rest of Archipelago.
|
||||
</p>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400/80 transition-all duration-700"
|
||||
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
|
||||
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
|
||||
</p>
|
||||
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@focusin="focusIframe"
|
||||
>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
tabindex="0"
|
||||
@load="handleIframeLoad"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
paint over the sync progress and read as a hard error. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
||||
the padlock reads as "blocked/denied" and this state is neither. -->
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden" :class="{ 'animate-pulse': warmingUp }">
|
||||
<img v-if="warmingUp" :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
<svg v-else class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-if="!mustOpenNewTab"
|
||||
@click="$emit('refresh')"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Retry now
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('openNewTabAndBack')"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in new tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
|
||||
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
appId: string
|
||||
appTitle: string
|
||||
appIcon: string
|
||||
loading: boolean
|
||||
iframeBlocked: boolean
|
||||
mustOpenNewTab: boolean
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// True while the container is up but its probe hasn't answered yet and the
|
||||
// auto-retries are still in flight — a warm-up, not a failure.
|
||||
warmingUp?: boolean
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
iframeLoad: []
|
||||
iframeError: []
|
||||
refresh: []
|
||||
openNewTabAndBack: []
|
||||
}>()
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// Faux load progress for the loading screen. Cross-origin iframes give no real
|
||||
// progress events, so ease toward ~92% while loading and snap to 100% on load —
|
||||
// far better UX than a black screen with a bare spinner.
|
||||
const loadProgress = ref(0)
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopProgress() {
|
||||
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||||
}
|
||||
|
||||
function startProgress() {
|
||||
stopProgress()
|
||||
loadProgress.value = 8
|
||||
progressTimer = setInterval(() => {
|
||||
// Decelerate as it approaches the cap so it never visually "finishes" early.
|
||||
const remaining = 92 - loadProgress.value
|
||||
loadProgress.value += Math.max(0.4, remaining * 0.08)
|
||||
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
|
||||
}, 180)
|
||||
}
|
||||
|
||||
watch(() => props.loading, (isLoading) => {
|
||||
if (isLoading) {
|
||||
startProgress()
|
||||
} else {
|
||||
stopProgress()
|
||||
loadProgress.value = 100
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
|
||||
|
||||
onBeforeUnmount(stopProgress)
|
||||
|
||||
function focusIframe() {
|
||||
iframeRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function handleIframeLoad() {
|
||||
emit('iframeLoad')
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}
|
||||
|
||||
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
|
||||
if (!props.appUrl || props.iframeBlocked) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ iframeRef })
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="sticky top-0 z-10 hidden md:flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
<!-- Back / Forward navigation -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="app-session-btn" aria-label="Back" title="Go back" @click="$emit('goBack')">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-btn" aria-label="Forward" title="Go forward" @click="$emit('goForward')">
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ appTitle }}</span>
|
||||
|
||||
<button class="app-session-btn" aria-label="Refresh" :disabled="isRefreshing" @click="$emit('refresh')">
|
||||
<svg class="w-5 h-5 transition-transform duration-300" :class="{ 'animate-spin': isRefreshing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Display mode: one-click switch -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
|
||||
aria-label="Right panel"
|
||||
title="Right panel"
|
||||
@click="$emit('setMode', 'panel')"
|
||||
>
|
||||
<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="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
|
||||
aria-label="Over whole app"
|
||||
title="Over whole app"
|
||||
@click="$emit('setMode', 'overlay')"
|
||||
>
|
||||
<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="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
|
||||
aria-label="Open fullscreen"
|
||||
title="Open fullscreen"
|
||||
@click="$emit('setMode', 'fullscreen')"
|
||||
>
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="app-session-btn" aria-label="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>
|
||||
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DisplayMode } from './appSessionConfig'
|
||||
|
||||
defineProps<{
|
||||
appTitle: string
|
||||
isRefreshing: boolean
|
||||
displayMode: DisplayMode
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
goBack: []
|
||||
goForward: []
|
||||
refresh: []
|
||||
openNewTab: []
|
||||
close: []
|
||||
setMode: [mode: DisplayMode]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<!-- Mobile gamepad overlay — NES-styled D-pad + action buttons.
|
||||
Sends postMessage({ type: 'arcade-input', key, player, action }) to iframe. -->
|
||||
<div class="mobile-gamepad">
|
||||
<!-- D-Pad (left side) -->
|
||||
<div class="gamepad-dpad">
|
||||
<button
|
||||
class="dpad-btn dpad-up"
|
||||
@touchstart.prevent="down('ArrowUp')"
|
||||
@touchend.prevent="up('ArrowUp')"
|
||||
@touchcancel.prevent="up('ArrowUp')"
|
||||
aria-label="Up"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4l-6 8h12z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="dpad-btn dpad-left"
|
||||
@touchstart.prevent="down('ArrowLeft')"
|
||||
@touchend.prevent="up('ArrowLeft')"
|
||||
@touchcancel.prevent="up('ArrowLeft')"
|
||||
aria-label="Left"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 12l8-6v12z"/></svg>
|
||||
</button>
|
||||
<div class="dpad-center" />
|
||||
<button
|
||||
class="dpad-btn dpad-right"
|
||||
@touchstart.prevent="down('ArrowRight')"
|
||||
@touchend.prevent="up('ArrowRight')"
|
||||
@touchcancel.prevent="up('ArrowRight')"
|
||||
aria-label="Right"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 12l-8-6v12z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="dpad-btn dpad-down"
|
||||
@touchstart.prevent="down('ArrowDown')"
|
||||
@touchend.prevent="up('ArrowDown')"
|
||||
@touchcancel.prevent="up('ArrowDown')"
|
||||
aria-label="Down"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20l6-8H6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: START / SELECT + utility buttons -->
|
||||
<div class="gamepad-meta">
|
||||
<div class="meta-row">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
</div>
|
||||
<div class="meta-utility">
|
||||
<button class="util-btn" aria-label="Refresh" @click="$emit('refresh')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Open in browser" @click="$emit('openBrowser')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Close" @click="$emit('close')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (right side) — triangle layout -->
|
||||
<div class="gamepad-actions">
|
||||
<button
|
||||
class="action-btn action-c"
|
||||
@touchstart.prevent="down('c')"
|
||||
@touchend.prevent="up('c')"
|
||||
@touchcancel.prevent="up('c')"
|
||||
aria-label="Special"
|
||||
></button>
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="action-btn action-b"
|
||||
@touchstart.prevent="down('b')"
|
||||
@touchend.prevent="up('b')"
|
||||
@touchcancel.prevent="up('b')"
|
||||
aria-label="Kick"
|
||||
></button>
|
||||
<button
|
||||
class="action-btn action-a"
|
||||
@touchstart.prevent="down('a')"
|
||||
@touchend.prevent="up('a')"
|
||||
@touchcancel.prevent="up('a')"
|
||||
aria-label="Punch"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
iframeRef: HTMLIFrameElement | null
|
||||
player?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
openBrowser: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function send(key: string, action: 'down' | 'up') {
|
||||
props.iframeRef?.contentWindow?.postMessage(
|
||||
{ type: 'arcade-input', key, player: props.player ?? 1, action },
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
function down(key: string) { send(key, 'down') }
|
||||
function up(key: string) { send(key, 'up') }
|
||||
function tap(key: string) { send(key, 'down'); setTimeout(() => send(key, 'up'), 80) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-gamepad {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ── D-Pad ── */
|
||||
.gamepad-dpad {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 48px 48px;
|
||||
grid-template-rows: 48px 48px 48px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dpad-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.dpad-btn:active {
|
||||
background: rgba(251, 146, 60, 0.3);
|
||||
color: white;
|
||||
}
|
||||
.dpad-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.dpad-up { grid-column: 2; grid-row: 1; }
|
||||
.dpad-left { grid-column: 1; grid-row: 2; }
|
||||
.dpad-center {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dpad-right { grid-column: 3; grid-row: 2; }
|
||||
.dpad-down { grid-column: 2; grid-row: 3; }
|
||||
|
||||
/* ── Meta buttons (START / SELECT) ── */
|
||||
.gamepad-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-utility {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.util-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.util-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.meta-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta-btn:active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* ── Action buttons — triangle layout ── */
|
||||
.gamepad-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid;
|
||||
transition: background 0.1s, transform 0.1s;
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.action-a {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
border-color: rgba(251, 146, 60, 0.5);
|
||||
color: #fb923c;
|
||||
}
|
||||
.action-a:active {
|
||||
background: rgba(251, 146, 60, 0.45);
|
||||
}
|
||||
|
||||
.action-b {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.action-b:active {
|
||||
background: rgba(96, 165, 250, 0.45);
|
||||
}
|
||||
|
||||
.action-c {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #ffffff;
|
||||
}
|
||||
.action-c:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppSessionFrame from '../AppSessionFrame.vue'
|
||||
|
||||
// Regression cover for the operator-reported defect: a container that is up
|
||||
// but has not finished warming up (bitcoind serving RPC -28, lnd before the
|
||||
// wallet unlocks) rendered the hard "App not reachable" failure copy for the
|
||||
// whole warm-up window. The retry machinery already tolerated it — only the
|
||||
// headline lied.
|
||||
|
||||
function mountFrame(props: Record<string, unknown> = {}) {
|
||||
return mount(AppSessionFrame, {
|
||||
props: {
|
||||
appUrl: 'http://localhost:8332/',
|
||||
appId: 'bitcoin-knots',
|
||||
appTitle: 'Bitcoin',
|
||||
appIcon: '/icons/bitcoin.png',
|
||||
loading: false,
|
||||
iframeBlocked: true,
|
||||
mustOpenNewTab: false,
|
||||
autoRetryCount: 1,
|
||||
refreshKey: 0,
|
||||
...props,
|
||||
},
|
||||
global: { stubs: { AppLoadingScreen: true, Transition: false } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSessionFrame warm-up state', () => {
|
||||
it('reads as starting, not unreachable, while the container is warming up', () => {
|
||||
const text = mountFrame({ warmingUp: true }).text()
|
||||
expect(text).toContain('Bitcoin is starting…')
|
||||
expect(text).not.toContain('App not reachable')
|
||||
})
|
||||
|
||||
it('says the container is running so the copy does not imply it is stopped', () => {
|
||||
const text = mountFrame({ warmingUp: true }).text()
|
||||
expect(text).toContain("container is running but hasn't finished warming up")
|
||||
expect(text).not.toContain('the container is stopped')
|
||||
})
|
||||
|
||||
it('still surfaces the automatic re-check while warming up', () => {
|
||||
expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain(
|
||||
'Checking again automatically (3)',
|
||||
)
|
||||
})
|
||||
|
||||
it('reverts to the real failure once warm-up is over (retries exhausted)', () => {
|
||||
const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text()
|
||||
expect(text).toContain('App not reachable')
|
||||
expect(text).not.toContain('is starting…')
|
||||
})
|
||||
|
||||
it('leaves the explicit blocked-reason path untouched', () => {
|
||||
const text = mountFrame({
|
||||
warmingUp: false,
|
||||
blockedReason: 'Waiting for Bitcoin to finish syncing.',
|
||||
blockedTitle: 'Waiting for Bitcoin sync',
|
||||
}).text()
|
||||
expect(text).toContain('Waiting for Bitcoin sync')
|
||||
expect(text).not.toContain('App not reachable')
|
||||
})
|
||||
|
||||
it('leaves the new-tab path untouched', () => {
|
||||
const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text()
|
||||
expect(text).toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appOrigin, matchPageScheme } from '../appSessionConfig'
|
||||
|
||||
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
|
||||
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
|
||||
// nodes must be completely unaffected, which is what most of these pin.
|
||||
|
||||
function setLocation(protocol: string, hostname: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol, hostname },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('appOrigin', () => {
|
||||
it('stays on http for an http dashboard', () => {
|
||||
setLocation('http:', 'test-node.local')
|
||||
expect(appOrigin(8334)).toBe('http://test-node.local:8334')
|
||||
})
|
||||
|
||||
it('follows an https dashboard onto the app port', () => {
|
||||
setLocation('https:', 'test-node.local')
|
||||
expect(appOrigin(8334)).toBe('https://test-node.local:8334')
|
||||
})
|
||||
|
||||
it('keeps the hostname the user actually typed, not a fixed name', () => {
|
||||
setLocation('https:', '100.64.0.5')
|
||||
expect(appOrigin(3000)).toBe('https://100.64.0.5:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchPageScheme', () => {
|
||||
it('leaves backend-reported http URLs alone on an http page', () => {
|
||||
setLocation('http:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
|
||||
})
|
||||
|
||||
it('upgrades a backend-reported http URL on an https page', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
|
||||
it('does not touch anything but the scheme', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
|
||||
})
|
||||
|
||||
it('leaves an already-https URL untouched', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
|
||||
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves direct app ports against the current browser host', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.0.2.10:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.0.2.10:7778')
|
||||
expect(resolveAppUrl('botfights')).toBe('http://192.0.2.10:9100')
|
||||
})
|
||||
|
||||
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
||||
// manifest.yml) — assert against the manifest-generated value, which is
|
||||
// exactly what this test exists to protect.
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
|
||||
})
|
||||
|
||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('meshtastic')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
|
||||
})
|
||||
|
||||
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
/** Per-app default display mode. Used when the user hasn't explicitly picked
|
||||
* a mode for that app (an explicit pick is remembered per app and wins).
|
||||
* Apps not listed default to 'panel'. */
|
||||
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
|
||||
}
|
||||
|
||||
/** Initial display mode for an app session: per-app user choice → per-app
|
||||
* default → panel. Strictly per-app — deliberately NO global fallback, so
|
||||
* one app's mode change can never affect how another app opens. */
|
||||
export function initialDisplayMode(id: string): DisplayMode {
|
||||
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
|
||||
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
|
||||
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
|
||||
}
|
||||
|
||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||
export const APP_PORTS: Record<string, number> = {
|
||||
...GENERATED_APP_PORTS,
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool-web': 4080,
|
||||
'ollama': 11434,
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'nginx-proxy-manager': 8081,
|
||||
'netbird': 8087,
|
||||
'tailscale': 8240,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
/** Apps that need nginx proxy for iframe embedding.
|
||||
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
|
||||
export const PROXY_APPS: Record<string, string> = {
|
||||
'gitea': '/app/gitea/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
}
|
||||
|
||||
/** App launches use direct ports. Do not route through /app/... path proxies. */
|
||||
export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** External HTTPS apps -- always loaded directly */
|
||||
export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
...GENERATED_APP_TITLES,
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
'nostrudel': 'noStrudel',
|
||||
}
|
||||
|
||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||
export const NEW_TAB_APPS = new Set([
|
||||
...GENERATED_NEW_TAB_APPS,
|
||||
'nginx-proxy-manager',
|
||||
'tailscale',
|
||||
])
|
||||
|
||||
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||
// Demo: route to the app's mock UI or real external site (mempool.space,
|
||||
// indee.tx1138.com). Carry through a deep-link path (e.g. /tx/<hash> for
|
||||
// mempool). Non-demoable apps fall through to a generic notice page.
|
||||
if (IS_DEMO) {
|
||||
const base = demoAppUrl(id)
|
||||
if (base) {
|
||||
if (!routeQueryPath) return base
|
||||
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
|
||||
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
|
||||
}
|
||||
return `/app/${id}/`
|
||||
}
|
||||
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
|
||||
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return appOrigin(8334)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
|
||||
// content and the frame is blocked outright, so follow the page instead.
|
||||
base = matchPageScheme(base)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
// Local apps launch by host port.
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = appOrigin(port)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's origin on this host, on the SAME scheme as the page.
|
||||
*
|
||||
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
||||
* mixed content before any cookie question arises — and it is also what makes
|
||||
* the two origins schemefully cross-site, so the session cookie is withheld.
|
||||
* Following the page's scheme fixes both at once and keeps plain HTTP working
|
||||
* exactly as before on nodes that serve the dashboard over HTTP.
|
||||
*
|
||||
* On HTTPS this requires the app port to actually serve TLS with a certificate
|
||||
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
|
||||
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||
* so an untrusted app port renders nothing rather than prompting.
|
||||
*/
|
||||
export function appOrigin(port: number): string {
|
||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||
export function matchPageScheme(url: string): string {
|
||||
if (pageScheme() !== 'https:') return url
|
||||
return url.replace(/^http:\/\//i, 'https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's scheme, defaulting to http.
|
||||
*
|
||||
* A real browser always has location.protocol; this defends the non-browser
|
||||
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
|
||||
* is the safe direction — it preserves today's behaviour rather than inventing
|
||||
* an https URL for a port that may not serve TLS.
|
||||
*/
|
||||
function pageScheme(): string {
|
||||
const p = window.location?.protocol
|
||||
return p === 'https:' || p === 'http:' ? p : 'http:'
|
||||
}
|
||||
|
||||
/** Resolve a human-readable title for an app */
|
||||
export function resolveAppTitle(id: string): string {
|
||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */
|
||||
|
||||
export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"aiui": 5180,
|
||||
"archy-mempool-web": 4080,
|
||||
"archy-nbxplorer": 32838,
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8088,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"fips-ui": 8336,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
"immich": 2283,
|
||||
"indeedhub": 7778,
|
||||
"jellyfin": 8096,
|
||||
"lnd-ui": 18083,
|
||||
"mempool": 4080,
|
||||
"mempool-api": 8999,
|
||||
"morphos-server": 8089,
|
||||
"netbird": 8087,
|
||||
"nextcloud": 8085,
|
||||
"nostr-rs-relay": 18081,
|
||||
"photoprism": 2342,
|
||||
"pine": 10380,
|
||||
"portainer": 9000,
|
||||
"router": 8084,
|
||||
"searxng": 8888,
|
||||
"strfry": 8090,
|
||||
"uptime-kuma": 3002,
|
||||
"vaultwarden": 8082,
|
||||
}
|
||||
|
||||
export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"aiui": "AI Assistant",
|
||||
"archy-btcpay-db": "BTCPay Postgres",
|
||||
"archy-mempool-db": "Mempool MariaDB",
|
||||
"archy-mempool-web": "Mempool Web",
|
||||
"archy-nbxplorer": "NBXplorer",
|
||||
"barkd": "Ark Wallet",
|
||||
"bitcoin-core": "Bitcoin Core",
|
||||
"bitcoin-knots": "Bitcoin Knots",
|
||||
"bitcoin-ui": "Bitcoin UI",
|
||||
"botfights": "BotFights",
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"did-wallet": "Web5 DID Wallet",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
"fedimint-clientd": "Fedimint Client",
|
||||
"fedimint-gateway": "Fedimint Gateway",
|
||||
"filebrowser": "File Browser",
|
||||
"fips-ui": "FIPS Mesh",
|
||||
"gitea": "Gitea",
|
||||
"grafana": "Grafana",
|
||||
"homeassistant": "Home Assistant",
|
||||
"immich": "Immich",
|
||||
"immich-postgres": "Immich Postgres",
|
||||
"immich-redis": "Immich Redis",
|
||||
"indeedhub": "IndeeHub",
|
||||
"indeedhub-api": "IndeedHub API",
|
||||
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
|
||||
"indeedhub-minio": "IndeedHub MinIO",
|
||||
"indeedhub-postgres": "IndeedHub Postgres",
|
||||
"indeedhub-redis": "IndeedHub Redis",
|
||||
"indeedhub-relay": "IndeedHub Nostr Relay",
|
||||
"jellyfin": "Jellyfin",
|
||||
"lightning-stack": "Lightning Stack",
|
||||
"lnd": "LND",
|
||||
"lnd-ui": "LND UI",
|
||||
"mempool": "Mempool Explorer",
|
||||
"mempool-api": "Mempool API",
|
||||
"morphos-server": "MorphOS Server",
|
||||
"netbird": "NetBird",
|
||||
"netbird-dashboard": "NetBird Dashboard",
|
||||
"netbird-server": "NetBird Server",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nostr-rs-relay": "Nostr Relay (Rust)",
|
||||
"photoprism": "PhotoPrism",
|
||||
"pine": "Pine",
|
||||
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
|
||||
"pine-piper": "Pine Piper (TTS)",
|
||||
"pine-whisper": "Pine Whisper (STT)",
|
||||
"portainer": "Portainer",
|
||||
"router": "Mesh Router",
|
||||
"searxng": "SearXNG",
|
||||
"strfry": "Strfry Nostr Relay",
|
||||
"uptime-kuma": "Uptime Kuma",
|
||||
"vaultwarden": "Vaultwarden",
|
||||
}
|
||||
|
||||
export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
||||
"btcpay-server",
|
||||
"gitea",
|
||||
"grafana",
|
||||
"homeassistant",
|
||||
"immich",
|
||||
"nextcloud",
|
||||
"photoprism",
|
||||
"pine",
|
||||
"portainer",
|
||||
"uptime-kuma",
|
||||
"vaultwarden",
|
||||
])
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Composable for managing app identity selection and NIP-07 identity injection */
|
||||
|
||||
import { type Ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
|
||||
export interface SelectedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
}
|
||||
|
||||
function isIdentityAwareApp(id: string): boolean {
|
||||
return id === 'indeedhub' || id === 'nostrudel'
|
||||
}
|
||||
|
||||
export function useAppIdentity(
|
||||
appId: Ref<string>,
|
||||
iframeRef: Ref<HTMLIFrameElement | null>,
|
||||
showIdentityPicker: Ref<boolean>,
|
||||
) {
|
||||
function getStoredIdentity(): SelectedIdentity | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(IDENTITY_KEY + appId.value)
|
||||
return stored ? JSON.parse(stored) as SelectedIdentity : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function storeIdentity(identity: SelectedIdentity) {
|
||||
try { localStorage.setItem(IDENTITY_KEY + appId.value, JSON.stringify(identity)) } catch {}
|
||||
}
|
||||
|
||||
async function sendIdentity(identity: SelectedIdentity) {
|
||||
try {
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({ method: 'identity.sign', params: { id: identity.id, message: challenge } })
|
||||
iframeRef.value?.contentWindow?.postMessage({
|
||||
type: 'archipelago:identity', did: identity.did, name: identity.name,
|
||||
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey || null,
|
||||
nostr_npub: identity.nostr_npub || null, challenge, signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
storeIdentity(identity)
|
||||
sendIdentity(identity)
|
||||
}
|
||||
|
||||
/** Called on iframe load to inject identity if the app supports it */
|
||||
function onIframeLoadIdentity() {
|
||||
// Public demo: never interrupt the visitor with the identity picker —
|
||||
// the embedded IndeeHub is already signed in via the seeded throwaway
|
||||
// demo account (see docker/indee-demo-signin.js). Real-node behavior is
|
||||
// untouched (IS_DEMO is compile-time false there).
|
||||
if (IS_DEMO) return
|
||||
if (isIdentityAwareApp(appId.value)) {
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle identity request messages from iframe */
|
||||
function handleIdentityRequest() {
|
||||
if (IS_DEMO) return
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
getStoredIdentity,
|
||||
sendIdentity,
|
||||
onIdentitySelected,
|
||||
onIframeLoadIdentity,
|
||||
handleIdentityRequest,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Composable for NIP-07 Nostr signing bridge between parent and iframe */
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { SelectedIdentity } from './useAppIdentity'
|
||||
|
||||
export function useNostrBridge(
|
||||
getStoredIdentity: () => SelectedIdentity | null,
|
||||
getAppUrl: () => string,
|
||||
) {
|
||||
async function handleNostrRequest(event: MessageEvent) {
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
const storedIdentity = getStoredIdentity()
|
||||
const identityId = storedIdentity?.id || null
|
||||
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
if (method === 'getPublicKey') {
|
||||
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
|
||||
if (storedIdentity?.nostr_pubkey) {
|
||||
result = storedIdentity.nostr_pubkey
|
||||
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
|
||||
} else if (identityId) {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
|
||||
if (identityId) {
|
||||
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
|
||||
} else {
|
||||
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
}
|
||||
if (import.meta.env.DEV) console.log('[NIP-07] signEvent OK')
|
||||
} else if (method === 'getRelays') { result = {} }
|
||||
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin)
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
return { handleNostrRequest }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const APP_STORE_CATEGORIES = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'other', name: 'Other' },
|
||||
] as const
|
||||
|
||||
export const APP_STORE_SECTIONS = [
|
||||
{ id: 'discover', name: 'Discover' },
|
||||
...APP_STORE_CATEGORIES,
|
||||
] as const
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('goToApp', id)"
|
||||
@keydown.enter="handleEnter"
|
||||
>
|
||||
<!-- Installing indicator — no overlay, just replaces action buttons at bottom -->
|
||||
|
||||
<!-- Uninstalling — handled in button area below, no overlay -->
|
||||
|
||||
<!-- Uninstall Icon (not for web-only apps) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isUninstalling && !isInstalling && pkg.state !== 'installing'"
|
||||
@click.stop="$emit('showUninstall', id, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="title"
|
||||
class="app-card-icon archy-app-icon w-14 h-14"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h3 class="text-lg font-semibold text-white truncate" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="tier && tier !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tier }}</span>
|
||||
<span
|
||||
v-if="pkg['available-update']"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
|
||||
>Update</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p>
|
||||
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-white/70 text-sm mt-3 mb-3 line-clamp-2 min-h-[2.5rem]">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<svg
|
||||
v-if="isTransitioning"
|
||||
class="animate-spin h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
|
||||
{{ blockedReason }}
|
||||
</p>
|
||||
|
||||
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
||||
<!-- Installing progress — replaces action buttons -->
|
||||
<div v-if="isInstalling || pkg.state === 'installing'" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installProgress?.message || 'Installing...' }}
|
||||
</span>
|
||||
<span class="text-xs text-white/50">{{ Math.round(installProgress?.progress || 0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="install-progress-fill h-full bg-white/60 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${Math.max(installProgress?.progress || 2, 2)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstalling progress — truthful stage-driven bar (mirrors install) -->
|
||||
<div v-else-if="isUninstalling" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ uninstallStageLabel }}
|
||||
</span>
|
||||
<span v-if="uninstallProgress !== null" class="text-xs text-white/50">{{ uninstallProgress }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="install-progress-fill h-full bg-white/60 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${Math.max(uninstallProgress ?? 8, 4)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 flex gap-2">
|
||||
<!-- Update available -->
|
||||
<button
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@click.stop="$emit('update', id)"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
|
||||
:title="`Update to ${$ver(pkg['available-update'])}`"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.update') }}
|
||||
</button>
|
||||
<!-- Updating in progress -->
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('common.updating') }}
|
||||
</span>
|
||||
<!-- Launch -->
|
||||
<button
|
||||
v-if="canLaunch(pkg)"
|
||||
data-controller-launch-btn
|
||||
@click.stop="$emit('launch', id)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{{ t('common.launch') }}
|
||||
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</button>
|
||||
<!-- Start (play icon) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||
@click.stop="$emit('start', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="pkg.state === 'exited' ? 'Restart' : t('common.start')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
<!-- Starting (spinner) -->
|
||||
<button
|
||||
v-if="!isWebOnly && isLoading && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||||
disabled
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Stop (square icon) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="$emit('stop', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="t('common.stop')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1" /></svg>
|
||||
</button>
|
||||
<!-- Restart -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="$emit('restart', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="t('common.restart')"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Stopping (spinner) -->
|
||||
<button
|
||||
v-if="!isWebOnly && isLoading && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||||
disabled
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Build a lookup map for enriching sparse backend data during install
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
pkg: PackageDataEntry
|
||||
index: number
|
||||
showStagger: boolean
|
||||
isLoading: boolean
|
||||
isInstalling?: boolean
|
||||
installProgress?: { status: string; progress: number; message: string }
|
||||
isUninstalling: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToApp: [id: string]
|
||||
launch: [id: string]
|
||||
start: [id: string]
|
||||
stop: [id: string]
|
||||
restart: [id: string]
|
||||
update: [id: string]
|
||||
showUninstall: [id: string, pkg: PackageDataEntry]
|
||||
}>()
|
||||
|
||||
function handleEnter(e: KeyboardEvent) {
|
||||
// Controller nav already handled this Enter (preventDefault was called) — skip to avoid double navigation
|
||||
if (e.defaultPrevented) return
|
||||
emit('goToApp', props.id)
|
||||
}
|
||||
|
||||
const isWebOnly = computed(() => isWebOnlyApp(props.id))
|
||||
|
||||
// Enrich from marketplace when backend data is sparse (e.g. during install)
|
||||
const curated = computed(() => curatedMap.get(props.id))
|
||||
const title = computed(() => {
|
||||
const t = props.pkg.manifest?.title
|
||||
return (t && t !== props.id) ? t : (curated.value?.title || t || props.id)
|
||||
})
|
||||
const description = computed(() => {
|
||||
const d = props.pkg.manifest?.description?.short
|
||||
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
|
||||
})
|
||||
const icon = computed(() => resolveAppIcon(props.id, props.pkg, curated.value?.icon))
|
||||
const version = computed(() => {
|
||||
const v = props.pkg.manifest?.version
|
||||
return v || curated.value?.version || ''
|
||||
})
|
||||
const author = computed(() => props.pkg.manifest?.author || curated.value?.author || '')
|
||||
const tier = computed(() => {
|
||||
const t = props.pkg.manifest?.tier
|
||||
if (t && t !== '') return t
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(props.id)) return 'core'
|
||||
if (recommended.includes(props.id)) return 'recommended'
|
||||
return 'optional'
|
||||
})
|
||||
|
||||
// Live uninstall stage from backend, with a sensible fallback so the
|
||||
// label is never blank between WS pushes.
|
||||
const uninstallStageLabel = computed(() => {
|
||||
const raw = props.pkg['uninstall-stage']
|
||||
return raw ? raw : `${t('common.uninstalling')}…`
|
||||
})
|
||||
|
||||
// Map the backend's uninstall-stage label to a truthful percentage so the bar
|
||||
// progresses through the teardown instead of sitting at a solid full(-red)
|
||||
// block. Backend stages (set_uninstall_stage):
|
||||
// "Stopping containers (X/N)" → 10–50% (linear over the stack)
|
||||
// "Cleaning up volumes" → 70%
|
||||
// "Removing app data" → 90%
|
||||
// Unknown/between pushes → null → the bar parks low and the shimmer overlay
|
||||
// (install-progress-fill) carries the motion, exactly like a fixed install phase.
|
||||
const uninstallProgress = computed<number | null>(() => {
|
||||
const raw = props.pkg['uninstall-stage'] || ''
|
||||
const m = raw.match(/\((\d+)\s*\/\s*(\d+)\)/)
|
||||
if (m) {
|
||||
const done = Number(m[1])
|
||||
const total = Number(m[2])
|
||||
if (total > 0) {
|
||||
return Math.round(10 + Math.min(done / total, 1) * 40)
|
||||
}
|
||||
}
|
||||
if (/volume/i.test(raw)) return 70
|
||||
if (/data/i.test(raw)) return 90
|
||||
return null
|
||||
})
|
||||
|
||||
const isTransitioning = computed(() => {
|
||||
const s = props.pkg.state
|
||||
const h = props.pkg.health
|
||||
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
||||
})
|
||||
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Shimmer overlay on the install progress bar so users see motion even
|
||||
* when the bar is parked at a fixed phase percentage (pulling-image can
|
||||
* take minutes, and podman doesn't give us byte-level progress). */
|
||||
.install-progress-fill {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.55) 0%,
|
||||
rgba(255, 255, 255, 0.9) 50%,
|
||||
rgba(255, 255, 255, 0.55) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: install-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes install-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Respect user motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.install-progress-fill {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="app-icon-grid-wrap">
|
||||
<!-- Swipeable pages -->
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="app-icon-pages"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
class="app-icon-page"
|
||||
>
|
||||
<div
|
||||
v-for="([id, pkg]) in page"
|
||||
:key="id"
|
||||
class="app-icon-item"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="getTitle(id, pkg)"
|
||||
@pointerdown="startLongPress(id)"
|
||||
@pointerup="clearLongPress"
|
||||
@pointercancel="clearLongPress"
|
||||
@pointerleave="clearLongPress"
|
||||
@contextmenu.prevent="openAppOptions(id)"
|
||||
@click="handleTap(id, pkg)"
|
||||
@keydown.enter="handleTap(id, pkg)"
|
||||
@keydown.space.prevent="openAppOptions(id)"
|
||||
>
|
||||
<!-- Icon with status indicator -->
|
||||
<div class="app-icon-frame">
|
||||
<img
|
||||
:src="getIcon(id, pkg)"
|
||||
:alt="getTitle(id, pkg)"
|
||||
class="app-icon-img archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
v-if="pkg.state === 'running'"
|
||||
class="app-icon-status app-icon-status-running"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'exited'"
|
||||
class="app-icon-status app-icon-status-error"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'starting' || pkg.state === 'stopping' || pkg.state === 'installing'"
|
||||
class="app-icon-status app-icon-status-transition"
|
||||
></span>
|
||||
<!-- Installing overlay -->
|
||||
<div
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-installing"
|
||||
>
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Launching overlay — instant tap feedback while the app opens -->
|
||||
<div v-if="launchingId === id" class="app-icon-installing">
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||
<span
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-progress-label"
|
||||
:title="progressLabel(id, pkg)"
|
||||
>
|
||||
{{ progressLabel(id, pkg) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page dots -->
|
||||
<div v-if="pages.length > 1" class="app-icon-dots">
|
||||
<button
|
||||
v-for="(_, i) in pages"
|
||||
:key="i"
|
||||
class="app-icon-dot"
|
||||
:class="{ 'app-icon-dot-active': i === activePage }"
|
||||
:aria-label="`Page ${i + 1}`"
|
||||
@click="scrollToPage(i)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||
against every other modal in the app. -->
|
||||
<BaseModal
|
||||
:show="credentialModal.show"
|
||||
:title="credentialModal.title"
|
||||
max-width="max-w-lg"
|
||||
z-index="z-[2700]"
|
||||
@close="closeCredentialModal"
|
||||
>
|
||||
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||
{{ credentialModal.description }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './appCredentials'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
apps: [string, PackageDataEntry][]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToApp: [id: string]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Per-icon "launching" spinner so a tap is acknowledged instantly even while
|
||||
// the app session/iframe is still spinning up. Cleared when the launcher
|
||||
// overlay opens, with a fallback timeout for the open-in-new-tab path.
|
||||
const launchingId = ref<string | null>(null)
|
||||
let launchClearTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function markLaunching(id: string) {
|
||||
launchingId.value = id
|
||||
if (launchClearTimer) clearTimeout(launchClearTimer)
|
||||
launchClearTimer = setTimeout(() => {
|
||||
if (launchingId.value === id) launchingId.value = null
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
// Clear the spinner as soon as the app overlay actually opens.
|
||||
watch(() => appLauncher.isOpen, (open) => {
|
||||
if (open) {
|
||||
launchingId.value = null
|
||||
if (launchClearTimer) { clearTimeout(launchClearTimer); launchClearTimer = null }
|
||||
}
|
||||
})
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
|
||||
result.push(props.apps.slice(i, i + ITEMS_PER_PAGE))
|
||||
}
|
||||
return result.length ? result : [[]]
|
||||
})
|
||||
|
||||
function getTitle(id: string, pkg: PackageDataEntry): string {
|
||||
const t = pkg.manifest?.title
|
||||
if (t && t !== id) return t
|
||||
return curatedMap.get(id)?.title || t || id
|
||||
}
|
||||
|
||||
function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||
}
|
||||
|
||||
function progressLabel(id: string, pkg: PackageDataEntry): string {
|
||||
const install = serverStore.installingApps.get(id)
|
||||
if (install) {
|
||||
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
|
||||
}
|
||||
if (serverStore.uninstallingApps.has(id)) {
|
||||
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (longPressTriggered.value) {
|
||||
longPressTriggered.value = false
|
||||
return
|
||||
}
|
||||
if (canLaunch(pkg)) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||
if (shown) return
|
||||
launchNow(id, pkg)
|
||||
} else {
|
||||
emit('goToApp', id)
|
||||
}
|
||||
}
|
||||
|
||||
function startLongPress(id: string) {
|
||||
clearLongPress()
|
||||
longPressTriggered.value = false
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTriggered.value = true
|
||||
openAppOptions(id)
|
||||
}, 550)
|
||||
}
|
||||
|
||||
function clearLongPress() {
|
||||
if (!longPressTimer) return
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
|
||||
function openAppOptions(id: string) {
|
||||
clearLongPress()
|
||||
emit('goToApp', id)
|
||||
}
|
||||
|
||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
markLaunching(id)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
appLauncher.openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredentialModal() { credentialModal.value.show = false }
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
const entry = props.apps.find(([appId]) => appId === id)
|
||||
closeCredentialModal()
|
||||
if (entry) launchNow(entry[0], entry[1])
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try { await navigator.clipboard.writeText(value) } catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
const pageWidth = el.clientWidth
|
||||
if (pageWidth === 0) return
|
||||
activePage.value = Math.round(el.scrollLeft / pageWidth)
|
||||
}
|
||||
|
||||
function scrollToPage(index: number) {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Instant press feedback: the icon scales down the moment it's touched, so the
|
||||
tap is acknowledged even before the app finishes launching. */
|
||||
.app-icon-frame {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.app-icon-item:active .app-icon-frame {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.sideload-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-close-btn:hover,
|
||||
.sideload-close-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="uninstall-dialog-title"
|
||||
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70">
|
||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||
</p>
|
||||
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="deleteAppData"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-white/30 bg-black/30 text-red-500 focus:ring-red-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
||||
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm', deleteAppData)"
|
||||
:disabled="uninstalling"
|
||||
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="uninstalling"
|
||||
class="animate-spin h-4 w-4"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
appTitle: string
|
||||
uninstalling: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [deleteAppData: boolean]
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
const deleteAppData = ref(false)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
computed(() => props.show),
|
||||
() => emit('close'),
|
||||
{ restoreFocusRef },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
deleteAppData.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
|
||||
|
||||
<LightningChannelsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// When a setup wizard sent us here (?from=goal&goal=<id>), back returns to it.
|
||||
const fromGoalId = computed(() =>
|
||||
route.query.from === 'goal' && typeof route.query.goal === 'string' ? route.query.goal : null,
|
||||
)
|
||||
const backLabel = computed(() => (fromGoalId.value ? 'Back to Setup' : 'Back to LND'))
|
||||
|
||||
function goBack() {
|
||||
if (fromGoalId.value) {
|
||||
router.push(`/dashboard/goals/${fromGoalId.value}`)
|
||||
} else {
|
||||
router.replace('/dashboard/apps/lnd')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import AppIconGrid from '../AppIconGrid.vue'
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppIconGrid', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 1024,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.11' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens LND companion UI in the app panel', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBe('lnd')
|
||||
})
|
||||
|
||||
it('shows File Browser credentials before launch even when backend returns no credentials', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
// The credential modal is <Teleport to="body">'d (so its full-screen
|
||||
// backdrop isn't clipped by the dashboard's transformed layout) —
|
||||
// stub it to render inline so wrapper.text() still sees it.
|
||||
stubs: { teleport: true },
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('File Browser credentials')
|
||||
expect(wrapper.text()).toContain('Username')
|
||||
expect(wrapper.text()).toContain('admin')
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('opens unresolved new-tab apps externally on mobile', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.0.2.11:3001',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('shows backend uninstall stage while an app is removing', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
pkg['uninstall-stage'] = 'Stopping containers (2/7)'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Stopping containers (2/7)')
|
||||
})
|
||||
|
||||
it('supports legacy underscore uninstall stage data', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
;(pkg as PackageDataEntry & { uninstall_stage?: string }).uninstall_stage = 'Removing app data'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Removing app data')
|
||||
})
|
||||
|
||||
it('opens app details on long press without launching the app', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
const icon = wrapper.get('.app-icon-item')
|
||||
await icon.trigger('pointerdown')
|
||||
vi.advanceTimersByTime(550)
|
||||
await icon.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('opens app details from the keyboard options shortcut', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('keydown.space')
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params?.name) return `${key} ${params.name}`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('AppsUninstallModal', () => {
|
||||
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
||||
const wrapper = mount(AppsUninstallModal, {
|
||||
props: {
|
||||
show: true,
|
||||
appTitle: 'File Browser',
|
||||
uninstalling: false,
|
||||
},
|
||||
})
|
||||
|
||||
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox).not.toBeNull()
|
||||
checkbox?.click()
|
||||
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
||||
expect(confirmButton).not.toBeNull()
|
||||
confirmButton?.click()
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import LightningChannels from '@/components/LightningChannelsPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeChannel() {
|
||||
return {
|
||||
chan_id: '123',
|
||||
remote_pubkey: 'peer-pubkey',
|
||||
capacity: 100_000,
|
||||
local_balance: 60_000,
|
||||
remote_balance: 40_000,
|
||||
active: true,
|
||||
status: 'active',
|
||||
channel_point: 'txid:0',
|
||||
}
|
||||
}
|
||||
|
||||
describe('LightningChannels', () => {
|
||||
it('keeps channels visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
channels: [makeChannel()],
|
||||
total_inbound: 40_000,
|
||||
total_outbound: 60_000,
|
||||
})
|
||||
|
||||
// The panel's setup pulls a Pinia store via useTxExplorer — mount with a
|
||||
// fresh Pinia or setup throws before the first render.
|
||||
const wrapper = mount(LightningChannels, {
|
||||
global: { plugins: [createPinia()] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('100.0k sats')
|
||||
|
||||
const pending = deferred<{ channels: []; total_inbound: number; total_outbound: number }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadChannels: () => Promise<void> }).loadChannels()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('Refreshing channels...')
|
||||
expect(wrapper.text()).not.toContain('Loading channels...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveAppCredentials } from '../appCredentials'
|
||||
|
||||
describe('resolveAppCredentials', () => {
|
||||
it('uses backend credentials when they are available', () => {
|
||||
expect(resolveAppCredentials('filebrowser', {
|
||||
title: 'Backend credentials',
|
||||
credentials: [{ label: 'Password', value: 'secret' }],
|
||||
})?.credentials[0]?.value).toBe('secret')
|
||||
})
|
||||
|
||||
it('falls back to File Browser default credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('filebrowser', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('File Browser credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to PhotoPrism manifest credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('photoprism', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('PhotoPrism credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent credentials for unknown apps', () => {
|
||||
expect(resolveAppCredentials('unknown', { credentials: [] })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useLastKnownPackages, type PackageMap } from '../appPackageCache'
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useLastKnownPackages', () => {
|
||||
it('keeps the last package list visible while the scanner reports not ready', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
|
||||
containersScanned.value = false
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an empty list once the scanner has completed', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(cache.packages.value).toEqual({})
|
||||
expect(cache.lastKnownPackages.value).toEqual({})
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
category,
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('appsConfig service filtering', () => {
|
||||
it('treats bitcoin stack UI sidecars as services', () => {
|
||||
expect(isServiceContainer('bitcoin-ui')).toBe(true)
|
||||
expect(isServiceContainer('lnd-ui')).toBe(true)
|
||||
expect(isServiceContainer('electrs-ui')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats container aliases as services even with non-service keys', () => {
|
||||
const aliasPkg = makePkg('bitcoin-ui', 'Bitcoin UI', 'money')
|
||||
expect(isServicePackage('core-lnd-ui', aliasPkg)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes service-only categories from app category tabs', () => {
|
||||
const packages = ref<Record<string, PackageDataEntry>>({
|
||||
'core-bitcoin-ui': makePkg('bitcoin-ui', 'Bitcoin UI', 'money'),
|
||||
'filebrowser': makePkg('filebrowser', 'File Browser', 'data'),
|
||||
})
|
||||
|
||||
const allCategories = ref([
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
])
|
||||
|
||||
const visible = useCategoriesWithApps(packages, allCategories)
|
||||
expect(visible.value.map(c => c.id)).toEqual(['all', 'data'])
|
||||
})
|
||||
|
||||
it('filters apps tab by category using manifest-aware service checks', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-bitcoin-ui', makePkg('bitcoin-ui', 'Bitcoin UI', 'money')],
|
||||
['filebrowser', makePkg('filebrowser', 'File Browser', 'data')],
|
||||
['btcpay-server', makePkg('btcpay-server', 'BTCPay', 'commerce')],
|
||||
]
|
||||
|
||||
const appsAll = filterEntriesForTab(entries, 'apps', 'all')
|
||||
expect(appsAll.map(([id]) => id)).toEqual(['filebrowser', 'btcpay-server'])
|
||||
|
||||
const appsData = filterEntriesForTab(entries, 'apps', 'data')
|
||||
expect(appsData.map(([id]) => id)).toEqual(['filebrowser'])
|
||||
})
|
||||
|
||||
it('routes service aliases into services tab and excludes user apps', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-lnd-ui', makePkg('lnd-ui', 'LND UI', 'money')],
|
||||
['grafana', makePkg('grafana', 'Grafana', 'data')],
|
||||
]
|
||||
|
||||
const services = filterEntriesForTab(entries, 'services', 'all')
|
||||
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||
})
|
||||
|
||||
it('an unmapped id gets the A mark, not a guessed png that 404s', () => {
|
||||
// strfry 404'd live on 2026-08-07: no curated entry, no fallback entry,
|
||||
// no service prefix — the old `${id}.png` guess produced a broken tile.
|
||||
const pkg = makePkg('strfry', 'strfry', 'nostr')
|
||||
expect(resolveAppIcon('strfry', pkg)).toBe(DEFAULT_APP_ICON)
|
||||
})
|
||||
|
||||
it('classifies an unknown app by whether its manifest declares a UI (#45)', () => {
|
||||
// Headless: a LAN address but no declared UI → Website.
|
||||
const headless = makePkg('some-backend', 'Some Backend', 'other')
|
||||
headless.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9000' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(hasFrontendUi(headless)).toBe(false)
|
||||
expect(isWebsitePackage('some-backend', headless)).toBe(true)
|
||||
|
||||
// Front-end app: declares interfaces.main.ui → My Apps even when not in the
|
||||
// curated category map.
|
||||
const uiApp = makePkg('some-ui-app', 'Some UI App', 'other')
|
||||
;(uiApp.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:9001' } }
|
||||
uiApp.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9001' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(hasFrontendUi(uiApp)).toBe(true)
|
||||
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
|
||||
})
|
||||
|
||||
it('never offers Launch for an unknown container with a bare exposed port', () => {
|
||||
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
|
||||
// has a runtime lan-address — but no manifest-declared or probed UI. It
|
||||
// must classify as a service and must NOT get a Launch button.
|
||||
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
|
||||
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
|
||||
expect(canLaunch(selfDeployed)).toBe(false)
|
||||
})
|
||||
|
||||
it('offers Launch for an unknown container once the backend confirms a UI', () => {
|
||||
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
|
||||
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(confirmedUi)).toBe(true)
|
||||
})
|
||||
|
||||
it('never offers Launch for curated service containers even with a UI flag', () => {
|
||||
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
|
||||
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(service)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
|
||||
const known = makePkg('jellyfin', 'Jellyfin', 'media')
|
||||
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(known)).toBe(true)
|
||||
})
|
||||
|
||||
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||
pkg.state = PackageState.Starting
|
||||
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8175' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(launchBlockedReason('fedimint', pkg)).toContain('Bitcoin')
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { parseSideloadPortMapping, validateSideloadRequest } from '../sideloadValidation'
|
||||
|
||||
function makePkg(id: string, title: string, lanAddress?: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
installed: lanAddress
|
||||
? {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
status: 'running',
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'lan-address': lanAddress,
|
||||
'tor-address': '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sideloadValidation', () => {
|
||||
it('parses host and container port mappings', () => {
|
||||
expect(parseSideloadPortMapping('3009:80')).toEqual({ host: 3009, container: 80 })
|
||||
expect(parseSideloadPortMapping('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed port mappings', () => {
|
||||
expect(() => parseSideloadPortMapping('3009')).toThrow('host:container')
|
||||
expect(() => parseSideloadPortMapping('99999:80')).toThrow('between 1 and 65535')
|
||||
})
|
||||
|
||||
it('rejects duplicate app IDs', () => {
|
||||
const packages = { excalidraw: makePkg('excalidraw', 'Excalidraw') }
|
||||
expect(validateSideloadRequest('excalidraw', '3009:80', packages)).toContain('already installed')
|
||||
})
|
||||
|
||||
it('rejects reserved host ports', () => {
|
||||
expect(validateSideloadRequest('demo', '9000:80', {})).toContain('reserved')
|
||||
})
|
||||
|
||||
it('rejects host ports already used by installed apps', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '8083:80', packages)).toContain('File Browser')
|
||||
})
|
||||
|
||||
it('accepts available host ports', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '3018:80', packages)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
const FALLBACK_CREDENTIALS: Record<string, AppCredentialsResponse> = {
|
||||
filebrowser: {
|
||||
title: 'File Browser credentials',
|
||||
description: 'Use these credentials when File Browser asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
],
|
||||
},
|
||||
photoprism: {
|
||||
title: 'PhotoPrism credentials',
|
||||
description: 'Use these credentials when PhotoPrism asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveAppCredentials(appId: string, response?: AppCredentialsResponse | null): AppCredentialsResponse | null {
|
||||
if (response?.credentials?.length) return response
|
||||
return FALLBACK_CREDENTIALS[appId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
export type PackageMap = Record<string, PackageDataEntry>
|
||||
|
||||
export function useLastKnownPackages(
|
||||
livePackages: Ref<PackageMap>,
|
||||
containersScanned: Ref<boolean>,
|
||||
) {
|
||||
const lastKnownPackages = ref<PackageMap>({})
|
||||
|
||||
watch(
|
||||
livePackages,
|
||||
(packages) => {
|
||||
const hasPackages = Object.keys(packages).length > 0
|
||||
if (hasPackages || containersScanned.value) {
|
||||
lastKnownPackages.value = { ...packages }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const isUsingLastKnownPackages = computed(() => (
|
||||
!containersScanned.value &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
Object.keys(lastKnownPackages.value).length > 0
|
||||
))
|
||||
|
||||
const packages = computed<PackageMap>(() => (
|
||||
isUsingLastKnownPackages.value ? lastKnownPackages.value : livePackages.value
|
||||
))
|
||||
|
||||
return {
|
||||
packages,
|
||||
isUsingLastKnownPackages,
|
||||
lastKnownPackages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/** Static configuration for the Apps view */
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
])
|
||||
|
||||
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id || ''
|
||||
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
||||
}
|
||||
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isServiceContainer(id)) return true
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!manifestId && isServiceContainer(manifestId)
|
||||
}
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home', 'pine': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
||||
'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'uptime-kuma': 'networking',
|
||||
'botfights': 'community',
|
||||
}
|
||||
|
||||
export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
||||
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
||||
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
||||
return cat || 'other'
|
||||
}
|
||||
|
||||
export function runtimeLanAddress(pkg: PackageDataEntry): string {
|
||||
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
|
||||
}
|
||||
|
||||
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
|
||||
}
|
||||
|
||||
// True when the package's manifest declares a front-end UI interface. This is
|
||||
// the authoritative "is this a user-facing app?" signal (#45/#51): apps with a
|
||||
// UI belong in "My Apps", while headless services (databases, APIs, backends,
|
||||
// workers) declare no UI and belong in the "Services" tab.
|
||||
export function hasFrontendUi(pkg?: PackageDataEntry): boolean {
|
||||
return !!pkg?.manifest?.interfaces?.main?.ui
|
||||
}
|
||||
|
||||
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
// Headless infra (databases/backends/companions) keyed by container name are
|
||||
// services regardless of any stray UI string.
|
||||
if (isServicePackage(id, pkg)) return true
|
||||
// A declared front-end UI is the deciding factor: it's an app, not a website.
|
||||
if (hasFrontendUi(pkg)) return false
|
||||
// Curated known apps stay in My Apps even if their manifest predates the UI
|
||||
// interface field.
|
||||
if (isKnownApp(id, pkg)) return false
|
||||
// Anything still here has no declared UI and isn't a known launcher app:
|
||||
// databases, APIs, backends, workers. They belong in Services (not My Apps),
|
||||
// whether or not they expose a LAN address. (#10 — "anything that isn't the
|
||||
// frontend UI launcher".)
|
||||
return !!pkg
|
||||
}
|
||||
|
||||
export function filterEntriesForTab(
|
||||
entries: Array<[string, PackageDataEntry]>,
|
||||
activeTab: AppsTab,
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
return entries.filter(([id, pkg]) => {
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
||||
const isWebsite = isWebsitePackage(id, pkg)
|
||||
if (wantsWebsites ? !isWebsite : isWebsite) return false
|
||||
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
if (activeTab === 'services' && selectedCategory !== 'all') {
|
||||
return getServiceCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Group a (non-launcher) service container by type for the Services tab sub-nav
|
||||
// (#12). Heuristic over the container id + manifest id.
|
||||
export function getServiceCategory(id: string, pkg?: PackageDataEntry): string {
|
||||
const s = `${id} ${pkg?.manifest?.id || ''}`.toLowerCase()
|
||||
if (/postgres|mariadb|mysql|(^|[-_])db([-_]|$)/.test(s)) return 'database'
|
||||
if (/redis|valkey|(^|[-_])cache([-_]|$)/.test(s)) return 'cache'
|
||||
if (/(^|[-_])api([-_]|$)/.test(s)) return 'api'
|
||||
return 'backend'
|
||||
}
|
||||
|
||||
export function buildServiceCategories(t: (key: string) => string): Array<{ id: string; name: string }> {
|
||||
return [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'database', name: 'Databases' },
|
||||
{ id: 'cache', name: 'Caches' },
|
||||
{ id: 'api', name: 'APIs' },
|
||||
{ id: 'backend', name: 'Backends' },
|
||||
]
|
||||
}
|
||||
|
||||
// Web-only app IDs and their URLs
|
||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
|
||||
|
||||
export function isWebOnlyApp(id: string): boolean {
|
||||
return id in WEB_ONLY_APP_URLS
|
||||
}
|
||||
|
||||
// Web-only apps (no container) -- always show as installed bookmarks
|
||||
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {}
|
||||
|
||||
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
|
||||
export const TAB_LAUNCH_APPS = new Set([
|
||||
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
||||
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
|
||||
'netbird',
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
}
|
||||
|
||||
// Backend services that ship no icon of their own reuse their PARENT app's icon
|
||||
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
|
||||
// are explicit because icon extensions vary (.png / .webp / .svg).
|
||||
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
gitea: '/assets/img/app-icons/gitea.svg',
|
||||
// Apps whose icon extension isn't .png: without an explicit entry the
|
||||
// default `<id>.png` guess 404s on every render (console spam, and for
|
||||
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
|
||||
pine: '/assets/img/app-icons/pine.svg',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
'mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
||||
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
||||
// immich stack
|
||||
'immich-postgres': '/assets/img/app-icons/immich.png',
|
||||
'immich-redis': '/assets/img/app-icons/immich.png',
|
||||
'immich-server': '/assets/img/app-icons/immich.png',
|
||||
'immich_postgres': '/assets/img/app-icons/immich.png',
|
||||
'immich_redis': '/assets/img/app-icons/immich.png',
|
||||
// btcpay stack
|
||||
'archy-btcpay-db': '/assets/img/app-icons/btcpay-server.png',
|
||||
'archy-nbxplorer': '/assets/img/app-icons/btcpay-server.png',
|
||||
// mempool stack
|
||||
'archy-mempool-db': '/assets/img/app-icons/mempool.webp',
|
||||
'mempool-api': '/assets/img/app-icons/mempool.webp',
|
||||
'archy-mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||
'mysql-mempool': '/assets/img/app-icons/mempool.webp',
|
||||
// bitcoin / lightning companion UIs
|
||||
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
'archy-lnd-ui': '/assets/img/app-icons/lnd.png',
|
||||
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
// ElectrumX ships under a few historical ids (the backend was renamed
|
||||
// electrs → electrumx). Pin the whole family to the ElectrumX icon so My
|
||||
// Apps shows the right logo no matter which id the node has it installed
|
||||
// under.
|
||||
'electrs': '/assets/img/app-icons/electrumx.png',
|
||||
'electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
'electrumx': '/assets/img/app-icons/electrumx.png',
|
||||
}
|
||||
|
||||
// Parent-app icon by prefix, for stack members not listed explicitly above
|
||||
// (e.g. every indeedhub-* sub-container → indeedhub).
|
||||
const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
|
||||
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
|
||||
['immich-', '/assets/img/app-icons/immich.png'],
|
||||
['immich_', '/assets/img/app-icons/immich.png'],
|
||||
['pine-', '/assets/img/app-icons/pine.svg'],
|
||||
]
|
||||
|
||||
function serviceParentIcon(id: string): string | undefined {
|
||||
for (const [prefix, icon] of SERVICE_ICON_PREFIXES) {
|
||||
if (id.startsWith(prefix)) return icon
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||
if (
|
||||
icon.startsWith("/") ||
|
||||
icon.startsWith("http://") ||
|
||||
icon.startsWith("https://") ||
|
||||
icon.startsWith("data:image")
|
||||
) {
|
||||
return icon
|
||||
}
|
||||
return (
|
||||
curatedIcon ||
|
||||
APP_ICON_FALLBACKS[id] ||
|
||||
serviceParentIcon(id) ||
|
||||
// Never guess `${id}.png` — an unmapped id 404s (strfry did, 2026-08-07).
|
||||
// The A mark is the honest unknown-app tile.
|
||||
DEFAULT_APP_ICON
|
||||
)
|
||||
}
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
// Headless backends never get a Launch button, even with a published port.
|
||||
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
// A bare runtime address is only a launch signal for curated apps: the
|
||||
// backend now sets interfaces.main.ui strictly for confirmed web UIs
|
||||
// (manifest declaration or HTTP probe), so an unknown container with an
|
||||
// exposed non-UI port must not become launchable just for having one.
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui
|
||||
|| hasKnownLaunchUrl
|
||||
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
|
||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||
}
|
||||
// A static launch URL (e.g. a host-networked companion UI like
|
||||
// archy-electrs-ui) serves independently of the backend's own sync state, so
|
||||
// the tile stays launchable while the backend is still 'starting' (ElectrumX
|
||||
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
||||
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
||||
const blockedByHealth =
|
||||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
return !!hasUI && pkg.state === 'running' && !blockedByHealth
|
||||
}
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (
|
||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||
) {
|
||||
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
case PackageState.Exited:
|
||||
// Exit code 0 = clean shutdown (gray), non-zero = crash (red)
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-500/20 text-red-200'
|
||||
: 'bg-gray-500/20 text-gray-200'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-500/20 text-orange-200'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||
if (state === PackageState.Updating) return 'updating...'
|
||||
if (state === PackageState.Running) return 'running'
|
||||
if (state === PackageState.Exited || state === PackageState.Stopped) {
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function buildAllCategories(t: (key: string) => string) {
|
||||
return [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'media', name: 'Media' },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'other', name: t('marketplace.other') },
|
||||
]
|
||||
}
|
||||
|
||||
export function useCategoriesWithApps(
|
||||
packages: Ref<Record<string, PackageDataEntry>>,
|
||||
allCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
||||
return allCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Services-tab equivalent of useCategoriesWithApps: only show a service category
|
||||
// when at least one installed service belongs to it (#12).
|
||||
export function useServiceCategories(
|
||||
packages: Ref<Record<string, PackageDataEntry>>,
|
||||
serviceCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
||||
return serviceCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getServiceCategory(id, pkg) === cat.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
const currentSrc = target.src
|
||||
|
||||
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
|
||||
target.dataset.fallbackTried = "1"
|
||||
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = "1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
const RESERVED_HOST_PORTS = new Set([
|
||||
80, 443, 81,
|
||||
8332, 8333, 8334,
|
||||
9735, 10009, 8080,
|
||||
18083,
|
||||
4080, 8999, 50001,
|
||||
23000,
|
||||
8173, 8174, 8175,
|
||||
8123,
|
||||
3000,
|
||||
11434,
|
||||
9980, 9001,
|
||||
8240,
|
||||
9000,
|
||||
3001, 3002,
|
||||
8888,
|
||||
8096, 2342, 2283,
|
||||
8443,
|
||||
])
|
||||
|
||||
export interface ParsedPortMapping {
|
||||
host: number
|
||||
container: number
|
||||
}
|
||||
|
||||
export function parseSideloadPortMapping(value: string): ParsedPortMapping | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const match = trimmed.match(/^(\d{1,5}):(\d{1,5})$/)
|
||||
if (!match) {
|
||||
throw new Error('Port mapping must use host:container format, for example 3009:80.')
|
||||
}
|
||||
|
||||
const host = Number(match[1])
|
||||
const container = Number(match[2])
|
||||
if (!Number.isInteger(host) || host < 1 || host > 65535 || !Number.isInteger(container) || container < 1 || container > 65535) {
|
||||
throw new Error('Ports must be between 1 and 65535.')
|
||||
}
|
||||
return { host, container }
|
||||
}
|
||||
|
||||
export function packageUsesHostPort(pkg: PackageDataEntry, hostPort: number): boolean {
|
||||
const addresses = pkg.installed?.['interface-addresses'] || {}
|
||||
return Object.values(addresses).some((addr) => {
|
||||
const lan = addr?.['lan-address']
|
||||
if (!lan) return false
|
||||
try {
|
||||
const parsed = new URL(lan)
|
||||
return Number(parsed.port || (parsed.protocol === 'https:' ? '443' : '80')) === hostPort
|
||||
} catch {
|
||||
const match = lan.match(/:(\d+)(?:\/|$)/)
|
||||
return match ? Number(match[1]) === hostPort : false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSideloadRequest(
|
||||
id: string,
|
||||
portMapping: string,
|
||||
packages: Record<string, PackageDataEntry>,
|
||||
): string | null {
|
||||
if (packages[id]) return `An app with ID "${id}" is already installed.`
|
||||
|
||||
let parsed: ParsedPortMapping | null = null
|
||||
try {
|
||||
parsed = parseSideloadPortMapping(portMapping)
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid port mapping.'
|
||||
}
|
||||
if (!parsed) return null
|
||||
|
||||
if (RESERVED_HOST_PORTS.has(parsed.host)) {
|
||||
return `Host port ${parsed.host} is reserved by Archipelago or a packaged app. Choose another host port.`
|
||||
}
|
||||
|
||||
const existing = Object.entries(packages).find(([, pkg]) => packageUsesHostPort(pkg, parsed.host))
|
||||
if (existing) {
|
||||
const title = existing[1].manifest?.title || existing[0]
|
||||
return `Host port ${parsed.host} is already used by ${title}. Choose another host port.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/** Composable for app start/stop/restart/uninstall actions */
|
||||
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
|
||||
export function useAppsActions() {
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const loadingActions = ref<Record<string, boolean>>({})
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const actionTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const uninstalling = ref(false)
|
||||
// Use global store so uninstall state persists across navigation
|
||||
const uninstallingApps = serverStore.uninstallingApps
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
async function startApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.startPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 5000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start app:', err)
|
||||
showActionError(`Failed to start app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.stopPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 5000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
|
||||
showActionError(`Failed to stop app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.restartPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 8000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
|
||||
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
|
||||
uninstalling.value = true
|
||||
try {
|
||||
uninstallingApps.add(appId)
|
||||
await store.uninstallPackage(appId, options)
|
||||
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||
// when the container actually disappears from backend data
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
uninstallingApps.delete(appId)
|
||||
} finally {
|
||||
uninstalling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of actionTimers.values()) clearTimeout(t)
|
||||
actionTimers.clear()
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
loadingActions,
|
||||
actionError,
|
||||
uninstalling,
|
||||
uninstallingApps,
|
||||
showActionError,
|
||||
startApp,
|
||||
stopApp,
|
||||
restartApp,
|
||||
confirmUninstall,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function normalizeCloudPath(path: unknown, fallback = '/'): string {
|
||||
if (typeof path !== 'string' || !path.trim()) return fallback
|
||||
const trimmed = path.trim()
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
return withSlash.replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
export function parentCloudPath(path: string): string {
|
||||
const normalized = normalizeCloudPath(path)
|
||||
if (normalized === '/') return '/'
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf('/')) || '/'
|
||||
return parent
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Lifecycle / Offline Banner.
|
||||
Server restart/shutdown is deliberate → shown immediately. A plain
|
||||
connection blip is debounced (showConnIssue) so transient sub-grace
|
||||
reconnects don't flash. -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="(showLifecycle || showConnectionLost)"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
|
||||
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Reconnecting Banner (debounced) -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="showReconnecting"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span class="font-medium">Reconnecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const isOffline = computed(() => store.isOffline)
|
||||
const isRestarting = computed(() => store.isRestarting)
|
||||
const isShuttingDown = computed(() => store.isShuttingDown)
|
||||
|
||||
// A deliberate server lifecycle transition (restart/shutdown) is real and
|
||||
// user-initiated — surface it immediately, no debounce.
|
||||
const isLifecycleTransition = computed(() => isRestarting.value || isShuttingDown.value)
|
||||
const showLifecycle = computed(() => isLifecycleTransition.value && store.isAuthenticated)
|
||||
|
||||
// A plain connection blip (offline or reconnecting, not a lifecycle transition).
|
||||
// The overwhelming majority recover within a second or two (load spikes,
|
||||
// Tailscale/relay TCP resets), so showing the banner instantly makes a healthy
|
||||
// node read as unstable. Debounce: only surface after the issue persists past a
|
||||
// grace window; hide immediately on recovery.
|
||||
const hasConnIssue = computed(
|
||||
() => (store.isReconnecting || isOffline.value) && !isLifecycleTransition.value
|
||||
)
|
||||
|
||||
const SHOW_DELAY_MS = 2500
|
||||
// Right after the page loads or the tab returns to the foreground, a dead
|
||||
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
|
||||
// first paint races the initial connect). Reconnecting takes longer than
|
||||
// the steady-state grace on real links — radio wake-up, TLS, proxies — so
|
||||
// the 2.5s window made every tab-return flash "Connection lost" on a
|
||||
// perfectly healthy node. Give those moments a much longer runway; keep
|
||||
// the short window for genuine mid-session drops.
|
||||
const RESUME_GRACE_WINDOW_MS = 15000
|
||||
const RESUME_SHOW_DELAY_MS = 10000
|
||||
const showConnIssue = ref(false)
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
|
||||
|
||||
function onVisibilityResume() {
|
||||
if (!document.hidden) lastResumeAt = Date.now()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityResume)
|
||||
|
||||
function clearTimer() {
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
hasConnIssue,
|
||||
(issue) => {
|
||||
clearTimer()
|
||||
// The demo runs against a local mock — a connection banner there is
|
||||
// meaningless noise on what should be a flawless showcase.
|
||||
if (IS_DEMO) return
|
||||
if (issue) {
|
||||
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
|
||||
? RESUME_SHOW_DELAY_MS
|
||||
: SHOW_DELAY_MS
|
||||
pendingTimer = setTimeout(() => {
|
||||
showConnIssue.value = true
|
||||
pendingTimer = null
|
||||
}, delay)
|
||||
} else {
|
||||
// Recovered before the grace window elapsed — hide at once.
|
||||
showConnIssue.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibilityResume)
|
||||
})
|
||||
|
||||
// Debounced visual states the template renders.
|
||||
const showReconnecting = computed(
|
||||
() => showConnIssue.value && store.isReconnecting && store.isAuthenticated
|
||||
)
|
||||
const showConnectionLost = computed(
|
||||
() => showConnIssue.value && isOffline.value && !store.isReconnecting && store.isAuthenticated
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Float the connection banners over the UI instead of occupying layout space
|
||||
* (which previously pushed the whole dashboard down when reconnecting).
|
||||
* Pinned top-center, clear of the status bar via the safe-area inset that the
|
||||
* Android companion app injects (--safe-area-top), falling back to env(). */
|
||||
.conn-banner-overlay {
|
||||
position: fixed;
|
||||
top: calc(1rem + var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
left: 50%;
|
||||
z-index: 60;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 2rem);
|
||||
pointer-events: none; /* purely informational — never intercept taps */
|
||||
}
|
||||
|
||||
.conn-banner-enter-active,
|
||||
.conn-banner-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.conn-banner-enter-from,
|
||||
.conn-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
}
|
||||
.conn-banner-enter-to,
|
||||
.conn-banner-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||
<div
|
||||
v-if="showAppsTabs && !isAppSessionActive"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
|
||||
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0); padding-top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
|
||||
>
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
|
||||
>My Apps</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
|
||||
>App Store</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps?tab=services"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'services' } })"
|
||||
>Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Network/Cloud -->
|
||||
<div
|
||||
v-if="showNetworkTabs && !isAppSessionActive"
|
||||
class="md:hidden fixed left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
|
||||
:class="{ 'glass-throw-mobile-tabs-2': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
:style="{ top: showAppsTabs ? '80px' : '0', paddingTop: showAppsTabs ? '16px' : 'calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px)' }"
|
||||
>
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/web5' || route.path.startsWith('/dashboard/web5/') }"
|
||||
>Web5</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/') }"
|
||||
>Cloud</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/') }"
|
||||
>Network</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/mesh"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/mesh' || route.path.startsWith('/dashboard/mesh/') }"
|
||||
>Mesh</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Bottom Tab Bar (hidden when app is open fullscreen) -->
|
||||
<nav
|
||||
v-if="!isAppSessionActive"
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
:aria-label="t('dashboard.mobileNav')"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: var(--safe-area-bottom, env(safe-area-inset-bottom, 0px));"
|
||||
>
|
||||
<div class="flex justify-around items-center px-2 py-3 relative">
|
||||
<RouterLink
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
aria-current-value="page"
|
||||
@click="appLauncher.closePanel()"
|
||||
class="flex items-center justify-center w-14 h-14 rounded-xl text-white/70 transition-all duration-300 relative z-10"
|
||||
:class="{
|
||||
'nav-tab-active': item.isCombined
|
||||
? (item.path === '/dashboard/apps'
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
|
||||
: item.path === '/dashboard/web5'
|
||||
? (route.path.includes('/web5') || route.path.includes('/federation') || route.path.includes('/mesh'))
|
||||
: (route.path.includes('/cloud') || route.path.includes('/server')))
|
||||
: undefined
|
||||
}"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
>
|
||||
<svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<!-- Chat launcher -->
|
||||
<button
|
||||
@click="router.push('/dashboard/chat')"
|
||||
class="chat-launcher-btn-mobile flex items-center justify-center w-14 h-14 rounded-xl transition-all duration-300 relative z-10"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
isCombined?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
showZoomIn: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const uiMode = useUIModeStore()
|
||||
|
||||
const mobileTabBar = ref<HTMLElement | null>(null)
|
||||
const MOBILE_LAYOUT_MAX_WIDTH = 920
|
||||
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
|
||||
|
||||
// App sessions own their mobile controls, so the nav hides while one is open.
|
||||
// Mobile launches now use the store-driven panel (no route change) to keep the
|
||||
// background tab intact, so treat an active panel the same as a routed session.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
|
||||
return route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover')
|
||||
})
|
||||
|
||||
// Show persistent tabs for Network/Cloud on mobile
|
||||
const showNetworkTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
|
||||
if (route.name === 'cloud-folder') return false
|
||||
return route.path.includes('/server') || route.path.includes('/cloud') || route.path.includes('/web5') || route.path.includes('/mesh')
|
||||
})
|
||||
|
||||
// Top padding for content div to clear fixed mobile tab overlays.
|
||||
// Includes safe area inset for Android (read from CSS custom property set by WebView).
|
||||
const safeAreaTop = ref(0)
|
||||
|
||||
function readSafeAreaTop() {
|
||||
if (typeof window === 'undefined') return
|
||||
const val = getComputedStyle(document.documentElement).getPropertyValue('--safe-area-top').trim()
|
||||
if (val) safeAreaTop.value = parseInt(val, 10) || 0
|
||||
}
|
||||
|
||||
const mobileTabPaddingTop = computed(() => {
|
||||
if (typeof window === 'undefined' || viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return 0
|
||||
const sat = safeAreaTop.value
|
||||
if (showAppsTabs.value && showNetworkTabs.value) return 160 + sat
|
||||
if (showAppsTabs.value || showNetworkTabs.value) return 80 + sat
|
||||
return 0
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
showAppsTabs,
|
||||
showNetworkTabs,
|
||||
mobileTabPaddingTop,
|
||||
})
|
||||
|
||||
function updateTabBarHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
const el = mobileTabBar.value
|
||||
// offsetHeight is 0 when the bar is hidden (desktop `md:hidden`) or not yet
|
||||
// laid out. Writing `--mobile-tab-bar-height: 0px` would DEFEAT the `, 88px`
|
||||
// fallback baked into the `.mobile-scroll-pad` clearance calc (an explicit
|
||||
// 0px is still "set"), so the fixed tab bar ends up covering the last row of
|
||||
// content — the Cloud/files "bottom elements cut off" bug. Only write a real
|
||||
// measured height; otherwise remove the var so the fallback applies.
|
||||
if (el && el.offsetHeight > 0) {
|
||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${el.offsetHeight}px`)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
function onInsetsInjected() {
|
||||
readSafeAreaTop()
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
// Re-measure after the first paint: on mount the bar may not have its final
|
||||
// laid-out height yet (fonts/safe-area padding still settling), which would
|
||||
// leave the clearance var short.
|
||||
requestAnimationFrame(updateTabBarHeight)
|
||||
readSafeAreaTop()
|
||||
window.addEventListener('resize', onResize)
|
||||
// The Android WebView injects --safe-area-top asynchronously and fires this
|
||||
// event when it lands. An authenticated session mounts the dashboard BEFORE
|
||||
// the injection (fresh installs mount after login, long after it), so a
|
||||
// one-shot read here bakes in 0 and content slides under the growing fixed
|
||||
// tab bar — the update-install-only overlap bug.
|
||||
window.addEventListener('archy-insets', onInsetsInjected)
|
||||
// Fallback retry ladder for APKs that predate the event.
|
||||
for (const delay of [500, 1500, 3000, 6000]) {
|
||||
setTimeout(onInsetsInjected, delay)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('archy-insets', onInsetsInjected)
|
||||
})
|
||||
|
||||
// Re-measure on route changes
|
||||
watch(() => route.path, () => {
|
||||
nextTick(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
})
|
||||
|
||||
const gamerMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5', isCombined: true },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const easyMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const mobileNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyMobileNav
|
||||
if (uiMode.isChat) return chatMobileNav
|
||||
return gamerMobileNav
|
||||
})
|
||||
|
||||
function getIconPath(iconName: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
|
||||
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
|
||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
||||
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
||||
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
|
||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
settings: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
],
|
||||
}
|
||||
return icons[iconName] || []
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)">
|
||||
<KeepAlive :max="KEEP_ALIVE_MAX" :include="keepAliveIncludes">
|
||||
<component
|
||||
:is="wrapperFor(route.path)"
|
||||
:key="route.path"
|
||||
:mobile-tab-padding-top="mobileTabPaddingTop"
|
||||
:needs-mobile-back-button-space="needsMobileBackButtonSpace"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:class="isFullBleedPath(route.path) ? undefined : 'view-container flex-none'"
|
||||
/>
|
||||
</component>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// The KeepAlive host extracted from Dashboard.vue's nested RouterView (D-01),
|
||||
// restructured after the Task 3 checkpoint failure (broken margins + missing
|
||||
// slide transitions) to restore the pre-02-02 rendered DOM exactly.
|
||||
//
|
||||
// Structural invariants — the tests in __tests__/keepAliveTabs.test.ts pin
|
||||
// these, and dashboard-styles.css is the contract they serve:
|
||||
//
|
||||
// 1. The <Transition> child is a keyed `div.view-wrapper` that sits directly
|
||||
// in the RouterView slot (so it is the direct child of
|
||||
// `.perspective-container` in Dashboard.vue). Every transition in
|
||||
// dashboard-styles.css is a compound selector
|
||||
// (`.slide-up-enter-active.view-wrapper`, `.depth-forward-enter-from.view-wrapper`,
|
||||
// …), so the transition classes and `view-wrapper` MUST share one element,
|
||||
// with no intermediate wrapper flattening the 3D perspective or clipping
|
||||
// slide movement.
|
||||
// 2. The per-route wrapper shapes (full-bleed chat/mesh vs. the default
|
||||
// padded/scrollable shape) live INSIDE `div.view-wrapper` — `view-wrapper`
|
||||
// is `absolute inset-0` and must never be applied to the view's own root
|
||||
// inside the padded wrapper (that pins the view over the page padding:
|
||||
// the broken-margins regression).
|
||||
// 3. KeepAlive caching is reconciled with (1) and (2) by making the keyed
|
||||
// `div.view-wrapper` the root of a statically-defined per-route wrapper
|
||||
// component (dashboardViewWrappers.ts). KeepAlive sits between
|
||||
// <Transition> and the keyed wrapper vnode — the canonical composition —
|
||||
// and `:include` (matching the wrappers' static names, derived from
|
||||
// KEEP_ALIVE_PATHS) decides which wrappers are instance-cached. Caching
|
||||
// the wrapper caches its whole subtree, including the routed view.
|
||||
// 4. KeepAlive itself is never keyed, toggled with v-if, or nested under a
|
||||
// per-route element — any of those tears down its instance cache.
|
||||
//
|
||||
// Scroll behavior: each route's scroll container lives inside its keyed
|
||||
// wrapper, so non-kept routes reset to top on entry (the pre-02-02 behavior)
|
||||
// and the kept-alive tab's scroll container is part of its cached subtree —
|
||||
// no manual scroll-retention bookkeeping needed.
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useRouteTransitions } from './useRouteTransitions'
|
||||
import { KEEP_ALIVE_MAX } from './keepAliveRoutes'
|
||||
import { isFullBleedPath, keepAliveIncludeNames, wrapperFor } from './dashboardViewWrappers'
|
||||
|
||||
defineProps<{
|
||||
mobileTabPaddingTop: number | null
|
||||
needsMobileBackButtonSpace: boolean
|
||||
}>()
|
||||
|
||||
const { getTransitionName } = useRouteTransitions()
|
||||
const keepAliveIncludes = keepAliveIncludeNames()
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<aside
|
||||
v-show="!chatFullscreen"
|
||||
data-controller-zone="sidebar"
|
||||
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
|
||||
:class="{ 'sidebar-animate': showZoomIn }"
|
||||
>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner flex flex-col h-full min-h-0">
|
||||
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">{{ $ver(version) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
aria-current-value="page"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
@click="appLauncher.closePanel()"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
|
||||
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
|
||||
>{{ web5Badge.pendingRequestCount }}</span>
|
||||
<span
|
||||
v-if="item.path === '/dashboard/mesh' && meshStore.totalUnread > 0"
|
||||
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
|
||||
>{{ meshStore.totalUnread }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Chat launcher button -->
|
||||
<button
|
||||
@click="router.push('/dashboard/chat')"
|
||||
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
<span>AIUI</span>
|
||||
</button>
|
||||
|
||||
<!-- Logout - styled as nav item, below Settings -->
|
||||
<button
|
||||
@click="$emit('logout')"
|
||||
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom shrink-0">
|
||||
<div class="sidebar-controller px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
<CompanionIndicator />
|
||||
</div>
|
||||
|
||||
<!-- Online status -->
|
||||
<div class="px-6 pb-2">
|
||||
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
|
||||
<OnlineStatusPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="px-6 pb-6">
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useWeb5BadgeStore } from '@/stores/web5Badge'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import CompanionIndicator from '@/components/CompanionIndicator.vue'
|
||||
import ModeSwitcher from '@/components/ModeSwitcher.vue'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
isCombined?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
showZoomIn: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const uiMode = useUIModeStore()
|
||||
const web5Badge = useWeb5BadgeStore()
|
||||
const meshStore = useMeshStore()
|
||||
|
||||
const chatFullscreen = computed(() => route.path === '/dashboard/chat')
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
|
||||
const gamerDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
|
||||
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
||||
// { path: '/dashboard/fleet', label: 'Fleet', icon: 'fleet' }, // Hidden for beta
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const easyDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const desktopNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyDesktopNav
|
||||
if (uiMode.isChat) return chatDesktopNav
|
||||
return gamerDesktopNav
|
||||
})
|
||||
|
||||
function getIconPath(iconName: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
|
||||
marketplace: ['M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z'],
|
||||
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
|
||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
||||
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
||||
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
|
||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
settings: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
],
|
||||
}
|
||||
return icons[iconName] || []
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="healthNotifications.length > 0"
|
||||
class="fixed right-4 z-[200] flex flex-col gap-2 max-w-sm"
|
||||
style="top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
|
||||
>
|
||||
<div
|
||||
v-for="notif in healthNotifications"
|
||||
:key="notif.id"
|
||||
class="p-3 rounded-xl border backdrop-blur-lg shadow-lg"
|
||||
:class="notif.level === 'error'
|
||||
? 'bg-red-500/15 border-red-500/30'
|
||||
: notif.level === 'warning'
|
||||
? 'bg-yellow-500/15 border-yellow-500/30'
|
||||
: 'bg-blue-500/15 border-blue-500/30'"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0" :class="notif.level === 'error' ? 'text-red-400' : notif.level === 'warning' ? 'text-yellow-400' : 'text-blue-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ notif.title }}</p>
|
||||
<p class="text-xs text-white/60 mt-0.5">{{ notif.message }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-white/40 hover:text-white/80 transition-colors shrink-0"
|
||||
@click="dismissNotification(notif.id)"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
|
||||
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
const dismissedNotifications = ref<Set<string>>(new Set())
|
||||
|
||||
const healthNotifications = computed(() => {
|
||||
const notifs = store.data?.notifications ?? []
|
||||
const packages = store.data?.['package-data'] ?? {}
|
||||
const visible = notifs.filter((n) => {
|
||||
if (dismissedNotifications.value.has(n.id)) return false
|
||||
|
||||
const appId = n.app_id || appIdFromNotificationTitle(n.title)
|
||||
if (appId) {
|
||||
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
|
||||
const pkg = packages[appId]
|
||||
if (!pkg) return false
|
||||
if (pkg.health !== 'unhealthy') return false
|
||||
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
|
||||
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// Deduplicate: keep only the latest notification per container/title
|
||||
const seen = new Map<string, typeof visible[0]>()
|
||||
for (const n of visible) {
|
||||
seen.set(n.title, n)
|
||||
}
|
||||
return [...seen.values()].slice(-3)
|
||||
})
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
// Dismiss all notifications with the same title (container name)
|
||||
const notif = (store.data?.notifications ?? []).find(n => n.id === id)
|
||||
if (notif) {
|
||||
for (const n of store.data?.notifications ?? []) {
|
||||
if (n.title === notif.title) dismissedNotifications.value.add(n.id)
|
||||
}
|
||||
}
|
||||
dismissedNotifications.value.add(id)
|
||||
}
|
||||
|
||||
function appIdFromNotificationTitle(title: string): string | undefined {
|
||||
const suffix = ' is unhealthy'
|
||||
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
|
||||
}
|
||||
|
||||
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
|
||||
const ts = Date.parse(timestamp)
|
||||
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
|
||||
import HealthNotifications from '../HealthNotifications.vue'
|
||||
|
||||
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
|
||||
return {
|
||||
state,
|
||||
health,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
}
|
||||
}
|
||||
|
||||
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
|
||||
return {
|
||||
'server-info': {
|
||||
id: 'node',
|
||||
version: '1.0.0',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': {
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
updated: false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
'seed-backed': false,
|
||||
},
|
||||
'package-data': pkg ? { indeedhub: pkg } : {},
|
||||
notifications: [{
|
||||
id: 'health-1',
|
||||
level: 'error',
|
||||
title: 'indeedhub is unhealthy',
|
||||
message: 'indeedhub health check failed',
|
||||
timestamp,
|
||||
app_id: 'indeedhub',
|
||||
}],
|
||||
ui: {
|
||||
name: null,
|
||||
'ack-welcome': '',
|
||||
marketplace: {
|
||||
'selected-hosts': [],
|
||||
'known-hosts': {},
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('HealthNotifications', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows active unhealthy package notifications', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides stale package notifications once health recovers', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides package notifications while an app is being removed', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(
|
||||
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
|
||||
new Date('2026-06-10T11:20:00Z').toISOString(),
|
||||
)
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user