Draft generic audio manifest admission and public media adapter
This commit is contained in:
@@ -36,6 +36,29 @@ Audio controls do not require Nostr signing. Apps needing Nostr login must follo
|
|||||||
selection, scoped consent, server-verified authentication and cancellation. Never
|
selection, scoped consent, server-verified authentication and cancellation. Never
|
||||||
put signer permissions, wallet operations or signing keys into the media bridge.
|
put signer permissions, wallet operations or signing keys into the media bridge.
|
||||||
|
|
||||||
|
## Public adapter and independent example
|
||||||
|
|
||||||
|
[`examples/audio-app`](../examples/audio-app/) contains an independently authored
|
||||||
|
adapter and a small player using files the user selects locally. It includes no
|
||||||
|
V4V code, assets, recordings or authentication implementation. Serve the example
|
||||||
|
as an authenticated app; configure the exact dashboard origin in its deployment
|
||||||
|
owned `dashboard-origin` metadata. Do not derive trust from a query parameter.
|
||||||
|
|
||||||
|
Import `attachAudioBridge` from `archipelago-audio.mjs` and supply your actual
|
||||||
|
player's `snapshot`, `actions` and `unlocked` callbacks. Call `publish()` when
|
||||||
|
playback changes and `dispose()` when the player is destroyed. The sample uses
|
||||||
|
one queue for both in-app and dashboard actions, and never passes stream URLs or
|
||||||
|
credentials to the host. Its Node tests run with
|
||||||
|
`node --test examples/audio-app/archipelago-audio.test.mjs`.
|
||||||
|
|
||||||
|
The generic eligibility change is under qualification: new sessions require the
|
||||||
|
authenticated daemon's verified catalog, matching app identity and installed
|
||||||
|
version, plus an unambiguous iframe-compatible manifest. Community storefront
|
||||||
|
fallbacks cannot grant this integration. An already admitted session retains its
|
||||||
|
original frame/origin/nonce solely so its existing controls and cleanup remain
|
||||||
|
usable if catalog freshness expires. App removal, version or frame replacement
|
||||||
|
releases that admission; a new session must qualify again.
|
||||||
|
|
||||||
## Keep a single player and queue
|
## Keep a single player and queue
|
||||||
|
|
||||||
The app continues to own its audio element, queue, authorization and playback
|
The app continues to own its audio element, queue, authorization and playback
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Independently authored reference adapter for the public archipelago-v1 protocol.
|
||||||
|
// No application authentication, signing keys or stream URLs cross this bridge.
|
||||||
|
export function attachAudioBridge({ parentOrigin, snapshot, actions, unlocked = () => true, host = window }) {
|
||||||
|
const parsed = new URL(parentOrigin)
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== parentOrigin || parsed.username || parsed.password) throw new Error('Configure an exact trusted dashboard origin')
|
||||||
|
if (host.parent === host) return { publish() {}, dispose() {} }
|
||||||
|
let session = null, closed = false, queue = Promise.resolve()
|
||||||
|
const send = data => host.parent.postMessage({ version: 1, ...data }, parentOrigin)
|
||||||
|
function publish() {
|
||||||
|
if (closed || !session) return
|
||||||
|
const state = unlocked() ? snapshot() : null
|
||||||
|
if (!state || typeof state.title !== 'string' || typeof state.artist !== 'string'
|
||||||
|
|| typeof state.playing !== 'boolean' || typeof state.shuffle !== 'boolean'
|
||||||
|
|| !Number.isFinite(state.position) || !Number.isFinite(state.duration) || state.position < 0 || state.duration < 0) {
|
||||||
|
send({type:'archipelago:media-state',session,available:false}); return
|
||||||
|
}
|
||||||
|
send({type:'archipelago:media-state',session,available:true,title:state.title.slice(0,256),artist:state.artist.slice(0,256),
|
||||||
|
playing:state.playing,shuffle:state.shuffle,position:Math.min(state.position,state.duration),duration:state.duration,
|
||||||
|
artwork:typeof state.artwork === 'string' ? state.artwork.slice(0,2048) : ''})
|
||||||
|
}
|
||||||
|
function message(event) {
|
||||||
|
if (closed || event.source !== host.parent || event.origin !== parentOrigin || event.data?.version !== 1) return
|
||||||
|
const data = event.data
|
||||||
|
if (data.type === 'archipelago:media-connect') {
|
||||||
|
if (typeof data.session !== 'string' || !/^[0-9a-f]{32}$/.test(data.session)) return
|
||||||
|
session = data.session; publish(); return
|
||||||
|
}
|
||||||
|
if (data.type !== 'archipelago:media-control' || !session || data.session !== session || !unlocked()) return
|
||||||
|
if (!['play','pause','seek','next','previous','shuffle','snapshot'].includes(data.command)) return
|
||||||
|
if (data.command === 'seek' && (!Number.isFinite(data.position) || data.position < 0)) return
|
||||||
|
const expected = session
|
||||||
|
queue = queue.catch(() => {}).then(async () => {
|
||||||
|
if (closed || session !== expected || !unlocked()) return
|
||||||
|
if (data.command !== 'snapshot') {
|
||||||
|
const action = actions[data.command]
|
||||||
|
if (typeof action !== 'function') return
|
||||||
|
const state = snapshot()
|
||||||
|
if (data.command === 'seek') {
|
||||||
|
if (!Number.isFinite(state?.duration) || state.duration < 0) return
|
||||||
|
await action(Math.min(data.position, state.duration))
|
||||||
|
} else await action()
|
||||||
|
}
|
||||||
|
if (!closed && session === expected) publish()
|
||||||
|
}).catch(() => { if (!closed && session === expected) publish() })
|
||||||
|
}
|
||||||
|
const ready = () => { if (!closed) send({type:'archipelago:media-ready'}) }
|
||||||
|
function dispose() {
|
||||||
|
if (closed) return
|
||||||
|
if (session) send({type:'archipelago:media-state',session,available:false})
|
||||||
|
closed = true; session = null
|
||||||
|
host.removeEventListener('message',message); host.removeEventListener('pageshow',ready); host.removeEventListener('pagehide',leave)
|
||||||
|
actions.pause?.()
|
||||||
|
}
|
||||||
|
function leave(event) { if (!event.persisted) dispose() }
|
||||||
|
host.addEventListener('message',message); host.addEventListener('pageshow',ready); host.addEventListener('pagehide',leave)
|
||||||
|
ready()
|
||||||
|
return { publish, dispose }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { attachAudioBridge } from './archipelago-audio.mjs'
|
||||||
|
function fixture() {
|
||||||
|
const sent=[], handlers=new Map(), calls=[]
|
||||||
|
const parent={postMessage:(data,origin)=>sent.push({data,origin})}
|
||||||
|
const host={parent,addEventListener:(name,fn)=>handlers.set(name,fn),removeEventListener:name=>handlers.delete(name)}
|
||||||
|
let unlocked=true
|
||||||
|
const state={title:'Own track',artist:'Artist',playing:true,shuffle:false,position:4,duration:30}
|
||||||
|
const actions=Object.fromEntries(['play','pause','next','previous','shuffle','seek'].map(name=>[name,async value=>calls.push([name,value])]))
|
||||||
|
const bridge=attachAudioBridge({parentOrigin:'https://node.test',host,snapshot:()=>state,unlocked:()=>unlocked,actions})
|
||||||
|
const receive=(data,origin='https://node.test',source=parent)=>handlers.get('message')?.({source,origin,data:{version:1,...data}})
|
||||||
|
return {sent,handlers,calls,state,bridge,receive,lock:()=>{unlocked=false}}
|
||||||
|
}
|
||||||
|
test('validates parent and nonce; delegates real controls with bounded seeks',async()=>{
|
||||||
|
const f=fixture(),session='a'.repeat(32)
|
||||||
|
f.receive({type:'archipelago:media-connect',session},'https://foreign.test')
|
||||||
|
assert.equal(f.sent.length,1)
|
||||||
|
f.receive({type:'archipelago:media-connect',session})
|
||||||
|
f.receive({type:'archipelago:media-control',session:'b'.repeat(32),command:'play'})
|
||||||
|
f.receive({type:'archipelago:media-control',session,command:'seek',position:99})
|
||||||
|
f.receive({type:'archipelago:media-control',session,command:'previous'})
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.deepEqual(f.calls,[['seek',30],['previous',undefined]])
|
||||||
|
assert(f.sent.every(message=>message.origin==='https://node.test'))
|
||||||
|
f.lock();f.bridge.publish();assert.equal(f.sent.at(-1).data.available,false)
|
||||||
|
assert.equal('title' in f.sent.at(-1).data,false)
|
||||||
|
f.bridge.dispose();assert.equal(f.handlers.size,0)
|
||||||
|
})
|
||||||
|
test('stale queued commands do not control a replacement handshake',async()=>{
|
||||||
|
const f=fixture(),session='a'.repeat(32)
|
||||||
|
f.receive({type:'archipelago:media-connect',session})
|
||||||
|
f.receive({type:'archipelago:media-control',session,command:'next'})
|
||||||
|
f.receive({type:'archipelago:media-connect',session:'b'.repeat(32)})
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.deepEqual(f.calls,[])
|
||||||
|
f.bridge.dispose()
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<!-- Deployment-owned configuration: replace with the exact approved dashboard origin.
|
||||||
|
Never fill this value from a URL parameter or an unverified incoming message. -->
|
||||||
|
<meta name="dashboard-origin" content="https://node.example">
|
||||||
|
<title>Public audio bridge example</title>
|
||||||
|
<style>body{max-width:42rem;margin:2rem auto;padding:1rem;font:18px system-ui}button,input,audio{margin:.5rem 0;max-width:100%}button{min-height:44px;padding:0 1rem}</style>
|
||||||
|
<h1>Audio bridge example</h1><p>Select your own audio files. This example uploads nothing and contains no demo catalog, private app source or bundled recordings.</p>
|
||||||
|
<input id="files" type="file" accept="audio/*" multiple aria-label="Choose audio files">
|
||||||
|
<p id="track" aria-live="polite">No track selected</p><audio id="audio" controls></audio>
|
||||||
|
<div><button id="previous">Previous</button> <button id="next">Next</button> <button id="shuffle" aria-pressed="false">Shuffle</button></div>
|
||||||
|
<script type="module">
|
||||||
|
import { attachAudioBridge } from './archipelago-audio.mjs'
|
||||||
|
const audio=document.querySelector('#audio'), label=document.querySelector('#track'), shuffleButton=document.querySelector('#shuffle')
|
||||||
|
let files=[], urls=[], index=0, shuffled=false
|
||||||
|
function select(next){if(!files.length)return;index=(next+files.length)%files.length;audio.src=urls[index];label.textContent=files[index].name;bridge.publish()}
|
||||||
|
async function advance(){select(shuffled?Math.floor(Math.random()*files.length):index+1);if(files.length)await audio.play()}
|
||||||
|
const actions={play:()=>audio.play(),pause:()=>audio.pause(),seek:seconds=>{audio.currentTime=seconds},next:advance,
|
||||||
|
previous:async()=>{if(audio.currentTime>3)audio.currentTime=0;else select(index-1);if(files.length)await audio.play()},
|
||||||
|
shuffle:()=>{shuffled=!shuffled;shuffleButton.setAttribute('aria-pressed',String(shuffled))}}
|
||||||
|
const bridge=attachAudioBridge({parentOrigin:document.querySelector('meta[name="dashboard-origin"]').content,actions,
|
||||||
|
snapshot:()=>files.length?{title:files[index].name,artist:'Local file',playing:!audio.paused,shuffle:shuffled,position:audio.currentTime,duration:audio.duration}:null})
|
||||||
|
document.querySelector('#files').onchange=event=>{audio.pause();urls.forEach(URL.revokeObjectURL);files=Array.from(event.target.files);urls=files.map(file=>URL.createObjectURL(file));select(0)}
|
||||||
|
for(const name of ['previous','next','shuffle'])document.querySelector('#'+name).onclick=async()=>{try{await actions[name]()}finally{bridge.publish()}}
|
||||||
|
for(const name of ['play','pause','timeupdate','durationchange','loadedmetadata','error'])audio.addEventListener(name,bridge.publish)
|
||||||
|
audio.addEventListener('ended',()=>advance().catch(()=>bridge.publish()))
|
||||||
|
window.addEventListener('pagehide',event=>{if(!event.persisted){bridge.dispose();urls.forEach(URL.revokeObjectURL)}})
|
||||||
|
</script></html>
|
||||||
@@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({
|
|||||||
player: { updateExternal: vi.fn(), detachExternal: vi.fn(), releaseExternal: vi.fn(), setExternalVisible: vi.fn() },
|
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() },
|
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('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
|
||||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
|
||||||
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
|
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' })
|
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 { onBeforeUnmount, watch, type Ref } from 'vue'
|
||||||
import { appHasMediaBridge } from '@/views/discover/curatedApps'
|
import { appHasMediaBridge } from '@/views/discover/curatedApps'
|
||||||
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
|
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
|
||||||
export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: Ref<HTMLIFrameElement | null>, visible: Ref<boolean>) {
|
export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: Ref<HTMLIFrameElement | null>, visible: Ref<boolean>) {
|
||||||
const player = useAudioPlayer()
|
const player = useAudioPlayer()
|
||||||
const launcher = useAppLauncherStore()
|
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 session = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
const id = `${appId.value}:${session}`
|
const id = `${appId.value}:${session}`
|
||||||
const origin = () => { try { return new URL(url.value, window.location.origin).origin } catch { return '' } }
|
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) {
|
function command(command: string, position?: number) {
|
||||||
if (!appHasMediaBridge(appId.value) || !origin()) return
|
if (!matchesAdmission()) return
|
||||||
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, origin())
|
admitted!.source.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, admitted!.origin)
|
||||||
}
|
}
|
||||||
function connect() {
|
function connect() {
|
||||||
if (!appHasMediaBridge(appId.value) || !origin()) return
|
if (admitted && !matchesAdmission()) release()
|
||||||
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
|
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),
|
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
|
||||||
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
|
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) {
|
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
|
const data = event.data
|
||||||
if (data?.version !== 1) return
|
if (data?.version !== 1) return
|
||||||
if (data.type === 'archipelago:media-ready') { connect(); 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 (data.available !== true) { player.detachExternal(id); return }
|
||||||
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|
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
|
|| !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)
|
player.updateExternal(controller, state, visible.value)
|
||||||
}
|
}
|
||||||
watch(visible, shown => player.setExternalVisible(id, shown))
|
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 }
|
return { connect, handle }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
|
import { appHasMediaBridge, appRequiresHostFrame, appLaunchPolicyLoaded, ensureAppLaunchPolicy, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch, onScopeDispose } from 'vue'
|
import { ref, watch, onScopeDispose } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
@@ -237,7 +237,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// phone controls and better performance. Apps with manifest-declared host
|
// phone controls and better performance. Apps with manifest-declared host
|
||||||
// integrations stay in the dashboard frame so their parent bridge remains
|
// integrations stay in the dashboard frame so their parent bridge remains
|
||||||
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
|
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
|
||||||
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appHasMediaBridge(appId)) {
|
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appRequiresHostFrame(appId, pkg?.manifest.version) && !appHasMediaBridge(appId, pkg?.manifest.version)) {
|
||||||
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||||
if (launchUrl) {
|
if (launchUrl) {
|
||||||
@@ -274,7 +274,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// page never changes: panel mode renders beside the page, overlay and
|
// page never changes: panel mode renders beside the page, overlay and
|
||||||
// fullscreen modes render above it (AppSession styles per display mode).
|
// fullscreen modes render above it (AppSession styles per display mode).
|
||||||
// Closing always returns the user exactly where they launched from.
|
// Closing always returns the user exactly where they launched from.
|
||||||
if (appHasMediaBridge(appId)) mediaAppId.value = appId
|
if (pkg && appHasMediaBridge(appId, pkg.manifest.version)) mediaAppId.value = appId
|
||||||
panelPath.value = opts.path ?? null
|
panelPath.value = opts.path ?? null
|
||||||
panelAppId.value = appId
|
panelAppId.value = appId
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
|
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
|
||||||
* Previously each Apps view owned a private modal, so Home skipped the
|
* Previously each Apps view owned a private modal, so Home skipped the
|
||||||
* Portainer first-run token entirely. */
|
* Portainer first-run token entirely. */
|
||||||
function openSession(appId: string, opts: LaunchOptions = {}) {
|
function openSession(appId: string, opts: LaunchOptions = {}, checkedPolicy = false) {
|
||||||
const generation = ++launchGeneration
|
const generation = ++launchGeneration
|
||||||
if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) {
|
if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) {
|
||||||
useToast().info('Loading this node’s demo app…')
|
useToast().info('Loading this node’s demo app…')
|
||||||
@@ -296,6 +296,10 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!IS_DEMO && !appId.startsWith('node-demo-') && !checkedPolicy && !appLaunchPolicyLoaded()) {
|
||||||
|
void ensureAppLaunchPolicy().then(() => { if (generation === launchGeneration) openSession(appId, opts, true) })
|
||||||
|
return
|
||||||
|
}
|
||||||
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
|
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
|
||||||
// Apply the same readiness gate here so a container that has just entered
|
// Apply the same readiness gate here so a container that has just entered
|
||||||
// `running` cannot race nginx and show a transient 502 to the user.
|
// `running` cannot race nginx and show a transient 502 to the user.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {afterEach,describe,expect,it,vi} from 'vitest'
|
||||||
|
import {__setSignedCatalogForTests,appHasMediaBridge,appRequiresHostFrame,ensureAppLaunchPolicy} from '../curatedApps'
|
||||||
|
afterEach(()=>{__setSignedCatalogForTests(null);vi.unstubAllGlobals()})
|
||||||
|
describe('generic manifest media eligibility',()=>{
|
||||||
|
it('accepts a verified ordinary catalog app at its installed version',()=>{
|
||||||
|
__setSignedCatalogForTests({apps:{'public-audio':{version:'2',manifest:{app:{id:'public-audio',metadata:{launch:{media_controls:'archipelago-v1',requires_host_frame:true}}}}}}})
|
||||||
|
expect(appHasMediaBridge('public-audio','2')).toBe(true);expect(appRequiresHostFrame('public-audio','2')).toBe(true)
|
||||||
|
expect(appHasMediaBridge('public-audio','1')).toBe(false)
|
||||||
|
})
|
||||||
|
it('does not grant a public catalog authority over node-only demos or conflicting launch policy',()=>{
|
||||||
|
__setSignedCatalogForTests({apps:{'node-demo-injected':{version:'1',manifest:{app:{id:'node-demo-injected',metadata:{launch:{media_controls:'archipelago-v1'}}}}},bad:{version:'1',manifest:{app:{id:'bad',metadata:{launch:{media_controls:'archipelago-v1',open_in_new_tab:true}}}}}}})
|
||||||
|
expect(appHasMediaBridge('node-demo-injected','1')).toBe(false);expect(appHasMediaBridge('bad','1')).toBe(false)
|
||||||
|
})
|
||||||
|
it('loads only the authenticated verified endpoint before first launch',async()=>{
|
||||||
|
const fetcher=vi.fn(async(_url:string,_init?:RequestInit)=>({ok:true,json:async()=>({apps:{music:{version:'1',manifest:{app:{id:'music',metadata:{launch:{media_controls:'archipelago-v1'}}}}}}})}))
|
||||||
|
vi.stubGlobal('fetch',fetcher);await Promise.all([ensureAppLaunchPolicy(),ensureAppLaunchPolicy()])
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);expect(fetcher.mock.calls[0]?.[0]).toBe('/api/app-catalog');expect(appHasMediaBridge('music','1')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
|
|||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
container?: { image?: string }
|
container?: { image?: string }
|
||||||
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
|
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string; requires_host_frame?: boolean; open_in_new_tab?: boolean } }
|
||||||
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,41 @@ let nodeCatalogRequest: Promise<SignedAppCatalog | null> | null = null
|
|||||||
export function nodeAppIsAvailable(id: string): boolean {
|
export function nodeAppIsAvailable(id: string): boolean {
|
||||||
return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id])
|
return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id])
|
||||||
}
|
}
|
||||||
export function appHasMediaBridge(id: string): boolean {
|
function verifiedLaunchEntry(id: string): SignedAppEntry | undefined {
|
||||||
return nodeAppIsAvailable(id) && nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
|
// Node-only app names never acquire authority from the public catalog.
|
||||||
|
const entry = id.startsWith('node-demo-')
|
||||||
|
? (nodeAppIsAvailable(id) ? nodeCatalogCache?.apps[id] : undefined)
|
||||||
|
: signedCatalogCache?.apps[id]
|
||||||
|
return entry?.manifest?.app?.id === id ? entry : undefined
|
||||||
|
}
|
||||||
|
export function appHasMediaBridge(id: string, installedVersion?: string): boolean {
|
||||||
|
const entry = verifiedLaunchEntry(id)
|
||||||
|
if (!entry || (installedVersion !== undefined && entry.version !== installedVersion)) return false
|
||||||
|
const launch = entry.manifest?.app?.metadata?.launch
|
||||||
|
return launch?.media_controls === 'archipelago-v1' && launch.open_in_new_tab !== true
|
||||||
|
}
|
||||||
|
export function appRequiresHostFrame(id: string, installedVersion?: string): boolean {
|
||||||
|
const entry = verifiedLaunchEntry(id)
|
||||||
|
return Boolean(entry && (installedVersion === undefined || entry.version === installedVersion)
|
||||||
|
&& entry.manifest?.app?.metadata?.launch?.requires_host_frame === true
|
||||||
|
&& entry.manifest.app.metadata.launch.open_in_new_tab !== true)
|
||||||
|
}
|
||||||
|
let launchPolicyRequest: Promise<void> | null = null
|
||||||
|
export function appLaunchPolicyLoaded(): boolean { return signedCatalogCache !== null }
|
||||||
|
/** Read only the authenticated daemon-verified catalog; community fallback
|
||||||
|
* metadata must never grant a native integration. */
|
||||||
|
export async function ensureAppLaunchPolicy(): Promise<void> {
|
||||||
|
if (signedCatalogCache) return
|
||||||
|
if (!launchPolicyRequest) launchPolicyRequest = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/app-catalog', {credentials:'include',signal:AbortSignal.timeout(5000)})
|
||||||
|
if (!response.ok) return
|
||||||
|
const value = await response.json() as SignedAppCatalog
|
||||||
|
if (value.apps && !Array.isArray(value.apps)) signedCatalogCache = value
|
||||||
|
} catch { /* Unavailable policy cannot authorize a new integration. */ }
|
||||||
|
finally { launchPolicyRequest = null }
|
||||||
|
})()
|
||||||
|
await launchPolicyRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve node-owned launch policy independently of the public storefront.
|
/** Resolve node-owned launch policy independently of the public storefront.
|
||||||
|
|||||||
Reference in New Issue
Block a user