Archipelago — open-source initial import
This commit is contained in:
@@ -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,399 @@
|
||||
<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>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
||||
<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="credential-modal-body 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>
|
||||
<div class="credential-modal-actions mt-5 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>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from '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;
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</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,22 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<!-- Back Button -->
|
||||
<button @click="router.replace('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to LND
|
||||
</button>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
|
||||
|
||||
<LightningChannelsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
</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.168.1.198' },
|
||||
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.168.1.198: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,70 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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,
|
||||
})
|
||||
|
||||
const wrapper = mount(LightningChannels)
|
||||
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,108 @@
|
||||
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 } 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('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('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,377 @@
|
||||
/** 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',
|
||||
])
|
||||
|
||||
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',
|
||||
'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',
|
||||
'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'],
|
||||
]
|
||||
|
||||
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) ||
|
||||
`/assets/img/app-icons/${id}.png`
|
||||
)
|
||||
}
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user