🚸(frontend) guide users when the OS blocks browser media access

Introduce a new handling flow for the case where the operating
system itself is blocking browser access to the microphone or
camera, rather than the browser's own permission.

Detect the situation and surface guidance to the user, so they know
they need to allow the browser to access their microphone/camera in
the OS settings.

Only a minority of users are impacted, but the failure mode is very
confusing when it happens. Hopefully this reduces the amount of
support requests around it.
This commit is contained in:
lebaudantoine
2026-08-10 23:39:31 +02:00
committed by aleb_the_flash
parent dffcb83fff
commit e1a28f315d
17 changed files with 729 additions and 156 deletions
+1
View File
@@ -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
@@ -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
}
}}
>
<WatchMediaDeviceErrors />
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
@@ -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,
@@ -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<OS, StepsOs> = {
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 (
<Dialog
isOpen={isOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${label}`)}
onClose={closeSystemPermissionsDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t(`heading.${label}`)}</H>
<P>{t('intro', { device })}</P>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
{Array.from({ length: 2 }, (_, index) => (
<li key={index}>
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
</li>
))}
</ol>
{settingsUrl && (
<div className={css({ marginTop: '2rem' })}>
<Button
variant="primary"
size="sm"
onPress={() => {
window.open(settingsUrl, '_blank')
}}
>
{t('openSettings')}
</Button>
</div>
)}
</div>
</Dialog>
)
}
/**
* 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 (
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
<>
<SystemPermissions />
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
maxWidth: '400px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</div>
</div>
</Dialog>
</Dialog>
</>
)
}
@@ -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 <MediaDeviceErrorAlert error={error} kind={kind} onClose={clear} />
}
@@ -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 (
@@ -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 = <T extends ToggleSource>({
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()
@@ -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,
])
}
@@ -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
}
}
@@ -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<WarmupState>({
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<T extends LocalAudioTrack | LocalVideoTrack>({
@@ -157,6 +216,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
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',
@@ -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<MediaDeviceAlert>(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 }
}
+32
View File
@@ -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"
+32
View File
@@ -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"
+32
View File
@@ -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"
+32
View File
@@ -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"
+175 -14
View File
@@ -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<State>({
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<State>({
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<PermissionKind, 'granted' | 'denied' | undefined> = {
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<Record<PermissionKind, PermissionState>> = {}
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<PermissionDeniedScope> => {
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<boolean> => {
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<Record<PermissionKind, PermissionState>> = {}
for (const k of kinds) {
gumOutcome[k] = 'granted'
granted[k] = 'granted'
if (k === 'camera') permissionsStore.cameraSystemDenied = false
else permissionsStore.microphoneSystemDenied = false
}
setPermissions(granted)
}
+17
View File
@@ -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'
}