diff --git a/CHANGELOG.md b/CHANGELOG.md index f62ac7a4..ce6bd595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - ✨(frontend) prompt for permissions when toggling a denied device - ⚗️(frontend) capture console.error in PostHog - 📈(frontend) snapshot media devices on the happy path +- 🚸(frontend) guide users when the OS blocks browser media access ### Changed diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 215f6a74..5c7fd162 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -25,7 +25,6 @@ import { VideoConference } from '../livekit/prefabs/VideoConference' import { css } from '@/styled-system/css' import { BackgroundProcessorFactory } from '../livekit/components/blur' import { LocalUserChoices } from '@/stores/userChoices' -import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { useConfig } from '@/api/useConfig' import { isFireFox } from '@/utils/livekit' @@ -37,11 +36,7 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils' import { useSnapshot } from 'valtio' import { userPreferencesStore } from '@/stores/userPreferences' import { userStore } from '@/stores/user' -import { - PERMISSION_BY_DEVICE_KIND, - notePermissionDeniedFromGum, -} from '@/stores/permissions' -import { syncDeviceAvailability } from '@/stores/deviceAvailability' +import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors' export const Conference = ({ roomId, @@ -174,14 +169,6 @@ export const Conference = ({ prepareConnection() }, [room, apiConfig, isConnectionWarmedUp]) - const [mediaDeviceError, setMediaDeviceError] = useState<{ - error: MediaDeviceFailure | null - kind: MediaDeviceKind | null - }>({ - error: null, - kind: null, - }) - const isMobile = useIsMobile() const hasAutoMutedRef = useRef(false) @@ -302,36 +289,10 @@ export const Conference = ({ return } }} - onMediaDeviceFailure={(e, kind) => { - if (!e || !kind) return - void captureMediaEvent('media-device-error', { - log_code: 'media_devices_error_event', - path: 'connect_publish', - failure: e, - kind, - }) - switch (e) { - case MediaDeviceFailure.DeviceInUse: - setMediaDeviceError({ error: e, kind }) - break - case MediaDeviceFailure.NotFound: - setMediaDeviceError({ error: e, kind }) - void syncDeviceAvailability() - break - case MediaDeviceFailure.PermissionDenied: - notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind]) - break - default: - break - } - }} > + {!isMobile && } - setMediaDeviceError({ error: null, kind: null })} - /> diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 9b3ee5dc..75d03942 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -28,8 +28,8 @@ import { userChoicesStore, } from '@/stores/userChoices' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' +import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing' import { useJoinTracks } from '../livekit/hooks/useJoinTracks' -import { deviceAvailabilityStore } from '@/stores/deviceAvailability' const styles = { page: css({ @@ -326,12 +326,12 @@ const VideoPreview = ({ const cameraDenied = useCannotUseDevice('videoinput') const micDenied = useCannotUseDevice('audioinput') - const { hasCamera } = useSnapshot(deviceAvailabilityStore) + const cameraMissing = useDeviceMissing('videoinput') const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled) const { hint, permissionsButtonLabel } = getPreviewMessages({ - cameraFound: hasCamera, + cameraFound: !cameraMissing, cameraDenied, micDenied, videoEnabled, diff --git a/src/frontend/src/features/rooms/components/Permissions.tsx b/src/frontend/src/features/rooms/components/Permissions.tsx index a81a93a6..93f05bfd 100644 --- a/src/frontend/src/features/rooms/components/Permissions.tsx +++ b/src/frontend/src/features/rooms/components/Permissions.tsx @@ -1,13 +1,114 @@ import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions' import { css } from '@/styled-system/css' -import { Dialog, H } from '@/primitives' +import { Button, Dialog, H, P } from '@/primitives' import { RiEqualizer2Line } from '@remixicon/react' import { useEffect, useMemo } from 'react' import { useSnapshot } from 'valtio' -import { closePermissionsDialog, permissionsStore } from '@/stores/permissions' +import { + closePermissionsDialog, + closeSystemPermissionsDialog, + permissionsStore, +} from '@/stores/permissions' import { useTranslation } from 'react-i18next' import { injectIconIntoTranslation } from '@/utils/translation' import { isSafari } from '@/utils/livekit' +import { type OS, getOS } from '@/utils/os' + +type StepsOs = 'macos' | 'windows' | 'android' | 'other' + +const STEPS_OS: Record = { + macos: 'macos', + windows: 'windows', + android: 'android', + linux: 'other', + other: 'other', +} + +const getSystemSettingsUrl = (os: OS, label: string): string | null => { + if (os === 'macos') { + if (label === 'camera') + return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera' + if (label === 'microphone') + return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' + return 'x-apple.systempreferences:com.apple.preference.security?Privacy' + } + if (os === 'windows') { + if (label === 'camera') return 'ms-settings:privacy-webcam' + if (label === 'microphone') return 'ms-settings:privacy-microphone' + return 'ms-settings:privacy' + } + return null +} + +const SystemPermissions = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' }) + const permissions = useSnapshot(permissionsStore) + const os = useMemo(() => getOS() || 'other', []) + + const label = useMemo(() => { + if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) { + return 'cameraAndMicrophone' + } + if (permissions.cameraSystemDenied) return 'camera' + return 'microphone' + }, [permissions]) + + const isOpen = permissions.isSystemPermissionDialogOpen + + // Auto-close once access works again (the user fixed the OS settings). + useEffect(() => { + if ( + isOpen && + !permissions.microphoneSystemDenied && + !permissions.cameraSystemDenied + ) { + closeSystemPermissionsDialog() + } + }, [isOpen, permissions]) + + const device = t(`device.${label}`) + const settingsUrl = getSystemSettingsUrl(os, label) + + return ( + +
+ {t(`heading.${label}`)} +

{t('intro', { device })}

+
    + {Array.from({ length: 2 }, (_, index) => ( +
  1. + {t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })} +
  2. + ))} +
+ {settingsUrl && ( +
+ +
+ )} +
+
+ ) +} /** * Singleton component - ensures permissions sync runs only once across the app. @@ -65,68 +166,74 @@ export const Permissions = () => { const appTitle = `${import.meta.env.VITE_APP_TITLE}` return ( - -
+ + -
- - {t(`heading.${permissionLabel}`, { - appTitle, + -
    -
  1. - {isSafari() ? ( - t('body.openMenu.safari', { - appDomain: window.origin.replace('https://', ''), - }) - ) : ( - <> - {descriptionBeforeIcon} - - - - {descriptionAfterIcon} - - )} -
  2. -
  3. {t(`body.details.${permissionLabel}`)}
  4. -
+ /> +
+ + {t(`heading.${permissionLabel}`, { + appTitle, + })} + +
    +
  1. + {isSafari() ? ( + t('body.openMenu.safari', { + appDomain: window.origin.replace('https://', ''), + }) + ) : ( + <> + {descriptionBeforeIcon} + + + + {descriptionAfterIcon} + + )} +
  2. +
  3. {t(`body.details.${permissionLabel}`)}
  4. +
+
-
-
+ + ) } diff --git a/src/frontend/src/features/rooms/components/WatchMediaDeviceErrors.tsx b/src/frontend/src/features/rooms/components/WatchMediaDeviceErrors.tsx new file mode 100644 index 00000000..97ba190f --- /dev/null +++ b/src/frontend/src/features/rooms/components/WatchMediaDeviceErrors.tsx @@ -0,0 +1,11 @@ +import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' +import { useWatchMediaDeviceErrors } from '../livekit/hooks/useWatchMediaDeviceErrors' + +/** + * Single place responsible for the room's media device errors — mounts the + * watcher and renders the resulting user-facing alert. + */ +export const WatchMediaDeviceErrors = () => { + const { error, kind, clear } = useWatchMediaDeviceErrors() + return +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx index 252af1c1..c7dc0a44 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx @@ -4,8 +4,7 @@ import { useEffect, useMemo } from 'react' import { Select, SelectProps } from '@/primitives/Select' import type { Placement } from '@react-types/overlays' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' -import { useSnapshot } from 'valtio' -import { deviceAvailabilityStore } from '@/stores/deviceAvailability' +import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons' import type { LocalAudioTrack } from 'livekit-client' import { AudioLevelGauge } from './AudioLevelGauge' @@ -115,13 +114,7 @@ export const SelectDevice = ({ const deviceIcons = useDeviceIcons(kind) const cannotUseDevice = useCannotUseDevice(kind) - const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore) - const deviceMissing = - kind === 'videoinput' - ? !hasCamera - : kind === 'audioinput' - ? !hasMicrophone - : false + const deviceMissing = useDeviceMissing(kind) if (deviceMissing) { return ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index 240b24eb..5a57ab82 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx @@ -17,9 +17,8 @@ import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceEr import type { ButtonRecipeProps } from '@/primitives/buttonRecipe' import type { ToggleButtonProps } from '@/primitives/ToggleButton' import { openPermissionsDialog } from '@/stores/permissions' -import { useSnapshot } from 'valtio' -import { deviceAvailabilityStore } from '@/stores/deviceAvailability' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' +import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' @@ -95,8 +94,7 @@ export const ToggleDevice = ({ const deviceIcons = useDeviceIcons(kind) const cannotUseDevice = useCannotUseDevice(kind) - const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore) - const deviceMissing = kind === 'videoinput' ? !hasCamera : !hasMicrophone + const deviceMissing = useDeviceMissing(kind) const deviceShortcut = useDeviceShortcut(kind) const announce = useScreenReaderAnnounce() diff --git a/src/frontend/src/features/rooms/livekit/hooks/useCannotUseDevice.ts b/src/frontend/src/features/rooms/livekit/hooks/useCannotUseDevice.ts index b0da3d5b..14a4e387 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useCannotUseDevice.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useCannotUseDevice.ts @@ -9,6 +9,8 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => { isMicrophonePrompted, isCameraDenied, isCameraPrompted, + microphoneSystemDenied, + cameraSystemDenied, } = useSnapshot(permissionsStore) return useMemo(() => { @@ -17,9 +19,11 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => { switch (kind) { case 'audioinput': case 'audiooutput': // audiooutput uses microphone permissions - return isMicrophoneDenied || isMicrophonePrompted + return ( + isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied + ) case 'videoinput': - return isCameraDenied || isCameraPrompted + return isCameraDenied || isCameraPrompted || cameraSystemDenied default: return false @@ -31,5 +35,7 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => { isMicrophonePrompted, isCameraDenied, isCameraPrompted, + microphoneSystemDenied, + cameraSystemDenied, ]) } diff --git a/src/frontend/src/features/rooms/livekit/hooks/useDeviceMissing.ts b/src/frontend/src/features/rooms/livekit/hooks/useDeviceMissing.ts new file mode 100644 index 00000000..1f011ff5 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useDeviceMissing.ts @@ -0,0 +1,27 @@ +import { useSnapshot } from 'valtio' +import { deviceAvailabilityStore } from '@/stores/deviceAvailability' +import { permissionsStore } from '@/stores/permissions' + +/** + * enumerateDevices() may hide OS/app-blocked devices on Firefox Android. + * Only report "missing" when no permission block explains the absence, + * so the permission UI takes precedence. + */ +export const useDeviceMissing = (kind: MediaDeviceKind): boolean => { + const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore) + const { + cameraSystemDenied, + microphoneSystemDenied, + isCameraDenied, + isMicrophoneDenied, + } = useSnapshot(permissionsStore) + + switch (kind) { + case 'videoinput': + return !hasCamera && !cameraSystemDenied && !isCameraDenied + case 'audioinput': + return !hasMicrophone && !microphoneSystemDenied && !isMicrophoneDenied + default: + return false + } +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 8c38967d..50b57450 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -10,9 +10,15 @@ import { } from 'livekit-client' import { BackgroundProcessorFactory } from '../components/blur' import { + classifyPermissionError, + isLikelySystemNotFound, + isSystemPermissionError, + noteGumSuccess, notePermissionDeniedFromGum, + noteSystemPermissionDenied, type PermissionKind, } from '@/stores/permissions' +import { getOS } from '@/utils/os' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { saveAudioInputDeviceId, @@ -42,13 +48,38 @@ export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => { if ( MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied ) { - notePermissionDeniedFromGum(kind) - captureMediaEvent('permissions-denied', { path: 'join_preview', kind }) + void classifyPermissionError(e, kind).then((scope) => { + if (scope === 'system') { + noteSystemPermissionDenied(kind) + } else { + notePermissionDeniedFromGum(kind) + } + captureMediaEvent('permissions-denied', { + path: 'join_preview', + kind, + denied_scope: scope, + os: getOS(), + }) + }) return } if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) { - captureMediaEvent('device-not-found', { path: 'join_preview', kind }) + // Firefox reports OS-level blocks as NotFoundError (macOS privacy + // settings, missing Android app permissions). + void isLikelySystemNotFound(e, kind).then((system) => { + if (system) { + noteSystemPermissionDenied(kind) + captureMediaEvent('permissions-denied', { + path: 'join_preview', + kind, + denied_scope: 'system', + os: getOS(), + }) + return + } + captureMediaEvent('device-not-found', { path: 'join_preview', kind }) + }) return } @@ -72,6 +103,7 @@ export const requestDevicePermission = async ( ? await createLocalAudioTrack() : await createLocalVideoTrack() track.stop() + noteGumSuccess(PERMISSION_KIND[kind]) return true } catch (error) { onJoinPreviewError(error as Error, PERMISSION_KIND[kind]) @@ -79,14 +111,24 @@ export const requestDevicePermission = async ( } } +type WarmupState = { + audioReady: boolean + videoReady: boolean +} + /** * Requests camera and microphone once on mount (one combined call → at - * most one browser dialog) and releases them immediately. Returns true - * once settled; track acquisition must wait for it to avoid a second - * dialog. + * most one browser dialog) and releases them immediately. Readiness is + * per kind: track acquisition must wait for its own kind to be settled + * to avoid a second dialog, but a microphone that stalls or fails (e.g. + * OS-level block on Firefox) must not hold the camera hostage — that is + * how LiveKit behaves in-room (independent per-kind acquisitions). */ -function useWarmupPermissions(): boolean { - const [done, setDone] = useState(false) +function useWarmupPermissions(): WarmupState { + const [state, setState] = useState({ + audioReady: false, + videoReady: false, + }) const started = useRef(false) useEffect(() => { @@ -95,6 +137,8 @@ function useWarmupPermissions(): boolean { } started.current = true + const bothReady = () => setState({ audioReady: true, videoReady: true }) + const warmup = async () => { try { stopAll( @@ -103,35 +147,50 @@ function useWarmupPermissions(): boolean { video: true, }) ) + noteGumSuccess() + bothReady() } catch (error) { if ( MediaDeviceFailure.getFailure(error as Error) === - MediaDeviceFailure.PermissionDenied + MediaDeviceFailure.PermissionDenied && + !isSystemPermissionError(error) ) { // Retrying after a dismissal would show a second dialog. onJoinPreviewError(error as Error) + bothReady() return } // Combined requests fail atomically (e.g. missing webcam fails the - // mic too) — retry per kind; permission is settled, no dialog risk. - try { - stopAll(await navigator.mediaDevices.getUserMedia({ audio: true })) - } catch (e) { - onJoinPreviewError(e as Error, 'microphone') - } - try { - stopAll(await navigator.mediaDevices.getUserMedia({ video: true })) - } catch (e) { - onJoinPreviewError(e as Error, 'camera') - } - } finally { - setDone(true) + // mic too, and an OS-level block on one device fails both) — retry + // per kind to know which device is affected; permission is settled + // at the browser level, no dialog risk. The retries run in parallel + // and settle readiness independently. + void navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((stream) => { + stopAll(stream) + noteGumSuccess('microphone') + }) + .catch((e) => onJoinPreviewError(e as Error, 'microphone')) + .finally(() => + setState((current) => ({ ...current, audioReady: true })) + ) + void navigator.mediaDevices + .getUserMedia({ video: true }) + .then((stream) => { + stopAll(stream) + noteGumSuccess('camera') + }) + .catch((e) => onJoinPreviewError(e as Error, 'camera')) + .finally(() => + setState((current) => ({ ...current, videoReady: true })) + ) } } warmup() }, []) - return done + return state } function useLocalTrack({ @@ -157,6 +216,7 @@ function useLocalTrack({ let cancelled = false create() .then((newTrack) => { + noteGumSuccess(permissionKind) if (cancelled) { newTrack.stop() return @@ -218,7 +278,7 @@ export function useJoinTracks(): { processorConfig, } = useSnapshot(userChoicesStore) - const ready = useWarmupPermissions() + const { audioReady, videoReady } = useWarmupPermissions() const createAudio = useCallback( () => @@ -240,7 +300,7 @@ export function useJoinTracks(): { ) const audioTrack = useLocalTrack({ - ready, + ready: audioReady, enabled: audioEnabled, create: createAudio, permissionKind: 'microphone', @@ -248,7 +308,7 @@ export function useJoinTracks(): { }) const videoTrack = useLocalTrack({ - ready, + ready: videoReady, enabled: videoEnabled, create: createVideo, permissionKind: 'camera', diff --git a/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts new file mode 100644 index 00000000..99a871b1 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { MediaDeviceFailure, RoomEvent } from 'livekit-client' +import { + PERMISSION_BY_DEVICE_KIND, + type PermissionDeniedScope, + type PermissionKind, + classifyPermissionError, + isLikelySystemNotFound, + notePermissionDeniedFromGum, + noteSystemPermissionDenied, +} from '@/stores/permissions' +import { syncDeviceAvailability } from '@/stores/deviceAvailability' +import { captureMediaEvent } from '@/features/analytics/telemetry' +import { getOS } from '@/utils/os' + +type MediaDeviceAlert = { + error: MediaDeviceFailure | null + kind: MediaDeviceKind | null +} + +const NO_ALERT: MediaDeviceAlert = { error: null, kind: null } + +const capturePermissionsDenied = ( + scope: PermissionDeniedScope, + kind?: PermissionKind +) => + captureMediaEvent('permissions-denied', { + path: 'connect_publish', + kind, + denied_scope: scope, + os: getOS(), + }) + +/** + * Single owner of the room's media device errors + * (RoomEvent.MediaDevicesError): telemetry, the user-facing alert, device + * availability refresh, and browser- vs OS-level permission classification. + * Listens to the raw room event because onMediaDeviceFailure only exposes + * LiveKit's mapped enum, and the raw error is needed to tell a browser-level + * denial from an OS-level one (Chromium's "Permission denied by system", + * Firefox/macOS's NotFoundError). + */ +export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & { + clear: () => void +} => { + const room = useRoomContext() + const [alert, setAlert] = useState(NO_ALERT) + + useEffect(() => { + const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { + const failure = MediaDeviceFailure.getFailure(error) + if (!failure || !kind) return + + void captureMediaEvent('media-device-error', { + log_code: 'media_devices_error_event', + path: 'connect_publish', + failure, + kind, + }) + + const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] + switch (failure) { + case MediaDeviceFailure.DeviceInUse: + setAlert({ error: failure, kind }) + break + case MediaDeviceFailure.NotFound: + void syncDeviceAvailability() + // Firefox reports OS-level blocks as NotFoundError (macOS privacy + // settings, missing Android app permissions). + void isLikelySystemNotFound(error, permissionKind).then((system) => { + if (system) { + noteSystemPermissionDenied(permissionKind) + void capturePermissionsDenied('system', permissionKind) + } else { + setAlert({ error: failure, kind }) + } + }) + break + case MediaDeviceFailure.PermissionDenied: + void classifyPermissionError(error, permissionKind).then((scope) => { + void capturePermissionsDenied(scope, permissionKind) + if (scope === 'system') { + noteSystemPermissionDenied(permissionKind) + } else { + notePermissionDeniedFromGum(permissionKind) + } + }) + break + default: + break + } + } + room.on(RoomEvent.MediaDevicesError, onDeviceError) + return () => { + room.off(RoomEvent.MediaDevicesError, onDeviceError) + } + }, [room]) + + const clear = useCallback(() => setAlert(NO_ALERT), []) + + return { ...alert, clear } +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 83e37175..0e908f6a 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -154,6 +154,38 @@ } } }, + "systemPermissionDialog": { + "heading": { + "camera": "Erlauben Sie Ihrem Browser, Ihre Kamera zu verwenden", + "microphone": "Erlauben Sie Ihrem Browser, Ihr Mikrofon zu verwenden", + "cameraAndMicrophone": "Erlauben Sie Ihrem Browser, Ihre Kamera und Ihr Mikrofon zu verwenden" + }, + "device": { + "camera": "Kamera", + "microphone": "Mikrofon", + "cameraAndMicrophone": "Kamera und Ihr Mikrofon" + }, + "intro": "Ihr Browser hat bereits die Berechtigung, aber die Systemeinstellungen Ihres Computers blockieren den Zugriff auf Ihr(e) {{device}}.", + "steps": { + "macos": { + "1": "Öffnen Sie in den Systemeinstellungen „Datenschutz & Sicherheit“.", + "2": "Erlauben Sie Ihrem Browser den Zugriff auf Ihr(e) {{device}}." + }, + "windows": { + "1": "Öffnen Sie in den Windows-Einstellungen „Datenschutz und Sicherheit“.", + "2": "Aktivieren Sie den Zugriff auf {{device}} und erlauben Sie Desktop-Apps die Nutzung." + }, + "android": { + "1": "Öffnen Sie in den Android-Einstellungen „Apps“ und wählen Sie Ihren Browser aus.", + "2": "Erlauben Sie unter „Berechtigungen“ den Zugriff auf Ihr(e) {{device}} und laden Sie die Seite anschließend neu." + }, + "other": { + "1": "Erlauben Sie Ihrem Browser in den Datenschutzeinstellungen Ihres Systems den Zugriff auf Ihr(e) {{device}}.", + "2": "Versuchen Sie es anschließend erneut." + } + }, + "openSettings": "Systemeinstellungen öffnen" + }, "permissionsButton": { "tooltip": "Mehr Infos", "ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index c45926a2..0d8fb4d0 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -154,6 +154,38 @@ } } }, + "systemPermissionDialog": { + "heading": { + "camera": "Allow your browser to use your camera", + "microphone": "Allow your browser to use your microphone", + "cameraAndMicrophone": "Allow your browser to use your camera and microphone" + }, + "device": { + "camera": "camera", + "microphone": "microphone", + "cameraAndMicrophone": "camera and microphone" + }, + "intro": "Your browser already has permission, but your computer's system settings are blocking access to your {{device}}.", + "steps": { + "macos": { + "1": "In System Settings, open “Privacy & Security”.", + "2": "Allow your browser to access your {{device}}." + }, + "windows": { + "1": "In Windows Settings, open “Privacy & security”.", + "2": "Turn on {{device}} access and allow desktop apps to use it." + }, + "android": { + "1": "In Android Settings, open “Apps” and select your browser.", + "2": "In “Permissions”, allow access to the {{device}}, then reload this page." + }, + "other": { + "1": "In your system's privacy settings, allow your browser to access your {{device}}.", + "2": "Then try again." + } + }, + "openSettings": "Open system settings" + }, "permissionsButton": { "tooltip": "More info", "ariaLabel": "Permissions issue. Show more info" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 6217f9ef..ccacbf06 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -154,6 +154,38 @@ } } }, + "systemPermissionDialog": { + "heading": { + "camera": "Autorisez votre navigateur à utiliser votre caméra", + "microphone": "Autorisez votre navigateur à utiliser votre micro", + "cameraAndMicrophone": "Autorisez votre navigateur à utiliser votre caméra et votre micro" + }, + "device": { + "camera": "caméra", + "microphone": "micro", + "cameraAndMicrophone": "caméra et votre micro" + }, + "intro": "Votre navigateur a déjà l'autorisation, mais les réglages de votre ordinateur bloquent l'accès à votre {{device}}.", + "steps": { + "macos": { + "1": "Dans les réglages Système, ouvrez « Confidentialité et sécurité ».", + "2": "Autorisez votre navigateur à accéder à votre {{device}}." + }, + "windows": { + "1": "Dans les paramètres Windows, ouvrez « Confidentialité et sécurité ».", + "2": "Activez l'accès à votre {{device}} et autorisez les applications de bureau à l'utiliser." + }, + "android": { + "1": "Dans les paramètres Android, ouvrez « Applications » puis sélectionnez votre navigateur.", + "2": "Dans « Autorisations », autorisez l'accès à votre {{device}}, puis rechargez la page." + }, + "other": { + "1": "Dans les paramètres de confidentialité de votre système, autorisez votre navigateur à accéder à votre {{device}}.", + "2": "Puis réessayez." + } + }, + "openSettings": "Ouvrir les réglages système" + }, "permissionsButton": { "tooltip": "Plus d'infos", "ariaLabel": "Problème de permissions. Afficher plus d'infos" diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 7233a079..209da973 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -154,6 +154,38 @@ } } }, + "systemPermissionDialog": { + "heading": { + "camera": "Geef je browser toestemming om je camera te gebruiken", + "microphone": "Geef je browser toestemming om je microfoon te gebruiken", + "cameraAndMicrophone": "Geef je browser toestemming om je camera en microfoon te gebruiken" + }, + "device": { + "camera": "camera", + "microphone": "microfoon", + "cameraAndMicrophone": "camera en microfoon" + }, + "intro": "Je browser heeft al toestemming, maar de systeeminstellingen van je computer blokkeren de toegang tot je {{device}}.", + "steps": { + "macos": { + "1": "Open in Systeeminstellingen “Privacy en beveiliging”.", + "2": "Geef je browser toegang tot je {{device}}." + }, + "windows": { + "1": "Open in de Windows-instellingen “Privacy en beveiliging”.", + "2": "Zet {{device}}-toegang aan en sta desktop-apps toe deze te gebruiken." + }, + "android": { + "1": "Open in de Android-instellingen “Apps” en kies je browser.", + "2": "Sta bij “Machtigingen” toegang tot je {{device}} toe en herlaad daarna de pagina." + }, + "other": { + "1": "Geef je browser in de privacy-instellingen van je systeem toegang tot je {{device}}.", + "2": "Probeer het daarna opnieuw." + } + }, + "openSettings": "Systeeminstellingen openen" + }, "permissionsButton": { "tooltip": "Meer info", "ariaLabel": "Probleem met machtigingen. Meer info weergeven" diff --git a/src/frontend/src/stores/permissions.ts b/src/frontend/src/stores/permissions.ts index 747a819d..4b63ff84 100644 --- a/src/frontend/src/stores/permissions.ts +++ b/src/frontend/src/stores/permissions.ts @@ -1,12 +1,17 @@ import { proxy } from 'valtio' +import { isFireFox, isMacintosh } from '@/utils/livekit' +import { isAndroid } from '@/utils/os' type PermissionState = undefined | 'granted' | 'prompt' | 'denied' type State = { cameraPermission: PermissionState microphonePermission: PermissionState + cameraSystemDenied: boolean + microphoneSystemDenied: boolean isLoading: boolean isPermissionDialogOpen: boolean + isSystemPermissionDialogOpen: boolean requestOrigin?: 'audioinput' | 'videoinput' isCameraGranted: boolean isMicrophoneGranted: boolean @@ -19,8 +24,11 @@ type State = { export const permissionsStore = proxy({ cameraPermission: undefined, microphonePermission: undefined, + cameraSystemDenied: false, + microphoneSystemDenied: false, isLoading: true, isPermissionDialogOpen: false, + isSystemPermissionDialogOpen: false, requestOrigin: undefined, get isCameraGranted() { return this.cameraPermission === 'granted' @@ -45,10 +53,33 @@ export const permissionsStore = proxy({ export const openPermissionsDialog = ( requestOrigin?: 'audioinput' | 'videoinput' ) => { + // When the block is at the OS level, browser-side instructions are + // useless — route to the system permissions dialog instead. + const systemBlocked = + requestOrigin === 'audioinput' + ? permissionsStore.microphoneSystemDenied + : requestOrigin === 'videoinput' + ? permissionsStore.cameraSystemDenied + : permissionsStore.microphoneSystemDenied || + permissionsStore.cameraSystemDenied + if (systemBlocked) { + openSystemPermissionsDialog() + return + } permissionsStore.isPermissionDialogOpen = true permissionsStore.requestOrigin = requestOrigin } +export const openSystemPermissionsDialog = () => { + // Never show both dialogs stacked: the system-level one wins. + permissionsStore.isPermissionDialogOpen = false + permissionsStore.isSystemPermissionDialogOpen = true +} + +export const closeSystemPermissionsDialog = () => { + permissionsStore.isSystemPermissionDialogOpen = false +} + export const closePermissionsDialog = () => { permissionsStore.isPermissionDialogOpen = false } @@ -100,36 +131,166 @@ const labelsVisible = async () => { } } +/** + * Outcomes witnessed first-hand from getUserMedia this session. The + * Permissions API is a weaker signal in both directions on Firefox: + * + * - denials: Firefox < 151 keeps temporary (non-"remembered") denials at + * 'prompt' — the default mode on mobile — and some browsers cannot + * query camera/microphone at all; + * - grants: one-time grants are coupled to the active capture, so the + * query drops back to 'prompt' the moment a track stops (e.g. the user + * toggling a device off), and the PermissionStatus change event + * re-syncs at exactly that moment. + * + * Without this memory, every syncPermissions (focus, devicechange, + * status change) clobbers the state right back to 'prompt', which the UI + * renders as "permission needed" — mid-session, on devices that just + * worked. A queried 'prompt' therefore never downgrades a witnessed + * outcome; only hard evidence (a queried 'granted'/'denied', or the next + * gUM outcome) replaces it. + */ +const gumOutcome: Record = { + camera: undefined, + microphone: undefined, +} + export const syncPermissions = async () => { const [camera, microphone] = await Promise.all([ queryPermission('camera'), queryPermission('microphone'), ]) - if (camera && microphone) { - setPermissions({ camera, microphone }) - return - } - const granted = await labelsVisible() - const resolve = (kind: PermissionKind, queried?: PermissionState) => { - if (queried) return queried + const granted = camera && microphone ? () => false : await labelsVisible() + const resolve = ( + kind: PermissionKind, + queried?: PermissionState + ): PermissionState => { + // Hard evidence from the Permissions API wins in both directions. + if (queried === 'granted' || (!queried && granted(kind))) { + gumOutcome[kind] = 'granted' + return 'granted' + } + if (queried === 'denied') { + gumOutcome[kind] = 'denied' + return 'denied' + } + // 'prompt' (or no query support): a witnessed gUM outcome is the + // stronger signal — keep it. + if (gumOutcome[kind]) return gumOutcome[kind] + if (queried === 'prompt') return 'prompt' const current = kind === 'camera' ? permissionsStore.cameraPermission : permissionsStore.microphonePermission if (current === 'denied') return 'denied' - return granted(kind) ? 'granted' : 'prompt' + return 'prompt' } - setPermissions({ + const resolved = { camera: resolve('camera', camera), microphone: resolve('microphone', microphone), - }) + } + setPermissions(resolved) } export const notePermissionDeniedFromGum = (kind?: PermissionKind) => { - if (kind) { - setPermissions({ [kind]: 'denied' }) - void syncPermissions() - return + // A combined audio+video request fails atomically: no kind means the + // denial covers both devices. + const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera'] + const denied: Partial> = {} + for (const k of kinds) { + gumOutcome[k] = 'denied' + denied[k] = 'denied' + } + setPermissions(denied) + void syncPermissions() +} + +export type PermissionDeniedScope = 'browser' | 'system' + +/** + * Chromium reports OS-level blocks (macOS/Windows privacy settings) with an + * explicit, non-localized message: "Permission denied by system", as opposed + * to "Permission denied" for a browser-level block. + */ +export const isSystemPermissionError = (error: unknown): boolean => + (error as Error)?.name === 'NotAllowedError' && + /by system/i.test((error as Error)?.message ?? '') + +/** + * Decides whether a getUserMedia permission failure comes from the browser + * or from the OS. Fallback for non-Chromium browsers: gUM rejected with + * NotAllowedError although the browser-level permission is granted — the + * block can only come from the OS. + */ +export const classifyPermissionError = async ( + error: unknown, + kind?: PermissionKind +): Promise => { + if (isSystemPermissionError(error)) return 'system' + if ((error as Error)?.name !== 'NotAllowedError') return 'browser' + const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera'] + const states = await Promise.all(kinds.map(queryPermission)) + return states.every((state) => state === 'granted') ? 'system' : 'browser' +} + +/** + * Firefox surfaces OS-level blocks as NotFoundError ("The object can not + * be found here.") instead of NotAllowedError. + * + * - macOS: tell it apart from a genuinely missing device — the device + * still shows up in enumerateDevices. + * - Android: Fenix rejects with NotFoundError when the app itself lacks + * the Android camera/mic permission (mozilla-mobile/fenix#15023), often + * before any site prompt, and may hide the blocked devices from + * enumerateDevices entirely. Phones and tablets always have both + * devices, so "not found" there means an OS-level block, not missing + * hardware. + */ +export const isLikelySystemNotFound = async ( + error: unknown, + kind?: PermissionKind +): Promise => { + if ((error as Error)?.name !== 'NotFoundError') return false + if (!isFireFox()) return false + if (isAndroid()) return true + if (!isMacintosh()) return false + try { + const devices = await navigator.mediaDevices.enumerateDevices() + const deviceKinds = kind + ? [KIND_MAP[kind]] + : (['audioinput', 'videoinput'] as const) + return deviceKinds.every((deviceKind) => + devices.some((device) => device.kind === deviceKind) + ) + } catch { + return false + } +} + +export const noteSystemPermissionDenied = (kind?: PermissionKind) => { + // Only flags the state (shown as the blocked-device hint on the toggle); + // the dialog itself opens when the user clicks the blocked toggle. + const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera'] + for (const k of kinds) { + const key = k === 'camera' ? 'cameraSystemDenied' : 'microphoneSystemDenied' + permissionsStore[key] = true } void syncPermissions() } + +/** + * Successful acquisition proves the permission is granted and the OS no + * longer blocks the device — record it directly, since the Permissions + * API may still report 'prompt' (Firefox one-time grants). + */ +export const noteGumSuccess = (kind?: PermissionKind) => { + const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera'] + const granted: Partial> = {} + for (const k of kinds) { + gumOutcome[k] = 'granted' + granted[k] = 'granted' + if (k === 'camera') permissionsStore.cameraSystemDenied = false + else permissionsStore.microphoneSystemDenied = false + } + setPermissions(granted) +} diff --git a/src/frontend/src/utils/os.ts b/src/frontend/src/utils/os.ts new file mode 100644 index 00000000..961fc0d3 --- /dev/null +++ b/src/frontend/src/utils/os.ts @@ -0,0 +1,17 @@ +export type OS = 'macos' | 'windows' | 'linux' | 'android' | 'other' + +export const isAndroid = (): boolean => /android/i.test(navigator.userAgent) + +export const getOS = (): OS => { + const source = `${ + (navigator as { userAgentData?: { platform?: string } }).userAgentData + ?.platform ?? navigator.platform + } ${navigator.userAgent}` + // Android UAs also contain "Linux" (and desktop-mode ones may not say + // much else), so check it before the desktop platforms. + if (isAndroid()) return 'android' + if (/mac/i.test(source)) return 'macos' + if (/win/i.test(source)) return 'windows' + if (/linux|x11/i.test(source)) return 'linux' + return 'other' +}