From 5f1d59c7539c4f79dffd69982621a36b323146dc Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 10 Aug 2025 15:12:00 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20Safari=20permiss?= =?UTF-8?q?ion=20change=20detection=20with=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add polling mechanism to detect permission changes on Safari where permission change events are not reliably fired when users interact with system prompts. Implements 500ms polling when permissions are in 'prompt' state to catch grant/deny actions that Safari's event system misses. Polling stops when permissions resolve to prevent performance impact. Fixes UI inconsistency where Safari users' permission changes weren't detected, leaving outdated status displays. --- .../rooms/hooks/useWatchPermissions.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts b/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts index 5811059e..78e3c419 100644 --- a/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts +++ b/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts @@ -1,9 +1,13 @@ import { useEffect } from 'react' import { permissionsStore } from '@/stores/permissions' +import { isSafari } from '@/utils/livekit' + +const POLLING_TIME = 500 export const useWatchPermissions = () => { useEffect(() => { let cleanup: (() => void) | undefined + let intervalId: NodeJS.Timeout | undefined let isCancelled = false const checkPermissions = async () => { @@ -23,17 +27,103 @@ export const useWatchPermissions = () => { 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) { + console.error('Error polling permissions:', error) + } + } + }, 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) @@ -45,6 +135,10 @@ export const useWatchPermissions = () => { 'change', handleMicrophoneChange ) + if (intervalId) { + clearInterval(intervalId) + intervalId = undefined + } } } catch (error) { if (!isCancelled) {