mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-12 19:56:53 +00:00
🐛(frontend) fix permission store regression
`derive-valtio` was broken by a recent update, which cascaded into various regressions in the permission store. Take the opportunity to also refactor how permissions are handled. The store is now a pure cache with a single writer: every signal re-reads the browser via `syncPermissions()`, and the browser stays the only source of truth. Re-sync triggers, all event-driven (no polling): * `devicechange`: granting permission reveals device labels/ids, so it fires on grant in every browser, including Safari. This replaces the previous 500ms Safari polling. Denials are still caught by the concurrent `getUserMedia` rejection through `notePermissionDeniedFromGum`. * Window focus: covers the return from the browser or system permission UI. * Permissions API `change` events, where the query is supported.
This commit is contained in:
committed by
aleb_the_flash
parent
5d50671b3c
commit
c8a3ef6f61
@@ -20,6 +20,7 @@ and this project adheres to
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||
- 🐛(frontend) fix permission store regression
|
||||
|
||||
## [1.25.2] - 2026-08-06
|
||||
|
||||
|
||||
Generated
-10
@@ -22,7 +22,6 @@
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
@@ -4710,15 +4709,6 @@
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
|
||||
@@ -41,6 +41,10 @@ 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'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -313,6 +317,9 @@ export const Conference = ({
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
setMediaDeviceError({ error: e, kind })
|
||||
break
|
||||
case MediaDeviceFailure.PermissionDenied:
|
||||
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createLocalVideoTrack,
|
||||
type LocalAudioTrack,
|
||||
type LocalVideoTrack,
|
||||
MediaDeviceFailure,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
@@ -33,7 +34,11 @@ import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import {
|
||||
notePermissionDeniedFromGum,
|
||||
openPermissionsDialog,
|
||||
PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
@@ -54,8 +59,14 @@ import { useSnapshot } from 'valtio'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const onError = (e: Error) =>
|
||||
const onError = (e: Error, kind?: PermissionKind) => {
|
||||
reportError('join_preview_failure', e, { path: 'join_preview' })
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||
) {
|
||||
notePermissionDeniedFromGum(kind)
|
||||
}
|
||||
}
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
@@ -198,7 +209,7 @@ export const Join = ({
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
onError(error as Error, 'camera')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +245,7 @@ export const Join = ({
|
||||
})
|
||||
setDynamicAudioTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
onError(error as Error, 'microphone')
|
||||
}
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,165 +1,36 @@
|
||||
import { useEffect } from 'react'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const POLLING_TIME = 500
|
||||
import { syncPermissions } from '@/stores/permissions'
|
||||
|
||||
export const useWatchPermissions = () => {
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined
|
||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
||||
let isCancelled = false
|
||||
const sync = () => void syncPermissions()
|
||||
sync()
|
||||
|
||||
const checkPermissions = async () => {
|
||||
try {
|
||||
if (!navigator.permissions) {
|
||||
if (!isCancelled) {
|
||||
permissionsStore.cameraPermission = 'unavailable'
|
||||
permissionsStore.microphonePermission = 'unavailable'
|
||||
}
|
||||
return
|
||||
}
|
||||
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||
window.addEventListener('focus', sync)
|
||||
|
||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
/**
|
||||
* Safari Permission API Limitation Workaround
|
||||
*
|
||||
* Safari has a known issue where permission change events are not reliably fired
|
||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
||||
* https://developer.apple.com/forums/thread/757353
|
||||
*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
let statuses: PermissionStatus[] = []
|
||||
let cancelled = false
|
||||
if (navigator.permissions) {
|
||||
Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||
])
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
statuses = results
|
||||
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||
})
|
||||
.catch(() => {
|
||||
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||
})
|
||||
}
|
||||
checkPermissions()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
cleanup?.()
|
||||
cancelled = true
|
||||
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
||||
window.removeEventListener('focus', sync)
|
||||
statuses.forEach((s) => s.removeEventListener('change', sync))
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { derive } from 'derive-valtio'
|
||||
|
||||
type PermissionState =
|
||||
| undefined
|
||||
| 'granted'
|
||||
| 'prompt'
|
||||
| 'denied'
|
||||
| 'unavailable'
|
||||
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
|
||||
|
||||
type BaseState = {
|
||||
type State = {
|
||||
cameraPermission: PermissionState
|
||||
microphonePermission: PermissionState
|
||||
isLoading: boolean
|
||||
isPermissionDialogOpen: boolean
|
||||
requestOrigin?: 'audioinput' | 'videoinput'
|
||||
}
|
||||
|
||||
type DerivedState = {
|
||||
isCameraGranted: boolean
|
||||
isMicrophoneGranted: boolean
|
||||
isCameraDenied: boolean
|
||||
@@ -25,34 +16,31 @@ type DerivedState = {
|
||||
isMicrophonePrompted: boolean
|
||||
}
|
||||
|
||||
type State = BaseState & DerivedState
|
||||
|
||||
export const permissionsStore = proxy<BaseState>({
|
||||
export const permissionsStore = proxy<State>({
|
||||
cameraPermission: undefined,
|
||||
microphonePermission: undefined,
|
||||
isLoading: true,
|
||||
isPermissionDialogOpen: false,
|
||||
requestOrigin: undefined,
|
||||
}) as State
|
||||
|
||||
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 isCameraGranted() {
|
||||
return this.cameraPermission === 'granted'
|
||||
},
|
||||
{
|
||||
proxy: permissionsStore,
|
||||
}
|
||||
)
|
||||
get isMicrophoneGranted() {
|
||||
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 = (
|
||||
requestOrigin?: 'audioinput' | 'videoinput'
|
||||
@@ -64,3 +52,84 @@ export const openPermissionsDialog = (
|
||||
export const closePermissionsDialog = () => {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user