Add signed node-scoped demo catalogs and retained app media sessions
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user