Allow notification thumbnails from admitted LAN app origins
This commit is contained in:
@@ -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/`.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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<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),
|
||||
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' }) })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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<string>()
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user