Keep ordinary app launches available while optional policy loads

This commit is contained in:
archipelago
2026-10-07 01:05:33 -04:00
parent f1fb388ded
commit cffb74326a
6 changed files with 100 additions and 22 deletions
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
import { nextTick } from 'vue'
import { useToast } from '@/composables/useToast'
// The signed catalog's embedded manifests decide which ports the app gate
// fronts (TLS on the same port) — prime the same shape the live catalog
@@ -145,13 +146,14 @@ describe('useAppLauncherStore', () => {
const previousFetch = globalThis.fetch
let release!: (value: unknown) => void
vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { release = resolve })))
useAppStore().data = {'package-data':{[id]:{state:'running','ui-ready':true,manifest:{id,version:'1'}}}} as never
const launcher = useAppLauncherStore()
launcher.openSession(id)
expect(launcher.panelAppId).toBeNull()
if (cancel === 'close') launcher.closePanel()
if (cancel === 'another-app') launcher.openSession('mempool')
release({ ok: true, json: async () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: {
[id]: { version: '1', manifest: { app: { metadata: { launch: { media_controls: 'archipelago-v1' } } } } },
[id]: { version: '1', manifest: { app: { id, metadata: { launch: { media_controls: 'archipelago-v1' } } } } },
} }) })
await new Promise(resolve => setTimeout(resolve, 0))
expect(launcher.panelAppId).toBe(cancel === 'none' ? id : cancel === 'another-app' ? 'mempool' : null)
@@ -161,6 +163,30 @@ describe('useAppLauncherStore', () => {
vi.stubGlobal('fetch', previousFetch)
})
it('shows unavailable launch policy with explicit retry and ignores a canceled retry', async()=>{
__setSignedCatalogForTests(null)
const previousFetch=globalThis.fetch
vi.stubGlobal('fetch',vi.fn(async()=>({ok:false})))
const launcher=useAppLauncherStore();launcher.openSession('mempool')
expect(launcher.panelAppId).toBe('mempool')
expect(launcher.mediaAppId).toBeNull()
expect(useToast().toasts.value.at(-1)?.message).toContain('Checking')
await new Promise(resolve=>setTimeout(resolve,0))
const failure=useToast().toasts.value.at(-1)
expect(failure?.message).toContain('unavailable');expect(failure?.action?.label).toBe('Retry settings');expect(launcher.panelAppId).toBe('mempool');expect(launcher.mediaAppId).toBeNull()
launcher.closePanel();const calls=vi.mocked(fetch).mock.calls.length;failure?.action?.onClick();expect(fetch).toHaveBeenCalledTimes(calls)
vi.stubGlobal('fetch',previousFetch)
})
it.each(['close','another-app'])('does not reopen a late generic launch after %s',async(cancel)=>{
__setSignedCatalogForTests(null);const previousFetch=globalThis.fetch
let release!:(value:unknown)=>void
vi.stubGlobal('fetch',vi.fn(()=>new Promise(resolve=>{release=resolve})))
const launcher=useAppLauncherStore();launcher.openSession('filebrowser')
if(cancel==='close')launcher.closePanel();else launcher.openSession('mempool')
release({ok:true,json:async()=>SIGNED});await new Promise(resolve=>setTimeout(resolve,0))
expect(launcher.panelAppId).toBe(cancel==='close'?null:'mempool')
vi.stubGlobal('fetch',previousFetch)
})
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
+42 -10
View File
@@ -99,6 +99,8 @@ export const CREDENTIAL_INTERSTITIAL_APPS = new Set([
])
interface LaunchOptions {
/** Basic offline launch grants no optional media integration. */
basicFrameOnly?: boolean
path?: string
/** The shared interstitial already ran and the user pressed Continue. */
skipCredentialPrompt?: boolean
@@ -213,7 +215,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
credentials: [] as AppCredential[],
copied: '',
})
let pendingCredentialLaunch: { appId: string; path?: string } | null = null
let pendingCredentialLaunch: { appId: string; opts: LaunchOptions } | null = null
let credentialGeneration = 0
let previousActiveElement: HTMLElement | null = null
@@ -223,6 +225,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
let launchGeneration = 0
let mediaVersion: string | null = null
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
const pkg = useAppStore().data?.['package-data']?.[appId]
@@ -237,7 +240,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appRequiresHostFrame(appId, pkg?.manifest.version) && !appHasMediaBridge(appId, pkg?.manifest.version)) {
const retainedMedia = mediaAppId.value === appId && mediaVersion === pkg?.manifest.version
if (!IS_DEMO && isCompanionApp() && !opts.basicFrameOnly && !retainedMedia && !HOST_FRAME_APPS.has(appId) && !appRequiresHostFrame(appId, pkg?.manifest.version) && !appHasMediaBridge(appId, pkg?.manifest.version)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -274,7 +278,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from.
if (pkg && appHasMediaBridge(appId, pkg.manifest.version)) mediaAppId.value = appId
if (!opts.basicFrameOnly && pkg && appHasMediaBridge(appId, pkg.manifest.version)) { mediaAppId.value = appId; mediaVersion = pkg.manifest.version }
panelPath.value = opts.path ?? null
panelAppId.value = appId
}
@@ -284,6 +288,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
* Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}, checkedPolicy = false) {
const generation = ++launchGeneration
cancelCredentialLaunch()
const installed = useAppStore().data?.['package-data']?.[appId]
if (mediaAppId.value === appId && mediaVersion === installed?.manifest.version) {
openSessionNow(appId, opts)
return
}
if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) {
useToast().info('Loading this node’s demo app…')
void ensureNodeAppAvailable(appId).then(available => {
@@ -296,8 +306,28 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
})
return
}
if (!IS_DEMO && !appId.startsWith('node-demo-') && !checkedPolicy && !appLaunchPolicyLoaded()) {
void ensureAppLaunchPolicy().then(() => { if (generation === launchGeneration) openSession(appId, opts, true) })
if (!IS_DEMO && !appId.startsWith('node-demo-') && !checkedPolicy && !appLaunchPolicyLoaded() && !HOST_FRAME_APPS.has(appId)) {
// Basic use is immediate, including offline. Background policy discovery
// must never reload or move a frame the user has already started using.
openSession(appId, {...opts,basicFrameOnly:true}, true)
const policyGeneration = launchGeneration
useToast().info('App opened. Checking optional integration settings…')
void ensureAppLaunchPolicy().then(available => {
if (policyGeneration !== launchGeneration) return
if (!available) {
useToast().action('App opened with basic controls. Optional integration settings are unavailable.',
{label:'Retry settings',onClick:()=>{
if(policyGeneration !== launchGeneration) return
void ensureAppLaunchPolicy().then(recovered=>{
if(policyGeneration !== launchGeneration) return
if(recovered) useToast().info('Integration settings ready for the next app launch.')
else useToast().error('Integration settings remain unavailable. The app is still open.')
})
}}, {variant:'error'})
return
}
useToast().info('Integration settings ready for the next app launch.')
})
return
}
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
@@ -309,16 +339,16 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path)
void prepareCredentialLaunch(appId, opts)
return
}
openSessionNow(appId, opts)
}
async function prepareCredentialLaunch(appId: string, path?: string) {
async function prepareCredentialLaunch(appId: string, opts: LaunchOptions) {
const generation = ++credentialGeneration
const appName = useAppStore().data?.['package-data']?.[appId]?.manifest?.title || appId
pendingCredentialLaunch = { appId, path }
pendingCredentialLaunch = { appId, opts }
credentialPrompt.value = {
show: true,
loading: true,
@@ -348,7 +378,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
if (!resolved) {
credentialPrompt.value.show = false
pendingCredentialLaunch = null
openSessionNow(appId, { path, skipCredentialPrompt: true })
openSessionNow(appId, { ...opts, skipCredentialPrompt: true })
return
}
credentialPrompt.value = {
@@ -375,7 +405,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
if (pending) openSessionNow(pending.appId, { path: pending.path, skipCredentialPrompt: true })
if (pending) openSessionNow(pending.appId, { ...pending.opts, skipCredentialPrompt: true })
}
async function copyCredential(label: string, value: string) {
@@ -394,6 +424,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function closePanel() {
launchGeneration += 1
cancelCredentialLaunch()
panelAppId.value = null
panelPath.value = null
}
@@ -512,6 +543,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
launchGeneration += 1
bridge.cancelPending()
registrationBridge.cancel(); rentalBridge.cancel()
const toRestore = previousActiveElement