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 }
}