Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 624a2418ed
1578 changed files with 333060 additions and 0 deletions
+349
View File
@@ -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)" → 1050% (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>
+399
View File
@@ -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()
})
})
+25
View File
@@ -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,
}
}
+377
View File
@@ -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
}
+106
View File
@@ -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,
}
}