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,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="v4v">
<title>v4v</title>
<!-- Generated by scripts/generate-glass-logo.mjs; do not edit by hand.
Liquid-glass rendering of the brand mark (v4v-glyph.svg silhouette).
Palette: ink, white and canvas tokens (see tokens.css). -->
<circle cx="256" cy="256" r="240" fill="#0a0a0a"/><!-- color-ink token -->
<defs>
<linearGradient id="glass-body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa" stop-opacity="0.30"/><!-- color-white token -->
<stop offset="0.55" stop-color="#f0f4f8" stop-opacity="0.12"/><!-- color-canvas token -->
<stop offset="1" stop-color="#fafafa" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="glass-shade" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a0a0a" stop-opacity="0.34"/><!-- color-ink token -->
<stop offset="0.6" stop-color="#0a0a0a" stop-opacity="0.06"/>
<stop offset="1" stop-color="#0a0a0a" stop-opacity="0"/>
</linearGradient>
<linearGradient id="glass-streak" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa" stop-opacity="0.42"/>
<stop offset="0.42" stop-color="#fafafa" stop-opacity="0.05"/>
<stop offset="1" stop-color="#fafafa" stop-opacity="0"/>
</linearGradient>
<linearGradient id="glass-face0" x1="109.3" y1="102.4" x2="402.7" y2="281.4" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#c9d5e1"/><!-- toward color-canvas token -->
</linearGradient>
<linearGradient id="glass-face1" x1="161.3" y1="280.8" x2="353.3" y2="409.6" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#c9d5e1"/><!-- toward color-canvas token -->
</linearGradient>
<linearGradient id="glass-depth" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#9fb0c2"/><!-- toward color-canvas token, shaded -->
</linearGradient>
<clipPath id="glyph-clip"><path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z"/></clipPath>
</defs>
<!-- The depth layers sit behind everything, on a shaded ramp. -->
<g fill-opacity="0.21">
<path d="M256.96 280.64L224.62 248.31L129.47 343.47L161.80 375.80Z" fill="url(#glass-depth)"/>
<path d="M385.18 345.93L288.17 248.92L255.83 281.26L352.84 378.26Z" fill="url(#glass-depth)"/>
<path d="M370.46 148.06L370.46 102.33L141.04 102.33L141.04 148.06Z" fill="url(#glass-depth)"/>
</g>
<!-- Soft drop: stacked offsets stand in for a blur librsvg can trust. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 7.74319377949815)" fill="#0a0a0a" fill-opacity="0.28"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 5.010301857322332)" fill="#0a0a0a" fill-opacity="0.2"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 2.7328919221758174)" fill="#0a0a0a" fill-opacity="0.14"/>
<!-- Rim light: the silhouette dilated in eight directions. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-3.19 0.00)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(3.19 0.00)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0.00 -3.19)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0.00 3.19)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-2.23 -2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(2.23 -2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-2.23 2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(2.23 2.23)" fill="#fafafa" fill-opacity="0.22"/>
<!-- The white body: the mark as provided — solid white, with the bevel
shade and streak below giving it the 3D feel. -->
<!-- Gentle top-light over the body. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74Z" fill="url(#glass-face0)"/>
<path d="M225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" fill="url(#glass-face1)"/>
<!-- Inner bevel shade, clipped to the pane. -->
<g clip-path="url(#glyph-clip)">
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(3.64 5.01)" fill="url(#glass-shade)"/>
<rect x="0" y="0" width="512" height="198.05" fill="url(#glass-streak)" transform="rotate(-18 256 256)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

@@ -2,7 +2,7 @@
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="audioPlayer.currentName.value"
v-if="audioPlayer.currentName.value && !audioPlayer.externalVisible.value"
ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
>
@@ -42,6 +42,8 @@
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div>
<button v-if="audioPlayer.externalApp.value" class="glass-button px-3 py-2 rounded-lg text-sm text-white shrink-0" @click="audioPlayer.openExternal()" aria-label="Open playing app">Open app</button>
<!-- Close -->
<button
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors"
@@ -81,7 +83,7 @@ function clearPlayerHeightVar() {
document.documentElement.classList.remove('audio-active')
}
watch(() => audioPlayer.currentName.value, (name) => {
watch(() => audioPlayer.currentName.value && !audioPlayer.externalVisible.value, (name) => {
if (name) {
nextTick(setPlayerHeightVar)
} else {
@@ -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,
+5 -1
View File
@@ -1,3 +1,4 @@
import { appHasMediaBridge } from '@/views/discover/curatedApps'
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
@@ -235,6 +236,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Active app in the store-driven session (no route change) */
const panelAppId = ref<string | null>(null)
const mediaAppId = ref<string | null>(null)
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
@@ -251,7 +253,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId)) {
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appHasMediaBridge(appId)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -288,6 +290,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from.
if (appHasMediaBridge(appId)) mediaAppId.value = appId
panelPath.value = opts.path ?? null
panelAppId.value = appId
}
@@ -712,6 +715,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
close,
closePanel,
panelAppId,
mediaAppId,
panelPath,
credentialPrompt,
cancelCredentialLaunch,
+10 -2
View File
@@ -131,6 +131,7 @@ import {
} from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { PackageState } from '@/types/api'
import { useAppMediaBridge } from '@/composables/useAppMediaBridge'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
@@ -141,6 +142,7 @@ const props = defineProps<{
appIdProp?: string
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
pathProp?: string
suspended?: boolean
}>()
const emit = defineEmits<{
@@ -179,8 +181,8 @@ const appId = computed(() => {
// Display mode -- per-app user choice → per-app default → last global → panel
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appTitle = computed(() => packageEntry.value?.manifest?.title || resolveAppTitle(appId.value))
const appIcon = computed(() =>
packageEntry.value
? resolveAppIcon(appId.value, packageEntry.value)
@@ -279,6 +281,7 @@ function closeRouteSession() {
// --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
@@ -342,6 +345,7 @@ function syncInlineRect() {
}
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
if (props.suspended) return { display: 'none' }
if (!inlinePanelMode.value) return undefined
const r = inlineRect.value
// Never paint the inline backdrop over the whole viewport while unmeasured.
@@ -393,6 +397,7 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
// --- Lifecycle handlers ---
function onLoad() {
mediaBridge.connect()
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
loading.value = false
@@ -404,7 +409,7 @@ function onLoad() {
forgetAutoTabApp(appId.value)
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
// virtual keyboard) flow into the iframe without needing a pointer click.
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
try { if (!props.suspended) frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
// Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => {
try {
@@ -488,6 +493,7 @@ function closeSession() {
}
function onKeyDown(e: KeyboardEvent) {
if (props.suspended) return
if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent()
@@ -508,6 +514,8 @@ function onFullscreenChange() {
}
function onMessage(e: MessageEvent) {
if (e.source !== iframeRef.value?.contentWindow) return
mediaBridge.handle(e)
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
+6 -1
View File
@@ -93,14 +93,19 @@
<!-- Panel mode app session — renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<div v-if="appLauncher.panelAppId && appLauncher.panelAppId !== appLauncher.mediaAppId" class="app-panel-container">
<AppSession
v-if="appLauncher.panelAppId !== appLauncher.mediaAppId"
:key="appLauncher.panelAppId"
:app-id-prop="appLauncher.panelAppId"
:path-prop="appLauncher.panelPath ?? undefined"
@close="appLauncher.closePanel()"
/>
</div>
</Transition>
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
</div>
</main>
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: {
'node-demo-v4v': { version: '1', manifest: { app: { id: 'node-demo-v4v', name: 'V4V demo', ports: [{ host: 7475, auth: 'gated' }] } } },
}, storefront: { promotions: [{ id: 'node-demo-v4v', headline: 'Sovereign Music' }] } })
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
describe('node-scoped demo catalog', () => {
it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => {
vi.resetModules()
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: true, json: async () => ({ ...demo(), storefront: { promotions } }) }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog } = await import('../curatedApps')
const catalog = await fetchAppCatalog()
expect(catalog?.apps.map(app => app.id)).toContain('node-demo-v4v')
expect(catalog?.storefront?.promotions).toEqual([])
})
it('adds only the verified node response and never persists it into shared fallback', async () => {
vi.resetModules()
let available = true
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: available, json: async () => demo() }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog, portIsGateFronted } = await import('../curatedApps')
const first = await fetchAppCatalog()
expect(first?.apps.map(app => app.id)).toEqual(['mempool', 'node-demo-v4v'])
expect(first?.storefront?.promotions[0]?.headline).toBe('Sovereign Music')
expect(localStorage.getItem('archy_catalog')).not.toContain('node-demo-v4v')
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(true)
available = false
const second = await fetchAppCatalog()
expect(second?.apps.map(app => app.id)).toEqual(['mempool'])
expect(second?.storefront?.promotions ?? []).toHaveLength(0)
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(false)
})
})
+31 -3
View File
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
description?: string
category?: string
container?: { image?: string }
metadata?: { icon?: string; author?: string; repo?: string }
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
}
@@ -104,6 +104,11 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
* after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null
let nodeCatalogCache: SignedAppCatalog | null = null
export function appHasMediaBridge(id: string): boolean {
return nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
}
/** Launch aliases → the catalog app id that OWNS the UI port.
*
@@ -141,7 +146,7 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
* answered `gated`, or an https frame URL would point at a port that never
* serves TLS. */
export function portAuth(appId: string, hostPort: number | string): string | null {
const apps = signedCatalogCache?.apps
const apps = { ...signedCatalogCache?.apps, ...nodeCatalogCache?.apps }
if (!apps) return null
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
@@ -188,9 +193,32 @@ const CATALOG_URLS = [
'/catalog.json',
]
/** Node demos are requested separately and never persisted in browser fallback
* storage or the global release catalog. A copied catalog is rejected by the
* daemon unless its release signature and exact node audience match. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const nodeRequest = fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) })
.then(async response => response.ok ? await response.json() : null).catch(() => null)
const [base, node] = await Promise.all([fetchBaseAppCatalog(), nodeRequest])
nodeCatalogCache = null
if (!base || node?.scope !== 'single-node-demo' || !node.apps || Array.isArray(node.apps)
|| !Object.keys(node.apps).every(id => id.startsWith('node-demo-'))
|| !(Date.parse(node.expires_at) > Date.now())) return base
nodeCatalogCache = node as SignedAppCatalog
const known = new Set(base.apps.map(app => app.id))
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
const demoIds = new Set(additions.map(app => app.id))
const promotions = (Array.isArray(node.storefront?.promotions) ? node.storefront.promotions : [])
.filter((promotion: CatalogPromotion | null) => promotion && demoIds.has(promotion.id))
return { ...base, apps: [...base.apps, ...additions], storefront: {
popular: base.storefront?.popular ?? [],
promotions: [...(base.storefront?.promotions ?? []), ...promotions],
} }
}
/** Fetch app catalog from remote registry, with local fallback.
* Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
async function fetchBaseAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }