diff --git a/packages/core/src/runtime/webAudioRoute.test.ts b/packages/core/src/runtime/webAudioRoute.test.ts index f9c072c8cf..80da214b1c 100644 --- a/packages/core/src/runtime/webAudioRoute.test.ts +++ b/packages/core/src/runtime/webAudioRoute.test.ts @@ -48,6 +48,13 @@ function withSelfOrigin(origin: string, run: () => T): T { } } +/** The one console line `reportWebAudioMediaRoute` emits for `el`, or "". */ +function bypassLine(el: HTMLAudioElement): string { + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el)); + return (info.mock.calls[0]?.[0] as string) ?? ""; +} + describe("classifyWebAudioMediaRoute", () => { it("routes same-origin media through Web Audio", () => { expect(classifyWebAudioMediaRoute(audio({ src: "/assets/vo.mp3" }))).toEqual({ @@ -156,7 +163,23 @@ describe("classifyWebAudioMediaRoute", () => { const asset = `${SAME_ORIGIN}/assets/vo.mp3`; const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(audio({ src: asset }))); - expect(route).toEqual({ kind: "decode-only", reason: "cross_origin_no_cors", asset }); + expect(route).toEqual({ kind: "decode-only", reason: "opaque_document_origin", asset }); + }); + + it("reports opacity, not foreignness, when the document is opaque AND the asset is remote", () => { + // Both conditions hold. Opacity is the blocking one and the only one the + // author cannot fix by moving the asset, so it must win the reason. + const asset = `${CROSS_ORIGIN}/track.mp3`; + const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(audio({ src: asset }))); + + expect(route).toEqual({ kind: "decode-only", reason: "opaque_document_origin", asset }); + }); + + it("still lets a crossorigin opt-in rescue an opaque document", () => { + const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, crossorigin: "anonymous" }); + const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(el)); + + expect(route).toEqual({ kind: "web-audio" }); }); it("keeps today's same-origin result when window.origin genuinely matches", () => { @@ -256,13 +279,40 @@ describe("reportWebAudioMediaRoute", () => { expect(info).not.toHaveBeenCalled(); }); - it("names the dropped processing in the CORS bypass line", () => { - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-audio-group": "vo" }); + it("names the dropped processing, and offers a proxy, when a same-origin URL could exist", () => { + const line = bypassLine(audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-audio-group": "vo" })); - reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el)); + expect(line).toContain("audio-group"); + expect(line).toContain("proxy or download the asset to a same-origin URL"); + }); - expect(info.mock.calls[0]?.[0]).toContain("audio-group"); + it("tells an opaque document to fix the sandbox, not to move the asset", () => { + // The whole point of the second reason. Nothing is same-origin with an + // opaque origin, so the proxy advice names a fix that cannot exist here. + const el = audio({ src: `${SAME_ORIGIN}/assets/vo.mp3`, "data-audio-group": "vo" }); + + const line = withSelfOrigin("null", () => bypassLine(el)); + + expect(line).toContain("audio-group"); + expect(line).toContain("allow-same-origin"); + expect(line).toContain("Access-Control-Allow-Origin"); + expect(line).not.toContain("proxy or download"); + }); + + it("puts the opaque reason and its note on the bridge message, not just the console", () => { + const posted: unknown[] = []; + const post = vi + .spyOn(window.parent, "postMessage") + .mockImplementation((message: unknown) => void posted.push(message)); + vi.spyOn(console, "info").mockImplementation(() => {}); + const el = audio({ src: `${SAME_ORIGIN}/assets/vo.mp3` }); + + withSelfOrigin("null", () => reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el))); + + const details = (posted[0] as { details?: Record })?.details ?? {}; + expect(details.reason).toBe("opaque_document_origin"); + expect(String(details.note)).toContain("opaque"); + post.mockRestore(); }); describe("render mode", () => { diff --git a/packages/core/src/runtime/webAudioRoute.ts b/packages/core/src/runtime/webAudioRoute.ts index 9c1252f09c..3459a4f82b 100644 --- a/packages/core/src/runtime/webAudioRoute.ts +++ b/packages/core/src/runtime/webAudioRoute.ts @@ -35,7 +35,7 @@ import type { RuntimeJson } from "./types"; * `createMediaElementSource` from a `srcObject` element today, so this is * recorded as a boundary rather than fixed. * - * Second known gap, same shape: `isCorsSilenced` judges the RAW url string — + * Second known gap, same shape: `corsSilenceReason` judges the RAW url string — * the same-origin URL the author wrote, or whatever the browser resolved into * `currentSrc` — not wherever a server-side redirect chain actually lands. * A same-origin URL that 302s to a cross-origin CDN reads as `web-audio` here @@ -60,7 +60,18 @@ export type WebAudioMediaRoute = * attribute is the common shape of this bug — and that route keeps the whole * FX graph. So: withhold the node, still let the decode path try. */ - | { kind: "decode-only"; reason: "cross_origin_no_cors"; asset: string }; + | { kind: "decode-only"; reason: WebAudioSilenceReason; asset: string }; + +/** + * Why capture was withheld. The two cases need DIFFERENT remediation, which is + * the whole reason they are separate values: a foreign asset can be proxied to + * a same-origin URL, but an opaque document has no same-origin URL to proxy to. + */ +export type WebAudioSilenceReason = + /** The asset's origin differs from ours, and no `crossorigin` opt-in is set. */ + | "cross_origin_no_cors" + /** OUR document's security origin is opaque, so nothing is same-origin with it. */ + | "opaque_document_origin"; /** Fired when the runtime withholds Web Audio capture from a media element. */ const DIAGNOSTIC_BYPASS_CODE = "runtime_web_audio_bypass"; @@ -135,22 +146,45 @@ function routeCandidates(el: HTMLMediaElement): string[] { * pre-existing behaviour rather than losing Web Audio on a guess. The guard * changes behaviour ONLY in the case that is already known-broken. */ -function isCorsSilenced(rawUrl: string, el: HTMLMediaElement): boolean { - if (typeof window === "undefined") return false; +/** + * The candidate as an absolute URL, or null when it is not a fetch this check + * governs: unparseable, or a scheme (`data:`, `blob:`, `file:`) where origin + * comparison means nothing. + */ +function httpMediaUrl(rawUrl: string, el: HTMLMediaElement): URL | null { let url: URL; try { url = new URL(rawUrl, baseUri(el)); } catch { - return false; + return null; } - if (url.protocol !== "http:" && url.protocol !== "https:") return false; - // `location.origin` is the URL's origin, not the document's actual - // security origin: a sandboxed iframe without `allow-same-origin` still - // reports the parent-looking URL there while `window.origin` reads - // `"null"`. An opaque document can't prove same-origin with anything. - const selfOrigin = typeof window.origin === "string" ? window.origin : window.location.origin; - if (selfOrigin !== "null" && url.origin === selfOrigin) return false; - return !hasCorsOptIn(el); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url; +} + +/** + * Our OWN security origin, which is `"null"` for an opaque document. + * + * Not `location.origin`: that is the URL's origin, so a sandboxed iframe + * without `allow-same-origin` still reports the parent-looking URL there + * while `window.origin` reads `"null"`. An opaque document cannot prove + * same-origin with anything, including an asset served from its own host. + */ +function documentSecurityOrigin(): string { + return typeof window.origin === "string" ? window.origin : window.location.origin; +} + +function corsSilenceReason(rawUrl: string, el: HTMLMediaElement): WebAudioSilenceReason | null { + if (typeof window === "undefined") return null; + const url = httpMediaUrl(rawUrl, el); + if (!url) return null; + const selfOrigin = documentSecurityOrigin(); + const opaqueDocument = selfOrigin === "null"; + if (!opaqueDocument && url.origin === selfOrigin) return null; + if (hasCorsOptIn(el)) return null; + // Opacity wins when both hold. It is the blocking condition, and it is the + // one the author cannot fix by moving the asset. + return opaqueDocument ? "opaque_document_origin" : "cross_origin_no_cors"; } /** @@ -182,8 +216,9 @@ export function isRouteSelectionSettled(el: HTMLMediaElement): boolean { */ export function classifyWebAudioMediaRoute(el: HTMLMediaElement): WebAudioMediaRoute { for (const candidate of routeCandidates(el)) { - if (isCorsSilenced(candidate, el)) { - return { kind: "decode-only", reason: "cross_origin_no_cors", asset: candidate }; + const reason = corsSilenceReason(candidate, el); + if (reason) { + return { kind: "decode-only", reason, asset: candidate }; } } return { kind: "web-audio" }; @@ -252,6 +287,29 @@ const diagnosedElements = new WeakSet(); * The bypass always reports: it is an accident by definition, and the whole * complaint in #3458 is that nothing was said. */ +/** + * What was actually wrong, per reason. The opaque case is NOT "cross-origin + * media": the asset can be same-origin by URL and still unreadable, because it + * is OUR document that cannot prove an origin. + */ +const NOTE_BY_REASON: Record = { + cross_origin_no_cors: + "cross-origin media without a `crossorigin` opt-in is silent through createMediaElementSource (Web Audio spec); using native playback instead", + opaque_document_origin: + "this document's security origin is opaque (a sandbox without `allow-same-origin`), so no media is same-origin with it and createMediaElementSource would be silent (Web Audio spec); using native playback instead", +}; + +/** + * How to get the processing back, per reason. Keeping these apart is the point: + * telling an opaque document to "proxy the asset to a same-origin URL" names a + * fix that cannot exist, since nothing is same-origin with an opaque origin. + */ +const REMEDY_BY_REASON: Record = { + cross_origin_no_cors: "proxy or download the asset to a same-origin URL to keep it.", + opaque_document_origin: + "add `allow-same-origin` to the sandbox, or serve the asset with a `crossorigin` attribute and an `Access-Control-Allow-Origin` of `null` or `*`, to keep it.", +}; + export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMediaRoute): void { if (route.kind === "web-audio") return; if (isRenderMode()) return; @@ -263,7 +321,7 @@ export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMe asset: route.asset, reason: route.reason, lostProcessing: lost, - note: "cross-origin media without a `crossorigin` opt-in is silent through createMediaElementSource (Web Audio spec); using native playback instead", + note: NOTE_BY_REASON[route.reason], }; postRuntimeMessage({ source: "hf-preview", @@ -276,7 +334,7 @@ export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMe // same contract mediaProxy.ts's diagnostics use. const lostNote = lost.length > 0 - ? ` Native playback cannot reproduce: ${lost.join(", ")} — proxy or download the asset to a same-origin URL to keep it.` + ? ` Native playback cannot reproduce: ${lost.join(", ")} — ${REMEDY_BY_REASON[route.reason]}` : ""; console.info( `[hyperframes] ${DIAGNOSTIC_BYPASS_CODE}: "${route.asset}" (${route.reason}): ` +