/* eslint-disable, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */ /** * Manages screen sharing: getDisplayMedia / Electron desktop capturer, * system-audio capture, and attaching screen tracks to peers. */ import { WebRTCLogger } from '../logging/webrtc-logger'; import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config'; import { PeerData } from '../realtime.types'; import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO, TRANSCEIVER_SEND_RECV, TRANSCEIVER_RECV_ONLY } from '../realtime.constants'; import { DEFAULT_SCREEN_SHARE_START_OPTIONS, SCREEN_SHARE_QUALITY_PRESETS } from '../screen-share.config'; import { BrowserScreenShareCapture } from './screen-share-platforms/browser-screen-share.capture'; import { DesktopElectronScreenShareCapture } from './screen-share-platforms/desktop-electron-screen-share.capture'; import { LinuxElectronScreenShareCapture } from './screen-share-platforms/linux-electron-screen-share.capture'; import { getElectronApi } from '../../../core/platform/electron/get-electron-api'; import { ScreenShareElectronApi } from './screen-share-platforms/shared'; /** * Callbacks the ScreenShareManager needs from the owning service. */ export interface ScreenShareCallbacks { getActivePeers(): Map; getLocalMediaStream(): MediaStream | null; renegotiate(peerId: string): Promise; broadcastCurrentStates(): void; selectDesktopSource?( sources: readonly { id: string; name: string; thumbnail: string }[], options: { includeSystemAudio: boolean } ): Promise<{ includeSystemAudio: boolean; source: { id: string; name: string; thumbnail: string }; }>; updateLocalScreenShareState?(state: LocalScreenShareState): void; } type ScreenShareCaptureMethod = 'display-media' | 'electron-desktop' | 'linux-electron'; export interface LocalScreenShareState { active: boolean; captureMethod: ScreenShareCaptureMethod | null; includeSystemAudio: boolean; stream: MediaStream | null; suppressRemotePlayback: boolean; forceDefaultRemotePlaybackOutput: boolean; } export class ScreenShareManager { /** The active screen-capture stream. */ private activeScreenStream: MediaStream | null = null; /** Optional system-audio stream captured alongside the screen. */ private screenAudioStream: MediaStream | null = null; /** The quality preset currently applied to the active share. */ private activeScreenPreset: ScreenShareQualityPreset | null = null; /** Remote peers that explicitly requested screen-share video. */ private readonly requestedViewerPeerIds = new Set(); /** Browser `getDisplayMedia` capture path. */ private readonly browserScreenShareCapture: BrowserScreenShareCapture; /** Desktop Electron capture path for non-Linux desktop builds. */ private readonly desktopElectronScreenShareCapture: DesktopElectronScreenShareCapture; /** Linux Electron screen/audio capture path with isolated audio routing. */ private readonly linuxElectronScreenShareCapture: LinuxElectronScreenShareCapture; /** Whether screen sharing is currently active. */ private isScreenActive = false; constructor( private readonly logger: WebRTCLogger, private callbacks: ScreenShareCallbacks ) { this.browserScreenShareCapture = new BrowserScreenShareCapture(this.logger); this.desktopElectronScreenShareCapture = new DesktopElectronScreenShareCapture(this.logger, { getElectronApi: () => this.getElectronApi(), getSelectDesktopSource: () => this.callbacks.selectDesktopSource }); this.linuxElectronScreenShareCapture = new LinuxElectronScreenShareCapture(this.logger, { getElectronApi: () => this.getElectronApi(), onCaptureEnded: () => { if (this.isScreenActive) { this.stopScreenShare(); } }, startDisplayMedia: async (options, preset) => await this.browserScreenShareCapture.startCapture(options, preset) }); } /** * Replace the callback set at runtime. * Needed because of circular initialisation between managers. * * @param nextCallbacks - The new callback interface to wire into this manager. */ setCallbacks(nextCallbacks: ScreenShareCallbacks): void { this.callbacks = nextCallbacks; } /** Returns the current screen-capture stream, or `null` if inactive. */ getScreenStream(): MediaStream | null { return this.activeScreenStream; } /** Whether screen sharing is currently active. */ getIsScreenActive(): boolean { return this.isScreenActive; } /** * Begin screen sharing. * * On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing * path so remote voice playback is kept out of captured system audio. * On Windows Electron builds, prefers `getDisplayMedia` with system audio * so the separate mic `getUserMedia` stream is not disrupted; falls back to * Electron desktop capture only when `getDisplayMedia` fails entirely. * In browser contexts, uses `getDisplayMedia`. * * @param options - Screen-share capture options. * @returns The captured screen {@link MediaStream}. * @throws If both Electron and browser screen capture fail. */ async startScreenShare(options: ScreenShareStartOptions = DEFAULT_SCREEN_SHARE_START_OPTIONS): Promise { const shareOptions = { ...DEFAULT_SCREEN_SHARE_START_OPTIONS, ...options }; const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality]; const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable(); let captureMethod: ScreenShareCaptureMethod | null = null; try { this.logger.info('startScreenShare invoked', shareOptions); if (this.activeScreenStream) { this.stopScreenShare(); } await this.linuxElectronScreenShareCapture.awaitPendingReset(); this.activeScreenStream = null; if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) { try { this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset); captureMethod = 'linux-electron'; } catch (error) { this.rethrowIfScreenShareAborted(error); this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error); } } if (!this.activeScreenStream && shareOptions.includeSystemAudio) { try { this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset); captureMethod = 'display-media'; if (this.activeScreenStream.getAudioTracks().length === 0) { if (electronDesktopCaptureAvailable) { // On Windows Electron, keep the getDisplayMedia stream for video // rather than falling through to getUserMedia desktop audio which // can replace or kill the active mic stream. this.logger.warn( 'getDisplayMedia did not provide system audio; ' + 'continuing without system audio to preserve mic stream' ); shareOptions.includeSystemAudio = false; } else { this.logger.warn('getDisplayMedia did not provide system audio; trying next capture method'); this.activeScreenStream.getTracks().forEach((track) => track.stop()); this.activeScreenStream = null; captureMethod = null; } } } catch (error) { this.rethrowIfScreenShareAborted(error); this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error); } } if (!this.activeScreenStream && electronDesktopCaptureAvailable) { try { const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset); this.activeScreenStream = electronCapture.stream; shareOptions.includeSystemAudio = electronCapture.includeSystemAudio; captureMethod = 'electron-desktop'; } catch (error) { this.rethrowIfScreenShareAborted(error); this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error); } } if (!this.activeScreenStream) { this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset); captureMethod = 'display-media'; } this.configureScreenStream(preset); this.prepareScreenAudio(shareOptions.includeSystemAudio); this.activeScreenPreset = preset; this.attachScreenTracksToPeers(preset); this.isScreenActive = true; this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod); this.callbacks.broadcastCurrentStates(); const activeScreenStream = this.activeScreenStream; if (!activeScreenStream) { throw new Error('Screen sharing did not produce an active stream.'); } const screenVideoTrack = activeScreenStream.getVideoTracks()[0]; if (screenVideoTrack) { screenVideoTrack.onended = () => { this.logger.warn('Screen video track ended'); this.stopScreenShare(); }; } return activeScreenStream; } catch (error) { this.logger.error('Failed to start screen share', error); throw error; } } /** * Stop screen sharing and remove screen-share tracks on all peers. * * Stops all screen-capture tracks, resets screen transceivers to receive-only, * and triggers renegotiation. */ stopScreenShare(): void { if (this.activeScreenStream) { this.activeScreenStream.getTracks().forEach((track) => track.stop()); this.activeScreenStream = null; } this.linuxElectronScreenShareCapture.scheduleReset(); this.screenAudioStream = null; this.activeScreenPreset = null; this.isScreenActive = false; this.publishLocalScreenShareState(false, null); this.callbacks.broadcastCurrentStates(); this.callbacks.getActivePeers().forEach((peerData, peerId) => { this.detachScreenTracksFromPeer(peerData, peerId); }); } requestScreenShareForPeer(peerId: string): void { this.requestedViewerPeerIds.add(peerId); if (!this.isScreenActive || !this.activeScreenPreset) { return; } const peerData = this.callbacks.getActivePeers().get(peerId); if (!peerData) { return; } this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset); } stopScreenShareForPeer(peerId: string): void { this.requestedViewerPeerIds.delete(peerId); const peerData = this.callbacks.getActivePeers().get(peerId); if (!peerData) { return; } this.detachScreenTracksFromPeer(peerData, peerId); } clearScreenShareRequest(peerId: string): void { this.requestedViewerPeerIds.delete(peerId); } /** * Attach the current screen-share tracks to a newly-connected peer. * * This is needed when a peer connects after screen sharing already started, * because `startScreenShare()` only pushes tracks to peers that existed at * the time sharing began. */ syncScreenShareToPeer(peerId: string): void { if ( !this.requestedViewerPeerIds.has(peerId) || !this.isScreenActive || !this.activeScreenStream || !this.activeScreenPreset ) { return; } const peerData = this.callbacks.getActivePeers().get(peerId); if (!peerData) { return; } this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset); } /** Clean up all resources. */ destroy(): void { this.stopScreenShare(); } private getElectronApi(): ScreenShareElectronApi | null { return getElectronApi(); } private publishLocalScreenShareState( includeSystemAudio: boolean, captureMethod: ScreenShareCaptureMethod | null ): void { this.callbacks.updateLocalScreenShareState?.({ active: this.isScreenActive, captureMethod: this.isScreenActive ? captureMethod : null, includeSystemAudio: this.isScreenActive ? includeSystemAudio : false, stream: this.isScreenActive ? this.activeScreenStream : null, suppressRemotePlayback: this.isScreenActive && this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare( includeSystemAudio, captureMethod === 'electron-desktop' ), forceDefaultRemotePlaybackOutput: this.isScreenActive && includeSystemAudio && captureMethod === 'linux-electron' }); } /** * Create a dedicated stream for system audio captured alongside the screen. * * @param includeSystemAudio - Whether system audio should be sent. */ private prepareScreenAudio(includeSystemAudio: boolean): void { const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null; if (!screenAudioTrack) { if (includeSystemAudio) { this.logger.warn('System audio was requested, but no screen audio track was captured'); } this.screenAudioStream = null; return; } this.screenAudioStream = new MediaStream([screenAudioTrack]); this.logger.attachTrackDiagnostics(screenAudioTrack, 'screenAudio'); this.logger.logStream('screenAudio', this.screenAudioStream); } /** * Attach screen video and optional system-audio tracks to all * active peer connections, then trigger SDP renegotiation. * * @param options - Screen-share capture options. * @param preset - Selected quality preset for sender tuning. */ private attachScreenTracksToPeers( preset: ScreenShareQualityPreset ): void { this.callbacks.getActivePeers().forEach((peerData, peerId) => { if (!this.requestedViewerPeerIds.has(peerId)) { return; } this.attachScreenTracksToPeer(peerData, peerId, preset); }); } private attachScreenTracksToPeer( peerData: PeerData, peerId: string, preset: ScreenShareQualityPreset ): void { if (!this.activeScreenStream) { return; } const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0]; if (!screenVideoTrack) { return; } this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`); let videoSender = peerData.videoSender || peerData.connection.getSenders().find((sender) => sender.track?.kind === TRACK_KIND_VIDEO); if (!videoSender) { const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }); videoSender = videoTransceiver.sender; peerData.videoSender = videoSender; } else { const videoTransceiver = peerData.connection.getTransceivers().find( (transceiver) => transceiver.sender === videoSender ); if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) { videoTransceiver.direction = TRANSCEIVER_SEND_RECV; } } peerData.screenVideoSender = videoSender; if (typeof videoSender.setStreams === 'function') { videoSender.setStreams(this.activeScreenStream); } videoSender.replaceTrack(screenVideoTrack) .then(() => { this.logger.info('screen video replaceTrack ok', { peerId }); void this.applyScreenShareVideoParameters(videoSender, preset, peerId); }) .catch((error) => this.logger.error('screen video replaceTrack failed', error)); const screenAudioTrack = this.screenAudioStream?.getAudioTracks()[0] || null; if (screenAudioTrack) { this.logger.attachTrackDiagnostics(screenAudioTrack, `screenAudio:${peerId}`); let screenAudioSender = peerData.screenAudioSender; if (!screenAudioSender) { const screenAudioTransceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); screenAudioSender = screenAudioTransceiver.sender; } else { const screenAudioTransceiver = peerData.connection.getTransceivers().find( (transceiver) => transceiver.sender === screenAudioSender ); if (screenAudioTransceiver?.direction === TRANSCEIVER_RECV_ONLY) { screenAudioTransceiver.direction = TRANSCEIVER_SEND_RECV; } } peerData.screenAudioSender = screenAudioSender; if (typeof screenAudioSender.setStreams === 'function') { screenAudioSender.setStreams(this.activeScreenStream); } screenAudioSender.replaceTrack(screenAudioTrack) .then(() => this.logger.info('screen audio replaceTrack ok', { peerId })) .catch((error) => this.logger.error('screen audio replaceTrack failed', error)); } this.callbacks.renegotiate(peerId); } private detachScreenTracksFromPeer(peerData: PeerData, peerId: string): void { const transceivers = peerData.connection.getTransceivers(); const videoTransceiver = transceivers.find( (transceiver) => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender ); const screenAudioTransceiver = transceivers.find( (transceiver) => transceiver.sender === peerData.screenAudioSender ); if (videoTransceiver) { videoTransceiver.sender.replaceTrack(null).catch((error) => { this.logger.error('Failed to clear screen video sender track', error, { peerId }); }); if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) { videoTransceiver.direction = TRANSCEIVER_RECV_ONLY; } } if (screenAudioTransceiver) { screenAudioTransceiver.sender.replaceTrack(null).catch((error) => { this.logger.error('Failed to clear screen audio sender track', error, { peerId }); }); if (screenAudioTransceiver.direction === TRANSCEIVER_SEND_RECV) { screenAudioTransceiver.direction = TRANSCEIVER_RECV_ONLY; } } peerData.screenVideoSender = undefined; peerData.screenAudioSender = undefined; this.callbacks.renegotiate(peerId); } private isScreenShareSelectionAborted(error: unknown): boolean { return error instanceof Error && (error.name === 'AbortError' || error.name === 'NotAllowedError'); } private rethrowIfScreenShareAborted(error: unknown): void { if (this.isScreenShareSelectionAborted(error)) { throw error; } } private configureScreenStream(preset: ScreenShareQualityPreset): void { const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0]; if (!screenVideoTrack) { throw new Error('Screen capture returned no video track.'); } if ('contentHint' in screenVideoTrack) { screenVideoTrack.contentHint = preset.contentHint; } this.logger.attachTrackDiagnostics(screenVideoTrack, 'screenVideo'); this.logger.logStream('screen', this.activeScreenStream); if (typeof screenVideoTrack.applyConstraints === 'function') { screenVideoTrack.applyConstraints({ width: { ideal: preset.width, max: preset.width }, height: { ideal: preset.height, max: preset.height }, frameRate: { ideal: preset.frameRate, max: preset.frameRate } }).catch((error) => { this.logger.warn('Failed to re-apply screen video constraints', error); }); } } private async applyScreenShareVideoParameters( sender: RTCRtpSender, preset: ScreenShareQualityPreset, peerId: string ): Promise { try { const params = sender.getParameters(); const encodings = params.encodings?.length ? params.encodings : [{} as RTCRtpEncodingParameters]; params.encodings = encodings.map((encoding, index) => index === 0 ? { ...encoding, maxBitrate: preset.maxBitrateBps, maxFramerate: preset.frameRate, scaleResolutionDownBy: preset.scaleResolutionDownBy ?? encoding.scaleResolutionDownBy ?? 1 } : encoding); (params as RTCRtpSendParameters & { degradationPreference?: string }).degradationPreference = preset.degradationPreference; await sender.setParameters(params); this.logger.info('Applied screen-share sender parameters', { peerId, maxBitrate: preset.maxBitrateBps, maxFramerate: preset.frameRate }); } catch (error) { this.logger.warn('Failed to apply screen-share sender parameters', error, { peerId }); } } }