From 49232fd54740439c2030b733c472bc9b8b83dad4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 02:20:40 -0400 Subject: [PATCH] Retain media sessions admitted after installed app state arrives --- .../__tests__/useAppMediaBridge.test.ts | 32 ++++++++++++++++--- neode-ui/src/composables/useAppMediaBridge.ts | 11 ++++++- .../src/stores/__tests__/appLauncher.test.ts | 24 ++++++++++++++ neode-ui/src/stores/appLauncher.ts | 16 ++++++++++ .../__tests__/AppSessionMobileNewTab.test.ts | 2 +- .../__tests__/AppSessionSlowLoad.test.ts | 2 +- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts index a0b8e6e7..55c6a291 100644 --- a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts +++ b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts @@ -2,18 +2,42 @@ 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, version: '1', + allowed: true, version: '1', versionState: null as {value: string | undefined} | null, 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() }, + launcher: { mediaAppId: 'node-demo-v4v' as string | null, panelAppId: null as string | null, openSession: vi.fn(), mediaSessionGeneration: vi.fn(() => 1), retainMediaSession: vi.fn(() => true) }, })) -vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{get version(){return fixture.version}}}}}})})) +vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{get version(){return fixture.versionState ? fixture.versionState.value : 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.version = '1'; fixture.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null }) + beforeEach(() => { vi.clearAllMocks(); fixture.version = '1'; fixture.versionState = null; fixture.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null }) + it('admits and retains the original active frame when installed state arrives after media-ready', async () => { + fixture.versionState = ref(undefined) + fixture.launcher.mediaAppId = null + const child = {postMessage: vi.fn()} + const visible = ref(true) + 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), visible) + return () => null + }})) + bridge.handle({source:child, origin:'https://node.test:7475', data:{type:'archipelago:media-ready',version:1}} as unknown as MessageEvent) + expect(child.postMessage).not.toHaveBeenCalled() + fixture.versionState.value = '1' + await nextTick() + expect(fixture.launcher.retainMediaSession).toHaveBeenCalledWith('node-demo-v4v','1',1) + const session = child.postMessage.mock.calls[0]![0].session + visible.value = false + bridge.handle({source:child, origin:'https://node.test:7475', data:{type:'archipelago:media-state',version:1, + session,available:true,title:'Song',artist:'Artist',playing:true,position:4,duration:20}} as unknown as MessageEvent) + expect(fixture.player.updateExternal).toHaveBeenCalledWith(expect.anything(),expect.objectContaining({playing:true}),false) + wrapper.unmount() + }) + it('checks frame, origin, nonce and finite state before attaching controls', async () => { const child = { postMessage: vi.fn() } const visible = ref(false) diff --git a/neode-ui/src/composables/useAppMediaBridge.ts b/neode-ui/src/composables/useAppMediaBridge.ts index d250b2de..4f977fdd 100644 --- a/neode-ui/src/composables/useAppMediaBridge.ts +++ b/neode-ui/src/composables/useAppMediaBridge.ts @@ -8,6 +8,8 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R const player = useAudioPlayer() const launcher = useAppLauncherStore() const app = useAppStore() + const launchGeneration = launcher.mediaSessionGeneration(appId.value) + let everAdmitted = false const installedVersion = () => app.data?.['package-data']?.[appId.value]?.manifest?.version let admitted: { source: Window; origin: string; appId: string; version: string } | null = null const session = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('') @@ -34,6 +36,10 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R const version = installedVersion(), source = frame.value?.contentWindow if (!version || !source || !origin() || !appHasMediaBridge(appId.value, version)) return admitted = {source,origin:origin(),appId:appId.value,version} + everAdmitted = true + // Installed state can arrive after iframe load. Retention belongs to this + // actual admitted launch, not only the package snapshot at button-click. + launcher.retainMediaSession(appId.value, version, launchGeneration) } admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin) } @@ -64,7 +70,10 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R player.updateExternal(controller, state, visible.value) } watch(visible, shown => player.setExternalVisible(id, shown)) - watch([appId, url, frame, installedVersion], () => { if (admitted && !matchesAdmission()) release() }, {flush:'sync'}) + watch([appId, url, frame, installedVersion], () => { + if (admitted && !matchesAdmission()) release() + else if (!everAdmitted && installedVersion()) connect() + }, {flush:'sync'}) onBeforeUnmount(release) return { connect, handle } } diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 0aafaf5e..87c2aa4a 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -141,6 +141,30 @@ describe('useAppLauncherStore', () => { }) }) + it.each(['active', 'closed', 'superseded', 'reopened'])('binds late media retention to the original launch: %s', state => { + const id = 'late-media-fixture' + __setSignedCatalogForTests({apps:{[id]:{version:'1',manifest:{app:{id,metadata:{launch:{media_controls:'archipelago-v1'}}}}}}} as never) + const app = useAppStore() + app.data = {'package-data':{}} as never + const launcher = useAppLauncherStore() + launcher.openSession(id, {skipCredentialPrompt:true}) + expect(launcher.panelAppId).toBe(id) + const generation = launcher.mediaSessionGeneration(id) + expect(launcher.mediaAppId).toBeNull() + app.data = {'package-data':{[id]:{state:'running','ui-ready':true,manifest:{id,version:'1'}}}} as never + if (state === 'closed' || state === 'reopened') launcher.closePanel() + if (state === 'superseded') launcher.openSession('mempool', {skipCredentialPrompt:true}) + if (state === 'reopened') { launcher.openSession(id, {skipCredentialPrompt:true}); launcher.mediaAppId=null } + expect(launcher.retainMediaSession(id,'1',generation)).toBe(state === 'active') + expect(launcher.mediaAppId).toBe(state === 'active' ? id : null) + if (state === 'active') { + launcher.closePanel() + expect(launcher.mediaAppId).toBe(id) + launcher.openSession(id) + expect(launcher.panelAppId).toBe(id) + } + }) + it.each(['close', 'another-app', 'none'])('waits for node demo launch policy, cancellation: %s', async cancel => { const id = `node-demo-delayed-${cancel}` const previousFetch = globalThis.fetch diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index aec9416f..210f47c2 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -227,6 +227,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { let launchGeneration = 0 let mediaVersion: string | null = null + /** Capture the active launch, so a late frame callback cannot retain a + * closed session or a newer launch of the same app. */ + function mediaSessionGeneration(appId: string): number | null { + return panelAppId.value === appId ? launchGeneration : null + } + function retainMediaSession(appId: string, version: string, generation: number | null): boolean { + const installed = useAppStore().data?.['package-data']?.[appId] + if (generation === null || generation !== launchGeneration || panelAppId.value !== appId + || installed?.manifest.version !== version || !appHasMediaBridge(appId, version)) return false + mediaAppId.value = appId + mediaVersion = version + return true + } + function openSessionNow(appId: string, opts: LaunchOptions = {}) { const pkg = useAppStore().data?.['package-data']?.[appId] if (pkg?.['ui-ready'] === false) { @@ -632,6 +646,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { closePanel, panelAppId, mediaAppId, + mediaSessionGeneration, + retainMediaSession, panelPath, credentialPrompt, cancelCredentialLaunch, diff --git a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts index 04534898..6932aadb 100644 --- a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts +++ b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts @@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ })) vi.mock('@/stores/appLauncher', () => ({ - useAppLauncherStore: () => ({ panelAppId: null }), + useAppLauncherStore: () => ({ panelAppId: null, mediaSessionGeneration: () => null, retainMediaSession: () => false }), })) vi.mock('@/stores/app', () => ({ diff --git a/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts b/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts index a647ea83..ac782a9f 100644 --- a/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts +++ b/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts @@ -5,7 +5,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import AppSession from '../AppSession.vue' import { useAppStore } from '@/stores/app' vi.mock('vue-router', () => ({ useRoute: () => ({ params: { appId: 'indeedhub' }, query: {}, fullPath: '/dashboard/app-session/indeedhub' }), useRouter: () => ({ replace: vi.fn(() => Promise.resolve()), push: vi.fn(() => Promise.resolve()) }) })) -vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ panelAppId: null }) })) +vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ panelAppId: null, mediaSessionGeneration: () => null, retainMediaSession: () => false }) })) vi.mock('@/stores/app', async () => { const { reactive } = await import('vue') const state = reactive({ data: { 'package-data': {} } })