fix(voice): stop asking for camera and mic access on page load (#2657)

This commit is contained in:
Hampus
2026-09-11 02:00:48 +02:00
committed by GitHub
parent 54128e049a
commit 35d73eae76
8 changed files with 339 additions and 54 deletions
-2
View File
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
};
}, []);
useEffect(() => startScreenSharePiPController(), []);
useEffect(() => startMediaDeviceStartupPreload(), []);
useServiceWorkerBadge();
useKeybindManager(i18n);
useDesktopElectronBridges();
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
import AccountManager from '@app/features/auth/state/AccountManager';
import Authentication from '@app/features/auth/state/Authentication';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
import {clsx} from 'clsx';
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
return (
<>
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
{isAuthenticated && (
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
)}
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
<div
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {act, createElement} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {beforeEach, describe, expect, test, vi} from 'vitest';
const preload = vi.hoisted(() => ({
start: vi.fn(),
stop: vi.fn(),
}));
vi.mock('@app/features/voice/utils/MediaDeviceStartupPreload', () => ({
startMediaDeviceStartupPreload: () => {
preload.start();
return preload.stop;
},
}));
const {MediaDeviceStartupPreloadManager} = await import(
'@app/features/voice/components/MediaDeviceStartupPreloadManager'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
document.body.replaceChildren();
const host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
describe('MediaDeviceStartupPreloadManager', () => {
test('starts the preload once loaded and stops it on unmount', async () => {
act(() => {
root.render(createElement(MediaDeviceStartupPreloadManager));
});
await vi.waitFor(() => expect(preload.start).toHaveBeenCalledTimes(1));
expect(preload.stop).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
expect(preload.stop).toHaveBeenCalledTimes(1);
});
test('never starts the preload when unmounted before it finishes loading', async () => {
act(() => {
root.render(createElement(MediaDeviceStartupPreloadManager));
});
act(() => {
root.unmount();
});
await import('@app/features/voice/utils/MediaDeviceStartupPreload');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preload.start).not.toHaveBeenCalled();
expect(preload.stop).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
import {useEffect} from 'react';
const logger = new Logger('MediaDeviceStartupPreloadManager');
export const MediaDeviceStartupPreloadManager: React.FC = () => {
useEffect(() => {
let disposed = false;
let stopPreload: (() => void) | null = null;
void loadLazyModule(() => import('@app/features/voice/utils/MediaDeviceStartupPreload'))
.then(({startMediaDeviceStartupPreload}) => {
if (disposed) return;
stopPreload = startMediaDeviceStartupPreload();
})
.catch((error) => {
logger.warn('Failed to load media device startup preload', {error});
});
return () => {
disposed = true;
stopPreload?.();
};
}, []);
return null;
};
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const mediaPermission = vi.hoisted(() => ({
isInitialized: () => true,
isMicrophoneGranted: () => true,
isCameraGranted: () => true,
getMicrophonePermissionState: (): PermissionState => 'granted',
getCameraPermissionState: (): PermissionState => 'granted',
addChangeListener: (listener: () => void): (() => void) => {
listener();
return () => {};
},
}));
vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({default: mediaPermission}));
const makeDevice = (kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo =>
({
deviceId,
groupId: `${deviceId}-group`,
kind,
label,
toJSON: () => ({deviceId, kind, label}),
}) as MediaDeviceInfo;
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
let browserExposesLabels = false;
const fakeMediaDevices = {
enumerateDevices: async (): Promise<Array<MediaDeviceInfo>> => [
makeDevice('audioinput', 'mic-1', browserExposesLabels ? 'Studio Mic' : ''),
makeDevice('videoinput', 'cam-1', browserExposesLabels ? 'Studio Camera' : ''),
],
getUserMedia: async (constraints: MediaStreamConstraints): Promise<MediaStream> => {
getUserMediaCalls.push(constraints);
return {getTracks: () => []} as unknown as MediaStream;
},
addEventListener: () => {},
removeEventListener: () => {},
};
let originalNavigator: PropertyDescriptor | undefined;
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
getUserMediaCalls.length = 0;
browserExposesLabels = false;
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {userAgent: 'node', mediaDevices: fakeMediaDevices},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {matchMedia: () => ({matches: false})},
});
vi.resetModules();
});
afterEach(() => {
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator);
} else {
Reflect.deleteProperty(globalThis, 'navigator');
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
});
const runStartupPreload = async () => {
const {startMediaDeviceStartupPreload} = await import('@app/features/voice/utils/MediaDeviceStartupPreload');
const {default: VoiceDevicePermissionState} = await import('@app/features/voice/engine/VoiceDevicePermissionState');
const stopPreload = startMediaDeviceStartupPreload();
const state = await VoiceDevicePermissionState.ensureDevices();
stopPreload();
return state;
};
describe('startMediaDeviceStartupPreload', () => {
test('never opens capture for a granted microphone and camera when the browser hides device labels', async () => {
const state = await runStartupPreload();
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
});
test('confirms a granted microphone and camera from exposed device labels without opening capture', async () => {
browserExposesLabels = true;
const state = await runStartupPreload();
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
});
});
@@ -12,9 +12,9 @@ export function startMediaDeviceStartupPreload(): () => void {
let lastPermissionStateKey: string | null = null;
const preloadDevices = () => {
if (stopped) return;
const requestPermissionTypes: Array<VoiceMediaPermissionType> = [];
if (MediaPermission.isMicrophoneGranted()) requestPermissionTypes.push('audio');
if (MediaPermission.isCameraGranted()) requestPermissionTypes.push('video');
const grantedPermissionTypes: Array<VoiceMediaPermissionType> = [];
if (MediaPermission.isMicrophoneGranted()) grantedPermissionTypes.push('audio');
if (MediaPermission.isCameraGranted()) grantedPermissionTypes.push('video');
const permissionStateKey = [
MediaPermission.isInitialized() ? 'initialized' : 'pending',
MediaPermission.getMicrophonePermissionState() ?? 'unknown',
@@ -22,16 +22,18 @@ export function startMediaDeviceStartupPreload(): () => void {
].join(':');
const deviceState = VoiceDevicePermissionState.getState();
const forceRefresh = lastPermissionStateKey !== null && lastPermissionStateKey !== permissionStateKey;
const requestedPermissionStatesSettled = requestPermissionTypes.every(
const grantedPermissionStatesSettled = grantedPermissionTypes.every(
(type) => deviceState.permissionStatus[type] !== 'idle',
);
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && requestedPermissionStatesSettled) {
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && grantedPermissionStatesSettled) {
return;
}
lastPermissionStateKey = permissionStateKey;
void VoiceDevicePermissionState.ensureDevices({forceRefresh, requestPermissionTypes}).catch((error) => {
logger.debug('Failed to preload media devices', {error});
});
void VoiceDevicePermissionState.ensureDevices({forceRefresh, confirmPermissionTypes: grantedPermissionTypes}).catch(
(error) => {
logger.debug('Failed to preload media devices', {error});
},
);
};
const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices);
return () => {
@@ -28,12 +28,16 @@ export function hasDeviceLabels(devices: ReadonlyArray<MediaDeviceInfo>): boolea
export type VoiceMediaPermissionType = 'audio' | 'video';
export type VoiceMediaPermissionStatus = 'idle' | 'loading' | 'granted' | 'denied';
type VoiceMediaPermissionIntent = 'confirm' | 'request';
const MAX_ENUMERATION_CHAIN_PASSES = 3;
const VOICE_MEDIA_PERMISSION_TYPES: ReadonlyArray<VoiceMediaPermissionType> = ['audio', 'video'];
const VOICE_MEDIA_PERMISSION_INTENTS: ReadonlyArray<VoiceMediaPermissionIntent> = ['confirm', 'request'];
const MAX_ENUMERATION_CHAIN_PASSES = 1 + VOICE_MEDIA_PERMISSION_TYPES.length * VOICE_MEDIA_PERMISSION_INTENTS.length;
export interface EnsureVoiceDevicesOptions {
requestPermissions?: boolean;
requestPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
confirmPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
forceRefresh?: boolean;
}
@@ -393,8 +397,8 @@ class VoiceDeviceManager {
};
private listeners = new Set<Listener>();
private enumerationChainPromise: Promise<VoiceDeviceState> | null = null;
private scheduledEnumerationPermissionTypes = new Set<VoiceMediaPermissionType>();
private enumerationChainPermissionTypes = new Set<VoiceMediaPermissionType>();
private scheduledEnumerationPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
private enumerationChainPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
private hasEnumeratedDevices = false;
constructor() {
@@ -416,11 +420,10 @@ class VoiceDeviceManager {
}
public async ensureDevices(options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> {
const requestPermissionTypes = this.resolveRequestedPermissionTypes(options);
const requestPermissions = requestPermissionTypes.length > 0;
const permissionIntents = this.resolvePermissionIntents(options);
const forceRefresh = options.forceRefresh ?? false;
logger.debug('ensureDevices called', {
requestPermissionTypes,
permissionIntents: Object.fromEntries(permissionIntents),
forceRefresh,
hasEnumeratingPromise: !!this.enumerationChainPromise,
currentState: {
@@ -428,39 +431,50 @@ class VoiceDeviceManager {
permissionStatus: this.state.permissionStatus,
},
});
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(requestPermissions)) {
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(permissionIntents.size > 0)) {
logger.debug('Using cached device state');
return this.state;
}
if (this.enumerationChainPromise) {
this.scheduleMissingPermissionTypes(requestPermissionTypes);
this.scheduleMissingPermissionIntents(permissionIntents);
logger.debug('Joining existing enumeration promise');
return this.enumerationChainPromise;
}
logger.debug('Creating new enumeration promise');
return this.startEnumerationChain(requestPermissionTypes);
return this.startEnumerationChain(permissionIntents);
}
private scheduleMissingPermissionTypes(requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>): void {
private scheduleMissingPermissionIntents(
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): void {
for (const [type, intent] of permissionIntents) {
const chainIntent = this.enumerationChainPermissionIntents.get(type);
if (chainIntent === 'request' || chainIntent === intent) continue;
this.enumerationChainPermissionIntents.set(type, intent);
this.scheduledEnumerationPermissionIntents.set(type, intent);
}
}
private resolvePermissionIntents(
options: EnsureVoiceDevicesOptions,
): Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent> {
const permissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
for (const type of options.confirmPermissionTypes ?? []) {
permissionIntents.set(type, 'confirm');
}
const requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType> =
options.requestPermissionTypes ?? (options.requestPermissions === true ? VOICE_MEDIA_PERMISSION_TYPES : []);
for (const type of requestPermissionTypes) {
if (this.enumerationChainPermissionTypes.has(type)) continue;
this.enumerationChainPermissionTypes.add(type);
this.scheduledEnumerationPermissionTypes.add(type);
permissionIntents.set(type, 'request');
}
return permissionIntents;
}
private resolveRequestedPermissionTypes(options: EnsureVoiceDevicesOptions): Array<VoiceMediaPermissionType> {
if (options.requestPermissionTypes) {
return [...new Set(options.requestPermissionTypes)];
}
return options.requestPermissions === true ? ['audio', 'video'] : [];
}
private canUseCachedState(requestPermissions: boolean): boolean {
private canUseCachedState(hasPermissionIntents: boolean): boolean {
if (!this.hasEnumeratedDevices) {
return false;
}
return !requestPermissions;
return !hasPermissionIntents;
}
private updatePermissionStatusForTypes(
@@ -475,42 +489,44 @@ class VoiceDeviceManager {
}
private startEnumerationChain(
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
this.enumerationChainPermissionTypes = new Set(requestPermissionTypes);
const pendingPromise = this.runEnumerationChain(requestPermissionTypes).finally(() => {
this.enumerationChainPermissionIntents = new Map(permissionIntents);
const pendingPromise = this.runEnumerationChain(permissionIntents).finally(() => {
if (this.enumerationChainPromise !== pendingPromise) return;
logger.debug('Enumeration promise completed');
this.enumerationChainPromise = null;
this.scheduledEnumerationPermissionTypes.clear();
this.enumerationChainPermissionTypes.clear();
this.scheduledEnumerationPermissionIntents.clear();
this.enumerationChainPermissionIntents.clear();
});
this.enumerationChainPromise = pendingPromise;
return pendingPromise;
}
private async runEnumerationChain(
initialPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
initialPermissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
let requestPermissionTypes = [...initialPermissionTypes];
let permissionIntents = initialPermissionIntents;
let state = this.state;
for (let pass = 0; pass < MAX_ENUMERATION_CHAIN_PASSES; pass += 1) {
state = await this.enumerateDevices(requestPermissionTypes);
if (this.scheduledEnumerationPermissionTypes.size === 0) return state;
requestPermissionTypes = [...this.scheduledEnumerationPermissionTypes];
this.scheduledEnumerationPermissionTypes.clear();
state = await this.enumerateDevices(permissionIntents);
if (this.scheduledEnumerationPermissionIntents.size === 0) return state;
permissionIntents = new Map(this.scheduledEnumerationPermissionIntents);
this.scheduledEnumerationPermissionIntents.clear();
}
if (this.scheduledEnumerationPermissionTypes.size > 0) {
if (this.scheduledEnumerationPermissionIntents.size > 0) {
throw new Error(`Voice device enumeration exceeded ${MAX_ENUMERATION_CHAIN_PASSES} bounded passes`);
}
return state;
}
private async enumerateDevices(
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
const permissionTypes = [...permissionIntents.keys()];
const requestPermissionTypes = permissionTypes.filter((type) => permissionIntents.get(type) === 'request');
const requestPermissions = requestPermissionTypes.length > 0;
logger.debug('enumerateDevices started', {requestPermissionTypes});
logger.debug('enumerateDevices started', {permissionIntents: Object.fromEntries(permissionIntents)});
if (!navigator.mediaDevices?.enumerateDevices) {
logger.debug('Navigator or mediaDevices API not available');
return this.state;
@@ -535,20 +551,20 @@ class VoiceDeviceManager {
hasLabel: !!d.label,
})),
});
const permissionTypesWithLabels = requestPermissionTypes.filter((type) => {
const permissionTypesWithLabels = permissionTypes.filter((type) => {
const requiredKind = type === 'audio' ? 'audioinput' : 'videoinput';
return devices.some((device) => device.kind === requiredKind && device.label !== '');
});
for (const type of permissionTypesWithLabels) {
permissionStatus[type] = 'granted';
}
if (requestPermissions && permissionTypesWithLabels.length === requestPermissionTypes.length) {
const unresolvedRequestTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
if (requestPermissions && unresolvedRequestTypes.length === 0) {
logger.debug('Devices have labels, permissions already granted');
} else if (requestPermissions && isDesktop()) {
} else if (unresolvedRequestTypes.length > 0 && isDesktop()) {
logger.debug('No labels detected; attempting native permission flow');
const unresolvedNativeTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
const nativeResults = await Promise.all(
unresolvedNativeTypes.map(async (type) => {
unresolvedRequestTypes.map(async (type) => {
try {
return {
type,
@@ -24,13 +24,17 @@ const makeStream = (kinds: Array<'audio' | 'video'>): MediaStream => {
};
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
const enumerationHooks: Array<() => void> = [];
let labelledDevices = false;
const fakeMediaDevices: FakeMediaDevices = {
enumerateDevices: async () =>
labelledDevices
enumerateDevices: async () => {
await Promise.resolve();
enumerationHooks.shift()?.();
return labelledDevices
? [makeDevice('audioinput', 'mic-1', 'Studio Mic'), makeDevice('videoinput', 'cam-1', 'Studio Camera')]
: [makeDevice('audioinput', 'mic-1', ''), makeDevice('videoinput', 'cam-1', '')],
: [makeDevice('audioinput', 'mic-1', ''), makeDevice('videoinput', 'cam-1', '')];
},
getUserMedia: async (constraints) => {
getUserMediaCalls.push(constraints);
labelledDevices = true;
@@ -43,12 +47,21 @@ const fakeMediaDevices: FakeMediaDevices = {
removeEventListener: () => {},
};
const fakeElectron = {
platform: 'darwin',
checkMediaAccess: vi.fn(async () => 'granted'),
requestMediaAccess: vi.fn(async () => true),
};
let originalNavigator: PropertyDescriptor | undefined;
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
getUserMediaCalls.length = 0;
enumerationHooks.length = 0;
labelledDevices = false;
fakeElectron.checkMediaAccess.mockClear();
fakeElectron.requestMediaAccess.mockClear();
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'navigator', {
@@ -80,6 +93,13 @@ const loadManager = async () => {
return module.voiceDeviceManager;
};
const runAsDesktop = () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {matchMedia: () => ({matches: false}), electron: fakeElectron},
});
};
describe('voiceDeviceManager permission types', () => {
test('an audio-only request never asks the browser for video', async () => {
const manager = await loadManager();
@@ -111,4 +131,63 @@ describe('voiceDeviceManager permission types', () => {
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
expect(state.inputDevices.length).toBeGreaterThan(0);
});
test('a confirmation never asks the browser for access when device labels are hidden', async () => {
const manager = await loadManager();
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio', 'video'], forceRefresh: true});
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
});
test('a confirmation marks only the confirmed type granted from exposed device labels', async () => {
labelledDevices = true;
const manager = await loadManager();
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'idle'});
});
test('a request that joins an in-flight confirmation of the same type still asks the browser', async () => {
const manager = await loadManager();
const confirmation = manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
const request = manager.ensureDevices({requestPermissionTypes: ['audio']});
const [, state] = await Promise.all([confirmation, request]);
expect(getUserMediaCalls).toEqual([{audio: true, video: false}]);
expect(state.permissionStatus.audio).toBe('granted');
});
test('confirmations escalated into requests for every type finish within the enumeration pass bound', async () => {
const manager = await loadManager();
const joins: Array<Promise<unknown>> = [];
enumerationHooks.push(
() => joins.push(manager.ensureDevices({confirmPermissionTypes: ['audio']})),
() => joins.push(manager.ensureDevices({requestPermissionTypes: ['audio']})),
() => joins.push(manager.ensureDevices({confirmPermissionTypes: ['video']})),
() => {},
() => joins.push(manager.ensureDevices({requestPermissionTypes: ['video']})),
);
const state = await manager.ensureDevices({forceRefresh: true});
await Promise.all(joins);
expect(getUserMediaCalls).toEqual([{audio: true, video: false}]);
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
});
test('a confirmation never runs the native permission flow on desktop', async () => {
runAsDesktop();
const manager = await loadManager();
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
expect(fakeElectron.checkMediaAccess).not.toHaveBeenCalled();
expect(fakeElectron.requestMediaAccess).not.toHaveBeenCalled();
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus.audio).toBe('idle');
});
test('a request runs the native permission flow on desktop', async () => {
runAsDesktop();
const manager = await loadManager();
const state = await manager.ensureDevices({requestPermissionTypes: ['audio'], forceRefresh: true});
expect(fakeElectron.checkMediaAccess).toHaveBeenCalledWith('microphone');
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus.audio).toBe('granted');
});
});