Retain media sessions admitted after installed app state arrives

This commit is contained in:
archipelago
2026-10-07 02:20:49 -04:00
parent fdc596854e
commit 49232fd547
6 changed files with 80 additions and 7 deletions
@@ -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<string | undefined>(undefined)
fixture.launcher.mediaAppId = null
const child = {postMessage: vi.fn()}
const visible = ref(true)
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), 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)
+10 -1
View File
@@ -8,6 +8,8 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, 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<string>, url: Ref<string>, 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<string>, url: Ref<string>, 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 }
}
@@ -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
+16
View File
@@ -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,
@@ -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', () => ({
@@ -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': {} } })