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 { defineComponent, nextTick, ref, shallowRef } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
const fixture = vi.hoisted(() => ({
|
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() },
|
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('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
|
||||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
||||||
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
|
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
|
||||||
import { useAppMediaBridge } from '../useAppMediaBridge'
|
import { useAppMediaBridge } from '../useAppMediaBridge'
|
||||||
|
|
||||||
describe('app media session boundary', () => {
|
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 () => {
|
it('checks frame, origin, nonce and finite state before attaching controls', async () => {
|
||||||
const child = { postMessage: vi.fn() }
|
const child = { postMessage: vi.fn() }
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
|||||||
const player = useAudioPlayer()
|
const player = useAudioPlayer()
|
||||||
const launcher = useAppLauncherStore()
|
const launcher = useAppLauncherStore()
|
||||||
const app = useAppStore()
|
const app = useAppStore()
|
||||||
|
const launchGeneration = launcher.mediaSessionGeneration(appId.value)
|
||||||
|
let everAdmitted = false
|
||||||
const installedVersion = () => app.data?.['package-data']?.[appId.value]?.manifest?.version
|
const installedVersion = () => app.data?.['package-data']?.[appId.value]?.manifest?.version
|
||||||
let admitted: { source: Window; origin: string; appId: string; version: string } | null = null
|
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('')
|
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
|
const version = installedVersion(), source = frame.value?.contentWindow
|
||||||
if (!version || !source || !origin() || !appHasMediaBridge(appId.value, version)) return
|
if (!version || !source || !origin() || !appHasMediaBridge(appId.value, version)) return
|
||||||
admitted = {source,origin:origin(),appId:appId.value,version}
|
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)
|
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)
|
player.updateExternal(controller, state, visible.value)
|
||||||
}
|
}
|
||||||
watch(visible, shown => player.setExternalVisible(id, shown))
|
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)
|
onBeforeUnmount(release)
|
||||||
return { connect, handle }
|
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 => {
|
it.each(['close', 'another-app', 'none'])('waits for node demo launch policy, cancellation: %s', async cancel => {
|
||||||
const id = `node-demo-delayed-${cancel}`
|
const id = `node-demo-delayed-${cancel}`
|
||||||
const previousFetch = globalThis.fetch
|
const previousFetch = globalThis.fetch
|
||||||
|
|||||||
@@ -227,6 +227,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
let launchGeneration = 0
|
let launchGeneration = 0
|
||||||
let mediaVersion: string | null = null
|
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 = {}) {
|
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
||||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||||
if (pkg?.['ui-ready'] === false) {
|
if (pkg?.['ui-ready'] === false) {
|
||||||
@@ -632,6 +646,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
closePanel,
|
closePanel,
|
||||||
panelAppId,
|
panelAppId,
|
||||||
mediaAppId,
|
mediaAppId,
|
||||||
|
mediaSessionGeneration,
|
||||||
|
retainMediaSession,
|
||||||
panelPath,
|
panelPath,
|
||||||
credentialPrompt,
|
credentialPrompt,
|
||||||
cancelCredentialLaunch,
|
cancelCredentialLaunch,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/appLauncher', () => ({
|
vi.mock('@/stores/appLauncher', () => ({
|
||||||
useAppLauncherStore: () => ({ panelAppId: null }),
|
useAppLauncherStore: () => ({ panelAppId: null, mediaSessionGeneration: () => null, retainMediaSession: () => false }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/app', () => ({
|
vi.mock('@/stores/app', () => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import AppSession from '../AppSession.vue'
|
import AppSession from '../AppSession.vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
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('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 () => {
|
vi.mock('@/stores/app', async () => {
|
||||||
const { reactive } = await import('vue')
|
const { reactive } = await import('vue')
|
||||||
const state = reactive({ data: { 'package-data': {} } })
|
const state = reactive({ data: { 'package-data': {} } })
|
||||||
|
|||||||
Reference in New Issue
Block a user