Retain media sessions admitted after installed app state arrives
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': {} } })
|
||||
|
||||
Reference in New Issue
Block a user