576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
/* 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<string, PeerData>;
|
|
getLocalMediaStream(): MediaStream | null;
|
|
renegotiate(peerId: string): Promise<void>;
|
|
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<string>();
|
|
|
|
/** 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<MediaStream> {
|
|
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<void> {
|
|
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 });
|
|
}
|
|
}
|
|
}
|