From 0c1d1b57960de0035d38d9285e90894dc96c472a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 18:41:06 -0400 Subject: [PATCH] Allow notification thumbnails from admitted LAN app origins --- .../companion-audio-investigation-20261007.md | 49 +++++++++++++++++-- .../GlobalAudioPlayerExternal.test.ts | 2 +- .../__tests__/useAppMediaBridge.test.ts | 3 +- .../__tests__/useCompanionAudio.test.ts | 44 +++++++++++++++-- neode-ui/src/composables/useAppMediaBridge.ts | 2 +- neode-ui/src/composables/useAudioPlayer.ts | 7 ++- neode-ui/src/composables/useCompanionAudio.ts | 13 +++-- 7 files changed, 105 insertions(+), 15 deletions(-) diff --git a/docs/companion-audio-investigation-20261007.md b/docs/companion-audio-investigation-20261007.md index 15f33998..48ade996 100644 --- a/docs/companion-audio-investigation-20261007.md +++ b/docs/companion-audio-investigation-20261007.md @@ -1,6 +1,13 @@ # Companion background audio investigation — 2026-10-07 -Status: **OPEN — physical acceptance failed.** +Status: **OPEN — operator now reports APK57 background playback working; notification artwork missing.** + +Latest operator feedback after installing 0.5.37: "works now, doesn't show the song +image in the notification widget though only." Record reported background audio +and notification presence as working for that test. Do not infer full queue/control, +restart, process-death or video acceptance, or a confirmed cause for the earlier +failure: build57 added diagnostics rather than a known playback correction. +Artwork investigation is now the priority. Historical APK56 failure follows. APK 0.5.36 (build 56) plays V4V while foregrounded, but the operator reports audio stopping about five seconds after locking the phone or switching applications, @@ -70,8 +77,9 @@ diagnostics and copy the report. No need to use a different track or USB. packaging. No keystore was staged, copied into evidence, or replaced. - Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`. No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed. -- Physical background playback remains **unaccepted**; the diagnostic report is - the next evidence required from the affected phone. +- At diagnostic delivery physical background playback remained unaccepted. The + later operator report above establishes it worked in their APK57 test; artwork + remains unresolved on the phone. ## Companion shell routing check @@ -88,3 +96,38 @@ An initial Apps-button probe found no V4V card/package on `/dashboard/apps`; it therefore did not exercise an actual Apps launch button. Keep that failed probe distinct from the subsequent successful Discover/launcher check. No routing fix is justified by these results. Scripts/logs are in the durable diagnostic evidence. + +## Artwork origin correction + +Confirmed source defect: `useAppMediaBridge` admits cover URLs on the verified +app's origin (for example the node's HTTP :7475 port), but `useCompanionAudio` +previously rejected every HTTP cover outside the dashboard's own origin. Thus a +cover could render in the dashboard and never become a native thumbnail. + +The media controller now carries its admitted origin explicitly into the player; +an app's `media-state` payload cannot supply/override this authorization. Thumbnail +fetching accepts HTTP only from the dashboard or that admitted origin. HTTPS +artwork remains supported. Cross-origin requests do not send cookies, no referrer +is sent, and redirects fail closed. No stream URL, cover URL, credentials, or +second decoder enters the native bridge. Optional image failure still leaves +playback running. CORS remains enforced; this does not bypass an image server's +access policy or add a server-side image proxy. + +Evidence: + +- Focused four-file media suite: 26 tests passed. A test-only `Array.at` call was + corrected for the configured ES library after actual app-project typechecking; + the affected file was rerun separately. +- `vue-tsc --noEmit -p tsconfig.app.json` passed. The reference-only root tsconfig + has no files; its bare `--noEmit` result is not used as application evidence. +- Actual Chromium A/B fixture with an HTTP app on a separate port: old composable + never produced a thumbnail; corrected composable produced a real JPEG. Browser + checks also verified no cross-origin cookie, no followed redirect, and playback + remained active after the optional image error. +- No native runtime or APK version change. A positive native JPEG/notification/ + metadata regression is prepared separately and awaits its coordinated test slot. +- This identifies and fixes a real artwork boundary defect, not the operator's + exact cover URL. Physical notification-artwork acceptance remains open until + the qualified UI reaches the node and the operator verifies it. + +Evidence folder: `~/.local/state/archipelago/release-qualification/companion-artwork-20261007/`. diff --git a/neode-ui/src/components/__tests__/GlobalAudioPlayerExternal.test.ts b/neode-ui/src/components/__tests__/GlobalAudioPlayerExternal.test.ts index 2ef76658..1477fa2b 100644 --- a/neode-ui/src/components/__tests__/GlobalAudioPlayerExternal.test.ts +++ b/neode-ui/src/components/__tests__/GlobalAudioPlayerExternal.test.ts @@ -7,7 +7,7 @@ import GlobalAudioPlayer from '../GlobalAudioPlayer.vue' describe('native app player controls', () => { it('renders app artwork and synchronized controls without a second audio source', async () => { fixture.player = { - currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalShuffle: ref(true), + currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalArtworkOrigin: ref('https://node.test:7475'), externalShuffle: ref(true), playing: ref(true), loading: ref(false), error: ref(null), currentTime: ref(4), duration: ref(90), progress: ref(5), currentSrc: ref('app:session'), previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(), } diff --git a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts index 55c6a291..7bddef2d 100644 --- a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts +++ b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts @@ -49,7 +49,7 @@ describe('app media session boundary', () => { 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, artwork: '/media/cover.jpg', shuffle: true } + const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100, artwork: '/media/cover.jpg', artworkOrigin: 'http://untrusted.test', shuffle: true } 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 }) @@ -57,6 +57,7 @@ describe('app media session boundary', () => { message() expect(fixture.player.updateExternal).toHaveBeenCalledOnce() const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]! + expect(controller.artworkOrigin).toBe('https://node.test:7475'); expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true); controller.next(); controller.previous(); controller.shuffle(); expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']); diff --git a/neode-ui/src/composables/__tests__/useCompanionAudio.test.ts b/neode-ui/src/composables/__tests__/useCompanionAudio.test.ts index 422d8d3d..08c672c1 100644 --- a/neode-ui/src/composables/__tests__/useCompanionAudio.test.ts +++ b/neode-ui/src/composables/__tests__/useCompanionAudio.test.ts @@ -1,18 +1,18 @@ -import { mount } from '@vue/test-utils' +import { mount, flushPromises } from '@vue/test-utils' import { defineComponent, nextTick, ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useCompanionAudio } from '../useCompanionAudio' import type { useAudioPlayer } from '../useAudioPlayer' beforeEach(() => vi.useFakeTimers()) -afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio }) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete (window as any).ArchipelagoAudio }) function fixture(install = true) { const messages: any[] = [] const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) } if (install) (window as any).ArchipelagoAudio = bridge const player = { currentSrc: ref('https://node.test/private?token=secret'), currentName: ref('A song'), - playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''), + playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''), externalArtworkOrigin: ref(''), externalApp: ref(true), externalShuffle: ref(false), error: ref(null), pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(), stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }), @@ -81,6 +81,44 @@ describe('companion native audio control of the existing player', () => { f.wrapper.unmount(); const count = f.messages.length await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count) }) + it('decodes artwork from the admitted HTTP app port without forwarding cookies or following redirects', async () => { + const jpeg = 'data:image/jpeg;base64,/9j/fixture'; + const fetchImage = vi.fn(async () => ({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }), + body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([255, 216, 255])); controller.close() } }) })) + vi.stubGlobal('fetch', fetchImage) + vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ close: vi.fn() }))) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as any) + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(jpeg) + const f = fixture(); await nextTick() + f.player.externalArtworkOrigin.value = 'http://node.test:7475' + f.player.externalArtwork.value = 'http://node.test:7475/cover.jpg' + await nextTick(); await flushPromises() + expect(fetchImage).toHaveBeenCalledWith('http://node.test:7475/cover.jpg', expect.objectContaining({ credentials: 'same-origin', redirect: 'error', referrerPolicy: 'no-referrer' })) + expect(f.messages.some(value => value.artwork === jpeg)).toBe(true) + expect(JSON.stringify(f.messages)).not.toContain('/cover.jpg') + f.wrapper.unmount() + }) + it('rejects arbitrary HTTP artwork and credentials even with an admitted app origin', async () => { + const fetchImage = vi.fn(); vi.stubGlobal('fetch', fetchImage) + const f = fixture(); await nextTick() + f.player.externalArtworkOrigin.value = 'http://node.test:7475' + for (const url of ['http://other.test/image.jpg', 'http://node.test:7476/image.jpg', 'http://user:password@node.test:7475/image.jpg']) { + f.player.externalArtwork.value = url; await nextTick(); await flushPromises() + } + expect(fetchImage).not.toHaveBeenCalled() + expect(f.player.pause).not.toHaveBeenCalled() + f.wrapper.unmount() + }) + it('treats a denied redirect or image-fetch failure as optional without pausing music', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('redirect denied'))) + const f = fixture(); await nextTick() + f.player.externalArtworkOrigin.value = 'http://node.test:7475' + f.player.externalArtwork.value = 'http://node.test:7475/redirect' + await nextTick(); await flushPromises() + expect(f.player.pause).not.toHaveBeenCalled() + expect(f.messages[f.messages.length - 1]).toMatchObject({ playing: true }) + f.wrapper.unmount() + }) it('does not revive paused sessions and reports native start failures without leaking details', async () => { const f = fixture(); await nextTick() f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) }) diff --git a/neode-ui/src/composables/useAppMediaBridge.ts b/neode-ui/src/composables/useAppMediaBridge.ts index 4f977fdd..20296666 100644 --- a/neode-ui/src/composables/useAppMediaBridge.ts +++ b/neode-ui/src/composables/useAppMediaBridge.ts @@ -43,7 +43,7 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R } 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, get artworkOrigin() { return admitted?.origin || '' }, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position), next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'), open: () => launcher.openSession(appId.value), close: () => { if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null diff --git a/neode-ui/src/composables/useAudioPlayer.ts b/neode-ui/src/composables/useAudioPlayer.ts index 923773b4..fd5817d8 100644 --- a/neode-ui/src/composables/useAudioPlayer.ts +++ b/neode-ui/src/composables/useAudioPlayer.ts @@ -13,6 +13,8 @@ let initialized = false export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean } export interface ExternalAudioController { id: string + /** Origin admitted by the host media bridge, never taken from a media-state payload. */ + artworkOrigin?: string play: () => void pause: () => void seek: (position: number) => void @@ -27,6 +29,7 @@ const suppressedExternal = new Set() const externalVisible = ref(false) const externalApp = ref(false) const externalArtwork = ref('') +const externalArtworkOrigin = ref('') const externalShuffle = ref(false) function nextExternal() { external?.next?.() } function previousExternal() { external?.previous?.() } @@ -41,6 +44,7 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi external = controller } externalArtwork.value = state.artwork || '' + externalArtworkOrigin.value = controller.artworkOrigin || '' externalShuffle.value = state.shuffle === true externalApp.value = true externalVisible.value = visible @@ -56,6 +60,7 @@ function detachExternal(id: string) { if (external?.id !== id) return external = null externalArtwork.value = '' + externalArtworkOrigin.value = '' externalShuffle.value = false externalApp.value = false externalVisible.value = false @@ -181,7 +186,7 @@ const progress = computed(() => { export function useAudioPlayer() { return { - externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal, + externalArtwork, externalArtworkOrigin, externalShuffle, nextExternal, previousExternal, shuffleExternal, updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp, play, pause, diff --git a/neode-ui/src/composables/useCompanionAudio.ts b/neode-ui/src/composables/useCompanionAudio.ts index a46667d4..9f9c5bd4 100644 --- a/neode-ui/src/composables/useCompanionAudio.ts +++ b/neode-ui/src/composables/useCompanionAudio.ts @@ -14,11 +14,14 @@ function audioSessionId(): string { } /** Send a small decoded thumbnail, never a protected URL or authorization data. */ -async function thumbnail(url: string, signal: AbortSignal): Promise { +async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise { if (!url) return '' const source = new URL(url, window.location.href) - if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return '' - const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' }) + // An app's authenticated media origin can use a different port on LAN HTTP. + // Never authorize an arbitrary HTTP origin from the artwork payload itself. + if (source.username || source.password || !(source.protocol === 'https:' + || (source.protocol === 'http:' && (source.origin === window.location.origin || source.origin === admittedOrigin)))) return '' + const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer', redirect: 'error' }) if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return '' const reader = response.body?.getReader() if (!reader) return '' @@ -113,7 +116,7 @@ export function useCompanionAudio(player = useAudioPlayer()) { imageRequest?.abort(); artwork = '' if (!session || !player.externalArtwork.value) return const target = session; const request = new AbortController(); imageRequest = request - void thumbnail(player.externalArtwork.value, request.signal).then(value => { + void thumbnail(player.externalArtwork.value, player.externalArtworkOrigin.value, request.signal).then(value => { if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() } }).catch(() => { /* Optional image failure never interrupts music. */ }) } @@ -122,7 +125,7 @@ export function useCompanionAudio(player = useAudioPlayer()) { watch([player.currentTime, player.duration], () => { if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000) }) - watch(player.externalArtwork, () => { loadArtwork(); publish() }) + watch([player.externalArtwork, player.externalArtworkOrigin], () => { loadArtwork(); publish() }) const sync = () => publish() window.addEventListener('pageshow', sync) document.addEventListener('visibilitychange', sync)