diff --git a/scripts/test/widget-voice.ts b/scripts/test/widget-voice.ts index c4e56185..1c7da33f 100644 --- a/scripts/test/widget-voice.ts +++ b/scripts/test/widget-voice.ts @@ -5,7 +5,7 @@ // appending would silently destroy the sentence they just wrote, on someone // else's site, with no undo. These pin the merge rules. // Run: npx tsx scripts/test/widget-voice.ts -import { formatElapsed, mergeTranscript } from "../../widget/voice"; +import { formatElapsed, isMicrophoneAllowedByPolicy, mergeTranscript } from "../../widget/voice"; let pass = 0; let fail = 0; @@ -53,6 +53,56 @@ ok( "an already-over-cap box is clipped rather than grown", ); +// ---- Permissions-Policy: the reason a "Speak" button could only ever fail ---- +// +// orangecat.ch sends `permissions-policy: camera=(), microphone=(), geolocation=()`. +// An empty allowlist denies the mic to EVERY origin including the site itself, +// so getUserMedia rejects with NotAllowedError and no prompt is ever shown — +// measured there as permissionState "denied", not "prompt". +// +// The trap: navigator.mediaDevices.getUserMedia still EXISTS in that state. A +// support check that only looks for the API therefore passes, draws the +// button, and hands the visitor an error they cannot resolve — there is +// nothing for them to allow. Only the policy tells the truth. +const realDocument = (globalThis as { document?: unknown }).document; +function withFeaturePolicy(value: unknown, run: () => void) { + (globalThis as { document?: unknown }).document = value; + try { + run(); + } finally { + if (realDocument === undefined) delete (globalThis as { document?: unknown }).document; + else (globalThis as { document?: unknown }).document = realDocument; + } +} + +withFeaturePolicy({ featurePolicy: { allowsFeature: (f: string) => f !== "microphone" } }, () => { + ok( + isMicrophoneAllowedByPolicy() === false, + "a document whose policy denies the microphone reports it as blocked", + ); +}); +withFeaturePolicy({ featurePolicy: { allowsFeature: () => true } }, () => { + ok(isMicrophoneAllowedByPolicy() === true, "an allowing policy reports allowed"); +}); +// Unknown must mean allowed: featurePolicy is non-standard and missing in some +// browsers. Failing closed there would hide a WORKING mic, which is a worse +// error than a click that fails with a readable message. +withFeaturePolicy({}, () => { + ok(isMicrophoneAllowedByPolicy() === true, "no featurePolicy API — treated as allowed"); +}); +withFeaturePolicy( + { + featurePolicy: { + allowsFeature: () => { + throw new Error("boom"); + }, + }, + }, + () => { + ok(isMicrophoneAllowedByPolicy() === true, "a throwing featurePolicy is treated as allowed"); + }, +); + // ---- formatElapsed ---- ok(formatElapsed(0) === "0:00", "zero renders as 0:00"); ok(formatElapsed(7_000) === "0:07", "seconds are zero-padded"); diff --git a/src/app/docs/feedback-widget/page.tsx b/src/app/docs/feedback-widget/page.tsx index 1566d2c7..5c54d763 100644 --- a/src/app/docs/feedback-widget/page.tsx +++ b/src/app/docs/feedback-widget/page.tsx @@ -73,6 +73,21 @@ export default function FeedbackWidgetDocsPage() { existing inbox row (shown as ×N) instead of creating a duplicate — the volume signal survives, the noise doesn't.
++ They can also speak instead of typing — useful on a phone, where + describing a bug by thumb is where most reports die. The transcript lands in the same + box, editable, and the visitor still presses Send. +
+
+ The mic needs your site's permission. If your site sends a{" "}
+ Permissions-Policy header with microphone=() — an empty
+ allowlist, which several security presets ship by default — browsers block the
+ microphone for every origin including your own, and never show a permission prompt. The
+ widget detects this and simply doesn't offer the button, rather than showing one
+ that can only fail. To enable it, allow your own origin:{" "}
+ Permissions-Policy: microphone=(self). Everything else in the widget works
+ regardless.
+