mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
refactor(voice): request mic and camera permission separately (#2387)
This commit is contained in:
@@ -102,7 +102,7 @@ class NewDeviceMonitoring {
|
|||||||
|
|
||||||
private handleDeviceStateChange(state: VoiceDeviceState): void {
|
private handleDeviceStateChange(state: VoiceDeviceState): void {
|
||||||
if (!this.isStarted) return;
|
if (!this.isStarted) return;
|
||||||
if (state.permissionStatus !== 'granted') {
|
if (state.permissionStatus.audio !== 'granted') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.suppressAlerts) {
|
if (this.suppressAlerts) {
|
||||||
|
|||||||
+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 {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||||
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
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 VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||||
import type {VoiceDeviceState} from '@app/features/voice/utils/VoiceDeviceManager';
|
import type {VoiceDeviceState} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
|
const logger = new Logger('useMediaPermission');
|
||||||
|
|
||||||
type PermissionType = 'audio' | 'video';
|
type PermissionType = 'audio' | 'video';
|
||||||
type BrowserPermissionState = 'denied' | 'granted' | 'prompt';
|
type BrowserPermissionState = 'denied' | 'granted' | 'prompt';
|
||||||
|
|
||||||
@@ -34,21 +37,27 @@ const resolveStatusFromVoiceState = ({
|
|||||||
deviceState,
|
deviceState,
|
||||||
isExplicitlyDenied,
|
isExplicitlyDenied,
|
||||||
osPermissionLooksGranted,
|
osPermissionLooksGranted,
|
||||||
|
type,
|
||||||
}: {
|
}: {
|
||||||
cachedPermissionState: BrowserPermissionState | null;
|
cachedPermissionState: BrowserPermissionState | null;
|
||||||
deviceState: VoiceDeviceState;
|
deviceState: VoiceDeviceState;
|
||||||
isExplicitlyDenied: boolean;
|
isExplicitlyDenied: boolean;
|
||||||
osPermissionLooksGranted: boolean;
|
osPermissionLooksGranted: boolean;
|
||||||
|
type: PermissionType;
|
||||||
}): PermissionState['status'] => {
|
}): PermissionState['status'] => {
|
||||||
if (isExplicitlyDenied) return 'denied';
|
if (isExplicitlyDenied) return 'denied';
|
||||||
if (deviceState.permissionStatus === 'loading') return 'loading';
|
if (cachedPermissionState === 'denied') return 'denied';
|
||||||
if (deviceState.permissionStatus === 'granted') return 'granted';
|
const permissionStatus = deviceState.permissionStatus[type];
|
||||||
if (deviceState.permissionStatus === 'denied') return 'denied';
|
if (permissionStatus === 'denied') return 'denied';
|
||||||
if (cachedPermissionState === 'granted' || osPermissionLooksGranted) return 'loading';
|
if (cachedPermissionState === 'granted' || osPermissionLooksGranted) {
|
||||||
|
return permissionStatus === 'loading' ? 'loading' : 'granted';
|
||||||
|
}
|
||||||
|
if (permissionStatus === 'loading') return 'loading';
|
||||||
|
if (permissionStatus === 'granted') return 'granted';
|
||||||
return 'idle';
|
return 'idle';
|
||||||
};
|
};
|
||||||
export const useMediaPermission = (type: PermissionType, options: UseMediaPermissionOptions = {}) => {
|
export const useMediaPermission = (type: PermissionType, options: UseMediaPermissionOptions = {}) => {
|
||||||
const {autoRequest = true} = options;
|
const {autoRequest = false} = options;
|
||||||
const micExplicitlyDenied = MediaPermission.microphoneExplicitlyDenied;
|
const micExplicitlyDenied = MediaPermission.microphoneExplicitlyDenied;
|
||||||
const cameraExplicitlyDenied = MediaPermission.cameraExplicitlyDenied;
|
const cameraExplicitlyDenied = MediaPermission.cameraExplicitlyDenied;
|
||||||
const isExplicitlyDenied = type === 'audio' ? micExplicitlyDenied : cameraExplicitlyDenied;
|
const isExplicitlyDenied = type === 'audio' ? micExplicitlyDenied : cameraExplicitlyDenied;
|
||||||
@@ -62,6 +71,7 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
deviceState: initialDeviceState,
|
deviceState: initialDeviceState,
|
||||||
isExplicitlyDenied,
|
isExplicitlyDenied,
|
||||||
osPermissionLooksGranted,
|
osPermissionLooksGranted,
|
||||||
|
type,
|
||||||
}),
|
}),
|
||||||
devices: devicesFromVoiceState(initialDeviceState, type),
|
devices: devicesFromVoiceState(initialDeviceState, type),
|
||||||
deviceState: initialDeviceState,
|
deviceState: initialDeviceState,
|
||||||
@@ -74,6 +84,7 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
deviceState,
|
deviceState,
|
||||||
isExplicitlyDenied,
|
isExplicitlyDenied,
|
||||||
osPermissionLooksGranted,
|
osPermissionLooksGranted,
|
||||||
|
type,
|
||||||
}),
|
}),
|
||||||
devices: devicesFromVoiceState(deviceState, type),
|
devices: devicesFromVoiceState(deviceState, type),
|
||||||
deviceState,
|
deviceState,
|
||||||
@@ -85,25 +96,16 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
);
|
);
|
||||||
const unlockDevices = useCallback(async (): Promise<PermissionState> => {
|
const unlockDevices = useCallback(async (): Promise<PermissionState> => {
|
||||||
setState((prev) => ({...prev, status: 'loading'}));
|
setState((prev) => ({...prev, status: 'loading'}));
|
||||||
const deviceState = await VoiceDevicePermissionState.ensureDevices({
|
const granted = await VoiceDevicePermissionState.requestPermissionFor(type);
|
||||||
requestPermissions: true,
|
const deviceState = VoiceDevicePermissionState.getState();
|
||||||
forceRefresh: true,
|
const nextState = {
|
||||||
});
|
status: granted ? 'granted' : deviceState.permissionStatus[type] === 'denied' ? 'denied' : 'idle',
|
||||||
if (deviceState.permissionStatus === 'granted') {
|
devices: devicesFromVoiceState(deviceState, type),
|
||||||
if (type === 'audio') {
|
deviceState,
|
||||||
MediaPermission.updateMicrophonePermissionGranted({refreshDevices: false});
|
} satisfies PermissionState;
|
||||||
} else {
|
setState(nextState);
|
||||||
MediaPermission.updateCameraPermissionGranted({refreshDevices: false});
|
return nextState;
|
||||||
}
|
}, [type]);
|
||||||
} else if (deviceState.permissionStatus === 'denied') {
|
|
||||||
if (type === 'audio') {
|
|
||||||
MediaPermission.markMicrophoneExplicitlyDenied();
|
|
||||||
} else {
|
|
||||||
MediaPermission.markCameraExplicitlyDenied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return applyVoiceDeviceState(deviceState);
|
|
||||||
}, [applyVoiceDeviceState, type]);
|
|
||||||
const requestPermission = useCallback(async () => {
|
const requestPermission = useCallback(async () => {
|
||||||
if (isExplicitlyDenied) {
|
if (isExplicitlyDenied) {
|
||||||
handleMediaPermissionBlocked(type === 'audio' ? 'microphone' : 'camera');
|
handleMediaPermissionBlocked(type === 'audio' ? 'microphone' : 'camera');
|
||||||
@@ -111,7 +113,9 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
}
|
}
|
||||||
const currentDeviceState = VoiceDevicePermissionState.getState();
|
const currentDeviceState = VoiceDevicePermissionState.getState();
|
||||||
const currentDevices = devicesFromVoiceState(currentDeviceState, type);
|
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);
|
applyVoiceDeviceState(currentDeviceState);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -122,7 +126,8 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return nextState.status === 'granted' && hasPrimaryDeviceForType(nextState.devices, type);
|
return nextState.status === 'granted' && hasPrimaryDeviceForType(nextState.devices, type);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error('Media permission request failed', {type, error});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [type, isExplicitlyDenied, applyVoiceDeviceState, unlockDevices]);
|
}, [type, isExplicitlyDenied, applyVoiceDeviceState, unlockDevices]);
|
||||||
@@ -136,11 +141,16 @@ export const useMediaPermission = (type: PermissionType, options: UseMediaPermis
|
|||||||
setState((prev) => ({...prev, status: 'denied'}));
|
setState((prev) => ({...prev, status: 'denied'}));
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
void unlockDevicesRef.current().catch(() => {});
|
void unlockDevicesRef
|
||||||
}, [isExplicitlyDenied, type, autoRequest, osPermissionLooksGranted]);
|
.current()
|
||||||
|
.catch((error) => logger.error('Automatic media permission request failed', {type, error}));
|
||||||
|
}, [applyVoiceDeviceState, isExplicitlyDenied, type, autoRequest]);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isExplicitlyDenied,
|
isExplicitlyDenied,
|
||||||
|
|||||||
@@ -2029,7 +2029,7 @@ class MediaEngineFacade extends Store {
|
|||||||
|
|
||||||
syncLocalVoiceStateWithServer(partial?: VoiceStateSyncPartial): void {
|
syncLocalVoiceStateWithServer(partial?: VoiceStateSyncPartial): void {
|
||||||
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
||||||
const micGranted = MediaPermission.isMicrophoneGranted() || devicePermission === 'granted';
|
const micGranted = MediaPermission.isMicrophoneGranted() || devicePermission.audio === 'granted';
|
||||||
if (!micGranted || LocalVoiceState.getMutedByPermission()) {
|
if (!micGranted || LocalVoiceState.getMutedByPermission()) {
|
||||||
LocalVoiceState.ensurePermissionMute();
|
LocalVoiceState.ensurePermissionMute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type DeviceListener = (state: VoiceDeviceState) => void;
|
|||||||
class VoiceDevicePermissionState {
|
class VoiceDevicePermissionState {
|
||||||
deviceState: VoiceDeviceState = voiceDeviceManager.getState();
|
deviceState: VoiceDeviceState = voiceDeviceManager.getState();
|
||||||
private deviceListeners = new Set<DeviceListener>();
|
private deviceListeners = new Set<DeviceListener>();
|
||||||
private permissionRequestInFlight: Promise<boolean> | null = null;
|
private permissionRequestsInFlight = new Map<'audio' | 'video', Promise<boolean>>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
voiceDeviceManager.subscribe((state) => this.handleDeviceStateChange(state));
|
voiceDeviceManager.subscribe((state) => this.handleDeviceStateChange(state));
|
||||||
@@ -57,12 +57,20 @@ class VoiceDevicePermissionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestPermissionFor(type: 'audio' | 'video'): Promise<boolean> {
|
async requestPermissionFor(type: 'audio' | 'video'): Promise<boolean> {
|
||||||
if (this.permissionRequestInFlight) {
|
const permissionGranted =
|
||||||
return this.permissionRequestInFlight;
|
type === 'audio' ? MediaPermission.isMicrophoneGranted() : MediaPermission.isCameraGranted();
|
||||||
|
const hasPrimaryDevice =
|
||||||
|
type === 'audio'
|
||||||
|
? this.deviceState.inputDevices.some((device) => device.kind === 'audioinput')
|
||||||
|
: this.deviceState.videoDevices.some((device) => device.kind === 'videoinput');
|
||||||
|
if (permissionGranted && hasPrimaryDevice) return true;
|
||||||
|
const inFlightRequest = this.permissionRequestsInFlight.get(type);
|
||||||
|
if (inFlightRequest) {
|
||||||
|
return inFlightRequest;
|
||||||
}
|
}
|
||||||
const requestPromise = (async (): Promise<boolean> => {
|
const requestPromise = (async (): Promise<boolean> => {
|
||||||
const state = await this.ensureDevices({requestPermissions: true});
|
const state = await this.ensureDevices({requestPermissionTypes: [type], forceRefresh: true});
|
||||||
if (state.permissionStatus === 'granted') {
|
if (state.permissionStatus[type] === 'granted') {
|
||||||
if (type === 'audio') {
|
if (type === 'audio') {
|
||||||
MediaPermission.updateMicrophonePermissionGranted({refreshDevices: false});
|
MediaPermission.updateMicrophonePermissionGranted({refreshDevices: false});
|
||||||
} else {
|
} else {
|
||||||
@@ -70,7 +78,7 @@ class VoiceDevicePermissionState {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (state.permissionStatus === 'denied') {
|
if (state.permissionStatus[type] === 'denied') {
|
||||||
if (type === 'audio') {
|
if (type === 'audio') {
|
||||||
MediaPermission.markMicrophoneExplicitlyDenied();
|
MediaPermission.markMicrophoneExplicitlyDenied();
|
||||||
} else {
|
} else {
|
||||||
@@ -78,16 +86,16 @@ class VoiceDevicePermissionState {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return type === 'audio' ? MediaPermission.isMicrophoneGranted() : MediaPermission.isCameraGranted();
|
return false;
|
||||||
})()
|
})()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Failed to request media permission', {type, error});
|
logger.error('Failed to request media permission', {type, error});
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.permissionRequestInFlight = null;
|
this.permissionRequestsInFlight.delete(type);
|
||||||
});
|
});
|
||||||
this.permissionRequestInFlight = requestPromise;
|
this.permissionRequestsInFlight.set(type, requestPromise);
|
||||||
return requestPromise;
|
return requestPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||||
|
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||||
|
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
||||||
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
|
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
|
||||||
|
import {isPermissionDeniedError} from '@app/features/voice/engine/v2/VoiceEngineV2AppAdapterAssertions';
|
||||||
import VoiceEngineV2AppMediaStateAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppMediaStateAdapter';
|
import VoiceEngineV2AppMediaStateAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppMediaStateAdapter';
|
||||||
import {ensureNativeMediaPermission} from '@app/features/voice/engine/voice_screen_share_manager/NativePermissionGate';
|
import {ensureNativeMediaPermission} from '@app/features/voice/engine/voice_screen_share_manager/NativePermissionGate';
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ export async function runCameraTransition(
|
|||||||
}
|
}
|
||||||
await args.publish();
|
await args.publish();
|
||||||
if (args.enabled) {
|
if (args.enabled) {
|
||||||
|
MediaPermission.updateCameraPermissionGranted();
|
||||||
VoiceEngineV2AppMediaStateAdapter.applyCameraState(true, {
|
VoiceEngineV2AppMediaStateAdapter.applyCameraState(true, {
|
||||||
forceSync: syncSettledState,
|
forceSync: syncSettledState,
|
||||||
sendUpdate: args.sendUpdate,
|
sendUpdate: args.sendUpdate,
|
||||||
@@ -71,6 +75,12 @@ export async function runCameraTransition(
|
|||||||
SoundCommands.playSound(args.enabled ? SoundType.CameraOn : SoundType.CameraOff);
|
SoundCommands.playSound(args.enabled ? SoundType.CameraOn : SoundType.CameraOff);
|
||||||
return 'applied';
|
return 'applied';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (args.enabled && isPermissionDeniedError(error)) {
|
||||||
|
MediaPermission.markCameraExplicitlyDenied();
|
||||||
|
if (args.sendUpdate) handleMediaPermissionBlocked('camera');
|
||||||
|
args.onPermissionDenied?.();
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
const actual = args.readActualEnabled();
|
const actual = args.readActualEnabled();
|
||||||
assert.equal(typeof actual, 'boolean', 'actual-state reader must return a boolean');
|
assert.equal(typeof actual, 'boolean', 'actual-state reader must return a boolean');
|
||||||
args.onFailure?.(actual, error);
|
args.onFailure?.(actual, error);
|
||||||
|
|||||||
@@ -367,11 +367,11 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
);
|
);
|
||||||
this.transitionMediaState({type: 'permission.warmup.start'});
|
this.transitionMediaState({type: 'permission.warmup.start'});
|
||||||
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
||||||
if (MediaPermission.isMicrophoneGranted() || devicePermission === 'granted') {
|
if (MediaPermission.isMicrophoneGranted() || devicePermission.audio === 'granted') {
|
||||||
this.transitionMediaState({type: 'permission.warmup.granted'});
|
this.transitionMediaState({type: 'permission.warmup.granted'});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (MediaPermission.isMicrophoneExplicitlyDenied() || devicePermission === 'denied') {
|
if (MediaPermission.isMicrophoneExplicitlyDenied() || devicePermission.audio === 'denied') {
|
||||||
this.transitionMediaState({type: 'permission.warmup.denied'});
|
this.transitionMediaState({type: 'permission.warmup.denied'});
|
||||||
this.handleMicrophonePermissionDenied();
|
this.handleMicrophonePermissionDenied();
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type LocalVoiceStateSnapshot,
|
type LocalVoiceStateSnapshot,
|
||||||
transitionLocalVoiceStateSnapshot,
|
transitionLocalVoiceStateSnapshot,
|
||||||
} from '@app/features/voice/state/LocalVoiceStateMachine';
|
} from '@app/features/voice/state/LocalVoiceStateMachine';
|
||||||
import type {VoiceDeviceState} from '@app/features/voice/utils/VoiceDeviceManager';
|
import type {VoiceDeviceState, VoiceMediaPermissionStatus} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||||
import {makeAutoObservable, observable, reaction, runInAction} from 'mobx';
|
import {makeAutoObservable, observable, reaction, runInAction} from 'mobx';
|
||||||
|
|
||||||
const logger = new Logger('LocalVoiceState');
|
const logger = new Logger('LocalVoiceState');
|
||||||
@@ -61,8 +61,8 @@ class LocalVoiceState implements LocalVoiceConnectionState {
|
|||||||
private persistenceHydrationPromise: Promise<void>;
|
private persistenceHydrationPromise: Promise<void>;
|
||||||
private _disposers: Array<() => void> = [];
|
private _disposers: Array<() => void> = [];
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private lastDevicePermissionStatus: VoiceDeviceState['permissionStatus'] | null =
|
private lastDeviceAudioPermissionStatus: VoiceMediaPermissionStatus | null =
|
||||||
VoiceDevicePermissionState.getState().permissionStatus;
|
VoiceDevicePermissionState.getState().permissionStatus.audio;
|
||||||
private isNotifyingServerOfPermissionMute = false;
|
private isNotifyingServerOfPermissionMute = false;
|
||||||
private connectionStates = observable.object<Record<string, LocalVoiceConnectionState>>({});
|
private connectionStates = observable.object<Record<string, LocalVoiceConnectionState>>({});
|
||||||
private machineSnapshot: LocalVoiceStateSnapshot;
|
private machineSnapshot: LocalVoiceStateSnapshot;
|
||||||
@@ -367,20 +367,21 @@ class LocalVoiceState implements LocalVoiceConnectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleDevicePermissionStatus(status: VoiceDeviceState['permissionStatus']): void {
|
private handleDevicePermissionStatus(status: VoiceDeviceState['permissionStatus']): void {
|
||||||
if (status === this.lastDevicePermissionStatus) {
|
const audioStatus = status.audio;
|
||||||
|
if (audioStatus === this.lastDeviceAudioPermissionStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastDevicePermissionStatus = status;
|
this.lastDeviceAudioPermissionStatus = audioStatus;
|
||||||
if (status === 'granted') {
|
if (audioStatus === 'granted') {
|
||||||
void this.applyPermissionGrant();
|
void this.applyPermissionGrant();
|
||||||
} else if (status === 'denied') {
|
} else if (audioStatus === 'denied') {
|
||||||
this.applyTransientPermissionMute();
|
this.applyTransientPermissionMute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enforcePermissionMuteIfNeeded(): void {
|
private enforcePermissionMuteIfNeeded(): void {
|
||||||
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
||||||
const granted = MediaPermission.isMicrophoneGranted() || devicePermission === 'granted';
|
const granted = MediaPermission.isMicrophoneGranted() || devicePermission.audio === 'granted';
|
||||||
if (granted) {
|
if (granted) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.transitionLocalState({type: 'permission.grant', activeConnectionId: this.getActiveConnectionId()});
|
this.transitionLocalState({type: 'permission.grant', activeConnectionId: this.getActiveConnectionId()});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
|
const ensureDevices = vi.fn(async () => ({}));
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/VoiceDevicePermissionState', () => ({
|
||||||
|
default: {ensureDevices},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/devices/MediaDeviceCache', () => ({
|
||||||
|
mediaDeviceCache: {invalidate},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {refreshMediaDeviceLists, MediaDeviceRefreshType} = await import('@app/features/voice/utils/MediaDeviceRefresh');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshMediaDeviceLists', () => {
|
||||||
|
test('an audio refresh only requests audio permission, never video', async () => {
|
||||||
|
await refreshMediaDeviceLists({type: MediaDeviceRefreshType.audio});
|
||||||
|
expect(invalidate).toHaveBeenCalledWith(MediaDeviceRefreshType.audio);
|
||||||
|
expect(ensureDevices).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ensureDevices).toHaveBeenCalledWith({requestPermissionTypes: ['audio'], forceRefresh: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video refresh only requests video permission, never audio', async () => {
|
||||||
|
await refreshMediaDeviceLists({type: MediaDeviceRefreshType.video});
|
||||||
|
expect(invalidate).toHaveBeenCalledWith(MediaDeviceRefreshType.video);
|
||||||
|
expect(ensureDevices).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ensureDevices).toHaveBeenCalledWith({requestPermissionTypes: ['video'], forceRefresh: true});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,10 @@ export async function refreshMediaDeviceLists(options: RefreshMediaDeviceListsOp
|
|||||||
const {type} = options;
|
const {type} = options;
|
||||||
mediaDeviceCache.invalidate(type);
|
mediaDeviceCache.invalidate(type);
|
||||||
try {
|
try {
|
||||||
await VoiceDevicePermissionState.ensureDevices({requestPermissions: true, forceRefresh: true});
|
await VoiceDevicePermissionState.ensureDevices({
|
||||||
|
requestPermissionTypes: [type === MediaDeviceRefreshType.audio ? 'audio' : 'video'],
|
||||||
|
forceRefresh: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to refresh media device lists', error);
|
logger.error('Failed to refresh media device lists', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||||
|
import type {VoiceMediaPermissionType} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||||
|
|
||||||
const logger = new Logger('MediaDeviceStartupPreload');
|
const logger = new Logger('MediaDeviceStartupPreload');
|
||||||
|
|
||||||
@@ -11,7 +12,9 @@ export function startMediaDeviceStartupPreload(): () => void {
|
|||||||
let lastPermissionStateKey: string | null = null;
|
let lastPermissionStateKey: string | null = null;
|
||||||
const preloadDevices = () => {
|
const preloadDevices = () => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const requestPermissions = MediaPermission.isMicrophoneGranted() || MediaPermission.isCameraGranted();
|
const requestPermissionTypes: Array<VoiceMediaPermissionType> = [];
|
||||||
|
if (MediaPermission.isMicrophoneGranted()) requestPermissionTypes.push('audio');
|
||||||
|
if (MediaPermission.isCameraGranted()) requestPermissionTypes.push('video');
|
||||||
const permissionStateKey = [
|
const permissionStateKey = [
|
||||||
MediaPermission.isInitialized() ? 'initialized' : 'pending',
|
MediaPermission.isInitialized() ? 'initialized' : 'pending',
|
||||||
MediaPermission.getMicrophonePermissionState() ?? 'unknown',
|
MediaPermission.getMicrophonePermissionState() ?? 'unknown',
|
||||||
@@ -19,11 +22,14 @@ export function startMediaDeviceStartupPreload(): () => void {
|
|||||||
].join(':');
|
].join(':');
|
||||||
const deviceState = VoiceDevicePermissionState.getState();
|
const deviceState = VoiceDevicePermissionState.getState();
|
||||||
const forceRefresh = lastPermissionStateKey !== null && lastPermissionStateKey !== permissionStateKey;
|
const forceRefresh = lastPermissionStateKey !== null && lastPermissionStateKey !== permissionStateKey;
|
||||||
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && deviceState.permissionStatus !== 'idle') {
|
const requestedPermissionStatesSettled = requestPermissionTypes.every(
|
||||||
|
(type) => deviceState.permissionStatus[type] !== 'idle',
|
||||||
|
);
|
||||||
|
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && requestedPermissionStatesSettled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastPermissionStateKey = permissionStateKey;
|
lastPermissionStateKey = permissionStateKey;
|
||||||
void VoiceDevicePermissionState.ensureDevices({forceRefresh, requestPermissions}).catch((error) => {
|
void VoiceDevicePermissionState.ensureDevices({forceRefresh, requestPermissionTypes}).catch((error) => {
|
||||||
logger.debug('Failed to preload media devices', {error});
|
logger.debug('Failed to preload media devices', {error});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ export function hasDeviceLabels(devices: ReadonlyArray<MediaDeviceInfo>): boolea
|
|||||||
return devices.some((d) => d.label && d.label.trim().length > 0);
|
return devices.some((d) => d.label && d.label.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionStatus = 'idle' | 'loading' | 'granted' | 'denied';
|
export type VoiceMediaPermissionType = 'audio' | 'video';
|
||||||
|
export type VoiceMediaPermissionStatus = 'idle' | 'loading' | 'granted' | 'denied';
|
||||||
|
|
||||||
|
const MAX_ENUMERATION_CHAIN_PASSES = 3;
|
||||||
|
|
||||||
export interface EnsureVoiceDevicesOptions {
|
export interface EnsureVoiceDevicesOptions {
|
||||||
requestPermissions?: boolean;
|
requestPermissions?: boolean;
|
||||||
|
requestPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ export interface VoiceDeviceState {
|
|||||||
inputDevices: Array<MediaDeviceInfo>;
|
inputDevices: Array<MediaDeviceInfo>;
|
||||||
outputDevices: Array<MediaDeviceInfo>;
|
outputDevices: Array<MediaDeviceInfo>;
|
||||||
videoDevices: Array<MediaDeviceInfo>;
|
videoDevices: Array<MediaDeviceInfo>;
|
||||||
permissionStatus: PermissionStatus;
|
permissionStatus: Record<VoiceMediaPermissionType, VoiceMediaPermissionStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Listener = (state: VoiceDeviceState) => void;
|
type Listener = (state: VoiceDeviceState) => void;
|
||||||
@@ -382,13 +386,15 @@ class VoiceDeviceManager {
|
|||||||
inputDevices: [],
|
inputDevices: [],
|
||||||
outputDevices: [],
|
outputDevices: [],
|
||||||
videoDevices: [],
|
videoDevices: [],
|
||||||
permissionStatus: 'idle',
|
permissionStatus: {
|
||||||
|
audio: 'idle',
|
||||||
|
video: 'idle',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
private listeners = new Set<Listener>();
|
private listeners = new Set<Listener>();
|
||||||
private enumeratingPromise: Promise<VoiceDeviceState> | null = null;
|
private enumerationChainPromise: Promise<VoiceDeviceState> | null = null;
|
||||||
private queuedPermissionEnumerationPromise: Promise<VoiceDeviceState> | null = null;
|
private scheduledEnumerationPermissionTypes = new Set<VoiceMediaPermissionType>();
|
||||||
private currentEnumerationRequestsPermissions = false;
|
private enumerationChainPermissionTypes = new Set<VoiceMediaPermissionType>();
|
||||||
private shouldRequestPermissions = false;
|
|
||||||
private hasEnumeratedDevices = false;
|
private hasEnumeratedDevices = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -410,86 +416,116 @@ class VoiceDeviceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async ensureDevices(options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> {
|
public async ensureDevices(options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> {
|
||||||
const requestPermissions = options.requestPermissions ?? false;
|
const requestPermissionTypes = this.resolveRequestedPermissionTypes(options);
|
||||||
|
const requestPermissions = requestPermissionTypes.length > 0;
|
||||||
const forceRefresh = options.forceRefresh ?? false;
|
const forceRefresh = options.forceRefresh ?? false;
|
||||||
logger.debug('ensureDevices called', {
|
logger.debug('ensureDevices called', {
|
||||||
requestPermissions,
|
requestPermissionTypes,
|
||||||
forceRefresh,
|
forceRefresh,
|
||||||
shouldRequestPermissions: this.shouldRequestPermissions,
|
hasEnumeratingPromise: !!this.enumerationChainPromise,
|
||||||
hasEnumeratingPromise: !!this.enumeratingPromise,
|
|
||||||
currentState: {
|
currentState: {
|
||||||
inputDeviceCount: this.state.inputDevices.length,
|
inputDeviceCount: this.state.inputDevices.length,
|
||||||
permissionStatus: this.state.permissionStatus,
|
permissionStatus: this.state.permissionStatus,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (requestPermissions) {
|
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(requestPermissions)) {
|
||||||
this.shouldRequestPermissions = true;
|
|
||||||
}
|
|
||||||
const shouldRequest = this.shouldRequestPermissions || requestPermissions;
|
|
||||||
if (!forceRefresh && !this.enumeratingPromise && this.canUseCachedState(shouldRequest)) {
|
|
||||||
logger.debug('Using cached device state');
|
logger.debug('Using cached device state');
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
if (this.enumeratingPromise) {
|
if (this.enumerationChainPromise) {
|
||||||
if (shouldRequest && !this.currentEnumerationRequestsPermissions) {
|
this.scheduleMissingPermissionTypes(requestPermissionTypes);
|
||||||
logger.debug('Queueing permissioned enumeration after current enumeration');
|
|
||||||
if (!this.queuedPermissionEnumerationPromise) {
|
|
||||||
this.queuedPermissionEnumerationPromise = this.enumeratingPromise
|
|
||||||
.catch(() => this.state)
|
|
||||||
.then(() => this.startEnumeration(true))
|
|
||||||
.finally(() => {
|
|
||||||
this.queuedPermissionEnumerationPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.queuedPermissionEnumerationPromise;
|
|
||||||
}
|
|
||||||
logger.debug('Joining existing enumeration promise');
|
logger.debug('Joining existing enumeration promise');
|
||||||
return this.enumeratingPromise;
|
return this.enumerationChainPromise;
|
||||||
}
|
}
|
||||||
logger.debug('Creating new enumeration promise');
|
logger.debug('Creating new enumeration promise');
|
||||||
return this.startEnumeration(shouldRequest);
|
return this.startEnumerationChain(requestPermissionTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleMissingPermissionTypes(requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>): void {
|
||||||
|
for (const type of requestPermissionTypes) {
|
||||||
|
if (this.enumerationChainPermissionTypes.has(type)) continue;
|
||||||
|
this.enumerationChainPermissionTypes.add(type);
|
||||||
|
this.scheduledEnumerationPermissionTypes.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(requestPermissions: boolean): boolean {
|
||||||
if (!this.hasEnumeratedDevices) {
|
if (!this.hasEnumeratedDevices) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!requestPermissions) {
|
return !requestPermissions;
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
private updatePermissionStatusForTypes(
|
||||||
|
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||||
|
status: VoiceMediaPermissionStatus,
|
||||||
|
): void {
|
||||||
|
const permissionStatus = {...this.state.permissionStatus};
|
||||||
|
for (const type of requestPermissionTypes) {
|
||||||
|
permissionStatus[type] = status;
|
||||||
}
|
}
|
||||||
return this.state.permissionStatus === 'granted';
|
this.updateState({permissionStatus});
|
||||||
}
|
}
|
||||||
|
|
||||||
private startEnumeration(requestPermissions: boolean): Promise<VoiceDeviceState> {
|
private startEnumerationChain(
|
||||||
this.currentEnumerationRequestsPermissions = requestPermissions;
|
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||||
const pendingPromise = this.enumerateDevices(requestPermissions).catch((error) => {
|
): Promise<VoiceDeviceState> {
|
||||||
logger.debug('Failed to enumerate media devices:', error);
|
this.enumerationChainPermissionTypes = new Set(requestPermissionTypes);
|
||||||
throw error;
|
const pendingPromise = this.runEnumerationChain(requestPermissionTypes).finally(() => {
|
||||||
});
|
if (this.enumerationChainPromise !== pendingPromise) return;
|
||||||
this.enumeratingPromise = pendingPromise;
|
logger.debug('Enumeration promise completed');
|
||||||
return pendingPromise.finally(() => {
|
this.enumerationChainPromise = null;
|
||||||
if (this.enumeratingPromise === pendingPromise) {
|
this.scheduledEnumerationPermissionTypes.clear();
|
||||||
logger.debug('Enumeration promise completed');
|
this.enumerationChainPermissionTypes.clear();
|
||||||
this.enumeratingPromise = null;
|
|
||||||
this.currentEnumerationRequestsPermissions = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
this.enumerationChainPromise = pendingPromise;
|
||||||
|
return pendingPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enumerateDevices(requestPermissions: boolean): Promise<VoiceDeviceState> {
|
private async runEnumerationChain(
|
||||||
logger.debug('enumerateDevices started', {requestPermissions});
|
initialPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||||
|
): Promise<VoiceDeviceState> {
|
||||||
|
let requestPermissionTypes = [...initialPermissionTypes];
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
if (this.scheduledEnumerationPermissionTypes.size > 0) {
|
||||||
|
throw new Error(`Voice device enumeration exceeded ${MAX_ENUMERATION_CHAIN_PASSES} bounded passes`);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enumerateDevices(
|
||||||
|
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||||
|
): Promise<VoiceDeviceState> {
|
||||||
|
const requestPermissions = requestPermissionTypes.length > 0;
|
||||||
|
logger.debug('enumerateDevices started', {requestPermissionTypes});
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
logger.debug('Navigator or mediaDevices API not available');
|
logger.debug('Navigator or mediaDevices API not available');
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
if (requestPermissions && this.state.permissionStatus !== 'granted') {
|
const permissionTypesToLoad = requestPermissionTypes.filter(
|
||||||
|
(type) => this.state.permissionStatus[type] !== 'granted',
|
||||||
|
);
|
||||||
|
if (permissionTypesToLoad.length > 0) {
|
||||||
logger.debug('Setting permission status to loading');
|
logger.debug('Setting permission status to loading');
|
||||||
this.updateState({permissionStatus: 'loading'});
|
this.updatePermissionStatusForTypes(permissionTypesToLoad, 'loading');
|
||||||
}
|
}
|
||||||
|
const permissionStatus = {...this.state.permissionStatus};
|
||||||
try {
|
try {
|
||||||
logger.debug('Calling navigator.mediaDevices.enumerateDevices()');
|
logger.debug('Calling navigator.mediaDevices.enumerateDevices()');
|
||||||
let devices = await navigator.mediaDevices.enumerateDevices();
|
let devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
let permissionStatus = this.state.permissionStatus;
|
|
||||||
logger.debug('Initial enumeration result', {
|
logger.debug('Initial enumeration result', {
|
||||||
deviceCount: devices.length,
|
deviceCount: devices.length,
|
||||||
devices: devices.map((d) => ({
|
devices: devices.map((d) => ({
|
||||||
@@ -499,61 +535,99 @@ class VoiceDeviceManager {
|
|||||||
hasLabel: !!d.label,
|
hasLabel: !!d.label,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const hasLabels = devices.some((device) => device.label && device.label !== '');
|
const permissionTypesWithLabels = requestPermissionTypes.filter((type) => {
|
||||||
let usedNativeFlow = false;
|
const requiredKind = type === 'audio' ? 'audioinput' : 'videoinput';
|
||||||
if (hasLabels) {
|
return devices.some((device) => device.kind === requiredKind && device.label !== '');
|
||||||
|
});
|
||||||
|
for (const type of permissionTypesWithLabels) {
|
||||||
|
permissionStatus[type] = 'granted';
|
||||||
|
}
|
||||||
|
if (requestPermissions && permissionTypesWithLabels.length === requestPermissionTypes.length) {
|
||||||
logger.debug('Devices have labels, permissions already granted');
|
logger.debug('Devices have labels, permissions already granted');
|
||||||
permissionStatus = 'granted';
|
|
||||||
} else if (requestPermissions && isDesktop()) {
|
} else if (requestPermissions && isDesktop()) {
|
||||||
logger.debug('No labels detected; attempting native permission flow');
|
logger.debug('No labels detected; attempting native permission flow');
|
||||||
const [nativeMic, nativeCamera] = await Promise.all([
|
const unresolvedNativeTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
|
||||||
ensureNativePermission('microphone'),
|
const nativeResults = await Promise.all(
|
||||||
ensureNativePermission('camera'),
|
unresolvedNativeTypes.map(async (type) => {
|
||||||
]);
|
try {
|
||||||
usedNativeFlow = nativeMic !== 'unsupported' || nativeCamera !== 'unsupported';
|
return {
|
||||||
if (nativeMic === 'denied' || nativeCamera === 'denied') {
|
type,
|
||||||
permissionStatus = 'denied';
|
result: await ensureNativePermission(type === 'audio' ? 'microphone' : 'camera'),
|
||||||
} else if (nativeMic === 'granted' || nativeCamera === 'granted') {
|
};
|
||||||
permissionStatus = 'granted';
|
} catch (error) {
|
||||||
|
logger.warn('Native media permission request failed', {type, error});
|
||||||
|
return {type, result: 'not-determined' as const};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const {type, result} of nativeResults) {
|
||||||
|
if (result === 'granted') permissionStatus[type] = 'granted';
|
||||||
|
if (result === 'denied') permissionStatus[type] = 'denied';
|
||||||
|
}
|
||||||
|
if (nativeResults.some(({result}) => result === 'granted')) {
|
||||||
|
try {
|
||||||
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Device re-enumeration after native permission grant failed', {error});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasLabels && requestPermissions && (!usedNativeFlow || permissionStatus !== 'granted')) {
|
const browserPermissionTypes = requestPermissionTypes.filter(
|
||||||
const isIOSPWA = Platform.isIOSWeb && Platform.isPWA;
|
(type) => permissionStatus[type] !== 'granted' && permissionStatus[type] !== 'denied',
|
||||||
|
);
|
||||||
|
if (browserPermissionTypes.length > 0) {
|
||||||
|
const isIOSPWA =
|
||||||
|
browserPermissionTypes.length === 1 &&
|
||||||
|
browserPermissionTypes[0] === 'audio' &&
|
||||||
|
Platform.isIOSWeb &&
|
||||||
|
Platform.isPWA;
|
||||||
let skipGetUserMedia = false;
|
let skipGetUserMedia = false;
|
||||||
if (isIOSPWA && navigator.permissions) {
|
if (isIOSPWA && navigator.permissions) {
|
||||||
try {
|
try {
|
||||||
const micPermission = await navigator.permissions.query({name: 'microphone' as PermissionName});
|
const micPermission = await navigator.permissions.query({name: 'microphone' as PermissionName});
|
||||||
if (micPermission.state === 'granted') {
|
if (micPermission.state === 'granted') {
|
||||||
logger.debug('iOS PWA: microphone permission already granted via Permissions API, skipping getUserMedia');
|
logger.debug('iOS PWA: microphone permission already granted via Permissions API, skipping getUserMedia');
|
||||||
permissionStatus = 'granted';
|
permissionStatus.audio = 'granted';
|
||||||
devices = await navigator.mediaDevices.enumerateDevices();
|
try {
|
||||||
skipGetUserMedia = devices.some((d) => d.label && d.label !== '');
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
skipGetUserMedia = devices.some((device) => device.kind === 'audioinput' && device.label !== '');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('iOS PWA device re-enumeration after permission grant failed', {error});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
logger.debug('iOS PWA microphone permission query failed', {error});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!skipGetUserMedia) {
|
if (!skipGetUserMedia) {
|
||||||
logger.debug('No labels found, requesting permissions via getUserMedia');
|
logger.debug('No labels found, requesting permissions via getUserMedia');
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: true,
|
audio: browserPermissionTypes.includes('audio'),
|
||||||
video: true,
|
video: browserPermissionTypes.includes('video'),
|
||||||
});
|
});
|
||||||
logger.debug('getUserMedia succeeded, stopping tracks');
|
logger.debug('getUserMedia succeeded, stopping tracks');
|
||||||
stream.getTracks().forEach((track) => {
|
stream.getTracks().forEach((track) => {
|
||||||
logger.debug('Stopping track', {kind: track.kind, label: track.label});
|
logger.debug('Stopping track', {kind: track.kind, label: track.label});
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
permissionStatus = 'granted';
|
for (const type of browserPermissionTypes) {
|
||||||
|
permissionStatus[type] = 'granted';
|
||||||
|
}
|
||||||
logger.debug('Re-enumerating devices after permission grant');
|
logger.debug('Re-enumerating devices after permission grant');
|
||||||
devices = await navigator.mediaDevices.enumerateDevices();
|
try {
|
||||||
logger.debug('Re-enumeration result', {
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
deviceCount: devices.length,
|
logger.debug('Re-enumeration result', {
|
||||||
devices: devices.map((d) => ({
|
deviceCount: devices.length,
|
||||||
kind: d.kind,
|
devices: devices.map((d) => ({
|
||||||
hasDeviceId: d.deviceId.trim().length > 0,
|
kind: d.kind,
|
||||||
label: d.label,
|
hasDeviceId: d.deviceId.trim().length > 0,
|
||||||
})),
|
label: d.label,
|
||||||
});
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Device re-enumeration after browser permission grant failed', {error});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('getUserMedia failed', {
|
logger.debug('getUserMedia failed', {
|
||||||
error,
|
error,
|
||||||
@@ -564,9 +638,13 @@ class VoiceDeviceManager {
|
|||||||
error instanceof DOMException &&
|
error instanceof DOMException &&
|
||||||
(error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError')
|
(error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError')
|
||||||
) {
|
) {
|
||||||
permissionStatus = 'denied';
|
for (const type of browserPermissionTypes) {
|
||||||
|
permissionStatus[type] = 'denied';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
permissionStatus = 'granted';
|
for (const type of browserPermissionTypes) {
|
||||||
|
permissionStatus[type] = 'idle';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +660,7 @@ class VoiceDeviceManager {
|
|||||||
inputDevices,
|
inputDevices,
|
||||||
outputDevices,
|
outputDevices,
|
||||||
videoDevices,
|
videoDevices,
|
||||||
permissionStatus: this.resolvePermissionStatus(requestPermissions, permissionStatus),
|
permissionStatus,
|
||||||
};
|
};
|
||||||
this.hasEnumeratedDevices = true;
|
this.hasEnumeratedDevices = true;
|
||||||
logger.debug('Final device state', {
|
logger.debug('Final device state', {
|
||||||
@@ -593,27 +671,16 @@ class VoiceDeviceManager {
|
|||||||
});
|
});
|
||||||
this.updateState(nextState);
|
this.updateState(nextState);
|
||||||
return this.state;
|
return this.state;
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
logger.debug('enumerateDevices failed with exception', _error);
|
logger.warn('Voice device enumeration failed', {error, requestPermissionTypes});
|
||||||
if (requestPermissions) {
|
for (const type of requestPermissionTypes) {
|
||||||
this.updateState({permissionStatus: 'denied'});
|
if (permissionStatus[type] === 'loading') permissionStatus[type] = 'idle';
|
||||||
}
|
}
|
||||||
|
this.updateState({permissionStatus});
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePermissionStatus(requestPermissions: boolean, computedStatus: PermissionStatus): PermissionStatus {
|
|
||||||
if (!requestPermissions) {
|
|
||||||
if (this.state.permissionStatus === 'denied') {
|
|
||||||
return 'denied';
|
|
||||||
}
|
|
||||||
if (this.state.permissionStatus === 'granted') {
|
|
||||||
return 'granted';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return computedStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateState(partial: Partial<VoiceDeviceState>) {
|
private updateState(partial: Partial<VoiceDeviceState>) {
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
@@ -624,7 +691,9 @@ class VoiceDeviceManager {
|
|||||||
|
|
||||||
private handleDeviceChange = () => {
|
private handleDeviceChange = () => {
|
||||||
this.hasEnumeratedDevices = false;
|
this.hasEnumeratedDevices = false;
|
||||||
void this.ensureDevices({requestPermissions: this.shouldRequestPermissions});
|
void this.ensureDevices({requestPermissions: false}).catch((error) => {
|
||||||
|
logger.warn('Voice device refresh after device change failed', {error});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
|
interface FakeMediaDevices {
|
||||||
|
enumerateDevices: () => Promise<Array<MediaDeviceInfo>>;
|
||||||
|
getUserMedia: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
||||||
|
addEventListener: () => void;
|
||||||
|
removeEventListener: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeDevice = (kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo =>
|
||||||
|
({
|
||||||
|
deviceId,
|
||||||
|
groupId: `${deviceId}-group`,
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
toJSON: () => ({deviceId, kind, label}),
|
||||||
|
}) as MediaDeviceInfo;
|
||||||
|
|
||||||
|
const makeStream = (kinds: Array<'audio' | 'video'>): MediaStream => {
|
||||||
|
const tracks = kinds.map((kind) => ({kind, label: `${kind}-track`, stop: vi.fn()}));
|
||||||
|
return {getTracks: () => tracks} as unknown as MediaStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
|
||||||
|
let labelledDevices = false;
|
||||||
|
|
||||||
|
const fakeMediaDevices: FakeMediaDevices = {
|
||||||
|
enumerateDevices: async () =>
|
||||||
|
labelledDevices
|
||||||
|
? [makeDevice('audioinput', 'mic-1', 'Studio Mic'), makeDevice('videoinput', 'cam-1', 'Studio Camera')]
|
||||||
|
: [makeDevice('audioinput', 'mic-1', ''), makeDevice('videoinput', 'cam-1', '')],
|
||||||
|
getUserMedia: async (constraints) => {
|
||||||
|
getUserMediaCalls.push(constraints);
|
||||||
|
labelledDevices = true;
|
||||||
|
const kinds: Array<'audio' | 'video'> = [];
|
||||||
|
if (constraints.audio) kinds.push('audio');
|
||||||
|
if (constraints.video) kinds.push('video');
|
||||||
|
return makeStream(kinds);
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let originalNavigator: PropertyDescriptor | undefined;
|
||||||
|
let originalWindow: PropertyDescriptor | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getUserMediaCalls.length = 0;
|
||||||
|
labelledDevices = 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 loadManager = async () => {
|
||||||
|
const module = await import('@app/features/voice/utils/VoiceDeviceManager');
|
||||||
|
return module.voiceDeviceManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('voiceDeviceManager permission types', () => {
|
||||||
|
test('an audio-only request never asks the browser for video', async () => {
|
||||||
|
const manager = await loadManager();
|
||||||
|
const state = await manager.ensureDevices({requestPermissionTypes: ['audio'], forceRefresh: true});
|
||||||
|
expect(getUserMediaCalls).toEqual([{audio: true, video: false}]);
|
||||||
|
expect(state.permissionStatus.audio).toBe('granted');
|
||||||
|
expect(state.permissionStatus.video).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video-only request never asks the browser for audio', async () => {
|
||||||
|
const manager = await loadManager();
|
||||||
|
const state = await manager.ensureDevices({requestPermissionTypes: ['video'], forceRefresh: true});
|
||||||
|
expect(getUserMediaCalls).toEqual([{audio: false, video: true}]);
|
||||||
|
expect(state.permissionStatus.video).toBe('granted');
|
||||||
|
expect(state.permissionStatus.audio).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the legacy requestPermissions boolean still asks for both types', async () => {
|
||||||
|
const manager = await loadManager();
|
||||||
|
const state = await manager.ensureDevices({requestPermissions: true, forceRefresh: true});
|
||||||
|
expect(getUserMediaCalls).toEqual([{audio: true, video: true}]);
|
||||||
|
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a passive enumeration never prompts', async () => {
|
||||||
|
const manager = await loadManager();
|
||||||
|
const state = await manager.ensureDevices({requestPermissions: false, forceRefresh: true});
|
||||||
|
expect(getUserMediaCalls).toEqual([]);
|
||||||
|
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
|
||||||
|
expect(state.inputDevices.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user