huge refacto of the permissions handling + fix various regression

/**
 * Keeps permissionsStore aligned with the browser. The store is a cache,
 * never a second source of truth: every signal re-reads the browser via
 * syncPermissions() (single writer).
 *
 * Re-sync triggers, all event-driven — no polling:
 *  - devicechange: granting permission reveals device labels/ids, so this
 *    fires on grant in every browser INCLUDING Safari — replacing the
 *    previous 500ms Safari polling. (A denial is caught by the concurrent
 *    getUserMedia rejection → notePermissionDeniedFromGum.)
 *  - window focus: returning from the browser/system permission UI.
 *  - Permissions API 'change' events, where the query is supported.
 */
This commit is contained in:
lebaudantoine
2026-08-07 20:14:26 +02:00
parent ddcc4062b9
commit d4df0df027
6 changed files with 147 additions and 196 deletions
-10
View File
@@ -22,7 +22,6 @@
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.1",
@@ -4710,15 +4709,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-1
View File
@@ -29,7 +29,6 @@
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.1",
@@ -41,6 +41,10 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user' import { userStore } from '@/stores/user'
import {
PERMISSION_BY_DEVICE_KIND,
notePermissionDeniedFromGum,
} from '@/stores/permissions'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -313,6 +317,9 @@ export const Conference = ({
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
setMediaDeviceError({ error: e, kind }) setMediaDeviceError({ error: e, kind })
break break
case MediaDeviceFailure.PermissionDenied:
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
break
default: default:
break break
} }
@@ -8,6 +8,7 @@ import {
createLocalVideoTrack, createLocalVideoTrack,
type LocalAudioTrack, type LocalAudioTrack,
type LocalVideoTrack, type LocalVideoTrack,
MediaDeviceFailure,
Track, Track,
} from 'livekit-client' } from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
@@ -33,7 +34,11 @@ import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom' import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
import { openPermissionsDialog } from '@/stores/permissions' import {
notePermissionDeniedFromGum,
openPermissionsDialog,
PermissionKind,
} from '@/stores/permissions'
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
@@ -54,8 +59,14 @@ import { useSnapshot } from 'valtio'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
const onError = (e: Error) => const onError = (e: Error, kind?: PermissionKind) => {
reportError('join_preview_failure', e, { path: 'join_preview' }) reportError('join_preview_failure', e, { path: 'join_preview' })
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
notePermissionDeniedFromGum(kind)
}
}
const Effects = ({ const Effects = ({
videoTrack, videoTrack,
@@ -198,7 +209,7 @@ export const Join = ({
}) })
setDynamicVideoTrack(track) setDynamicVideoTrack(track)
} catch (error) { } catch (error) {
onError(error as Error) onError(error as Error, 'camera')
} }
} }
@@ -234,7 +245,7 @@ export const Join = ({
}) })
setDynamicAudioTrack(track) setDynamicAudioTrack(track)
} catch (error) { } catch (error) {
onError(error as Error) onError(error as Error, 'microphone')
} }
} }
if ( if (
@@ -1,165 +1,36 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { permissionsStore } from '@/stores/permissions' import { syncPermissions } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
import { reportError } from '@/features/analytics/telemetry'
const POLLING_TIME = 500
export const useWatchPermissions = () => { export const useWatchPermissions = () => {
useEffect(() => { useEffect(() => {
let cleanup: (() => void) | undefined const sync = () => void syncPermissions()
let intervalId: ReturnType<typeof setTimeout> | undefined sync()
let isCancelled = false
const checkPermissions = async () => { navigator.mediaDevices?.addEventListener?.('devicechange', sync)
try { window.addEventListener('focus', sync)
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
const [cameraPermission, microphonePermission] = await Promise.all([ let statuses: PermissionStatus[] = []
navigator.permissions.query({ name: 'camera' }), let cancelled = false
navigator.permissions.query({ name: 'microphone' }), if (navigator.permissions) {
]) Promise.all([
navigator.permissions.query({ name: 'camera' as PermissionName }),
if (isCancelled) return navigator.permissions.query({ name: 'microphone' as PermissionName }),
])
/** .then((results) => {
* Safari Permission API Limitation Workaround if (cancelled) return
* statuses = results
* Safari has a known issue where permission change events are not reliably fired statuses.forEach((s) => s.addEventListener('change', sync))
* when users interact with permission prompts. This is documented in Apple's forums: })
* https://developer.apple.com/forums/thread/757353 .catch(() => {
* // Query unsupported: devicechange/focus + gUM outcomes cover it.
* The problem: })
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
* - This leaves the UI in an inconsistent state showing outdated permission status
*
* The solution:
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
* - Continue polling until both permissions are no longer in 'prompt' state
* - This ensures we catch permission changes even when Safari fails to fire events
*
* This polling is Safari-specific and only activates when needed to minimize performance impact.
*/
if (
isSafari() &&
(cameraPermission.state === 'prompt' ||
microphonePermission.state === 'prompt')
) {
// Start polling every 1 second if either permission is in 'prompt' state
if (!intervalId) {
intervalId = setInterval(async () => {
try {
const [updatedCamera, updatedMicrophone] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
const cameraChanged =
permissionsStore.cameraPermission !== updatedCamera.state
const microphoneChanged =
permissionsStore.microphonePermission !==
updatedMicrophone.state
if (cameraChanged) {
permissionsStore.cameraPermission = updatedCamera.state
}
if (microphoneChanged) {
permissionsStore.microphonePermission =
updatedMicrophone.state
}
if (
updatedCamera.state !== 'prompt' &&
updatedMicrophone.state !== 'prompt'
) {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
reportError('permission_poll_failure', error, {
context: 'Error polling permissions:',
})
}
}
}, POLLING_TIME)
}
}
permissionsStore.cameraPermission = cameraPermission.state
permissionsStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.cameraPermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.microphonePermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
cameraPermission.addEventListener('change', handleCameraChange)
microphonePermission.addEventListener('change', handleMicrophoneChange)
cleanup = () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
reportError('permission_poll_failure', error, {
context: 'Error checking permissions:',
})
}
} finally {
if (!isCancelled) {
permissionsStore.isLoading = false
}
}
} }
checkPermissions()
return () => { return () => {
isCancelled = true cancelled = true
cleanup?.() navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
window.removeEventListener('focus', sync)
statuses.forEach((s) => s.removeEventListener('change', sync))
} }
}, []) }, [])
} }
+101 -28
View File
@@ -1,22 +1,17 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState = type PermissionState =
| undefined | undefined
| 'granted' | 'granted'
| 'prompt' | 'prompt'
| 'denied' | 'denied'
| 'unavailable'
type BaseState = { type State = {
cameraPermission: PermissionState cameraPermission: PermissionState
microphonePermission: PermissionState microphonePermission: PermissionState
isLoading: boolean isLoading: boolean
isPermissionDialogOpen: boolean isPermissionDialogOpen: boolean
requestOrigin?: 'audioinput' | 'videoinput' requestOrigin?: 'audioinput' | 'videoinput'
}
type DerivedState = {
isCameraGranted: boolean isCameraGranted: boolean
isMicrophoneGranted: boolean isMicrophoneGranted: boolean
isCameraDenied: boolean isCameraDenied: boolean
@@ -25,34 +20,31 @@ type DerivedState = {
isMicrophonePrompted: boolean isMicrophonePrompted: boolean
} }
type State = BaseState & DerivedState export const permissionsStore = proxy<State>({
export const permissionsStore = proxy<BaseState>({
cameraPermission: undefined, cameraPermission: undefined,
microphonePermission: undefined, microphonePermission: undefined,
isLoading: true, isLoading: true,
isPermissionDialogOpen: false, isPermissionDialogOpen: false,
requestOrigin: undefined, requestOrigin: undefined,
}) as State get isCameraGranted() {
return this.cameraPermission === 'granted'
derive(
{
isCameraGranted: (get) =>
get(permissionsStore).cameraPermission == 'granted',
isMicrophoneGranted: (get) =>
get(permissionsStore).microphonePermission == 'granted',
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
}, },
{ get isMicrophoneGranted() {
proxy: permissionsStore, return this.microphonePermission === 'granted'
} },
) get isCameraDenied() {
return this.cameraPermission === 'denied'
},
get isMicrophoneDenied() {
return this.microphonePermission === 'denied'
},
get isCameraPrompted() {
return this.cameraPermission === 'prompt'
},
get isMicrophonePrompted() {
return this.microphonePermission === 'prompt'
},
})
export const openPermissionsDialog = ( export const openPermissionsDialog = (
requestOrigin?: 'audioinput' | 'videoinput' requestOrigin?: 'audioinput' | 'videoinput'
@@ -64,3 +56,84 @@ export const openPermissionsDialog = (
export const closePermissionsDialog = () => { export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false permissionsStore.isPermissionDialogOpen = false
} }
export type PermissionKind = 'camera' | 'microphone'
const KIND_MAP = {
camera: 'videoinput',
microphone: 'audioinput',
} as const
export const PERMISSION_BY_DEVICE_KIND: Partial<
Record<MediaDeviceKind, PermissionKind>
> = {
videoinput: 'camera',
audioinput: 'microphone',
}
export const setPermissions = (
p: Partial<Record<PermissionKind, PermissionState>>
) => {
if (p.camera && p.camera !== permissionsStore.cameraPermission) {
permissionsStore.cameraPermission = p.camera
}
if (p.microphone && p.microphone !== permissionsStore.microphonePermission) {
permissionsStore.microphonePermission = p.microphone
}
permissionsStore.isLoading = false
}
const queryPermission = async (name: PermissionKind) => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return undefined
}
}
const labelsVisible = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
return (kind: PermissionKind) =>
devices.some((d) => d.kind === KIND_MAP[kind] && !!d.label)
} catch {
return (_kind: PermissionKind) => false
}
}
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 current =
kind === 'camera'
? permissionsStore.cameraPermission
: permissionsStore.microphonePermission
if (current === 'denied') return 'denied'
return granted(kind) ? 'granted' : 'prompt'
}
setPermissions({
camera: resolve('camera', camera),
microphone: resolve('microphone', microphone),
})
}
export const notePermissionDeniedFromGum = (kind?: PermissionKind) => {
if (kind) {
setPermissions({ [kind]: 'denied' })
void syncPermissions()
return
}
void syncPermissions()
}