Add signed node-scoped demo catalogs and retained app media sessions

This commit is contained in:
archipelago
2026-10-06 00:53:34 -04:00
parent 131c39cf74
commit e54f83df8f
18 changed files with 706 additions and 20 deletions
@@ -0,0 +1,48 @@
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,
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('../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.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null })
it('checks frame, origin, nonce and finite state before attaching controls', async () => {
const child = { postMessage: vi.fn() }
const visible = ref(false)
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.connect()
const hello = child.postMessage.mock.calls[0]![0]
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475')
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100 }
const message = (data = state, origin = 'https://node.test:7475', source: unknown = child) => bridge.handle({ data, origin, source } as MessageEvent)
message(state, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
expect(fixture.player.updateExternal).not.toHaveBeenCalled()
message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
expect(snapshot.position).toBe(22); expect(shown).toBe(false)
controller.seek(30)
expect(child.postMessage.mock.calls.at(-1)![0]).toMatchObject({ command: 'seek', position: 30, session: hello.session })
controller.open(); expect(fixture.launcher.openSession).toHaveBeenCalledWith('node-demo-v4v')
visible.value = true; await nextTick()
expect(fixture.player.setExternalVisible).toHaveBeenCalledWith(controller.id, true)
fixture.allowed = false; message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
fixture.allowed = true
wrapper.unmount()
expect(fixture.player.releaseExternal).toHaveBeenCalledWith(controller.id)
expect(child.postMessage.mock.calls.at(-1)![0]).toMatchObject({ command: 'pause' })
})
})
@@ -61,6 +61,29 @@ describe('useAudioPlayer', () => {
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
})
it('controls an app player without copying its stream or losing its position', () => {
const player = useAudioPlayer()
player.play('/local.mp3', 'Local')
const controller = { id: 'demo-session', play: vi.fn(), pause: vi.fn(), seek: vi.fn(), open: vi.fn(), close: vi.fn() }
player.updateExternal(controller, { title: 'Demo', artist: 'Artist', playing: true, position: 42, duration: 100 }, true)
expect(lastMockAudio?.paused).toBe(true)
expect(player.currentSrc.value).toBe('app:demo-session')
lastMockAudio?.simulateEvent('pause')
expect(player.playing.value).toBe(true)
expect(player.currentTime.value).toBe(42)
player.setExternalVisible('demo-session', false)
expect(player.externalVisible.value).toBe(false)
player.pause(); player.seek(55); player.openExternal()
expect(controller.pause).toHaveBeenCalledOnce()
expect(controller.seek).toHaveBeenCalledWith(55)
expect(controller.open).toHaveBeenCalledOnce()
player.play('app:demo-session', 'Demo')
expect(controller.play).toHaveBeenCalledOnce()
player.stop()
expect(controller.close).toHaveBeenCalledOnce()
expect(player.currentName.value).toBe('')
})
it('returns all expected properties', () => {
const player = useAudioPlayer()
expect(player.play).toBeTypeOf('function')