mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-12 19:56:53 +00:00
🚸(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:
committed by
aleb_the_flash
parent
dffcb83fff
commit
e1a28f315d
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user