From cffb74326a00f7657aab82118639c907d77e2f8d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 01:04:05 -0400 Subject: [PATCH] Keep ordinary app launches available while optional policy loads --- examples/audio-app/archipelago-audio.mjs | 2 +- examples/audio-app/archipelago-audio.test.mjs | 7 +++ .../__tests__/useAppMediaBridge.test.ts | 17 ++++-- .../src/stores/__tests__/appLauncher.test.ts | 28 +++++++++- neode-ui/src/stores/appLauncher.ts | 52 +++++++++++++++---- neode-ui/src/views/discover/curatedApps.ts | 16 +++--- 6 files changed, 100 insertions(+), 22 deletions(-) diff --git a/examples/audio-app/archipelago-audio.mjs b/examples/audio-app/archipelago-audio.mjs index e5440dd8..6e865564 100644 --- a/examples/audio-app/archipelago-audio.mjs +++ b/examples/audio-app/archipelago-audio.mjs @@ -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) diff --git a/examples/audio-app/archipelago-audio.test.mjs b/examples/audio-app/archipelago-audio.test.mjs index 4cb4ef92..faef99b2 100644 --- a/examples/audio-app/archipelago-audio.test.mjs +++ b/examples/audio-app/archipelago-audio.test.mjs @@ -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) +}) diff --git a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts index 62e6db59..d808a93c 100644 --- a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts +++ b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts @@ -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 + 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' +}) diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 950f67a9..369cb740 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -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 diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index cb9b8b93..aec9416f 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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/ for mempool) */ const panelPath = ref(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 diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 7f73b134..e2f58c3f 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -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 | null = null +let launchPolicyRequest: Promise | 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 { - if (signedCatalogCache) return +export async function ensureAppLaunchPolicy(): Promise { + 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.