🐛(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:
lebaudantoine
2026-08-07 20:14:26 +02:00
committed by aleb_the_flash
parent 5d50671b3c
commit c8a3ef6f61
7 changed files with 149 additions and 201 deletions
+1
View File
@@ -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
-10
View File
@@ -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",
-1
View File
@@ -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))
}
}, [])
}
+102 -33
View File
@@ -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()
}