Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion scripts/test/widget-voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions src/app/docs/feedback-widget/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ export default function FeedbackWidgetDocsPage() {
existing inbox row (shown as <em>×N</em>) instead of creating a duplicate — the volume
signal survives, the noise doesn&apos;t.
</p>
<p>
They can also <strong>speak instead of typing</strong> — 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.
</p>
<p className="ui-public-callout">
<strong>The mic needs your site&apos;s permission.</strong> If your site sends a{" "}
<code>Permissions-Policy</code> header with <code>microphone=()</code> — 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&apos;t offer the button, rather than showing one
that can only fail. To enable it, allow your own origin:{" "}
<code>Permissions-Policy: microphone=(self)</code>. Everything else in the widget works
regardless.
</p>
</section>

<section className="mb-10 space-y-4 sm:mb-12">
Expand Down
6 changes: 6 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
59 changes: 50 additions & 9 deletions widget/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading