Keep ordinary app launches available while optional policy loads
This commit is contained in:
@@ -49,7 +49,7 @@ export function attachAudioBridge({ parentOrigin, snapshot, actions, unlocked =
|
||||
if (session) send({type:'archipelago:media-state',session,available:false})
|
||||
closed = true; session = null
|
||||
host.removeEventListener('message',message); host.removeEventListener('pageshow',ready); host.removeEventListener('pagehide',leave)
|
||||
actions.pause?.()
|
||||
try { Promise.resolve(actions.pause?.()).catch(() => {}) } catch { /* App teardown still releases the bridge. */ }
|
||||
}
|
||||
function leave(event) { if (!event.persisted) dispose() }
|
||||
host.addEventListener('message',message); host.addEventListener('pageshow',ready); host.addEventListener('pagehide',leave)
|
||||
|
||||
@@ -36,3 +36,10 @@ test('stale queued commands do not control a replacement handshake',async()=>{
|
||||
assert.deepEqual(f.calls,[])
|
||||
f.bridge.dispose()
|
||||
})
|
||||
|
||||
test('dispose releases listeners even if the app pause action rejects',async()=>{
|
||||
const handlers=new Map()
|
||||
const host={parent:{postMessage(){}},addEventListener:(name,fn)=>handlers.set(name,fn),removeEventListener:name=>handlers.delete(name)}
|
||||
const bridge=attachAudioBridge({parentOrigin:'https://node.test',host,snapshot:()=>null,actions:{pause:async()=>{throw Error('player already closed')}}})
|
||||
bridge.dispose();await new Promise(setImmediate);assert.equal(handlers.size,0)
|
||||
})
|
||||
|
||||
@@ -2,18 +2,18 @@ import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick, ref, shallowRef } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const fixture = vi.hoisted(() => ({
|
||||
allowed: true,
|
||||
allowed: true, version: '1',
|
||||
player: { updateExternal: vi.fn(), detachExternal: vi.fn(), releaseExternal: vi.fn(), setExternalVisible: vi.fn() },
|
||||
launcher: { mediaAppId: 'node-demo-v4v' as string | null, panelAppId: null as string | null, openSession: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{version:'1'}}}}})}))
|
||||
vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{get version(){return fixture.version}}}}}})}))
|
||||
vi.mock('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
|
||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
||||
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
|
||||
import { useAppMediaBridge } from '../useAppMediaBridge'
|
||||
|
||||
describe('app media session boundary', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); fixture.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null })
|
||||
beforeEach(() => { vi.clearAllMocks(); fixture.version = '1'; fixture.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null })
|
||||
it('checks frame, origin, nonce and finite state before attaching controls', async () => {
|
||||
const child = { postMessage: vi.fn() }
|
||||
const visible = ref(false)
|
||||
@@ -62,3 +62,14 @@ it('retains controls and cleanup after catalog expiry but does not admit a new s
|
||||
const outsider={postMessage:vi.fn()};const denied=mount(defineComponent({setup(){const b=useAppMediaBridge(ref('node-demo-v4v'),ref('https://node.test:7475/'),shallowRef({contentWindow:outsider} as unknown as HTMLIFrameElement),ref(false));b.connect();return()=>null}}))
|
||||
expect(outsider.postMessage).not.toHaveBeenCalled();denied.unmount()
|
||||
})
|
||||
|
||||
it('rejects state and commands from the admitted frame after installed version changes',()=>{
|
||||
fixture.version='1';fixture.allowed=true;vi.clearAllMocks()
|
||||
const child={postMessage:vi.fn()};let bridge!:ReturnType<typeof useAppMediaBridge>
|
||||
const wrapper=mount(defineComponent({setup(){bridge=useAppMediaBridge(ref('node-demo-v4v'),ref('https://node.test:7475/'),shallowRef({contentWindow:child} as unknown as HTMLIFrameElement),ref(false));return()=>null}}))
|
||||
bridge.connect();const session=child.postMessage.mock.calls[0]![0].session
|
||||
fixture.version='2'
|
||||
bridge.handle({source:child,origin:'https://node.test:7475',data:{type:'archipelago:media-state',version:1,session,available:true,title:'Old',artist:'Artist',playing:true,position:1,duration:10}} as unknown as MessageEvent)
|
||||
expect(fixture.player.updateExternal).not.toHaveBeenCalled()
|
||||
wrapper.unmount();expect(child.postMessage.mock.lastCall![0].command).toBe('pause');fixture.version='1'
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,22 +129,24 @@ export function appRequiresHostFrame(id: string, installedVersion?: string): boo
|
||||
&& entry.manifest?.app?.metadata?.launch?.requires_host_frame === true
|
||||
&& entry.manifest.app.metadata.launch.open_in_new_tab !== true)
|
||||
}
|
||||
let launchPolicyRequest: Promise<void> | null = null
|
||||
let launchPolicyRequest: Promise<boolean> | null = null
|
||||
export function appLaunchPolicyLoaded(): boolean { return signedCatalogCache !== null }
|
||||
/** Read only the authenticated daemon-verified catalog; community fallback
|
||||
* metadata must never grant a native integration. */
|
||||
export async function ensureAppLaunchPolicy(): Promise<void> {
|
||||
if (signedCatalogCache) return
|
||||
export async function ensureAppLaunchPolicy(): Promise<boolean> {
|
||||
if (signedCatalogCache) return true
|
||||
if (!launchPolicyRequest) launchPolicyRequest = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/app-catalog', {credentials:'include',signal:AbortSignal.timeout(5000)})
|
||||
if (!response.ok) return
|
||||
if (!response.ok) return false
|
||||
const value = await response.json() as SignedAppCatalog
|
||||
if (value.apps && !Array.isArray(value.apps)) signedCatalogCache = value
|
||||
} catch { /* Unavailable policy cannot authorize a new integration. */ }
|
||||
if (!value.apps || typeof value.apps !== 'object' || Array.isArray(value.apps)) return false
|
||||
signedCatalogCache = value
|
||||
return true
|
||||
} catch { return false /* Unavailable policy cannot authorize a new integration. */ }
|
||||
finally { launchPolicyRequest = null }
|
||||
})()
|
||||
await launchPolicyRequest
|
||||
return await launchPolicyRequest
|
||||
}
|
||||
|
||||
/** Resolve node-owned launch policy independently of the public storefront.
|
||||
|
||||
Reference in New Issue
Block a user