refactor(voice): request mic and camera permission separately (#2387)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:33:45 +02:00
1 parent 749d2091eb
commit 90f4a222b7
13 files changed
+592 -157

No files matched your search

@@ -0,0 +1,178 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
EnsureVoiceDevicesOptions,
VoiceDeviceState,
VoiceMediaPermissionType,
} from '@app/features/voice/utils/VoiceDeviceManager';
import {act, createElement, useEffect} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const deviceManagerMock = vi.hoisted(() => {
const idleState = (): VoiceDeviceState => ({
inputDevices: [],
outputDevices: [],
videoDevices: [],
permissionStatus: {audio: 'idle', video: 'idle'},
});
const listeners = new Set<(state: VoiceDeviceState) => void>();
const mock = {
state: idleState(),
permissionRequests: [] as Array<ReadonlyArray<VoiceMediaPermissionType>>,
pending: [] as Array<(state: VoiceDeviceState) => void>,
reset(): void {
mock.state = idleState();
mock.permissionRequests = [];
mock.pending = [];
listeners.clear();
},
settlePendingWith(next: VoiceDeviceState): void {
const pending = mock.pending;
mock.pending = [];
mock.state = next;
for (const listener of listeners) listener(next);
for (const resolve of pending) resolve(next);
},
manager: {
getState: (): VoiceDeviceState => mock.state,
subscribe: (listener: (state: VoiceDeviceState) => void): (() => void) => {
listeners.add(listener);
listener(mock.state);
return () => listeners.delete(listener);
},
ensureDevices: (options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> => {
const requestPermissionTypes =
options.requestPermissionTypes ?? (options.requestPermissions === true ? ['audio', 'video'] : []);
if (requestPermissionTypes.length === 0) return Promise.resolve(mock.state);
mock.permissionRequests.push([...requestPermissionTypes]);
return new Promise<VoiceDeviceState>((resolve) => {
mock.pending.push(resolve);
});
},
},
};
return mock;
});
const mediaPermissionMock = vi.hoisted(() => ({
microphoneExplicitlyDenied: false,
cameraExplicitlyDenied: false,
microphonePermissionState: null as PermissionState | null,
cameraPermissionState: null as PermissionState | null,
isMicrophoneGranted: vi.fn(() => false),
isCameraGranted: vi.fn(() => false),
updateMicrophonePermissionGranted: vi.fn(),
updateCameraPermissionGranted: vi.fn(),
markMicrophoneExplicitlyDenied: vi.fn(),
markCameraExplicitlyDenied: vi.fn(),
}));
const handleMediaPermissionBlocked = vi.hoisted(() => vi.fn());
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({voiceDeviceManager: deviceManagerMock.manager}));
vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({default: mediaPermissionMock}));
vi.mock('@app/features/permissions/system/commands/MacPermissionsModalCommands', () => ({
handleMediaPermissionBlocked,
}));
const {useMediaPermission} = await import('@app/features/user/components/modals/tabs/hooks/useMediaPermission');
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const MICROPHONE: MediaDeviceInfo = {
deviceId: 'mic-1',
groupId: 'group-1',
kind: 'audioinput',
label: 'Microphone',
toJSON: () => ({}),
};
const grantedState = (): VoiceDeviceState => ({
inputDevices: [MICROPHONE],
outputDevices: [],
videoDevices: [],
permissionStatus: {audio: 'granted', video: 'idle'},
});
const deniedState = (): VoiceDeviceState => ({
inputDevices: [],
outputDevices: [],
videoDevices: [],
permissionStatus: {audio: 'denied', video: 'idle'},
});
let host: HTMLDivElement;
let root: Root;
const requesters: Array<() => Promise<boolean>> = [];
function Consumer(): null {
const {requestPermission} = useMediaPermission('audio');
useEffect(() => {
requesters.push(requestPermission);
return () => {
const index = requesters.indexOf(requestPermission);
if (index !== -1) requesters.splice(index, 1);
};
}, [requestPermission]);
return null;
}
function renderConsumers(count: number): void {
act(() => {
root.render(Array.from({length: count}, (_, index) => createElement(Consumer, {key: index})));
});
}
beforeEach(() => {
deviceManagerMock.reset();
requesters.length = 0;
mediaPermissionMock.microphoneExplicitlyDenied = false;
mediaPermissionMock.microphonePermissionState = null;
mediaPermissionMock.isMicrophoneGranted.mockReturnValue(false);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
vi.clearAllMocks();
});
describe('useMediaPermission', () => {
it('raises a single device permission request when two consumers ask at once', async () => {
renderConsumers(2);
expect(requesters).toHaveLength(2);
const results = await act(async () => {
const pending = Promise.all([requesters[0](), requesters[1]()]);
await Promise.resolve();
deviceManagerMock.settlePendingWith(grantedState());
return pending;
});
expect(deviceManagerMock.permissionRequests).toEqual([['audio']]);
expect(results).toEqual([true, true]);
expect(mediaPermissionMock.updateMicrophonePermissionGranted).toHaveBeenCalledTimes(1);
});
it('reports a denied request through the shared permission state', async () => {
renderConsumers(1);
const granted = await act(async () => {
const pending = requesters[0]();
await Promise.resolve();
deviceManagerMock.settlePendingWith(deniedState());
return pending;
});
expect(granted).toBe(false);
expect(mediaPermissionMock.markMicrophoneExplicitlyDenied).toHaveBeenCalledTimes(1);
expect(handleMediaPermissionBlocked).toHaveBeenCalledWith('microphone');
});
});
@@ -2,10 +2,13 @@
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
import {Logger} from '@app/features/platform/utils/AppLogger';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import type {VoiceDeviceState} from '@app/features/voice/utils/VoiceDeviceManager';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const logger = new Logger('useMediaPermission');
type PermissionType = 'audio' | 'video';
type BrowserPermissionState = 'denied' | 'granted' | 'prompt';
@@ -34,21 +37,27 @@ const resolveStatusFromVoiceState = ({
deviceState,
isExplicitlyDenied,
osPermissionLooksGranted,
type,
}: {
cachedPermissionState: BrowserPermissionState | null;
deviceState: VoiceDeviceState;
isExplicitlyDenied: boolean;
osPermissionLooksGranted: boolean;
type: PermissionType;
}): PermissionState['status'] => {
if (isExplicitlyDenied) return 'denied';
if (deviceState.permissionStatus === 'loading') return 'loading';
if (deviceState.permissionStatus === 'granted') return 'granted';
if (deviceState.permissionStatus === 'denied') return 'denied';
if (cachedPermissionState === 'granted' || osPermissionLooksGranted) return 'loading';
if (cachedPermissionState === 'denied') return 'denied';
const permissionStatus = deviceState.permissionStatus[type];
if (permissionStatus === 'denied') return 'denied';
if (cachedPermissionState === 'granted' || osPermissionLooksGranted) {
return permissionStatus === 'loading' ? 'loading' : 'granted';
}
if (permissionStatus === 'loading') return 'loading';
if (permissionStatus === 'granted') return 'granted';
return 'idle';
};
export const useMediaPermission = (type: PermissionType, options: UseMediaPermissionOptions = {}) => {
const {autoRequest = true} = options;
const {autoRequest = false} = options;
const micExplicitlyDenied = MediaPermission.microphoneExplicitlyDenied;
const cameraExplicitlyDenied = MediaPermission.cameraExplicitlyDenied;
const isExplicitlyDenied = type === 'audio' ? micExplicitlyDenied : cameraExplicitlyDenied;
@@ -62,6 +71,7 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
deviceState: initialDeviceState,
isExplicitlyDenied,
osPermissionLooksGranted,
type,
}),
devices: devicesFromVoiceState(initialDeviceState, type),
deviceState: initialDeviceState,
@@ -74,6 +84,7 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
deviceState,
isExplicitlyDenied,
osPermissionLooksGranted,
type,
}),
devices: devicesFromVoiceState(deviceState, type),
deviceState,
@@ -85,25 +96,16 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
);
const unlockDevices = useCallback(async (): Promise<PermissionState> => {
setState((prev) => ({...prev, status: 'loading'}));
const deviceState = await VoiceDevicePermissionState.ensureDevices({
requestPermissions: true,
forceRefresh: true,
});
if (deviceState.permissionStatus === 'granted') {
if (type === 'audio') {
MediaPermission.updateMicrophonePermissionGranted({refreshDevices: false});
} else {
MediaPermission.updateCameraPermissionGranted({refreshDevices: false});
}
} else if (deviceState.permissionStatus === 'denied') {
if (type === 'audio') {
MediaPermission.markMicrophoneExplicitlyDenied();
} else {
MediaPermission.markCameraExplicitlyDenied();
}
}
return applyVoiceDeviceState(deviceState);
}, [applyVoiceDeviceState, type]);
const granted = await VoiceDevicePermissionState.requestPermissionFor(type);
const deviceState = VoiceDevicePermissionState.getState();
const nextState = {
status: granted ? 'granted' : deviceState.permissionStatus[type] === 'denied' ? 'denied' : 'idle',
devices: devicesFromVoiceState(deviceState, type),
deviceState,
} satisfies PermissionState;
setState(nextState);
return nextState;
}, [type]);
const requestPermission = useCallback(async () => {
if (isExplicitlyDenied) {
handleMediaPermissionBlocked(type === 'audio' ? 'microphone' : 'camera');
@@ -111,7 +113,9 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
}
const currentDeviceState = VoiceDevicePermissionState.getState();
const currentDevices = devicesFromVoiceState(currentDeviceState, type);
if (currentDeviceState.permissionStatus === 'granted' && hasPrimaryDeviceForType(currentDevices, type)) {
const permissionAlreadyGranted =
type === 'audio' ? MediaPermission.isMicrophoneGranted() : MediaPermission.isCameraGranted();
if (permissionAlreadyGranted && hasPrimaryDeviceForType(currentDevices, type)) {
applyVoiceDeviceState(currentDeviceState);
return true;
}
@@ -122,7 +126,8 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
return false;
}
return nextState.status === 'granted' && hasPrimaryDeviceForType(nextState.devices, type);
} catch {
} catch (error) {
logger.error('Media permission request failed', {type, error});
return false;
}
}, [type, isExplicitlyDenied, applyVoiceDeviceState, unlockDevices]);
@@ -136,11 +141,16 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
setState((prev) => ({...prev, status: 'denied'}));
return;
}
if (!autoRequest && !osPermissionLooksGranted) {
if (!autoRequest) {
void VoiceDevicePermissionState.ensureDevices({requestPermissions: false})
.then(applyVoiceDeviceState)
.catch((error) => logger.warn('Passive media device enumeration failed', {type, error}));
return;
}
void unlockDevicesRef.current().catch(() => {});
}, [isExplicitlyDenied, type, autoRequest, osPermissionLooksGranted]);
void unlockDevicesRef
.current()
.catch((error) => logger.error('Automatic media permission request failed', {type, error}));
}, [applyVoiceDeviceState, isExplicitlyDenied, type, autoRequest]);
return {
...state,
isExplicitlyDenied,