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')
@@ -0,0 +1,41 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
import { appHasMediaBridge } from '@/views/discover/curatedApps'
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
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 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 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())
}
function connect() {
if (!appHasMediaBridge(appId.value) || !origin()) return
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
}
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
open: () => launcher.openSession(appId.value), close: () => {
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
},
}
function handle(event: MessageEvent) {
if (!appHasMediaBridge(appId.value) || 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 (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
const state: ExternalAudioState = { title: data.title.slice(0, 256), artist: data.artist.slice(0, 256), playing: data.playing,
position: Math.min(data.position, data.duration), duration: data.duration }
player.updateExternal(controller, state, visible.value)
}
watch(visible, shown => player.setExternalVisible(id, shown))
onBeforeUnmount(() => { command('pause'); player.releaseExternal(id) })
return { connect, handle }
}
+73 -10
View File
@@ -10,43 +10,93 @@ const duration = ref(0)
const error = ref<string | null>(null)
let initialized = false
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number }
export interface ExternalAudioController {
id: string
play: () => void
pause: () => void
seek: (position: number) => void
open: () => void
close: () => void
}
let external: ExternalAudioController | null = null
const suppressedExternal = new Set<string>()
const externalVisible = ref(false)
const externalApp = ref(false)
function updateExternal(controller: ExternalAudioController, state: ExternalAudioState, visible: boolean) {
if (suppressedExternal.has(controller.id)) { if (!state.playing) suppressedExternal.delete(controller.id); return }
if (external?.id !== controller.id) {
if (!state.playing) return
audio.value?.pause()
if (external) { suppressedExternal.add(external.id); external.pause() }
external = controller
}
externalApp.value = true
externalVisible.value = visible
currentSrc.value = `app:${controller.id}`
currentName.value = [state.title, state.artist].filter(Boolean).join(' · ')
playing.value = state.playing
currentTime.value = state.position
duration.value = state.duration
loading.value = false
error.value = null
}
function detachExternal(id: string) {
if (external?.id !== id) return
external = null
externalApp.value = false
externalVisible.value = false
currentSrc.value = null
currentName.value = ''
playing.value = false
currentTime.value = 0
duration.value = 0
loading.value = false
error.value = null
}
function releaseExternal(id: string) { detachExternal(id); suppressedExternal.delete(id) }
function setExternalVisible(id: string, visible: boolean) { if (external?.id === id) externalVisible.value = visible }
function openExternal() { external?.open() }
/** Create the Audio element and attach listeners once */
function init() {
if (initialized) return
initialized = true
audio.value = new Audio()
audio.value.addEventListener('timeupdate', () => {
const listen = (event: string, callback: () => void) => audio.value!.addEventListener(event, () => { if (!external && currentSrc.value) callback() })
listen('timeupdate', () => {
currentTime.value = audio.value?.currentTime ?? 0
})
audio.value.addEventListener('loadedmetadata', () => {
listen('loadedmetadata', () => {
duration.value = audio.value?.duration ?? 0
error.value = null
})
// Buffering / connecting over mesh|Tor → show a loader until it can play.
audio.value.addEventListener('loadstart', () => {
listen('loadstart', () => {
loading.value = true
})
audio.value.addEventListener('waiting', () => {
listen('waiting', () => {
loading.value = true
})
audio.value.addEventListener('canplay', () => {
listen('canplay', () => {
loading.value = false
})
audio.value.addEventListener('playing', () => {
listen('playing', () => {
loading.value = false
})
audio.value.addEventListener('ended', () => {
listen('ended', () => {
playing.value = false
loading.value = false
})
audio.value.addEventListener('pause', () => {
listen('pause', () => {
playing.value = false
})
audio.value.addEventListener('play', () => {
listen('play', () => {
playing.value = true
error.value = null
})
audio.value.addEventListener('error', () => {
listen('error', () => {
playing.value = false
loading.value = false
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
@@ -54,6 +104,8 @@ function init() {
}
function play(src: string, name: string) {
if (external && currentSrc.value === src) { external.play(); return }
if (external) { suppressedExternal.add(external.id); external.pause(); detachExternal(external.id) }
init()
error.value = null
@@ -64,6 +116,8 @@ function play(src: string, name: string) {
if (currentSrc.value !== src) {
loading.value = true
currentTime.value = 0
duration.value = 0
audio.value!.src = src
currentSrc.value = src
currentName.value = name
@@ -75,22 +129,26 @@ function play(src: string, name: string) {
// already wired up there. The 'error' listener sets the same state, so
// this just needs to stop the rejection from going unhandled.
audio.value!.play().catch(() => {
if (external || currentSrc.value !== src) return
playing.value = false
loading.value = false
})
}
function pause() {
if (external) { external.pause(); return }
audio.value?.pause()
}
function seek(time: number) {
if (external) { external.seek(time); return }
if (audio.value) {
audio.value.currentTime = time
}
}
function stop() {
if (external) { const current = external; suppressedExternal.add(current.id); current.pause(); detachExternal(current.id); current.close(); return }
if (audio.value) {
audio.value.pause()
audio.value.currentTime = 0
@@ -98,6 +156,10 @@ function stop() {
playing.value = false
currentSrc.value = null
currentName.value = ''
currentTime.value = 0
duration.value = 0
loading.value = false
error.value = null
}
const progress = computed(() => {
@@ -107,6 +169,7 @@ const progress = computed(() => {
export function useAudioPlayer() {
return {
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
play,
pause,
seek,