From 15e57e2668c9c3edd2eba154e8e4ecccbb10b9b1 Mon Sep 17 00:00:00 2001 From: Dmitry Petrakov Date: Fri, 31 Jul 2026 01:02:49 +0300 Subject: [PATCH 1/3] fix(huddle): guard mediaDevices access when the API is unavailable `navigator.mediaDevices` is undefined in a non-secure context, and huddle touched it unguarded from two mount-time effects. The whole React tree crashed to the error boundary with "undefined is not an object (evaluating 'navigator.mediaDevices.enumerateDevices')" before the user ever started a huddle. Route the five call sites through `availableMediaDevices()`, mirroring the guard already used in `profile/lib/animatedAvatarCapture.ts`. The input device list degrades to empty and the hot-plug listeners are skipped; the output device list is unaffected because it comes from Rust. The `getUserMedia` join path now throws a recognizable sentinel that `formatHuddleActionError` maps to actionable copy instead of crashing. Fixes #3118 Signed-off-by: Dmitry Petrakov --- desktop/src/features/huddle/HuddleContext.tsx | 25 ++++++---- .../features/huddle/lib/huddleError.test.mjs | 15 ++++++ .../src/features/huddle/lib/huddleError.ts | 9 ++++ .../features/huddle/lib/mediaDevices.test.mjs | 49 +++++++++++++++++++ .../src/features/huddle/lib/mediaDevices.ts | 18 +++++++ .../features/huddle/lib/useAudioDevices.ts | 21 +++++--- 6 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 desktop/src/features/huddle/lib/mediaDevices.test.mjs create mode 100644 desktop/src/features/huddle/lib/mediaDevices.ts diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index d63b669f15..d29974e37d 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -5,6 +5,10 @@ import * as React from "react"; import { setupAudioWorklet, type AudioWorkletHandle } from "./lib/audioWorklet"; import { type AudioInputDevice, useAudioDevices } from "./lib/useAudioDevices"; import { formatHuddleActionError } from "./lib/huddleError"; +import { + availableMediaDevices, + MICROPHONE_UNAVAILABLE_ERROR, +} from "./lib/mediaDevices"; import { type VoiceInputMode, useHuddlePttState, @@ -216,15 +220,14 @@ export function HuddleProvider({ .catch(() => { /* best-effort */ }); - navigator.mediaDevices.addEventListener( - "devicechange", - refreshOutputDevices, - ); + // The device list itself comes from Rust and always loads; only the + // hot-plug listener needs `navigator.mediaDevices`, which is absent in a + // non-secure context. + const media = availableMediaDevices(); + if (!media) return; + media.addEventListener("devicechange", refreshOutputDevices); return () => { - navigator.mediaDevices.removeEventListener( - "devicechange", - refreshOutputDevices, - ); + media.removeEventListener("devicechange", refreshOutputDevices); }; }, []); @@ -581,7 +584,11 @@ export function HuddleProvider({ if (selectedDeviceId) { audioConstraints.deviceId = { exact: selectedDeviceId }; } - const stream = await navigator.mediaDevices.getUserMedia({ + const media = availableMediaDevices(); + if (!media?.getUserMedia) { + throw new Error(MICROPHONE_UNAVAILABLE_ERROR); + } + const stream = await media.getUserMedia({ audio: audioConstraints, }); const audioTrack = stream.getAudioTracks()[0]; diff --git a/desktop/src/features/huddle/lib/huddleError.test.mjs b/desktop/src/features/huddle/lib/huddleError.test.mjs index ec61b83467..b138b6b380 100644 --- a/desktop/src/features/huddle/lib/huddleError.test.mjs +++ b/desktop/src/features/huddle/lib/huddleError.test.mjs @@ -2,10 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatHuddleActionError } from "./huddleError.ts"; +import { MICROPHONE_UNAVAILABLE_ERROR } from "./mediaDevices.ts"; const AUDIO_UNAVAILABLE_MESSAGE = "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; +const MICROPHONE_UNAVAILABLE_MESSAGE = + "Microphone access isn’t available in this window. Try restarting Buzz."; + test("maps the relay deployment rejection to actionable copy", () => { assert.equal( formatHuddleActionError( @@ -23,6 +27,17 @@ test("recognizes the relay error code when present", () => { ); }); +test("maps the missing-mediaDevices sentinel to actionable copy", () => { + assert.equal( + formatHuddleActionError(new Error(MICROPHONE_UNAVAILABLE_ERROR), "join"), + MICROPHONE_UNAVAILABLE_MESSAGE, + ); + assert.equal( + formatHuddleActionError(MICROPHONE_UNAVAILABLE_ERROR, "start"), + MICROPHONE_UNAVAILABLE_MESSAGE, + ); +}); + test("preserves other string and Error messages", () => { assert.equal( formatHuddleActionError("Microphone unavailable", "join"), diff --git a/desktop/src/features/huddle/lib/huddleError.ts b/desktop/src/features/huddle/lib/huddleError.ts index 7ee47b2133..cde63299e5 100644 --- a/desktop/src/features/huddle/lib/huddleError.ts +++ b/desktop/src/features/huddle/lib/huddleError.ts @@ -1,8 +1,13 @@ +import { MICROPHONE_UNAVAILABLE_ERROR } from "./mediaDevices"; + export type HuddleAction = "join" | "start"; const HUDDLE_AUDIO_UNAVAILABLE_MESSAGE = "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; +const MICROPHONE_UNAVAILABLE_MESSAGE = + "Microphone access isn’t available in this window. Try restarting Buzz."; + function rawErrorMessage(error: unknown): string | null { if (error instanceof Error) { return error.message; @@ -27,6 +32,10 @@ export function formatHuddleActionError( return HUDDLE_AUDIO_UNAVAILABLE_MESSAGE; } + if (normalized?.includes(MICROPHONE_UNAVAILABLE_ERROR)) { + return MICROPHONE_UNAVAILABLE_MESSAGE; + } + if (message) { return message; } diff --git a/desktop/src/features/huddle/lib/mediaDevices.test.mjs b/desktop/src/features/huddle/lib/mediaDevices.test.mjs new file mode 100644 index 0000000000..dc7ef447e5 --- /dev/null +++ b/desktop/src/features/huddle/lib/mediaDevices.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { availableMediaDevices } from "./mediaDevices.ts"; + +/** Swap `globalThis.navigator` for the duration of `run`. */ +function withNavigator(value, run) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + value, + configurable: true, + writable: true, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "navigator", descriptor); + } else { + delete globalThis.navigator; + } + } +} + +test("returns the MediaDevices object when the API is present", () => { + const media = { enumerateDevices: () => Promise.resolve([]) }; + withNavigator({ mediaDevices: media }, () => { + assert.equal(availableMediaDevices(), media); + }); +}); + +test("returns null when navigator.mediaDevices is undefined", () => { + // The non-secure-context WKWebView case that crashed the app (#3118). + withNavigator({}, () => { + assert.equal(availableMediaDevices(), null); + }); +}); + +test("returns null when mediaDevices exists but exposes no enumerateDevices", () => { + withNavigator({ mediaDevices: {} }, () => { + assert.equal(availableMediaDevices(), null); + }); +}); + +test("returns null when there is no navigator at all", () => { + withNavigator(undefined, () => { + assert.equal(availableMediaDevices(), null); + }); +}); diff --git a/desktop/src/features/huddle/lib/mediaDevices.ts b/desktop/src/features/huddle/lib/mediaDevices.ts new file mode 100644 index 0000000000..723a183e82 --- /dev/null +++ b/desktop/src/features/huddle/lib/mediaDevices.ts @@ -0,0 +1,18 @@ +/** + * `navigator.mediaDevices` is absent in a non-secure context — a WKWebView that + * did not get a secure origin exposes no `mediaDevices` at all. Touching it + * unguarded from a mount-time effect throws before the user ever starts a + * huddle, taking the whole React tree down with it. + * + * Returns the live `MediaDevices` object, or `null` when the API is missing. + * Mirrors the guard already used in + * `features/profile/lib/animatedAvatarCapture.ts`. + */ +export function availableMediaDevices(): MediaDevices | null { + const media = + typeof navigator === "undefined" ? undefined : navigator.mediaDevices; + return typeof media?.enumerateDevices === "function" ? media : null; +} + +/** Raw error thrown when a huddle needs a mic but the API is unavailable. */ +export const MICROPHONE_UNAVAILABLE_ERROR = "microphone_unavailable"; diff --git a/desktop/src/features/huddle/lib/useAudioDevices.ts b/desktop/src/features/huddle/lib/useAudioDevices.ts index 13347b14b6..43db7c62f0 100644 --- a/desktop/src/features/huddle/lib/useAudioDevices.ts +++ b/desktop/src/features/huddle/lib/useAudioDevices.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { AudioWorkletHandle } from "./audioWorklet"; +import { availableMediaDevices } from "./mediaDevices"; export type AudioInputDevice = { deviceId: string; @@ -22,9 +23,16 @@ export function useAudioDevices( const micGainRef = React.useRef(1); // Enumerate audio input devices on mount and when devices change. + // No-op where `navigator.mediaDevices` is absent (non-secure context) — + // the device list stays empty rather than crashing the tree on mount. React.useEffect(() => { - function refreshDevices() { - navigator.mediaDevices + const media = availableMediaDevices(); + if (!media) return; + + // Arrow const, not a hoisted `function` — a declaration would float above + // the null guard and lose the narrowing on `media`. + const refreshDevices = () => { + media .enumerateDevices() .then((devices) => setAudioDevices( @@ -39,14 +47,11 @@ export function useAudioDevices( .catch(() => { /* best-effort */ }); - } + }; refreshDevices(); - navigator.mediaDevices.addEventListener("devicechange", refreshDevices); + media.addEventListener("devicechange", refreshDevices); return () => { - navigator.mediaDevices.removeEventListener( - "devicechange", - refreshDevices, - ); + media.removeEventListener("devicechange", refreshDevices); }; }, []); From ab7746d9802e86415424c3628e94152cd7d5372f Mon Sep 17 00:00:00 2001 From: Dmitry Petrakov Date: Fri, 31 Jul 2026 11:45:44 +0300 Subject: [PATCH 2/3] fix(huddle): log once when the mediaDevices API is unavailable Review feedback on #3817: the guard left the mount-time path silent, so a locked-down environment produced an empty device list and no diagnostic at all. Support only had a signal once someone tried to join, and even then only the user-facing copy. Emit one `console.warn` from `availableMediaDevices()`, latched so it fires at most once per process. Three call sites reach it and mount-time effects run twice under `React.StrictMode`, so an unguarded log would emit five identical lines and bury the signal. The latch is environment state, not community state, so it is deliberately not wired into `resetCommunityState()`. Signed-off-by: Dmitry Petrakov --- .../features/huddle/lib/mediaDevices.test.mjs | 11 +++- .../src/features/huddle/lib/mediaDevices.ts | 24 +++++++- .../huddle/lib/mediaDevicesWarning.test.mjs | 61 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/huddle/lib/mediaDevicesWarning.test.mjs diff --git a/desktop/src/features/huddle/lib/mediaDevices.test.mjs b/desktop/src/features/huddle/lib/mediaDevices.test.mjs index dc7ef447e5..014ff28e78 100644 --- a/desktop/src/features/huddle/lib/mediaDevices.test.mjs +++ b/desktop/src/features/huddle/lib/mediaDevices.test.mjs @@ -3,17 +3,26 @@ import test from "node:test"; import { availableMediaDevices } from "./mediaDevices.ts"; -/** Swap `globalThis.navigator` for the duration of `run`. */ +/** + * Swap `globalThis.navigator` for the duration of `run`, and swallow the + * missing-API diagnostic so it does not pollute test output. The + * once-per-process behaviour of that warning is covered separately in + * `mediaDevicesWarning.test.mjs`, which needs its own file to see the latch + * unset. + */ function withNavigator(value, run) { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const originalWarn = console.warn; Object.defineProperty(globalThis, "navigator", { value, configurable: true, writable: true, }); + console.warn = () => {}; try { return run(); } finally { + console.warn = originalWarn; if (descriptor) { Object.defineProperty(globalThis, "navigator", descriptor); } else { diff --git a/desktop/src/features/huddle/lib/mediaDevices.ts b/desktop/src/features/huddle/lib/mediaDevices.ts index 723a183e82..ebf744a1c9 100644 --- a/desktop/src/features/huddle/lib/mediaDevices.ts +++ b/desktop/src/features/huddle/lib/mediaDevices.ts @@ -8,10 +8,32 @@ * Mirrors the guard already used in * `features/profile/lib/animatedAvatarCapture.ts`. */ +/** + * Latched so the diagnostic below is emitted at most once per process. + * + * This is environment state, not community state: whether the webview exposes + * `mediaDevices` cannot change when the user switches communities. It is + * therefore deliberately NOT wired into `resetCommunityState()`. + */ +let missingApiWarned = false; + export function availableMediaDevices(): MediaDevices | null { const media = typeof navigator === "undefined" ? undefined : navigator.mediaDevices; - return typeof media?.enumerateDevices === "function" ? media : null; + if (typeof media?.enumerateDevices === "function") { + return media; + } + + // One line, once. Callers hit this from three sites and mount-time effects + // run twice under `React.StrictMode`, so an unguarded log would emit five + // identical lines and bury the signal it exists to provide. + if (!missingApiWarned) { + missingApiWarned = true; + console.warn( + "[mediaDevices] navigator.mediaDevices is unavailable (non-secure context); huddle audio is disabled in this window", + ); + } + return null; } /** Raw error thrown when a huddle needs a mic but the API is unavailable. */ diff --git a/desktop/src/features/huddle/lib/mediaDevicesWarning.test.mjs b/desktop/src/features/huddle/lib/mediaDevicesWarning.test.mjs new file mode 100644 index 0000000000..afa2e99a74 --- /dev/null +++ b/desktop/src/features/huddle/lib/mediaDevicesWarning.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { availableMediaDevices } from "./mediaDevices.ts"; + +// Deliberately its own file. The once-per-process latch lives in module scope, +// and the node test runner gives each FILE its own process — so this is the +// only place that observes the latch in its initial state. Folding these cases +// into `mediaDevices.test.mjs` would make them depend on declaration order +// there, because those tests consume the latch on their first null result. + +/** Run `body` with `navigator` and `console.warn` swapped out. */ +function captureWarnings(navigatorValue, body) { + const navigatorDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + const originalWarn = console.warn; + const warnings = []; + + Object.defineProperty(globalThis, "navigator", { + value: navigatorValue, + configurable: true, + writable: true, + }); + console.warn = (...args) => warnings.push(args.join(" ")); + + try { + body(); + } finally { + console.warn = originalWarn; + if (navigatorDescriptor) { + Object.defineProperty(globalThis, "navigator", navigatorDescriptor); + } else { + delete globalThis.navigator; + } + } + return warnings; +} + +test("warns exactly once no matter how many callers hit the missing API", () => { + const warnings = captureWarnings({}, () => { + // Three call sites, and StrictMode runs the two mount effects twice. + for (let i = 0; i < 5; i += 1) { + assert.equal(availableMediaDevices(), null); + } + }); + + assert.equal(warnings.length, 1, "expected a single diagnostic line"); + assert.match(warnings[0], /^\[mediaDevices\]/); + assert.match(warnings[0], /non-secure context/); +}); + +test("stays quiet on later calls once the API is present again", () => { + const media = { enumerateDevices: () => Promise.resolve([]) }; + const warnings = captureWarnings({ mediaDevices: media }, () => { + assert.equal(availableMediaDevices(), media); + }); + + assert.deepEqual(warnings, []); +}); From 8130da50cc4e6aab475518114f81f01b5a11d821 Mon Sep 17 00:00:00 2001 From: Dmitry Petrakov Date: Sun, 9 Aug 2026 12:24:42 +0300 Subject: [PATCH 3/3] test(huddle): cover the mount-time crash with a jsdom render The guards themselves had no coverage: the helper was unit-tested, but nothing exercised the actual regression, which is a component throwing during its mount effect. jsdom and @testing-library/react landed in the desktop dev deps since this branch was opened, so that gap can now be closed. jsdom implements no `navigator.mediaDevices`, which is precisely the non-secure-context state from #3118. Rendering a harness around `useAudioDevices` in that environment reproduces the crash: reverting the guard fails this test with `TypeError: Cannot read properties of undefined (reading 'enumerateDevices')`, the same error the issue reports. Signed-off-by: Dmitry Petrakov --- desktop/src/features/huddle/HuddleContext.tsx | 15 +---- .../features/huddle/lib/mediaDevices.test.mjs | 27 ++++++++- .../src/features/huddle/lib/mediaDevices.ts | 12 ++++ .../huddle/lib/useAudioDevices.test.mjs | 59 +++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 desktop/src/features/huddle/lib/useAudioDevices.test.mjs diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index d29974e37d..69ec3b6504 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -5,10 +5,7 @@ import * as React from "react"; import { setupAudioWorklet, type AudioWorkletHandle } from "./lib/audioWorklet"; import { type AudioInputDevice, useAudioDevices } from "./lib/useAudioDevices"; import { formatHuddleActionError } from "./lib/huddleError"; -import { - availableMediaDevices, - MICROPHONE_UNAVAILABLE_ERROR, -} from "./lib/mediaDevices"; +import { availableMediaDevices, requireMediaDevices } from "./lib/mediaDevices"; import { type VoiceInputMode, useHuddlePttState, @@ -220,9 +217,7 @@ export function HuddleProvider({ .catch(() => { /* best-effort */ }); - // The device list itself comes from Rust and always loads; only the - // hot-plug listener needs `navigator.mediaDevices`, which is absent in a - // non-secure context. + // Only the hot-plug listener needs mediaDevices; the list comes from Rust. const media = availableMediaDevices(); if (!media) return; media.addEventListener("devicechange", refreshOutputDevices); @@ -584,11 +579,7 @@ export function HuddleProvider({ if (selectedDeviceId) { audioConstraints.deviceId = { exact: selectedDeviceId }; } - const media = availableMediaDevices(); - if (!media?.getUserMedia) { - throw new Error(MICROPHONE_UNAVAILABLE_ERROR); - } - const stream = await media.getUserMedia({ + const stream = await requireMediaDevices().getUserMedia({ audio: audioConstraints, }); const audioTrack = stream.getAudioTracks()[0]; diff --git a/desktop/src/features/huddle/lib/mediaDevices.test.mjs b/desktop/src/features/huddle/lib/mediaDevices.test.mjs index 014ff28e78..572dce1e0d 100644 --- a/desktop/src/features/huddle/lib/mediaDevices.test.mjs +++ b/desktop/src/features/huddle/lib/mediaDevices.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { availableMediaDevices } from "./mediaDevices.ts"; +import { + availableMediaDevices, + MICROPHONE_UNAVAILABLE_ERROR, + requireMediaDevices, +} from "./mediaDevices.ts"; /** * Swap `globalThis.navigator` for the duration of `run`, and swallow the @@ -56,3 +60,24 @@ test("returns null when there is no navigator at all", () => { assert.equal(availableMediaDevices(), null); }); }); + +test("requireMediaDevices throws the sentinel when the API is missing", () => { + // The join path cannot degrade, so it throws a value huddleError can map to + // copy instead of letting a raw TypeError reach the error boundary. + withNavigator({}, () => { + assert.throws( + () => requireMediaDevices(), + (error) => error.message === MICROPHONE_UNAVAILABLE_ERROR, + ); + }); +}); + +test("requireMediaDevices returns the object when getUserMedia is present", () => { + const media = { + enumerateDevices: () => Promise.resolve([]), + getUserMedia: () => Promise.resolve({}), + }; + withNavigator({ mediaDevices: media }, () => { + assert.equal(requireMediaDevices(), media); + }); +}); diff --git a/desktop/src/features/huddle/lib/mediaDevices.ts b/desktop/src/features/huddle/lib/mediaDevices.ts index ebf744a1c9..58d6f1ff77 100644 --- a/desktop/src/features/huddle/lib/mediaDevices.ts +++ b/desktop/src/features/huddle/lib/mediaDevices.ts @@ -38,3 +38,15 @@ export function availableMediaDevices(): MediaDevices | null { /** Raw error thrown when a huddle needs a mic but the API is unavailable. */ export const MICROPHONE_UNAVAILABLE_ERROR = "microphone_unavailable"; + +/** + * Like [`availableMediaDevices`], for the join path, which cannot degrade: a + * huddle without a microphone is not a huddle. Throws the sentinel that + * `formatHuddleActionError` turns into user-facing copy, instead of letting + * the raw `undefined is not an object` reach the error boundary. + */ +export function requireMediaDevices(): MediaDevices { + const media = availableMediaDevices(); + if (!media?.getUserMedia) throw new Error(MICROPHONE_UNAVAILABLE_ERROR); + return media; +} diff --git a/desktop/src/features/huddle/lib/useAudioDevices.test.mjs b/desktop/src/features/huddle/lib/useAudioDevices.test.mjs new file mode 100644 index 0000000000..fb844c2aa7 --- /dev/null +++ b/desktop/src/features/huddle/lib/useAudioDevices.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// jsdom does not implement `navigator.mediaDevices`, so the default environment +// here IS the non-secure-context case from #3118: the property is simply +// absent. Mounting a component that touches it unguarded throws during the +// mount effect and takes the tree down. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + // `globalThis.navigator` is getter-only on Node 24, so Object.assign cannot + // reach it. Neither Node's nor jsdom's navigator implements `mediaDevices`, + // which is exactly the state under test. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("mounts without throwing when navigator.mediaDevices is absent", async () => { + assert.equal( + globalThis.navigator.mediaDevices, + undefined, + "precondition: jsdom exposes no mediaDevices", + ); + + const { createElement, useRef } = await import("react"); + const { render, screen } = await import("@testing-library/react"); + const { useAudioDevices } = await import("./useAudioDevices.ts"); + + function Harness() { + const workletRef = useRef(null); + const { audioDevices } = useAudioDevices(workletRef); + return createElement("p", null, `devices:${audioDevices.length}`); + } + + render(createElement(Harness)); + + // Rendered at all means the mount effect did not throw; empty list means it + // degraded rather than inventing devices. + assert.ok(screen.getByText("devices:0")); +});