diff --git a/docs/app-media-integration.md b/docs/app-media-integration.md index 8407a553..256685f6 100644 --- a/docs/app-media-integration.md +++ b/docs/app-media-integration.md @@ -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 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 The app continues to own its audio element, queue, authorization and playback diff --git a/examples/audio-app/archipelago-audio.mjs b/examples/audio-app/archipelago-audio.mjs new file mode 100644 index 00000000..e5440dd8 --- /dev/null +++ b/examples/audio-app/archipelago-audio.mjs @@ -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 } +} diff --git a/examples/audio-app/archipelago-audio.test.mjs b/examples/audio-app/archipelago-audio.test.mjs new file mode 100644 index 00000000..4cb4ef92 --- /dev/null +++ b/examples/audio-app/archipelago-audio.test.mjs @@ -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() +}) diff --git a/examples/audio-app/index.html b/examples/audio-app/index.html new file mode 100644 index 00000000..c263f27b --- /dev/null +++ b/examples/audio-app/index.html @@ -0,0 +1,28 @@ + + + + +Public audio bridge example + +

Audio bridge example

Select your own audio files. This example uploads nothing and contains no demo catalog, private app source or bundled recordings.

+ +

No track selected

+
+ diff --git a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts index c49860c0..62e6db59 100644 --- a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts +++ b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts @@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({ 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('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{version:'1'}}}}})})) vi.mock('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player })) vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher })) 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' }) }) }) + +it('retains controls and cleanup after catalog expiry but does not admit a new session',()=>{ + const child={postMessage:vi.fn()};let bridge!:ReturnType + 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() +}) diff --git a/neode-ui/src/composables/useAppMediaBridge.ts b/neode-ui/src/composables/useAppMediaBridge.ts index 370ffe2e..d250b2de 100644 --- a/neode-ui/src/composables/useAppMediaBridge.ts +++ b/neode-ui/src/composables/useAppMediaBridge.ts @@ -1,21 +1,41 @@ import { onBeforeUnmount, watch, type Ref } from 'vue' import { appHasMediaBridge } from '@/views/discover/curatedApps' import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer' +import { useAppStore } from '@/stores/app' import { useAppLauncherStore } from '@/stores/appLauncher' export function useAppMediaBridge(appId: Ref, url: Ref, frame: Ref, visible: Ref) { const player = useAudioPlayer() 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 id = `${appId.value}:${session}` 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) { - if (!appHasMediaBridge(appId.value) || !origin()) return - frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, origin()) + if (!matchesAdmission()) return + admitted!.source.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, admitted!.origin) } function connect() { - if (!appHasMediaBridge(appId.value) || !origin()) return - frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin()) + if (admitted && !matchesAdmission()) release() + 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), next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'), @@ -24,11 +44,11 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R }, } 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 if (data?.version !== 1) 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 (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 @@ -44,6 +64,7 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R player.updateExternal(controller, state, visible.value) } 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 } } diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 8f456acc..cb9b8b93 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 { ref, watch, onScopeDispose } from 'vue' 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 // 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) && !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 launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl) if (launchUrl) { @@ -274,7 +274,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 + if (pkg && appHasMediaBridge(appId, pkg.manifest.version)) mediaAppId.value = appId panelPath.value = opts.path ?? null panelAppId.value = appId } @@ -282,7 +282,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { /** One launch gate for Home, My Apps, Discover, Spotlight and details. * Previously each Apps view owned a private modal, so Home skipped the * Portainer first-run token entirely. */ - function openSession(appId: string, opts: LaunchOptions = {}) { + function openSession(appId: string, opts: LaunchOptions = {}, checkedPolicy = false) { const generation = ++launchGeneration if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) { useToast().info('Loading this node’s demo app…') @@ -296,6 +296,10 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { }) 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. // 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. diff --git a/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts b/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts new file mode 100644 index 00000000..1078d3b0 --- /dev/null +++ b/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 7c3d9d48..7f73b134 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -68,7 +68,7 @@ export interface SignedAppEntry { description?: string category?: 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 }[] } } @@ -110,8 +110,41 @@ let nodeCatalogRequest: Promise | null = null export function nodeAppIsAvailable(id: string): boolean { return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id]) } -export function appHasMediaBridge(id: string): boolean { - return nodeAppIsAvailable(id) && nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1' +function verifiedLaunchEntry(id: string): SignedAppEntry | undefined { + // 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 | 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 { + 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.