Keep ordinary app launches available while optional policy loads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user