Allow notification thumbnails from admitted LAN app origins

This commit is contained in:
archipelago
2026-10-07 18:41:06 -04:00
parent 80ebf75313
commit 0c1d1b5796
7 changed files with 105 additions and 15 deletions
+46 -3
View File
@@ -1,6 +1,13 @@
# Companion background audio investigation — 2026-10-07 # 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 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, 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. packaging. No keystore was staged, copied into evidence, or replaced.
- Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`. - Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`.
No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed. No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed.
- Physical background playback remains **unaccepted**; the diagnostic report is - At diagnostic delivery physical background playback remained unaccepted. The
the next evidence required from the affected phone. later operator report above establishes it worked in their APK57 test; artwork
remains unresolved on the phone.
## Companion shell routing check ## 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 therefore did not exercise an actual Apps launch button. Keep that failed probe
distinct from the subsequent successful Discover/launcher check. No routing fix distinct from the subsequent successful Discover/launcher check. No routing fix
is justified by these results. Scripts/logs are in the durable diagnostic evidence. 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/`.
@@ -7,7 +7,7 @@ import GlobalAudioPlayer from '../GlobalAudioPlayer.vue'
describe('native app player controls', () => { describe('native app player controls', () => {
it('renders app artwork and synchronized controls without a second audio source', async () => { it('renders app artwork and synchronized controls without a second audio source', async () => {
fixture.player = { 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'), 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(), previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(),
} }
@@ -49,7 +49,7 @@ describe('app media session boundary', () => {
bridge.connect() bridge.connect()
const hello = child.postMessage.mock.calls[0]![0] const hello = child.postMessage.mock.calls[0]![0]
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475') 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) 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, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN }) message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
@@ -57,6 +57,7 @@ describe('app media session boundary', () => {
message() message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce() expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]! 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); expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true);
controller.next(); controller.previous(); controller.shuffle(); controller.next(); controller.previous(); controller.shuffle();
expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']); expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']);
@@ -1,18 +1,18 @@
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue' import { defineComponent, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCompanionAudio } from '../useCompanionAudio' import { useCompanionAudio } from '../useCompanionAudio'
import type { useAudioPlayer } from '../useAudioPlayer' import type { useAudioPlayer } from '../useAudioPlayer'
beforeEach(() => vi.useFakeTimers()) 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) { function fixture(install = true) {
const messages: any[] = [] const messages: any[] = []
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) } const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
if (install) (window as any).ArchipelagoAudio = bridge if (install) (window as any).ArchipelagoAudio = bridge
const player = { const player = {
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'), currentSrc: ref<string | null>('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<string | null>(null), externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(), 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 }), 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 f.wrapper.unmount(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count) 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 () => { it('does not revive paused sessions and reports native start failures without leaking details', async () => {
const f = fixture(); await nextTick() const f = fixture(); await nextTick()
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) }) f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
@@ -43,7 +43,7 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
} }
admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin) 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'), next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
open: () => launcher.openSession(appId.value), close: () => { open: () => launcher.openSession(appId.value), close: () => {
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
+6 -1
View File
@@ -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 ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean }
export interface ExternalAudioController { export interface ExternalAudioController {
id: string id: string
/** Origin admitted by the host media bridge, never taken from a media-state payload. */
artworkOrigin?: string
play: () => void play: () => void
pause: () => void pause: () => void
seek: (position: number) => void seek: (position: number) => void
@@ -27,6 +29,7 @@ const suppressedExternal = new Set<string>()
const externalVisible = ref(false) const externalVisible = ref(false)
const externalApp = ref(false) const externalApp = ref(false)
const externalArtwork = ref('') const externalArtwork = ref('')
const externalArtworkOrigin = ref('')
const externalShuffle = ref(false) const externalShuffle = ref(false)
function nextExternal() { external?.next?.() } function nextExternal() { external?.next?.() }
function previousExternal() { external?.previous?.() } function previousExternal() { external?.previous?.() }
@@ -41,6 +44,7 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
external = controller external = controller
} }
externalArtwork.value = state.artwork || '' externalArtwork.value = state.artwork || ''
externalArtworkOrigin.value = controller.artworkOrigin || ''
externalShuffle.value = state.shuffle === true externalShuffle.value = state.shuffle === true
externalApp.value = true externalApp.value = true
externalVisible.value = visible externalVisible.value = visible
@@ -56,6 +60,7 @@ function detachExternal(id: string) {
if (external?.id !== id) return if (external?.id !== id) return
external = null external = null
externalArtwork.value = '' externalArtwork.value = ''
externalArtworkOrigin.value = ''
externalShuffle.value = false externalShuffle.value = false
externalApp.value = false externalApp.value = false
externalVisible.value = false externalVisible.value = false
@@ -181,7 +186,7 @@ const progress = computed(() => {
export function useAudioPlayer() { export function useAudioPlayer() {
return { return {
externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal, externalArtwork, externalArtworkOrigin, externalShuffle, nextExternal, previousExternal, shuffleExternal,
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp, updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
play, play,
pause, pause,
@@ -14,11 +14,14 @@ function audioSessionId(): string {
} }
/** Send a small decoded thumbnail, never a protected URL or authorization data. */ /** Send a small decoded thumbnail, never a protected URL or authorization data. */
async function thumbnail(url: string, signal: AbortSignal): Promise<string> { async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
if (!url) return '' if (!url) return ''
const source = new URL(url, window.location.href) const source = new URL(url, window.location.href)
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return '' // An app's authenticated media origin can use a different port on LAN HTTP.
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' }) // 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 '' if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
const reader = response.body?.getReader() const reader = response.body?.getReader()
if (!reader) return '' if (!reader) return ''
@@ -113,7 +116,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
imageRequest?.abort(); artwork = '' imageRequest?.abort(); artwork = ''
if (!session || !player.externalArtwork.value) return if (!session || !player.externalArtwork.value) return
const target = session; const request = new AbortController(); imageRequest = request 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() } if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
}).catch(() => { /* Optional image failure never interrupts music. */ }) }).catch(() => { /* Optional image failure never interrupts music. */ })
} }
@@ -122,7 +125,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
watch([player.currentTime, player.duration], () => { watch([player.currentTime, player.duration], () => {
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000) if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
}) })
watch(player.externalArtwork, () => { loadArtwork(); publish() }) watch([player.externalArtwork, player.externalArtworkOrigin], () => { loadArtwork(); publish() })
const sync = () => publish() const sync = () => publish()
window.addEventListener('pageshow', sync) window.addEventListener('pageshow', sync)
document.addEventListener('visibilitychange', sync) document.addEventListener('visibilitychange', sync)