frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
+6 -2
View File
@@ -31,7 +31,7 @@
<img
:src="icon"
:alt="title"
class="app-card-icon w-14 h-14 object-cover bg-white/10"
class="app-card-icon archy-app-icon w-14 h-14"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
@@ -77,6 +77,9 @@
{{ 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 -->
@@ -207,7 +210,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PackageDataEntry } from '@/types/api'
import {
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
@@ -284,6 +287,7 @@ const isTransitioning = computed(() => {
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>
+76 -8
View File
@@ -18,15 +18,21 @@
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"
class="app-icon-img archy-app-icon"
@error="handleImageError"
/>
<!-- Status dot -->
@@ -44,7 +50,7 @@
></span>
<!-- Installing overlay -->
<div
v-if="serverStore.isInstalling(id)"
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">
@@ -55,6 +61,13 @@
</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>
@@ -72,7 +85,7 @@
</div>
<Transition name="fade">
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
<div class="sideload-modal credential-modal">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
@@ -107,6 +120,7 @@ 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'
@@ -135,6 +149,8 @@ const emit = defineEmits<{
const scrollContainer = ref<HTMLElement | null>(null)
const activePage = ref(0)
const longPressTriggered = ref(false)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const pages = computed(() => {
const result: [string, PackageDataEntry][][] = []
@@ -154,7 +170,22 @@ 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
@@ -164,6 +195,26 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
}
}
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) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -191,18 +242,29 @@ function launchNow(id: string, pkg: PackageDataEntry) {
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 })
if (!result.credentials?.length) return false
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: result.title || `${getTitle(id, pkg)} credentials`,
description: result.description || 'Use these credentials when the app asks you to sign in.',
credentials: result.credentials,
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 {
return false
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
}
}
@@ -270,6 +332,12 @@ function scrollToPage(index: number) {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal {
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);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
.credential-modal-actions {
flex-shrink: 0;
}
+26 -3
View File
@@ -26,6 +26,19 @@
<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>
@@ -37,7 +50,7 @@
{{ t('common.cancel') }}
</button>
<button
@click="$emit('confirm')"
@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"
>
@@ -61,7 +74,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
@@ -75,11 +88,12 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
confirm: []
confirm: [deleteAppData: boolean]
}>()
const modalRef = ref<HTMLElement | null>(null)
const restoreFocusRef = ref<HTMLElement | null>(null)
const deleteAppData = ref(false)
useModalKeyboard(
modalRef,
@@ -87,4 +101,13 @@ useModalKeyboard(
() => emit('close'),
{ restoreFocusRef },
)
watch(
() => props.show,
(show) => {
if (show) {
deleteAppData.value = false
}
},
)
</script>
+19 -5
View File
@@ -1,7 +1,7 @@
<template>
<div class="pb-16 md:pb-4">
<!-- Back Button -->
<button @click="router.push('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<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>
@@ -38,7 +38,7 @@
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -47,7 +47,7 @@
</div>
<!-- Error -->
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
@@ -63,6 +63,16 @@
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
@@ -119,7 +129,7 @@
</Transition>
<!-- Open Channel Modal -->
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@@ -165,7 +175,7 @@
</div>
<!-- Close Confirmation Modal -->
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
@@ -232,6 +242,7 @@ function capacityPercent(amount: number, capacity: number): number {
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
@@ -246,6 +257,7 @@ async function loadChannels() {
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
@@ -305,4 +317,6 @@ async function closeChannel() {
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>
@@ -1,12 +1,19 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
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 {
@@ -31,8 +38,12 @@ function makePkg(id: string): PackageDataEntry {
}
describe('AppIconGrid', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
setActivePinia(createPinia())
vi.useRealTimers()
pinia = createPinia()
setActivePinia(pinia)
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
@@ -51,14 +62,32 @@ describe('AppIconGrid', () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [createPinia()],
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
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],
},
})
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('routes desktop new-tab apps through app session on mobile', async () => {
@@ -71,13 +100,78 @@ describe('AppIconGrid', () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['gitea', makePkg('gitea')]] },
global: {
plugins: [createPinia()],
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBeNull()
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 '../LightningChannels.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ 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)
})
})
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { filterEntriesForTab, isServiceContainer, isServicePackage, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
import { canLaunch, filterEntriesForTab, isServiceContainer, isServicePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
@@ -81,4 +81,12 @@ describe('appsConfig service filtering', () => {
pkg['static-files']!.icon = 'git-branch'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
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,
}
}
+20 -10
View File
@@ -50,7 +50,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
'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',
@@ -165,6 +165,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
}
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
@@ -184,9 +186,23 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
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
}
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
}
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
@@ -272,14 +288,8 @@ export function handleImageError(e: Event) {
return
}
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
</svg>
`)}`
if (!currentSrc.includes("data:image")) {
target.src = placeholderSvg
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
}
+2 -2
View File
@@ -70,11 +70,11 @@ export function useAppsActions() {
}
}
async function confirmUninstall(appId: string) {
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
uninstalling.value = true
try {
uninstallingApps.add(appId)
await store.uninstallPackage(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) {