From de65a8eae923a630c12ee4abd11dabcae2c0be34 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 10:49:14 -0700 Subject: [PATCH] fix(registry): authenticate deck sound effect messages --- packages/core/src/runtime/deckAudio.test.ts | 74 +++++++++++++++++++++ registry/examples/airbnb-deck/demo.html | 4 ++ 2 files changed, 78 insertions(+) create mode 100644 packages/core/src/runtime/deckAudio.test.ts diff --git a/packages/core/src/runtime/deckAudio.test.ts b/packages/core/src/runtime/deckAudio.test.ts new file mode 100644 index 0000000000..8d37250877 --- /dev/null +++ b/packages/core/src/runtime/deckAudio.test.ts @@ -0,0 +1,74 @@ +import { JSDOM } from "jsdom"; +import { expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +const demoHtml = readFileSync( + resolve(__dirname, "../../../../registry/examples/airbnb-deck/demo.html"), + "utf8", +); + +it("accepts only current-player sound names while preserving unlock and mute behavior", async () => { + const clips: Array<{ + play: ReturnType; + pause: ReturnType; + volume: number; + currentTime: number; + }> = []; + const dom = new JSDOM(demoHtml, { + runScripts: "dangerously", + beforeParse(window) { + Object.defineProperty(window, "Audio", { + value: function () { + const clip = { + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(), + volume: 1, + currentTime: 0, + }; + clips.push(clip); + return clip; + }, + }); + }, + }); + try { + const w = dom.window; + const player = w.document.querySelector("hyperframes-player")!; + const frame = w.document.createElement("iframe"); + player.append(frame); + Object.defineProperty(player, "iframeElement", { get: () => player.querySelector("iframe") }); + const original = frame.contentWindow; + const send = (source: MessageEventSource | null, name: unknown) => + w.dispatchEvent(new w.MessageEvent("message", { source, data: { type: "hf-sfx", name } })); + send(original, "advance"); + expect(clips.every((clip) => clip.play.mock.calls.length === 0)).toBe(true); + w.dispatchEvent(new w.Event("pointerdown")); + await Promise.resolve(); + clips.forEach((clip) => clip.play.mockClear()); + for (const name of ["advance", "fragment", "branch-enter", "back"]) send(original, name); + expect(clips.map((clip) => clip.play.mock.calls.length)).toEqual([1, 1, 1, 1]); + for (const name of ["__proto__", "constructor", "unknown", ["advance"]]) send(original, name); + expect(Object.hasOwn(w.Object.prototype, "currentTime")).toBe(false); + send(null, "advance"); + const foreign = w.document.createElement("iframe"); + w.document.body.append(foreign); + send(foreign.contentWindow, "advance"); + expect(clips.map((clip) => clip.play.mock.calls.length)).toEqual([1, 1, 1, 1]); + const replacement = w.document.createElement("iframe"); + frame.replaceWith(replacement); + send(original, "advance"); + expect(clips[0]!.play).toHaveBeenCalledTimes(1); + send(replacement.contentWindow, "advance"); + expect(clips[0]!.play).toHaveBeenCalledTimes(2); + const slideshow = w.document.querySelector("hyperframes-slideshow")!; + slideshow.dispatchEvent(new w.CustomEvent("hf-sound", { detail: { muted: true } })); + send(replacement.contentWindow, "advance"); + expect(clips[0]!.play).toHaveBeenCalledTimes(2); + slideshow.dispatchEvent(new w.CustomEvent("hf-sound", { detail: { muted: false } })); + send(replacement.contentWindow, "advance"); + expect(clips[0]!.play).toHaveBeenCalledTimes(3); + expect(clips.map((clip) => clip.volume)).toEqual([0.45, 0.4, 0.4, 0.4]); + } finally { + dom.window.close(); + } +}); diff --git a/registry/examples/airbnb-deck/demo.html b/registry/examples/airbnb-deck/demo.html index d054911b5c..b01a46b3f8 100644 --- a/registry/examples/airbnb-deck/demo.html +++ b/registry/examples/airbnb-deck/demo.html @@ -168,8 +168,12 @@ window.addEventListener("click", unlock, true); window.addEventListener("message", function (e) { + var player = slideshow && slideshow.querySelector("hyperframes-player"); + var frame = player && player.iframeElement; + if (!frame || !frame.contentWindow || e.source !== frame.contentWindow) return; var d = e.data; if (!d || d.type !== "hf-sfx") return; + if (typeof d.name !== "string" || !Object.hasOwn(clips, d.name)) return; // Skip playback when muted — the component owns mute state. if (muted) return; var el = clips[d.name];