This commit is contained in:
2026-03-20 03:05:29 +01:00
parent 429bb9d8ff
commit fe9c1dd1c0
139 changed files with 6308 additions and 4854 deletions

View File

@@ -0,0 +1,575 @@
/* 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 });
}
}
}