Draft generic audio manifest admission and public media adapter
This commit is contained in:
@@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({
|
||||
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() },
|
||||
}))
|
||||
vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{version:'1'}}}}})}))
|
||||
vi.mock('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
|
||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
||||
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
|
||||
@@ -49,3 +50,15 @@ describe('app media session boundary', () => {
|
||||
expect(child.postMessage.mock.calls[child.postMessage.mock.calls.length - 1]![0]).toMatchObject({ command: 'pause' })
|
||||
})
|
||||
})
|
||||
|
||||
it('retains controls and cleanup after catalog expiry but does not admit a new session',()=>{
|
||||
const child={postMessage:vi.fn()};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),ref(false));return()=>null}}))
|
||||
fixture.allowed=true;bridge.connect();const session=child.postMessage.mock.calls[0]![0].session
|
||||
fixture.allowed=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:1,duration:10}} as unknown as MessageEvent)
|
||||
const controller=fixture.player.updateExternal.mock.lastCall![0];controller.pause();expect(child.postMessage.mock.lastCall![0].command).toBe('pause')
|
||||
wrapper.unmount();expect(child.postMessage.mock.lastCall![0].command).toBe('pause')
|
||||
const outsider={postMessage:vi.fn()};const denied=mount(defineComponent({setup(){const b=useAppMediaBridge(ref('node-demo-v4v'),ref('https://node.test:7475/'),shallowRef({contentWindow:outsider} as unknown as HTMLIFrameElement),ref(false));b.connect();return()=>null}}))
|
||||
expect(outsider.postMessage).not.toHaveBeenCalled();denied.unmount()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
import { appHasMediaBridge } from '@/views/discover/curatedApps'
|
||||
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: Ref<HTMLIFrameElement | null>, visible: Ref<boolean>) {
|
||||
const player = useAudioPlayer()
|
||||
const launcher = useAppLauncherStore()
|
||||
const app = useAppStore()
|
||||
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('')
|
||||
const id = `${appId.value}:${session}`
|
||||
const origin = () => { try { return new URL(url.value, window.location.origin).origin } catch { return '' } }
|
||||
function matchesAdmission() {
|
||||
return admitted !== null && admitted.source === frame.value?.contentWindow && admitted.origin === origin()
|
||||
&& admitted.appId === appId.value && admitted.version === installedVersion()
|
||||
}
|
||||
function release() {
|
||||
// Catalog expiry/removal cannot revoke our ability to stop an already
|
||||
// admitted session. The old nonce/source/origin still constrain cleanup.
|
||||
admitted?.source.postMessage({type:'archipelago:media-control',version:1,session,command:'pause'}, admitted.origin)
|
||||
admitted = null
|
||||
player.releaseExternal(id)
|
||||
}
|
||||
function command(command: string, position?: number) {
|
||||
if (!appHasMediaBridge(appId.value) || !origin()) return
|
||||
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, origin())
|
||||
if (!matchesAdmission()) return
|
||||
admitted!.source.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, admitted!.origin)
|
||||
}
|
||||
function connect() {
|
||||
if (!appHasMediaBridge(appId.value) || !origin()) return
|
||||
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
|
||||
if (admitted && !matchesAdmission()) release()
|
||||
if (!admitted) {
|
||||
const version = installedVersion(), source = frame.value?.contentWindow
|
||||
if (!version || !source || !origin() || !appHasMediaBridge(appId.value, version)) return
|
||||
admitted = {source,origin:origin(),appId:appId.value,version}
|
||||
}
|
||||
admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin)
|
||||
}
|
||||
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
|
||||
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
|
||||
@@ -24,11 +44,11 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
||||
},
|
||||
}
|
||||
function handle(event: MessageEvent) {
|
||||
if (!appHasMediaBridge(appId.value) || event.source !== frame.value?.contentWindow || event.origin !== origin()) return
|
||||
if (event.source !== frame.value?.contentWindow || event.origin !== origin()) return
|
||||
const data = event.data
|
||||
if (data?.version !== 1) return
|
||||
if (data.type === 'archipelago:media-ready') { connect(); return }
|
||||
if (data.type !== 'archipelago:media-state' || data.session !== session) return
|
||||
if (!matchesAdmission() || data.type !== 'archipelago:media-state' || data.session !== session) return
|
||||
if (data.available !== true) { player.detachExternal(id); return }
|
||||
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|
||||
|| !Number.isFinite(data.position) || !Number.isFinite(data.duration) || data.position < 0 || data.duration < 0) return
|
||||
@@ -44,6 +64,7 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
||||
player.updateExternal(controller, state, visible.value)
|
||||
}
|
||||
watch(visible, shown => player.setExternalVisible(id, shown))
|
||||
onBeforeUnmount(() => { command('pause'); player.releaseExternal(id) })
|
||||
watch([appId, url, frame, installedVersion], () => { if (admitted && !matchesAdmission()) release() }, {flush:'sync'})
|
||||
onBeforeUnmount(release)
|
||||
return { connect, handle }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user