mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(voice): stop asking for camera and mic access on page load (#2657)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user