feat(player): force audio lock on Claude desktop via UA fallback

The Claude desktop Electron client appears to strip the `audio-locked`
custom-element attribute before it reaches the DOM, so chat-host audio
still plays even though Claude web (which preserves the attribute)
correctly mutes. Verified via DevTools: web renders `<hyperframes-player
audio-locked>` and is silent; desktop omits the attribute and plays sound.

Self-impose the same restriction when `navigator.userAgent` matches the
Claude desktop UA (Claude/<ver> + Electron). Internally route everything
through a new `_isAudioLocked()` helper — attribute OR host fallback —
and apply the lock from `connectedCallback` since `attributeChangedCallback`
never fires when the attribute is missing.

The public `audioLocked` property still reflects only the attribute, so
external consumers (e.g. pacific widget mirroring state) are unaffected
by the safety net.

Tests: 6 new (forces mute on Claude desktop UA, re-asserts on unmute,
hides controls, no-op for regular browsers, no-op for non-Claude Electron
apps, public property remains attribute-only). Player suite green:
132 tests.

Refs: pacific #28773, experiment-framework #38809.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xiaye
2026-06-09 10:04:49 -07:00
co-authored by Claude Opus 4.7
parent 3d7d7c0291
commit cc45b1fa33
3 changed files with 137 additions and 2 deletions
@@ -1591,6 +1591,107 @@ describe("HyperframesPlayer audio lock", () => {
});
});
describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
// Some host renderers (observed on the Claude desktop Electron client) strip
// unknown custom-element attributes before they reach the DOM, so the
// `audio-locked` attribute is lost. The player self-imposes the lock based
// on UA detection so chat-host audio stays muted even without the attribute.
let player: HTMLElement & { muted: boolean; audioLocked: boolean };
let originalUserAgent: PropertyDescriptor | undefined;
function stubUserAgent(ua: string) {
Object.defineProperty(navigator, "userAgent", {
value: ua,
configurable: true,
});
}
beforeEach(async () => {
originalUserAgent = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(navigator),
"userAgent",
);
await import("./hyperframes-player.js");
player = document.createElement("hyperframes-player") as typeof player;
});
afterEach(() => {
if (originalUserAgent) {
Object.defineProperty(Object.getPrototypeOf(navigator), "userAgent", originalUserAgent);
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
it("forces muted on Claude desktop UA even without the audio-locked attribute", () => {
stubUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
);
document.body.appendChild(player);
expect(player.hasAttribute("audio-locked")).toBe(false);
expect(player.muted).toBe(true);
expect(player.hasAttribute("muted")).toBe(true);
});
it("re-asserts mute on Claude desktop when something tries to unmute", () => {
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
document.body.appendChild(player);
player.muted = false;
expect(player.hasAttribute("muted")).toBe(true);
player.removeAttribute("muted");
expect(player.hasAttribute("muted")).toBe(true);
});
it("hides the volume controls on Claude desktop without the attribute", () => {
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
player.setAttribute("controls", "");
document.body.appendChild(player);
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
expect(volumeWrap.style.display).toBe("none");
});
it("does NOT force mute on a regular browser UA", () => {
stubUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
);
document.body.appendChild(player);
expect(player.hasAttribute("audio-locked")).toBe(false);
expect(player.muted).toBe(false);
expect(player.hasAttribute("muted")).toBe(false);
});
it("does NOT force mute on Electron apps that aren't Claude desktop", () => {
// Other Electron clients (e.g. VS Code embedded view) shouldn't be muted.
stubUserAgent(
"Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
);
document.body.appendChild(player);
expect(player.muted).toBe(false);
});
it("keeps `audioLocked` property reflecting only the attribute, not the UA fallback", () => {
// External consumers (pacific widget, etc.) read `audioLocked` to mirror
// their own state. The UA fallback is an internal safety net and must not
// leak into the public property — otherwise unsetting `audioLocked` would
// appear to have no effect from the consumer's perspective.
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
document.body.appendChild(player);
expect(player.audioLocked).toBe(false);
});
});
// ── Playback rate ──
describe("HyperframesPlayer playback rate", () => {