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. +

diff --git a/src/app/globals.css b/src/app/globals.css index 186f30a3..197986b2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1191,6 +1191,12 @@ .ui-public-prose-h3 { @apply pt-2 text-lg font-semibold text-text-secondary; } + /* A caveat the reader must act on, not skim past — a prerequisite on their + side that silently disables a feature if unmet. Distinct from body prose, + quieter than an error. */ + .ui-public-callout { + @apply rounded-lg border border-border-default bg-surface-base px-4 py-3 text-sm leading-relaxed text-text-secondary; + } .ui-public-prose-p { @apply text-base leading-[1.8] text-text-secondary; } diff --git a/widget/voice.ts b/widget/voice.ts index 508bdd16..f4c0e5a4 100644 --- a/widget/voice.ts +++ b/widget/voice.ts @@ -53,17 +53,58 @@ export function pickAudioMime(): string { } /** - * Whether to offer the mic at all. getUserMedia is undefined on insecure - * origins, which is the common case for a customer testing on plain http — - * hence a support check rather than a try/catch at click time. + * Whether to offer the mic at all. + * + * Three separate ways it can be unavailable, and all three must be checked + * BEFORE drawing the button — a control that cannot work is worse than no + * control, because the visitor spends effort discovering that: + * + * 1. No MediaRecorder — old browser. + * 2. getUserMedia undefined — insecure origin, the common case for a + * customer testing over plain http. + * 3. Blocked by the host page's Permissions-Policy. This one is the reason + * the function grew: orangecat.ch sends + * `permissions-policy: camera=(), microphone=(), geolocation=()`. + * `microphone=()` is an EMPTY allowlist — denied for every origin + * including the site itself — so getUserMedia rejects with + * NotAllowedError and the browser never shows a prompt. Measured there: + * permissionState "denied", not "prompt". + * + * Crucially, navigator.mediaDevices.getUserMedia still EXISTS in that + * state, so checks 1 and 2 both pass and we happily drew a "Speak" + * button that could only ever fail. The visitor then sees "Microphone + * permission denied" and cannot fix it — there is nothing to allow. + * + * A script cannot override Permissions-Policy; that is the point of it. So + * the widget's job is to notice and stay quiet, and the site's job is to + * permit the mic (`microphone=(self)`) if it wants the feature. */ export function isVoiceSupported(): boolean { - return ( - typeof navigator !== "undefined" && - !!navigator.mediaDevices && - typeof navigator.mediaDevices.getUserMedia === "function" && - typeof MediaRecorder !== "undefined" - ); + if (typeof navigator === "undefined") return false; + if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { + return false; + } + if (typeof MediaRecorder === "undefined") return false; + return isMicrophoneAllowedByPolicy(); +} + +/** + * Does this document's Permissions-Policy permit the microphone? + * + * `document.featurePolicy` is non-standard and absent in some browsers, so an + * unknown answer is treated as ALLOWED: the alternative is hiding a working + * mic wherever the introspection API is missing, and a real block still fails + * loudly at click time with a message the visitor can read. + */ +export function isMicrophoneAllowedByPolicy(): boolean { + try { + const fp = (document as unknown as { featurePolicy?: { allowsFeature(f: string): boolean } }) + .featurePolicy; + if (!fp || typeof fp.allowsFeature !== "function") return true; + return fp.allowsFeature("microphone"); + } catch { + return true; + } } export type VoiceState = "idle" | "requesting" | "recording" | "transcribing" | "error";