fix(apps): preserve lifecycle state and wait for usable launch endpoints
This commit is contained in:
@@ -34,6 +34,7 @@ vi.mock('@/api/rpc-client', () => ({
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
|
||||
import { useAppStore } from '../app'
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -54,6 +55,25 @@ describe('useAppLauncherStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks both browser and embedded launch while HTTP is unready', () => {
|
||||
const app = useAppStore()
|
||||
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
|
||||
const launcher = useAppLauncherStore()
|
||||
launcher.openSession('gitea')
|
||||
expect(launcher.panelAppId).toBeNull()
|
||||
launcher.open({ url: 'http://192.0.2.10:3001/', title: 'Gitea', openInNewTab: true })
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(launcher.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('also gates a dynamic app resolved through its runtime URL', () => {
|
||||
useAppStore().data = { 'package-data': { custom: { state: 'running', 'ui-ready': false, manifest: { id: 'custom', title: 'Custom' }, installed: { 'interface-addresses': { main: { 'lan-address': 'http://localhost:18993/' } } } } } } as never
|
||||
const launcher = useAppLauncherStore()
|
||||
launcher.open({ url: 'http://192.0.2.10:18993/', title: 'Custom', openInNewTab: true })
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(launcher.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('starts closed with empty state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
|
||||
const fake = reactive<{ packages: Record<string, unknown> }>({ packages: {} })
|
||||
vi.mock('../sync', () => ({ useSyncStore: () => fake }))
|
||||
vi.mock('../../api/rpc-client', () => ({ rpcClient: {} }))
|
||||
import { useServerStore } from '../server'
|
||||
|
||||
function installing(phase = 'preparing-app') {
|
||||
return { state: 'installing', manifest: { title: 'Git Workshop' }, 'install-progress': { phase, size: 0, downloaded: 0 } }
|
||||
}
|
||||
|
||||
describe('installation state after hard refresh', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fake.packages = {}
|
||||
})
|
||||
|
||||
it('restores an in-flight install from an already-loaded server snapshot', () => {
|
||||
fake.packages = { 'archipelago-source': installing() }
|
||||
const store = useServerStore()
|
||||
expect(store.isInstalling('archipelago-source')).toBe(true)
|
||||
expect(store.installingApps.get('archipelago-source')).toMatchObject({
|
||||
progress: 20,
|
||||
message: 'Downloading, building and starting app…',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a long download visible and clears it on terminal success', async () => {
|
||||
const store = useServerStore()
|
||||
fake.packages = { 'nginx-proxy-manager': installing() }
|
||||
await nextTick()
|
||||
expect(store.isInstalling('nginx-proxy-manager')).toBe(true)
|
||||
fake.packages = { 'nginx-proxy-manager': installing() }
|
||||
await nextTick()
|
||||
expect(store.installingApps.get('nginx-proxy-manager')?.progress).toBe(20)
|
||||
fake.packages = { 'nginx-proxy-manager': { state: 'running' } }
|
||||
await nextTick()
|
||||
expect(store.isInstalling('nginx-proxy-manager')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -239,6 +239,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const panelPath = ref<string | null>(null)
|
||||
|
||||
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (pkg?.['ui-ready'] === false) {
|
||||
useToast().info(`${pkg.manifest?.title || appId} is not ready to open yet`)
|
||||
return
|
||||
}
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
|
||||
@@ -295,7 +300,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Apply the same readiness gate here so a container that has just entered
|
||||
// `running` cannot race nginx and show a transient 502 to the user.
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
|
||||
if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) {
|
||||
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
|
||||
return
|
||||
}
|
||||
@@ -394,6 +399,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
const pkg = resolvedId ? useAppStore().data?.['package-data']?.[resolvedId] : undefined
|
||||
if (pkg?.['ui-ready'] === false) {
|
||||
useToast().info(`${pkg.manifest?.title || resolvedId} is not ready to open yet`)
|
||||
return
|
||||
}
|
||||
|
||||
// Scheme discipline for everything launched on this host. Ports fronted
|
||||
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
||||
// port — on an HTTPS connection those must open over https. Ports that
|
||||
@@ -472,6 +483,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
|
||||
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
|
||||
if (m?.[1]) return m[1].toLowerCase()
|
||||
// Dynamic/sideloaded apps have no entry in the static port map.
|
||||
if (u.hostname === window.location.hostname && u.port) {
|
||||
for (const [id, pkg] of Object.entries(useAppStore().data?.['package-data'] || {})) {
|
||||
const address = pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
if (!address) continue
|
||||
try {
|
||||
const runtime = new URL(address)
|
||||
if (runtime.port === u.port && ['localhost', '127.0.0.1', window.location.hostname].includes(runtime.hostname)) return id
|
||||
} catch { /* malformed runtime address is not a launch target */ }
|
||||
}
|
||||
}
|
||||
// Check port-based apps
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { InstallPhase } from '../types/api'
|
||||
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
||||
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
|
||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||
'preparing-app': { progress: 20, message: 'Downloading, building and starting app…', status: 'downloading' },
|
||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
||||
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
|
||||
@@ -149,7 +150,7 @@ export const useServerStore = defineStore('server', () => {
|
||||
uninstallingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||
const existing = installingApps.value.get(appId)
|
||||
|
||||
@@ -88,6 +88,7 @@ export const PackageState = {
|
||||
export type PackageState = typeof PackageState[keyof typeof PackageState]
|
||||
|
||||
export interface PackageDataEntry {
|
||||
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
|
||||
state: PackageState
|
||||
health?: string | null // "healthy", "unhealthy", "starting", or null
|
||||
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
|
||||
@@ -180,6 +181,7 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
|
||||
export type InstallPhase =
|
||||
| 'preparing'
|
||||
| 'pulling-image'
|
||||
| 'preparing-app'
|
||||
| 'creating-container'
|
||||
| 'starting-container'
|
||||
| 'waiting-healthy'
|
||||
|
||||
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
|
||||
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
||||
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||
})
|
||||
|
||||
const features = computed(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:warming-up="warmingUp"
|
||||
@@ -375,6 +376,20 @@ const panelClasses = computed(() => {
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
// A cold/restarting upstream is held outside the iframe. Start one fresh
|
||||
// load when the scanner observes HTTP readiness; no manual refresh required.
|
||||
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
|
||||
if (ready === false) {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (autoRetryId) clearTimeout(autoRetryId)
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
loading.value = false
|
||||
} else if (previous === false && ready === true) {
|
||||
autoRetryCount.value = 0
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
@@ -433,6 +448,7 @@ function refresh() {
|
||||
|
||||
function startLoadTimeout() {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (loading.value) {
|
||||
loading.value = false
|
||||
@@ -442,11 +458,13 @@ function startLoadTimeout() {
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
first, then sync status arrives), and the sync screen is strictly
|
||||
more informative, so it takes precedence instead of the two
|
||||
rendering on top of each other. -->
|
||||
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
@@ -43,7 +43,7 @@
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
||||
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@@ -66,7 +66,7 @@
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
paint over the sync progress and read as a hard error. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
||||
the padlock reads as "blocked/denied" and this state is neither. -->
|
||||
@@ -78,7 +78,8 @@
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</template>
|
||||
<template v-else-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
@@ -95,6 +96,7 @@
|
||||
Retry now
|
||||
</button>
|
||||
<button
|
||||
v-if="!uiReadyBlocked"
|
||||
@click="$emit('openNewTabAndBack')"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
@@ -108,7 +110,7 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
|
||||
<div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
|
||||
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
|
||||
@@ -133,6 +135,7 @@ const props = defineProps<{
|
||||
mustOpenNewTab: boolean
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
uiReadyBlocked?: boolean
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// True while the container is up but its probe hasn't answered yet and the
|
||||
|
||||
@@ -66,3 +66,19 @@ describe('AppSessionFrame warm-up state', () => {
|
||||
expect(text).toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTTP readiness gate', () => {
|
||||
it('does not show a missing-configuration error during initial installation', () => {
|
||||
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
|
||||
expect(frame.text()).not.toContain('App not configured')
|
||||
expect(frame.find('iframe').exists()).toBe(false)
|
||||
})
|
||||
it('does not mount an iframe before readiness, then opens automatically', async () => {
|
||||
const frame = mountFrame({ iframeBlocked: false, uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…', blockedTitle: 'App not ready' })
|
||||
expect(frame.find('iframe').exists()).toBe(false)
|
||||
expect(frame.text()).toContain('opens automatically')
|
||||
expect(frame.text()).not.toContain('Open in new tab')
|
||||
await frame.setProps({ uiReadyBlocked: false, blockedReason: '' })
|
||||
expect(frame.find('iframe').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,3 +184,24 @@ describe('appsConfig service filtering', () => {
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTTP readiness independent of container health', () => {
|
||||
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
|
||||
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
|
||||
const pkg = makePkg(id, id, 'other')
|
||||
pkg['ui-ready'] = false
|
||||
pkg.health = 'healthy'
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(false)
|
||||
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
|
||||
pkg['ui-ready'] = true
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||
}
|
||||
})
|
||||
it('allows a ready companion while its backend is syncing', () => {
|
||||
const pkg = makePkg('lnd', 'Lightning', 'bitcoin')
|
||||
pkg.health = 'starting'
|
||||
pkg['ui-ready'] = true
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,6 +244,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
if (pkg['ui-ready'] === false) return false
|
||||
// Headless backends never get a Launch button, even with a published port.
|
||||
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
@@ -277,6 +278,7 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
* health check retain the legacy state/port behaviour.
|
||||
*/
|
||||
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (pkg['ui-ready'] !== undefined) return pkg['ui-ready']
|
||||
const manifest = pkg.manifest as unknown as Record<string, unknown>
|
||||
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
|
||||
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
|
||||
@@ -285,6 +287,10 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
|
||||
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
|
||||
return 'Waiting for the app to be ready…'
|
||||
}
|
||||
if (
|
||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||
|
||||
Reference in New Issue
Block a user