From 879dbda961efb1be18a46c3a10bb3deebe6d5104 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 28 Sep 2026 15:25:34 +0700 Subject: [PATCH] fix: require viewer consent before unmuting on web and desktop --- .../renderer/hooks/useWebRTCViewerAPI.test.ts | 96 ++++++--- .../src/renderer/hooks/useWebRTCViewerAPI.ts | 34 +++- .../hooks/useWebRTCViewerSFUAPI.test.ts | 186 +++++++++++++++++- .../renderer/hooks/useWebRTCViewerSFUAPI.ts | 95 ++++++--- .../src/renderer/routes/viewer.test.tsx | 27 +++ apps/desktop/src/renderer/routes/viewer.tsx | 7 + apps/web/src/app/session/[id]/page.test.tsx | 17 ++ apps/web/src/app/session/[id]/page.tsx | 7 + apps/web/src/hooks/useWebRTC.test.ts | 65 ++++++ apps/web/src/hooks/useWebRTC.ts | 173 +++++++++------- apps/web/src/hooks/useWebRTCSFU.test.ts | 184 +++++++++++++++++ apps/web/src/hooks/useWebRTCSFU.ts | 97 ++++++--- docs/microphone-consent.md | 35 ++++ packages/shared-types/src/index.ts | 2 + packages/shared-types/src/microphone.test.ts | 176 +++++++++++++++++ packages/shared-types/src/microphone.ts | 63 ++++++ 16 files changed, 1107 insertions(+), 157 deletions(-) create mode 100644 docs/microphone-consent.md create mode 100644 packages/shared-types/src/microphone.test.ts create mode 100644 packages/shared-types/src/microphone.ts diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.test.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.test.ts index 83a56bd8..d53bf416 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.test.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.test.ts @@ -159,7 +159,8 @@ describe('useWebRTCViewerAPI', () => { * Returns { hookResult, es, pc }. */ async function initWithConnected( - options: Parameters[0] = defaultOptions + options: Parameters[0] = defaultOptions, + connected = connectedEventData ) { let hookResult: { current: ReturnType }; @@ -175,7 +176,7 @@ describe('useWebRTCViewerAPI', () => { // Emit the connected event to trigger PC creation act(() => { - es.emit('connected', connectedEventData); + es.emit('connected', connected); }); const pc = MockRTCPeerConnection.instances[0]; @@ -684,40 +685,73 @@ describe('useWebRTCViewerAPI', () => { expect(onKicked).toHaveBeenCalled(); }); - it('should handle mute message from data channel', async () => { - const { hookResult, pc } = await initWithConnected(); + it.each(['viewer-1', 'server-viewer'])( + 'handles mute using server subscriber identity %s', + async (subscriberId) => { + const { hookResult, pc } = await initWithConnected( + defaultOptions, + JSON.stringify({ subscriberId }) + ); - expect(hookResult.current.micEnabled).toBe(true); + expect(hookResult.current.micEnabled).toBe(true); - const mockChannel = { - readyState: 'open', - onopen: null as (() => void) | null, - onclose: null as (() => void) | null, - onerror: null as ((err: unknown) => void) | null, - onmessage: null as ((event: MessageEvent) => void) | null, - close: vi.fn(), - send: vi.fn(), - }; + const mockChannel = { + readyState: 'open', + onopen: null as (() => void) | null, + onclose: null as (() => void) | null, + onerror: null as ((err: unknown) => void) | null, + onmessage: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + send: vi.fn(), + }; - act(() => { - pc.ondatachannel?.({ channel: mockChannel }); - mockChannel.onopen?.(); - }); + act(() => { + pc.ondatachannel?.({ channel: mockChannel }); + mockChannel.onopen?.(); + }); - // Simulate mute message - act(() => { - mockChannel.onmessage?.({ - data: JSON.stringify({ - type: 'mute', - muted: true, - participantId: 'viewer-1', - timestamp: Date.now(), - }), - } as MessageEvent); - }); + act(() => + mockChannel.onmessage?.({ + data: JSON.stringify({ + type: 'mute', + muted: true, + participantId: 'unrelated-viewer', + }), + } as MessageEvent) + ); + expect(hookResult.current.micEnabled).toBe(true); - expect(hookResult.current.micEnabled).toBe(false); - }); + // Simulate mute message + act(() => { + mockChannel.onmessage?.({ + data: JSON.stringify({ + type: 'mute', + muted: true, + participantId: subscriberId, + timestamp: Date.now(), + }), + } as MessageEvent); + }); + + expect(hookResult.current.micEnabled).toBe(false); + for (const muted of [false, undefined, 'false']) { + act(() => + mockChannel.onmessage?.({ + data: JSON.stringify({ type: 'mute', muted, participantId: subscriberId }), + } as MessageEvent) + ); + expect(mockAudioTrack.enabled).toBe(false); + } + expect(hookResult.current.unmuteRequested).toBe(true); + await act(async () => { + hookResult.current.reconnect(); + }); + expect(mockAudioTrack.enabled).toBe(false); + expect(hookResult.current.unmuteRequested).toBe(false); + act(() => hookResult.current.toggleMic()); + expect(mockAudioTrack.enabled).toBe(true); + } + ); it('should send control request via data channel', async () => { const { hookResult, pc } = await initWithConnected(); diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts index ae6fa105..8fc07f8b 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts @@ -87,6 +87,7 @@ interface UseWebRTCViewerAPIReturn { releaseControl: () => void; sendInput: (event: InputEvent) => void; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; } @@ -109,6 +110,8 @@ export function useWebRTCViewerAPI({ const [dataChannelReady, setDataChannelReady] = useState(false); const [micEnabled, setMicEnabled] = useState(false); const [hasMic, setHasMic] = useState(false); + const [unmuteRequested, setUnmuteRequested] = useState(false); + const micIntentRef = useRef(true); const peerConnectionRef = useRef(null); const remoteStreamRef = useRef(null); @@ -232,13 +235,24 @@ export function useWebRTCViewerAPI({ onKickedRef.current?.(message.reason); break; case 'mute': { + if ( + typeof message.muted !== 'boolean' || + (message.participantId && message.participantId !== signalSenderIdRef.current) + ) + break; + if (!message.muted) { + if (!micIntentRef.current) setUnmuteRequested(true); + break; + } + micIntentRef.current = false; + setUnmuteRequested(false); const micStream = micStreamRef.current; if (micStream) { micStream.getAudioTracks().forEach((track) => { - track.enabled = !message.muted; + track.enabled = false; }); - setMicEnabled(!message.muted); } + setMicEnabled(false); break; } } @@ -825,6 +839,7 @@ export function useWebRTCViewerAPI({ // Disconnect and clean up const disconnect = useCallback(() => { + setUnmuteRequested(false); lifecycleRef.current++; hostPeerIdRef.current = null; pendingCandidatesRef.current = []; @@ -885,15 +900,17 @@ export function useWebRTCViewerAPI({ const micStream = micStreamRef.current; if (!micStream) return; - const tracks = micStream.getAudioTracks(); + const tracks = micStream.getAudioTracks().filter((track) => track.readyState !== 'ended'); if (tracks.length === 0) return; - const newEnabled = !micEnabled; + const newEnabled = !micIntentRef.current; + micIntentRef.current = newEnabled; + setUnmuteRequested(false); tracks.forEach((track) => { track.enabled = newEnabled; }); setMicEnabled(newEnabled); - }, [micEnabled]); + }, []); // Initialize connection const initialize = useCallback(async () => { @@ -931,13 +948,17 @@ export function useWebRTCViewerAPI({ return; } markTrackAsSpeech(micStream.getAudioTracks()[0]); + micStream.getAudioTracks().forEach((track) => { + track.enabled = micIntentRef.current; + }); micStreamRef.current = micStream; setHasMic(true); - setMicEnabled(true); + setMicEnabled(micIntentRef.current); console.log('[WebRTCViewer] Microphone captured'); } catch (err: unknown) { if (!current()) return; console.warn('[WebRTCViewer] Could not access microphone:', err); + micIntentRef.current = false; micStreamRef.current = null; setHasMic(false); setMicEnabled(false); @@ -1128,6 +1149,7 @@ export function useWebRTCViewerAPI({ releaseControl, sendInput, micEnabled, + unmuteRequested, hasMic, toggleMic, }; diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts index 78294086..07c4f907 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts @@ -667,6 +667,190 @@ describe('useWebRTCViewerSFUAPI', () => { expect(mockPublishData).toHaveBeenCalled(); }); + async function micViewer() { + mockRemoteParticipants.set('presenter', { + identity: 'presenter', + videoTrackPublications: new Map([['screen', { source: 'screen_share' }]]), + }); + const view = renderHook(() => + useWebRTCViewerSFUAPI({ sessionId: 'session-1', participantId: 'viewer-1' }) + ); + await act(async () => { + for (let i = 0; i < 12; i++) await Promise.resolve(); + }); + return view; + } + function muteMessage(muted: unknown, target = 'viewer-1', room = mockRoomInstance) { + room.emit( + 'dataReceived', + new TextEncoder().encode(JSON.stringify({ type: 'mute', muted, participantId: target })), + { identity: 'presenter' } + ); + } + + it('requires local consent, ignores invalid/foreign mute, and keeps mute on reconnect', async () => { + const { result } = await micViewer(); + expect(result.current.micEnabled).toBe(true); + await act(async () => { + muteMessage(true, 'someone-else'); + muteMessage(undefined); + muteMessage('false'); + }); + expect(result.current.micEnabled).toBe(true); + await act(async () => { + muteMessage(true); + }); + expect(result.current.micEnabled).toBe(false); + mockSetMicrophoneEnabled.mockClear(); + await act(async () => { + muteMessage(false); + muteMessage(false); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalled(); + expect(result.current.unmuteRequested).toBe(true); + const oldRoom = mockRoomInstance; + await act(async () => { + result.current.reconnect(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(false); + act(() => muteMessage(false, 'viewer-1', oldRoom)); + expect(result.current.unmuteRequested).toBe(false); + await act(async () => { + result.current.toggleMic(); + }); + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(true); + }); + + it('does not override a mute received while connecting', async () => { + let resolve!: () => void; + mockConnect.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const { result } = await micViewer(); + act(() => muteMessage(true)); + await act(async () => { + resolve(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(false); + }); + + it('does not acquire a microphone after unmount during connect', async () => { + let resolve!: () => void; + mockConnect.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const { unmount } = await micViewer(); + unmount(); + await act(async () => { + resolve(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('contains failed local enable without reporting the microphone as active', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + mockSetMicrophoneEnabled.mockRejectedValueOnce(new Error('permission denied')); + await act(async () => { + result.current.toggleMic(); + }); + expect(result.current.micEnabled).toBe(false); + expect(result.current.hasMic).toBe(false); + await act(async () => { + muteMessage(true); + }); + expect(result.current.hasMic).toBe(false); + }); + + it('only shows unmute requests from a current screen publisher', async () => { + const { result } = await micViewer(); + await act(async () => { + muteMessage(true); + }); + const payload = new TextEncoder().encode( + JSON.stringify({ type: 'mute', muted: false, participantId: 'viewer-1' }) + ); + mockRemoteParticipants.set('guest', { + identity: 'guest', + metadata: '{"role":"host"}', + videoTrackPublications: new Map(), + }); + act(() => { + mockRoomInstance.emit('dataReceived', payload); + mockRoomInstance.emit('dataReceived', payload, { identity: 'guest' }); + }); + expect(result.current.unmuteRequested).toBe(false); + act(() => { + muteMessage(false); + }); + expect(result.current.unmuteRequested).toBe(true); + expect(result.current.micEnabled).toBe(false); + }); + + it('does not report active audio when host mute races local permission', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + let resolve!: () => void; + mockSetMicrophoneEnabled.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + await act(async () => { + result.current.toggleMic(); + }); + await act(async () => { + muteMessage(true); + }); + await act(async () => { + resolve(); + }); + expect(result.current.micEnabled).toBe(false); + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(false); + }); + + it('reasserts mute on SDK reconnect and ignores old-room state events', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + mockSetMicrophoneEnabled.mockClear(); + await act(async () => { + mockRoomInstance.emit('connectionStateChanged', 'reconnecting'); + mockRoomInstance.emit('connectionStateChanged', 'connected'); + }); + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(false); + expect(result.current.micEnabled).toBe(false); + const oldRoom = mockRoomInstance; + await act(async () => { + result.current.reconnect(); + }); + act(() => { + mockRoomInstance.emit('connectionStateChanged', 'connected'); + }); + await act(async () => { + oldRoom.emit('connectionStateChanged', 'disconnected'); + oldRoom.emit('disconnected'); + }); + expect(result.current.connectionState).toBe('connected'); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + }); + it('should toggle mic via LiveKit', async () => { let hookResult: { current: ReturnType }; @@ -681,7 +865,7 @@ describe('useWebRTCViewerSFUAPI', () => { expect(hookResult!.current.micEnabled).toBe(true); - act(() => { + await act(async () => { hookResult!.current.toggleMic(); }); diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts index 2c671e69..5e794be2 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts @@ -6,6 +6,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; +import { MicrophoneController } from '@pairux/shared-types'; import { Room, RoomEvent, @@ -58,6 +59,7 @@ interface UseWebRTCViewerSFUAPIReturn { releaseControl: () => void; sendInput: (event: InputEvent) => void; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; /** @@ -116,6 +118,10 @@ export function useWebRTCViewerSFUAPI({ const [dataChannelReady, setDataChannelReady] = useState(false); const [micEnabled, setMicEnabled] = useState(false); const [hasMic, setHasMic] = useState(false); + const [unmuteRequested, setUnmuteRequested] = useState(false); + const micIntentRef = useRef(true); + const micControllerRef = useRef(null); + const lifecycleRef = useRef(0); const roomRef = useRef(null); const inputSequenceRef = useRef(0); @@ -322,14 +328,25 @@ export function useWebRTCViewerSFUAPI({ onKickedRef.current?.(message.reason); break; case 'mute': { - const room = roomRef.current; - if (room) { - const micPub = room.localParticipant.getTrackPublication(Track.Source.Microphone); - if (micPub?.track) { - void room.localParticipant.setMicrophoneEnabled(!message.muted); - setMicEnabled(!message.muted); - } + if (typeof message.muted !== 'boolean' || message.participantId !== participantId) + break; + if (!message.muted) { + // Limit requests to a current screen publisher; never grant capture remotely. + const publisher = + sender && roomRef.current?.remoteParticipants.get(sender.identity); + if ( + !publisher || + !Array.from(publisher.videoTrackPublications.values()).some( + (publication) => publication.source === Track.Source.ScreenShare + ) + ) + break; + if (!micIntentRef.current) setUnmuteRequested(true); + break; } + micIntentRef.current = false; + setUnmuteRequested(false); + void micControllerRef.current?.setEnabled(false); break; } } @@ -459,6 +476,10 @@ export function useWebRTCViewerSFUAPI({ // Disconnect const disconnect = useCallback(() => { + lifecycleRef.current++; + micControllerRef.current?.dispose(); + micControllerRef.current = null; + setUnmuteRequested(false); if (statsIntervalRef.current) { clearInterval(statsIntervalRef.current); statsIntervalRef.current = null; @@ -487,22 +508,23 @@ export function useWebRTCViewerSFUAPI({ // Toggle mic const toggleMic = useCallback(() => { - const room = roomRef.current; - if (!room) return; - - const newEnabled = !micEnabled; - void room.localParticipant.setMicrophoneEnabled(newEnabled); - setMicEnabled(newEnabled); - }, [micEnabled]); + if (!micControllerRef.current) return; + micIntentRef.current = !micIntentRef.current; + setUnmuteRequested(false); + void micControllerRef.current.setEnabled(micIntentRef.current); + }, []); // Initialize: fetch token and connect const initialize = useCallback(async () => { + const generation = ++lifecycleRef.current; + const current = () => lifecycleRef.current === generation; try { setConnectionState('connecting'); // Get auth token from Electron const api = getElectronAPI(); const { token: authToken } = await api.invoke('auth:getToken', undefined); + if (!current()) return; if (!authToken) { setError('Authentication required'); setConnectionState('failed'); @@ -532,6 +554,7 @@ export function useWebRTCViewerSFUAPI({ const { data } = (await tokenRes.json()) as { data: { token: string; url: string; roomName: string; iceServers?: RTCIceServer[] }; }; + if (!current()) return; // Create and connect room. // adaptiveStream pauses video tracks that livekit doesn't see attached to @@ -552,6 +575,7 @@ export function useWebRTCViewerSFUAPI({ room.on( RoomEvent.TrackSubscribed, (track, _publication: RemoteTrackPublication, _participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; const mediaTrack = track.mediaStreamTrack; // Audio plays through its own element, one per participant, so that @@ -583,6 +607,7 @@ export function useWebRTCViewerSFUAPI({ // Track unsubscribed room.on(RoomEvent.TrackUnsubscribed, (track) => { + if (!current() || roomRef.current !== room) return; const mediaTrack = track.mediaStreamTrack; if (track.kind === Track.Kind.Audio) { @@ -614,8 +639,10 @@ export function useWebRTCViewerSFUAPI({ // Connection state changes room.on(RoomEvent.ConnectionStateChanged, (state: LKConnectionState) => { + if (!current() || roomRef.current !== room) return; setConnectionState(mapConnectionState(state)); if (state === LKConnectionState.Connected) { + void micControllerRef.current?.setEnabled(micIntentRef.current); setError(null); setDataChannelReady(true); } else if (state === LKConnectionState.Disconnected) { @@ -624,10 +651,13 @@ export function useWebRTCViewerSFUAPI({ }); // Data messages - room.on(RoomEvent.DataReceived, handleDataReceived); + room.on(RoomEvent.DataReceived, (payload, participant) => { + if (current() && roomRef.current === room) handleDataReceived(payload, participant); + }); // Detect host disconnect/reconnect room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; onPresenceChangeRef.current?.(); try { const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string }; @@ -640,6 +670,7 @@ export function useWebRTCViewerSFUAPI({ }); room.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; onPresenceChangeRef.current?.(); try { const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string }; @@ -652,6 +683,7 @@ export function useWebRTCViewerSFUAPI({ }); room.on(RoomEvent.Disconnected, () => { + if (!current() || roomRef.current !== room) return; setConnectionState('disconnected'); setDataChannelReady(false); }); @@ -664,15 +696,30 @@ export function useWebRTCViewerSFUAPI({ rtcConfig: buildSfuRtcConfig(data.iceServers), }); - // Enable mic after connecting - try { - await room.localParticipant.setMicrophoneEnabled(true); - setHasMic(true); - setMicEnabled(true); - } catch { - setHasMic(false); - setMicEnabled(false); + if (!current()) { + void room.disconnect(); + return; } + const mic = () => room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track; + const controller = new MicrophoneController({ + setEnabled: (enabled) => room.localParticipant.setMicrophoneEnabled(enabled), + silence: () => { + const track = mic(); + if (track) track.mediaStreamTrack.enabled = false; + }, + stop: () => { + mic()?.stop(); + }, + changed: (enabled, available) => { + if (!current()) return; + if (!available) micIntentRef.current = false; + setHasMic(available); + setMicEnabled(enabled); + }, + }); + micControllerRef.current = controller; + await controller.setEnabled(micIntentRef.current); + if (!current()) return; // Open the tailnet handshake now the room can carry data. Fire and // forget: the host draws the conclusion, and nothing here depends on it. @@ -683,6 +730,7 @@ export function useWebRTCViewerSFUAPI({ // Start stats collection statsIntervalRef.current = setInterval(() => void collectStats(), 2000); } catch (err) { + if (!current()) return; console.error('[WebRTCViewerSFU] Connection failed:', err); setConnectionState('failed'); setError(err instanceof Error ? err.message : 'Failed to connect'); @@ -725,6 +773,7 @@ export function useWebRTCViewerSFUAPI({ releaseControl, sendInput, micEnabled, + unmuteRequested, hasMic, toggleMic, setSpeakerMuted, diff --git a/apps/desktop/src/renderer/routes/viewer.test.tsx b/apps/desktop/src/renderer/routes/viewer.test.tsx index 5e391219..dfeb9e5f 100644 --- a/apps/desktop/src/renderer/routes/viewer.test.tsx +++ b/apps/desktop/src/renderer/routes/viewer.test.tsx @@ -42,6 +42,7 @@ const mockP2PHookResult: { requestControl: ReturnType; releaseControl: ReturnType; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: ReturnType; qualityMetrics: null; @@ -58,6 +59,7 @@ const mockP2PHookResult: { requestControl: vi.fn(), releaseControl: vi.fn(), micEnabled: true, + unmuteRequested: false, hasMic: true, toggleMic: vi.fn(), qualityMetrics: null, @@ -177,6 +179,9 @@ describe('ViewerPage', () => { mockP2PHookResult.remoteStream = null; mockP2PHookResult.error = null; mockP2PHookResult.micEnabled = true; + mockP2PHookResult.unmuteRequested = false; + mockSFUHookResult.unmuteRequested = false; + mockSFUHookResult.micEnabled = true; mockP2PHookResult.hasMic = true; // Reset disconnect and toggleMic mocks mockP2PHookResult.disconnect = vi.fn(); @@ -238,6 +243,28 @@ describe('ViewerPage', () => { expect(screen.getByTestId('video-viewer')).toBeInTheDocument(); }); + it.each(['p2p', 'sfu'] as const)( + 'shows a passive microphone request in %s mode', + async (mode) => { + mockInvoke.mockResolvedValue({ + success: true, + session: makeSession({ mode }), + participants: makeParticipants(), + }); + const hook = mode === 'p2p' ? mockP2PHookResult : mockSFUHookResult; + hook.micEnabled = false; + hook.unmuteRequested = true; + renderWithRouter(); + expect(await screen.findByText('Microphone requested. You are still muted.')).toHaveAttribute( + 'role', + 'status' + ); + expect(hook.toggleMic).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /Mic Off/ })); + expect(hook.toggleMic).toHaveBeenCalledOnce(); + } + ); + it('should render SFU viewer for sfu session', async () => { mockInvoke.mockResolvedValue({ success: true, diff --git a/apps/desktop/src/renderer/routes/viewer.tsx b/apps/desktop/src/renderer/routes/viewer.tsx index 8799593d..8d252207 100644 --- a/apps/desktop/src/renderer/routes/viewer.tsx +++ b/apps/desktop/src/renderer/routes/viewer.tsx @@ -43,6 +43,7 @@ interface ViewerHookResult { releaseControl: () => void; sendInput: (event: InputEvent) => void; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; /** @@ -315,6 +316,7 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont releaseControl, sendInput, micEnabled, + unmuteRequested, hasMic, toggleMic, setSpeakerMuted: setTransportSpeakerMuted, @@ -438,6 +440,11 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont + {unmuteRequested && !micEnabled && hasMic && ( +
+ Microphone requested. You are still muted. +
+ )} {/* WebRTC error message */} {webrtcError && connectionState !== 'failed' && connectionState !== 'disconnected' && (
diff --git a/apps/web/src/app/session/[id]/page.test.tsx b/apps/web/src/app/session/[id]/page.test.tsx index db8f3b14..42fdd389 100644 --- a/apps/web/src/app/session/[id]/page.test.tsx +++ b/apps/web/src/app/session/[id]/page.test.tsx @@ -21,6 +21,7 @@ const mockUseWebRTC = { sendInput: vi.fn(), // Microphone micEnabled: false, + unmuteRequested: false, hasMic: true, toggleMic: mockToggleMic, }; @@ -81,6 +82,7 @@ describe('SessionViewerPage', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(global.fetch).mockReset(); + mockUseWebRTC.unmuteRequested = false; }); describe('Loading state', () => { @@ -164,6 +166,21 @@ describe('SessionViewerPage', () => { }); }); + it('shows a passive unmute request and leaves enabling to the local button', async () => { + mockUseWebRTC.unmuteRequested = true; + const params = createResolvedParams('session-123'); + await act(async () => { + renderWithSuspense(); + }); + expect(await screen.findByText('Microphone requested. You are still muted.')).toHaveAttribute( + 'role', + 'status' + ); + expect(mockToggleMic).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /Mic Off/ })); + expect(mockToggleMic).toHaveBeenCalledOnce(); + }); + it('shows connection status badge', async () => { const params = createResolvedParams('session-123'); diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index 354a0bc2..ad5b6e7a 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -212,6 +212,7 @@ interface SessionViewerContentProps { releaseControl: () => void; sendInput: (event: InputEvent) => void; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; } @@ -232,6 +233,7 @@ function SessionViewerContent({ releaseControl, sendInput, micEnabled, + unmuteRequested, hasMic, toggleMic, }: SessionViewerContentProps) { @@ -334,6 +336,11 @@ function SessionViewerContent({
{/* Control bar */} + {unmuteRequested && !micEnabled && hasMic && ( +
+ Microphone requested. You are still muted. +
+ )}
{allowControl && ( { vi.restoreAllMocks(); }); + it('keeps remote unmute inert until local consent and preserves mute on reconnect', async () => { + const stream = createMockMicStream(); + mockGetUserMedia.mockResolvedValue(stream); + mockChannel.subscribe.mockImplementation((callback) => { + callback('SUBSCRIBED'); + return mockChannel; + }); + const { result } = renderHook(() => useWebRTC({ sessionId: 's', participantId: 'v' })); + await act(async () => { + await Promise.resolve(); + }); + const dc = { onmessage: null as ((e: MessageEvent) => void) | null, close: vi.fn() }; + act(() => { + MockRTCPeerConnection.instances[0]!.ondatachannel?.({ channel: dc }); + result.current.toggleMic(); + }); + for (const muted of [false, undefined, 'false']) { + act(() => dc.onmessage?.({ data: JSON.stringify({ type: 'mute', muted }) } as MessageEvent)); + expect(stream._audioTrack.enabled).toBe(false); + expect(result.current.micEnabled).toBe(false); + } + await act(async () => { + result.current.reconnect(); + }); + expect(stream._audioTrack.enabled).toBe(false); + act(() => result.current.toggleMic()); + expect(stream._audioTrack.enabled).toBe(true); + }); + + it('stops permission-granted audio arriving after unmount', async () => { + let resolve!: (stream: MediaStream) => void; + mockGetUserMedia.mockReturnValue( + new Promise((r) => { + resolve = r; + }) + ); + const { unmount } = renderHook(() => useWebRTC({ sessionId: 's', participantId: 'v' })); + unmount(); + const stream = createMockMicStream(); + await act(async () => { + resolve(stream); + }); + expect(stream._audioTrack.stop).toHaveBeenCalledOnce(); + expect(mockChannel.subscribe).not.toHaveBeenCalled(); + }); + it('does not recapture audio when consumer callback identities change', async () => { + mockGetUserMedia.mockResolvedValue(createMockMicStream()); + const { rerender } = renderHook(() => + useWebRTC({ + sessionId: 's', + participantId: 'v', + onStreamReady: () => {}, + onStreamEnded: () => {}, + }) + ); + await act(async () => { + await Promise.resolve(); + }); + rerender(); + await act(async () => { + await Promise.resolve(); + }); + expect(mockGetUserMedia).toHaveBeenCalledOnce(); + }); + const defaultOptions = { sessionId: 'session-1', participantId: 'viewer-1', diff --git a/apps/web/src/hooks/useWebRTC.ts b/apps/web/src/hooks/useWebRTC.ts index 12d4cc6a..c5923284 100644 --- a/apps/web/src/hooks/useWebRTC.ts +++ b/apps/web/src/hooks/useWebRTC.ts @@ -65,6 +65,7 @@ interface UseWebRTCReturn { sendInput: (event: InputEvent) => void; // Microphone micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; } @@ -87,6 +88,13 @@ export function useWebRTC({ const [dataChannelReady, setDataChannelReady] = useState(false); const [micEnabled, setMicEnabled] = useState(false); const [hasMic, setHasMic] = useState(false); + const [unmuteRequested, setUnmuteRequested] = useState(false); + const micIntentRef = useRef(true); + const lifecycleRef = useRef(0); + const onStreamReadyRef = useRef(onStreamReady); + const onStreamEndedRef = useRef(onStreamEnded); + onStreamReadyRef.current = onStreamReady; + onStreamEndedRef.current = onStreamEnded; const peerConnectionRef = useRef(null); const remoteStreamRef = useRef(null); @@ -159,43 +167,56 @@ export function useWebRTC({ }, []); // Handle incoming data channel messages - const handleDataChannelMessage = useCallback((event: MessageEvent) => { - try { - const message = JSON.parse(event.data) as ControlMessage | KickMessage | MuteMessage; - - if ('type' in message) { - switch (message.type) { - case 'control-grant': - setControlState('granted'); - onControlStateChangeRef.current?.('granted'); - break; - case 'control-revoke': - setControlState('view-only'); - onControlStateChangeRef.current?.('view-only'); - break; - case 'kick': - // Host kicked this viewer - setError('You were removed from the session'); - disconnectRef.current?.(); - onKickedRef.current?.(message.reason); - break; - case 'mute': { - // Host force-muted/unmuted this viewer's mic - const micStream = micStreamRef.current; - if (micStream) { - micStream.getAudioTracks().forEach((track) => { - track.enabled = !message.muted; - }); - setMicEnabled(!message.muted); + const handleDataChannelMessage = useCallback( + (event: MessageEvent) => { + try { + const message = JSON.parse(event.data) as ControlMessage | KickMessage | MuteMessage; + + if ('type' in message) { + switch (message.type) { + case 'control-grant': + setControlState('granted'); + onControlStateChangeRef.current?.('granted'); + break; + case 'control-revoke': + setControlState('view-only'); + onControlStateChangeRef.current?.('view-only'); + break; + case 'kick': + // Host kicked this viewer + setError('You were removed from the session'); + disconnectRef.current?.(); + onKickedRef.current?.(message.reason); + break; + case 'mute': { + if ( + typeof message.muted !== 'boolean' || + (message.participantId && message.participantId !== participantId) + ) + break; + if (!message.muted) { + if (!micIntentRef.current) setUnmuteRequested(true); + break; + } + micIntentRef.current = false; + setUnmuteRequested(false); + const micStream = micStreamRef.current; + if (micStream) { + micStream.getAudioTracks().forEach((track) => { + track.enabled = false; + }); + } + setMicEnabled(false); + break; } - break; } } + } catch { + // Invalid message format - ignore } - } catch { - // Invalid message format - ignore - } - }, []); + }, + [participantId] + ); // Setup data channel const setupDataChannel = useCallback( @@ -474,34 +495,31 @@ export function useWebRTC({ // Keep ref updated handleConnectionFailureRef.current = handleConnectionFailure; - const removeRemoteTrack = useCallback( - (trackId: string) => { - const cleanup = remoteTrackCleanupRef.current.get(trackId); - remoteTrackCleanupRef.current.delete(trackId); - cleanup?.(); + const removeRemoteTrack = useCallback((trackId: string) => { + const cleanup = remoteTrackCleanupRef.current.get(trackId); + remoteTrackCleanupRef.current.delete(trackId); + cleanup?.(); - const activeStream = remoteStreamRef.current; - if (!activeStream) return; + const activeStream = remoteStreamRef.current; + if (!activeStream) return; - const remainingTracks = activeStream - .getTracks() - .filter((track) => track.id !== trackId && track.readyState !== 'ended'); - if (remainingTracks.length === activeStream.getTracks().length) return; + const remainingTracks = activeStream + .getTracks() + .filter((track) => track.id !== trackId && track.readyState !== 'ended'); + if (remainingTracks.length === activeStream.getTracks().length) return; - if (remainingTracks.length === 0) { - remoteStreamRef.current = null; - setRemoteStream(null); - onStreamEnded?.(); - return; - } + if (remainingTracks.length === 0) { + remoteStreamRef.current = null; + setRemoteStream(null); + onStreamEndedRef.current?.(); + return; + } - const updatedStream = new MediaStream(remainingTracks); - remoteStreamRef.current = updatedStream; - setRemoteStream(updatedStream); - onStreamReady?.(updatedStream); - }, - [onStreamEnded, onStreamReady] - ); + const updatedStream = new MediaStream(remainingTracks); + remoteStreamRef.current = updatedStream; + setRemoteStream(updatedStream); + onStreamReadyRef.current?.(updatedStream); + }, []); // Create and configure peer connection const createPeerConnection = useCallback(() => { @@ -574,7 +592,7 @@ export function useWebRTC({ const mergedStream = new MediaStream(composite.getTracks()); remoteStreamRef.current = mergedStream; setRemoteStream(mergedStream); - onStreamReady?.(mergedStream); + onStreamReadyRef.current?.(mergedStream); }; // Handle ICE candidates @@ -633,7 +651,7 @@ export function useWebRTC({ }); remoteTrackCleanupRef.current.clear(); setRemoteStream(null); - onStreamEnded?.(); + onStreamEndedRef.current?.(); break; } }; @@ -654,17 +672,12 @@ export function useWebRTC({ }; return pc; - }, [ - participantId, - onStreamReady, - onStreamEnded, - removeRemoteTrack, - setupDataChannel, - sendSignal, - ]); + }, [participantId, removeRemoteTrack, setupDataChannel, sendSignal]); // Disconnect const disconnect = useCallback(() => { + lifecycleRef.current++; + setUnmuteRequested(false); // Stop stats collection if (statsIntervalRef.current) { clearInterval(statsIntervalRef.current); @@ -721,18 +734,22 @@ export function useWebRTC({ const micStream = micStreamRef.current; if (!micStream) return; - const tracks = micStream.getAudioTracks(); + const tracks = micStream.getAudioTracks().filter((track) => track.readyState !== 'ended'); if (tracks.length === 0) return; - const newEnabled = !micEnabled; + const newEnabled = !micIntentRef.current; + micIntentRef.current = newEnabled; + setUnmuteRequested(false); tracks.forEach((track) => { track.enabled = newEnabled; }); setMicEnabled(newEnabled); - }, [micEnabled]); + }, []); // Initialize connection const initialize = useCallback(async () => { + const generation = ++lifecycleRef.current; + const current = () => lifecycleRef.current === generation; const supabase = createClient(); // Capture microphone BEFORE setting up the peer connection @@ -742,13 +759,24 @@ export function useWebRTC({ audio: VOICE_AUDIO_CONSTRAINTS, video: false, }); + if (!current()) { + micStream.getTracks().forEach((track) => { + track.stop(); + }); + return; + } + micStream.getAudioTracks().forEach((track) => { + track.enabled = micIntentRef.current; + }); markTrackAsSpeech(micStream.getAudioTracks()[0]); micStreamRef.current = micStream; setHasMic(true); - setMicEnabled(true); + setMicEnabled(micIntentRef.current); console.log('[WebRTC] Microphone captured for viewer audio'); } catch (err: unknown) { + if (!current()) return; console.warn('[WebRTC] Could not access microphone, joining without audio:', err); + micIntentRef.current = false; micStreamRef.current = null; setHasMic(false); setMicEnabled(false); @@ -767,6 +795,7 @@ export function useWebRTC({ // Subscribe to signaling events channel .on('broadcast', { event: 'signal' }, ({ payload }) => { + if (!current() || channelRef.current !== channel) return; const message = payload as SignalMessage; // Only process messages not from self if (message.senderId !== participantId) { @@ -774,6 +803,7 @@ export function useWebRTC({ } }) .subscribe((status: string) => { + if (!current() || channelRef.current !== channel) return; if (status === 'SUBSCRIBED') { setConnectionState('connecting'); @@ -834,6 +864,7 @@ export function useWebRTC({ sendInput, // Microphone micEnabled, + unmuteRequested, hasMic, toggleMic, }; diff --git a/apps/web/src/hooks/useWebRTCSFU.test.ts b/apps/web/src/hooks/useWebRTCSFU.test.ts index d782df9e..9d592647 100644 --- a/apps/web/src/hooks/useWebRTCSFU.test.ts +++ b/apps/web/src/hooks/useWebRTCSFU.test.ts @@ -118,6 +118,190 @@ describe('useWebRTCSFU', () => { }); }); + async function micViewer() { + mockRemoteParticipants.set('presenter', { + identity: 'presenter', + videoTrackPublications: new Map([['screen', { source: 'screen_share' }]]), + }); + const view = renderHook(() => + useWebRTCSFU({ sessionId: 'session-1', participantId: 'viewer-1' }) + ); + await act(async () => { + for (let i = 0; i < 12; i++) await Promise.resolve(); + }); + return view; + } + function muteMessage(muted: unknown, target = 'viewer-1', room = mockRoomInstance) { + room.emit( + 'dataReceived', + new TextEncoder().encode(JSON.stringify({ type: 'mute', muted, participantId: target })), + { identity: 'presenter' } + ); + } + + it('requires local consent, ignores invalid/foreign mute, and keeps mute on reconnect', async () => { + const { result } = await micViewer(); + expect(result.current.micEnabled).toBe(true); + await act(async () => { + muteMessage(true, 'someone-else'); + muteMessage(undefined); + muteMessage('false'); + }); + expect(result.current.micEnabled).toBe(true); + await act(async () => { + muteMessage(true); + }); + expect(result.current.micEnabled).toBe(false); + mockSetMicrophoneEnabled.mockClear(); + await act(async () => { + muteMessage(false); + muteMessage(false); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalled(); + expect(result.current.unmuteRequested).toBe(true); + const oldRoom = mockRoomInstance; + await act(async () => { + result.current.reconnect(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(false); + act(() => muteMessage(false, 'viewer-1', oldRoom)); + expect(result.current.unmuteRequested).toBe(false); + await act(async () => { + result.current.toggleMic(); + }); + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(true); + }); + + it('does not override a mute received while connecting', async () => { + let resolve!: () => void; + mockConnect.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const { result } = await micViewer(); + act(() => muteMessage(true)); + await act(async () => { + resolve(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + expect(result.current.micEnabled).toBe(false); + }); + + it('does not acquire a microphone after unmount during connect', async () => { + let resolve!: () => void; + mockConnect.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const { unmount } = await micViewer(); + unmount(); + await act(async () => { + resolve(); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('contains failed local enable without reporting the microphone as active', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + mockSetMicrophoneEnabled.mockRejectedValueOnce(new Error('permission denied')); + await act(async () => { + result.current.toggleMic(); + }); + expect(result.current.micEnabled).toBe(false); + expect(result.current.hasMic).toBe(false); + await act(async () => { + muteMessage(true); + }); + expect(result.current.hasMic).toBe(false); + }); + + it('only shows unmute requests from a current screen publisher', async () => { + const { result } = await micViewer(); + await act(async () => { + muteMessage(true); + }); + const payload = new TextEncoder().encode( + JSON.stringify({ type: 'mute', muted: false, participantId: 'viewer-1' }) + ); + mockRemoteParticipants.set('guest', { + identity: 'guest', + metadata: '{"role":"host"}', + videoTrackPublications: new Map(), + }); + act(() => { + mockRoomInstance.emit('dataReceived', payload); + mockRoomInstance.emit('dataReceived', payload, { identity: 'guest' }); + }); + expect(result.current.unmuteRequested).toBe(false); + act(() => { + muteMessage(false); + }); + expect(result.current.unmuteRequested).toBe(true); + expect(result.current.micEnabled).toBe(false); + }); + + it('does not report active audio when host mute races local permission', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + let resolve!: () => void; + mockSetMicrophoneEnabled.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + await act(async () => { + result.current.toggleMic(); + }); + await act(async () => { + muteMessage(true); + }); + await act(async () => { + resolve(); + }); + expect(result.current.micEnabled).toBe(false); + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(false); + }); + + it('reasserts mute on SDK reconnect and ignores old-room state events', async () => { + const { result } = await micViewer(); + await act(async () => { + result.current.toggleMic(); + }); + mockSetMicrophoneEnabled.mockClear(); + await act(async () => { + mockRoomInstance.emit('connectionStateChanged', 'reconnecting'); + mockRoomInstance.emit('connectionStateChanged', 'connected'); + }); + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(false); + expect(result.current.micEnabled).toBe(false); + const oldRoom = mockRoomInstance; + await act(async () => { + result.current.reconnect(); + }); + act(() => { + mockRoomInstance.emit('connectionStateChanged', 'connected'); + }); + await act(async () => { + oldRoom.emit('connectionStateChanged', 'disconnected'); + oldRoom.emit('disconnected'); + }); + expect(result.current.connectionState).toBe('connected'); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalledWith(true); + }); + it('merges subscribed audio and video tracks into one remote stream and preserves video on audio unsubscribe', async () => { let hookResult: { current: ReturnType }; diff --git a/apps/web/src/hooks/useWebRTCSFU.ts b/apps/web/src/hooks/useWebRTCSFU.ts index 58a13b87..3a5a8908 100644 --- a/apps/web/src/hooks/useWebRTCSFU.ts +++ b/apps/web/src/hooks/useWebRTCSFU.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { MicrophoneController } from '@pairux/shared-types'; import { Room, RoomEvent, @@ -47,6 +48,7 @@ interface UseWebRTCSFUReturn { releaseControl: () => void; sendInput: (event: InputEvent) => void; micEnabled: boolean; + unmuteRequested: boolean; hasMic: boolean; toggleMic: () => void; } @@ -83,6 +85,10 @@ export function useWebRTCSFU({ const [dataChannelReady, setDataChannelReady] = useState(false); const [micEnabled, setMicEnabled] = useState(false); const [hasMic, setHasMic] = useState(false); + const [unmuteRequested, setUnmuteRequested] = useState(false); + const micIntentRef = useRef(true); + const micControllerRef = useRef(null); + const lifecycleRef = useRef(0); const roomRef = useRef(null); const inputSequenceRef = useRef(0); @@ -102,7 +108,7 @@ export function useWebRTCSFU({ // Handle incoming data messages from LiveKit const handleDataReceived = useCallback( - (payload: Uint8Array, _participant?: RemoteParticipant) => { + (payload: Uint8Array, sender?: RemoteParticipant) => { try { const text = decoder.decode(payload); const message = JSON.parse(text) as ControlMessage | KickMessage | MuteMessage; @@ -123,14 +129,25 @@ export function useWebRTCSFU({ onKickedRef.current?.(message.reason); break; case 'mute': { - const room = roomRef.current; - if (room) { - const micPub = room.localParticipant.getTrackPublication(Track.Source.Microphone); - if (micPub?.track) { - void room.localParticipant.setMicrophoneEnabled(!message.muted); - setMicEnabled(!message.muted); - } + if (typeof message.muted !== 'boolean' || message.participantId !== participantId) + break; + if (!message.muted) { + // Limit requests to a current screen publisher; never grant capture remotely. + const publisher = + sender && roomRef.current?.remoteParticipants.get(sender.identity); + if ( + !publisher || + !Array.from(publisher.videoTrackPublications.values()).some( + (publication) => publication.source === Track.Source.ScreenShare + ) + ) + break; + if (!micIntentRef.current) setUnmuteRequested(true); + break; } + micIntentRef.current = false; + setUnmuteRequested(false); + void micControllerRef.current?.setEnabled(false); break; } } @@ -270,6 +287,10 @@ export function useWebRTCSFU({ // Disconnect from room const disconnect = useCallback(() => { + lifecycleRef.current++; + micControllerRef.current?.dispose(); + micControllerRef.current = null; + setUnmuteRequested(false); if (statsIntervalRef.current) { clearInterval(statsIntervalRef.current); statsIntervalRef.current = null; @@ -294,16 +315,16 @@ export function useWebRTCSFU({ // Toggle microphone const toggleMic = useCallback(() => { - const room = roomRef.current; - if (!room) return; - - const newEnabled = !micEnabled; - void room.localParticipant.setMicrophoneEnabled(newEnabled); - setMicEnabled(newEnabled); - }, [micEnabled]); + if (!micControllerRef.current) return; + micIntentRef.current = !micIntentRef.current; + setUnmuteRequested(false); + void micControllerRef.current.setEnabled(micIntentRef.current); + }, []); // Initialize: fetch token and connect to LiveKit room const initialize = useCallback(async () => { + const generation = ++lifecycleRef.current; + const current = () => lifecycleRef.current === generation; try { setConnectionState('connecting'); @@ -327,6 +348,7 @@ export function useWebRTCSFU({ const { data } = (await tokenRes.json()) as { data: { token: string; url: string; roomName: string }; }; + if (!current()) return; // Create and connect room. // adaptiveStream pauses video tracks that livekit doesn't see attached to @@ -346,6 +368,7 @@ export function useWebRTCSFU({ room.on( RoomEvent.TrackSubscribed, (track, _publication: RemoteTrackPublication, _participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) { const mediaTrack = track.mediaStreamTrack; const existingTracks = remoteMediaStreamRef.current?.getTracks() ?? []; @@ -370,6 +393,7 @@ export function useWebRTCSFU({ // Track unsubscribed - host disconnected or stopped sharing. room.on(RoomEvent.TrackUnsubscribed, (track) => { + if (!current() || roomRef.current !== room) return; if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) { const prev = remoteMediaStreamRef.current; if (!prev) return; @@ -395,8 +419,10 @@ export function useWebRTCSFU({ // Connection state changes room.on(RoomEvent.ConnectionStateChanged, (state: LKConnectionState) => { + if (!current() || roomRef.current !== room) return; setConnectionState(mapConnectionState(state)); if (state === LKConnectionState.Connected) { + void micControllerRef.current?.setEnabled(micIntentRef.current); setError(null); setDataChannelReady(true); } else if (state === LKConnectionState.Disconnected) { @@ -405,10 +431,13 @@ export function useWebRTCSFU({ }); // Data messages - room.on(RoomEvent.DataReceived, handleDataReceived); + room.on(RoomEvent.DataReceived, (payload, participant) => { + if (current() && roomRef.current === room) handleDataReceived(payload, participant); + }); // Participant disconnected (detect host leaving) room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; // Check if it was the host by metadata try { const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string }; @@ -424,6 +453,7 @@ export function useWebRTCSFU({ // Host reconnected room.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => { + if (!current() || roomRef.current !== room) return; try { const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string }; if (meta.role === 'host') { @@ -436,26 +466,42 @@ export function useWebRTCSFU({ // Connection error room.on(RoomEvent.Disconnected, () => { + if (!current() || roomRef.current !== room) return; setConnectionState('disconnected'); setDataChannelReady(false); }); // Connect to LiveKit await room.connect(data.url || LIVEKIT_URL, data.token); - - // Capture mic after connecting - try { - await room.localParticipant.setMicrophoneEnabled(true); - setHasMic(true); - setMicEnabled(true); - } catch { - setHasMic(false); - setMicEnabled(false); + if (!current()) { + void room.disconnect(); + return; } + const mic = () => room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track; + const controller = new MicrophoneController({ + setEnabled: (enabled) => room.localParticipant.setMicrophoneEnabled(enabled), + silence: () => { + const track = mic(); + if (track) track.mediaStreamTrack.enabled = false; + }, + stop: () => { + mic()?.stop(); + }, + changed: (enabled, available) => { + if (!current()) return; + if (!available) micIntentRef.current = false; + setHasMic(available); + setMicEnabled(enabled); + }, + }); + micControllerRef.current = controller; + await controller.setEnabled(micIntentRef.current); + if (!current()) return; // Start stats collection statsIntervalRef.current = setInterval(() => void collectStats(), 2000); } catch (err) { + if (!current()) return; console.error('[WebRTC SFU] Connection failed:', err); setConnectionState('failed'); setError(err instanceof Error ? err.message : 'Failed to connect'); @@ -490,6 +536,7 @@ export function useWebRTCSFU({ releaseControl, sendInput, micEnabled, + unmuteRequested, hasMic, toggleMic, }; diff --git a/docs/microphone-consent.md b/docs/microphone-consent.md new file mode 100644 index 00000000..9da006ac --- /dev/null +++ b/docs/microphone-consent.md @@ -0,0 +1,35 @@ +# Viewer microphone consent + +Web and desktop P2P/SFU viewers treat a valid remote unmute as a request, never +as permission to enable microphone audio. The viewer uses the existing local +microphone button to accept. Repeated requests share one passive status notice. +SFU requests must be addressed to this viewer and come from a current screen +publisher. Missing or non-boolean mute values are ignored. + +Mute intent survives manual and LiveKit reconnect within the same hook instance. +Unmount/disconnect disposes pending acquisition work; late captures are stopped. +An asynchronous SFU failure leaves the UI muted rather than claiming success. +Muting an existing track silences it immediately, then queues the SDK operation. +A track acquired inside an unresolved SDK operation cannot be silenced until +the SDK exposes it. This is not a guarantee of zero transient audio packets. + +## Boundaries + +- Initial join still follows the existing mic-on behavior and browser permission. + A reload or remount is a new join, not durable saved consent. +- P2P track disabling silences outgoing audio; it does not turn off the physical + microphone or necessarily dismiss the operating system recording indicator. +- A screen publisher is a routing check, not proof of repository/session owner + authorization. This does not fix the pre-existing SFU token/metadata authority, + kick or control-grant authorization paths. Audit those before claiming rooms + are secure against malicious members. +- Tests cover mocked LiveKit permission/lifecycle races and real local Chromium + P2P audio/data-channel behavior. They do not certify LiveKit production, TURN, + Windows/macOS permissions, real microphone devices, or a signed Electron build. + +## Regression checks + +Run shared-types build/test, then the complete web and desktop test, typecheck +and lint scripts. Hook tests cover malformed/foreign messages, local consent, +permission denial, late capture, manual/SDK reconnect and stale room callbacks. +Viewer page tests cover a passive request and the explicit local button. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 7f9b235f..521c6276 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -137,3 +137,5 @@ export { tuneOpusForVoice, prioritizeAudioSender, } from './audio.js'; + +export { MicrophoneController } from './microphone.js'; diff --git a/packages/shared-types/src/microphone.test.ts b/packages/shared-types/src/microphone.test.ts new file mode 100644 index 00000000..d1b345e2 --- /dev/null +++ b/packages/shared-types/src/microphone.test.ts @@ -0,0 +1,176 @@ +import { expect, it, vi, type Mock } from 'vitest'; +import { MicrophoneController } from './microphone.js'; + +function fixture(): { + port: { + setEnabled: Mock<(enabled: boolean) => Promise>; + silence: Mock; + stop: Mock; + changed: Mock; + }; + mic: MicrophoneController; +} { + const port = { + setEnabled: vi.fn<(enabled: boolean) => Promise>().mockResolvedValue(undefined), + silence: vi.fn(), + stop: vi.fn(), + changed: vi.fn(), + }; + return { port, mic: new MicrophoneController(port) }; +} +it('only reports enabled after successful publication', async () => { + const { port, mic } = fixture(); + let resolve!: () => void; + port.setEnabled.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const work = mic.setEnabled(true); + await Promise.resolve(); + expect(port.changed).not.toHaveBeenCalled(); + resolve(); + await work; + expect(port.changed).toHaveBeenLastCalledWith(true, true); +}); +it('a later mute wins over pending permission and immediately silences existing audio', async () => { + const { port, mic } = fixture(); + let resolve!: () => void; + port.setEnabled.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const enabling = mic.setEnabled(true); + await Promise.resolve(); + const muting = mic.setEnabled(false); + expect(port.silence).toHaveBeenCalledOnce(); + resolve(); + await enabling; + await muting; + expect(port.changed).not.toHaveBeenCalledWith(true, true); + expect(port.setEnabled).toHaveBeenLastCalledWith(false); +}); +it('disposal stops tracks acquired after unmount without publishing enabled state', async () => { + const { port, mic } = fixture(); + let resolve!: () => void; + port.setEnabled.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }) + ); + const work = mic.setEnabled(true); + await Promise.resolve(); + mic.dispose(); + resolve(); + await work; + expect(port.stop).toHaveBeenCalledTimes(2); + expect(port.setEnabled).toHaveBeenLastCalledWith(false); + expect(port.changed).not.toHaveBeenCalled(); + await mic.setEnabled(true); + expect(port.setEnabled).toHaveBeenCalledTimes(2); +}); +it('catches enable and mute failures without an optimistic active mic', async () => { + for (const enabled of [true, false]) { + const { port, mic } = fixture(); + port.setEnabled.mockRejectedValueOnce(new Error('device unavailable')); + await expect(mic.setEnabled(enabled)).resolves.toBeUndefined(); + expect(port.stop).toHaveBeenCalled(); + expect(port.changed).toHaveBeenLastCalledWith(false, false); + await mic.setEnabled(true); + expect(port.changed).toHaveBeenLastCalledWith(true, true); + } +}); +it('does not report a device error after disposal while permission was pending', async () => { + const { port, mic } = fixture(); + let reject!: (error: Error) => void; + port.setEnabled.mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }) + ); + const work = mic.setEnabled(true); + await Promise.resolve(); + mic.dispose(); + reject(new Error('late permission denial')); + await expect(work).resolves.toBeUndefined(); + expect(port.stop).toHaveBeenCalledTimes(2); + expect(port.changed).not.toHaveBeenCalled(); +}); +it('does not publish stale muted state when disposed during the corrective SDK mute', async () => { + const { port, mic } = fixture(); + let allow!: () => void; + let muted!: () => void; + port.setEnabled + .mockImplementationOnce( + () => + new Promise((resolve) => { + allow = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + muted = resolve; + }) + ); + const enabling = mic.setEnabled(true); + await Promise.resolve(); + const muting = mic.setEnabled(false); + allow(); + await vi.waitFor(() => expect(port.setEnabled).toHaveBeenCalledTimes(2)); + expect(port.setEnabled).toHaveBeenLastCalledWith(false); + port.changed.mockClear(); + mic.dispose(); + muted(); + await enabling; + await muting; + expect(port.changed).not.toHaveBeenCalled(); +}); +it('coalesces same-frame toggles to the latest intent', async () => { + const { port, mic } = fixture(); + const a = mic.setEnabled(true); + const b = mic.setEnabled(false); + await a; + await b; + expect(port.setEnabled).not.toHaveBeenCalledWith(true); +}); +it('a successful mute does not turn a failed device into an available microphone', async () => { + const { port, mic } = fixture(); + port.setEnabled.mockRejectedValueOnce(new Error('permission denied')); + await mic.setEnabled(true); + port.changed.mockClear(); + await mic.setEnabled(false); + expect(port.changed.mock.calls).toEqual([ + [false, false], + [false, false], + ]); + await mic.setEnabled(true); + expect(port.changed).toHaveBeenLastCalledWith(true, true); +}); +it('flushes an SDK mute before a same-frame local re-enable', async () => { + let sdkMuted = true; + let audioEnabled = false; + const { port, mic } = fixture(); + port.silence.mockImplementation(() => { + audioEnabled = false; + }); + port.setEnabled.mockImplementation(async (enabled) => { + // LiveKit LocalAudioTrack.unmute is a no-op when isMuted is already false. + if (sdkMuted !== !enabled) { + sdkMuted = !enabled; + audioEnabled = enabled; + } + }); + await mic.setEnabled(true); + const muting = mic.setEnabled(false); + const enabling = mic.setEnabled(true); + await muting; + await enabling; + expect(port.setEnabled.mock.calls.map((call) => call[0])).toEqual([true, false, true]); + expect(audioEnabled).toBe(true); +}); diff --git a/packages/shared-types/src/microphone.ts b/packages/shared-types/src/microphone.ts new file mode 100644 index 00000000..2f64c49f --- /dev/null +++ b/packages/shared-types/src/microphone.ts @@ -0,0 +1,63 @@ +interface MicrophonePort { + setEnabled: (enabled: boolean) => Promise; + silence: () => void; + stop: () => void; + changed: (enabled: boolean, available: boolean) => void; +} + +/** Serializes local mic intent across asynchronous device/publication operations. */ +export class MicrophoneController { + private desired = false; + private disposed = false; + private available = true; + private queue = Promise.resolve(); + + constructor(private readonly port: MicrophonePort) {} + + // Re-read after asynchronous device operations; disposal can happen while awaiting. + private isDisposed(): boolean { + return this.disposed; + } + + setEnabled(enabled: boolean): Promise { + if (this.isDisposed()) return Promise.resolve(); + this.desired = enabled; + if (!enabled) { + this.port.silence(); + this.port.changed(false, this.available); + } + this.queue = this.queue.then(async () => { + if (this.isDisposed()) return; + // Never skip a queued mute: direct silencing must reach the SDK's mute + // state before a later unmute (which may otherwise be an SDK no-op). + const requested = enabled && this.desired; + try { + await this.port.setEnabled(requested); + if (requested) this.available = true; + if (this.isDisposed()) { + this.port.stop(); + await this.port.setEnabled(false); + } else if (!this.desired) { + // A mute may have arrived while an enable was waiting for permission. + this.port.silence(); + if (requested) await this.port.setEnabled(false); + if (!this.isDisposed()) this.port.changed(false, this.available); + } else if (requested) this.port.changed(true, true); + } catch { + this.port.stop(); + if (!this.isDisposed()) { + this.desired = false; + this.available = false; + this.port.changed(false, false); + } + } + }); + return this.queue; + } + + dispose(): void { + this.disposed = true; + this.desired = false; + this.port.stop(); + } +}