Skip to content
Open
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
16 changes: 7 additions & 9 deletions desktop/src/features/huddle/HuddleContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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, requireMediaDevices } from "./lib/mediaDevices";
import {
type VoiceInputMode,
useHuddlePttState,
Expand Down Expand Up @@ -216,15 +217,12 @@ export function HuddleProvider({
.catch(() => {
/* best-effort */
});
navigator.mediaDevices.addEventListener(
"devicechange",
refreshOutputDevices,
);
// Only the hot-plug listener needs mediaDevices; the list comes from Rust.
const media = availableMediaDevices();
if (!media) return;
media.addEventListener("devicechange", refreshOutputDevices);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
refreshOutputDevices,
);
media.removeEventListener("devicechange", refreshOutputDevices);
};
}, []);

Expand Down Expand Up @@ -581,7 +579,7 @@ export function HuddleProvider({
if (selectedDeviceId) {
audioConstraints.deviceId = { exact: selectedDeviceId };
}
const stream = await navigator.mediaDevices.getUserMedia({
const stream = await requireMediaDevices().getUserMedia({
audio: audioConstraints,
});
const audioTrack = stream.getAudioTracks()[0];
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/huddle/lib/huddleError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"),
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/huddle/lib/huddleError.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Expand Down
83 changes: 83 additions & 0 deletions desktop/src/features/huddle/lib/mediaDevices.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
availableMediaDevices,
MICROPHONE_UNAVAILABLE_ERROR,
requireMediaDevices,
} from "./mediaDevices.ts";

/**
* 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 {
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);
});
});

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);
});
});
52 changes: 52 additions & 0 deletions desktop/src/features/huddle/lib/mediaDevices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* `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`.
*/
/**
* 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;
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. */
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;
}
61 changes: 61 additions & 0 deletions desktop/src/features/huddle/lib/mediaDevicesWarning.test.mjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
59 changes: 59 additions & 0 deletions desktop/src/features/huddle/lib/useAudioDevices.test.mjs
Original file line number Diff line number Diff line change
@@ -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("<!doctype html><html><body></body></html>", {
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"));
});
21 changes: 13 additions & 8 deletions desktop/src/features/huddle/lib/useAudioDevices.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";

import type { AudioWorkletHandle } from "./audioWorklet";
import { availableMediaDevices } from "./mediaDevices";

export type AudioInputDevice = {
deviceId: string;
Expand All @@ -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(
Expand All @@ -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);
};
}, []);

Expand Down