mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
refactor(voice): request mic and camera permission separately (#2387)
This commit is contained in:
1 parent
749d2091eb
commit
90f4a222b7
13 files changed
+592
-157
No files matched your search
+178
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user