363 lines
9.9 KiB
TypeScript
363 lines
9.9 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
/* eslint-disable max-classes-per-file */
|
|
|
|
import { ipcRenderer, type DesktopCapturerSource } from 'electron';
|
|
import * as macScreenShare from '@indutny/mac-screen-share';
|
|
|
|
import * as log from '../logging/log';
|
|
import * as Errors from '../types/errors';
|
|
import type { PresentableSource } from '../types/Calling';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import {
|
|
REQUESTED_SCREEN_SHARE_WIDTH,
|
|
REQUESTED_SCREEN_SHARE_HEIGHT,
|
|
REQUESTED_SCREEN_SHARE_FRAMERATE,
|
|
} from '../calling/constants';
|
|
import { strictAssert } from './assert';
|
|
import { explodePromise } from './explodePromise';
|
|
import { isNotNil } from './isNotNil';
|
|
import { drop } from './drop';
|
|
import { SECOND } from './durations';
|
|
import { isOlderThan } from './timestamp';
|
|
|
|
// Chrome-only API for now, thus a declaration:
|
|
declare class MediaStreamTrackGenerator extends MediaStreamTrack {
|
|
constructor(options: { kind: 'video' });
|
|
|
|
public writable: WritableStream;
|
|
}
|
|
|
|
enum Step {
|
|
RequestingMedia = 'RequestingMedia',
|
|
Done = 'Done',
|
|
Error = 'Error',
|
|
|
|
// Skipped on macOS Sequoia
|
|
SelectingSource = 'SelectingSource',
|
|
SelectedSource = 'SelectedSource',
|
|
|
|
// macOS Sequoia
|
|
NativeMacOS = 'NativeMacOS',
|
|
}
|
|
|
|
type State = Readonly<
|
|
| {
|
|
step: Step.RequestingMedia;
|
|
promise: Promise<void>;
|
|
}
|
|
| {
|
|
step: Step.SelectingSource;
|
|
promise: Promise<void>;
|
|
sources: ReadonlyArray<DesktopCapturerSource>;
|
|
onSource: (source: DesktopCapturerSource | undefined) => void;
|
|
}
|
|
| {
|
|
step: Step.SelectedSource;
|
|
promise: Promise<void>;
|
|
}
|
|
| {
|
|
step: Step.NativeMacOS;
|
|
stream: macScreenShare.Stream;
|
|
}
|
|
| {
|
|
step: Step.Done;
|
|
}
|
|
| {
|
|
step: Step.Error;
|
|
}
|
|
>;
|
|
|
|
export const liveCapturers = new Set<DesktopCapturer>();
|
|
|
|
export type IpcResponseType = Readonly<{
|
|
id: string;
|
|
sources: ReadonlyArray<DesktopCapturerSource>;
|
|
}>;
|
|
|
|
export type DesktopCapturerOptionsType = Readonly<{
|
|
i18n: LocalizerType;
|
|
onPresentableSources(sources: ReadonlyArray<PresentableSource>): void;
|
|
onMediaStream(stream: MediaStream): void;
|
|
onError(error: Error): void;
|
|
}>;
|
|
|
|
export type DesktopCapturerBaton = Readonly<{
|
|
__desktop_capturer_baton: never;
|
|
}>;
|
|
|
|
export class DesktopCapturer {
|
|
private state: State;
|
|
|
|
private static getDisplayMediaPromise: Promise<MediaStream> | undefined;
|
|
|
|
private static isInitialized = false;
|
|
|
|
// For use as a key in weak maps
|
|
public readonly baton = {} as DesktopCapturerBaton;
|
|
|
|
constructor(private readonly options: DesktopCapturerOptionsType) {
|
|
if (!DesktopCapturer.isInitialized) {
|
|
DesktopCapturer.initialize();
|
|
}
|
|
|
|
if (macScreenShare.isSupported) {
|
|
this.state = {
|
|
step: Step.NativeMacOS,
|
|
stream: this.getNativeMacOSStream(),
|
|
};
|
|
} else {
|
|
this.state = { step: Step.RequestingMedia, promise: this.getStream() };
|
|
}
|
|
}
|
|
|
|
public abort(): void {
|
|
if (this.state.step === Step.NativeMacOS) {
|
|
this.state.stream.stop();
|
|
}
|
|
|
|
if (this.state.step === Step.SelectingSource) {
|
|
this.state.onSource(undefined);
|
|
}
|
|
|
|
this.state = { step: Step.Error };
|
|
}
|
|
|
|
public selectSource(id: string): void {
|
|
strictAssert(
|
|
this.state.step === Step.SelectingSource,
|
|
`Invalid state in "selectSource" ${this.state.step}`
|
|
);
|
|
|
|
const { promise, sources, onSource } = this.state;
|
|
const source = id == null ? undefined : sources.find(s => s.id === id);
|
|
this.state = { step: Step.SelectedSource, promise };
|
|
|
|
onSource(source);
|
|
}
|
|
|
|
/** @internal */
|
|
private onSources(
|
|
sources: ReadonlyArray<DesktopCapturerSource>
|
|
): Promise<DesktopCapturerSource | undefined> {
|
|
strictAssert(
|
|
this.state.step === Step.RequestingMedia,
|
|
`Invalid state in "onSources" ${this.state.step}`
|
|
);
|
|
|
|
const presentableSources = sources
|
|
.map(source => {
|
|
// If electron can't retrieve a thumbnail then it won't be able to
|
|
// present this source so we filter these out.
|
|
if (source.thumbnail.isEmpty()) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
appIcon:
|
|
source.appIcon && !source.appIcon.isEmpty()
|
|
? source.appIcon.toDataURL()
|
|
: undefined,
|
|
id: source.id,
|
|
name: this.translateSourceName(source),
|
|
isScreen: isScreenSource(source),
|
|
thumbnail: source.thumbnail.toDataURL(),
|
|
};
|
|
})
|
|
.filter(isNotNil);
|
|
|
|
const { promise } = this.state;
|
|
|
|
const { promise: source, resolve: onSource } = explodePromise<
|
|
DesktopCapturerSource | undefined
|
|
>();
|
|
this.state = { step: Step.SelectingSource, promise, sources, onSource };
|
|
|
|
this.options.onPresentableSources(presentableSources);
|
|
return source;
|
|
}
|
|
|
|
private async getStream(): Promise<void> {
|
|
liveCapturers.add(this);
|
|
try {
|
|
// Only allow one global getDisplayMedia() request at a time
|
|
if (!DesktopCapturer.getDisplayMediaPromise) {
|
|
DesktopCapturer.getDisplayMediaPromise =
|
|
navigator.mediaDevices.getDisplayMedia({
|
|
video: true,
|
|
});
|
|
}
|
|
const stream = await DesktopCapturer.getDisplayMediaPromise;
|
|
DesktopCapturer.getDisplayMediaPromise = undefined;
|
|
|
|
const videoTrack = stream.getVideoTracks()[0];
|
|
strictAssert(videoTrack, 'videoTrack does not exist');
|
|
|
|
// Apply constraints and ensure that there is at least 1 frame per second.
|
|
await videoTrack.applyConstraints({
|
|
width: {
|
|
max: REQUESTED_SCREEN_SHARE_WIDTH,
|
|
ideal: REQUESTED_SCREEN_SHARE_WIDTH,
|
|
},
|
|
height: {
|
|
max: REQUESTED_SCREEN_SHARE_HEIGHT,
|
|
ideal: REQUESTED_SCREEN_SHARE_HEIGHT,
|
|
},
|
|
frameRate: {
|
|
min: 1,
|
|
max: REQUESTED_SCREEN_SHARE_FRAMERATE,
|
|
ideal: REQUESTED_SCREEN_SHARE_FRAMERATE,
|
|
},
|
|
});
|
|
|
|
strictAssert(
|
|
this.state.step === Step.RequestingMedia ||
|
|
this.state.step === Step.SelectedSource,
|
|
`Invalid state in "getStream.success" ${this.state.step}`
|
|
);
|
|
|
|
this.options.onMediaStream(stream);
|
|
this.state = { step: Step.Done };
|
|
} catch (error) {
|
|
strictAssert(
|
|
this.state.step === Step.RequestingMedia ||
|
|
this.state.step === Step.SelectedSource,
|
|
`Invalid state in "getStream.error" ${this.state.step}`
|
|
);
|
|
this.options.onError(error);
|
|
this.state = { step: Step.Error };
|
|
} finally {
|
|
liveCapturers.delete(this);
|
|
DesktopCapturer.getDisplayMediaPromise = undefined;
|
|
}
|
|
}
|
|
|
|
private getNativeMacOSStream(): macScreenShare.Stream {
|
|
const track = new MediaStreamTrackGenerator({ kind: 'video' });
|
|
const writer = track.writable.getWriter();
|
|
|
|
const mediaStream = new MediaStream();
|
|
mediaStream.addTrack(track);
|
|
|
|
let isRunning = false;
|
|
|
|
let lastFrame: VideoFrame | undefined;
|
|
let lastFrameSentAt = 0;
|
|
|
|
let frameRepeater: NodeJS.Timeout | undefined;
|
|
|
|
const cleanup = () => {
|
|
lastFrame?.close();
|
|
if (frameRepeater != null) {
|
|
clearInterval(frameRepeater);
|
|
}
|
|
frameRepeater = undefined;
|
|
lastFrame = undefined;
|
|
};
|
|
|
|
const stream = new macScreenShare.Stream({
|
|
width: REQUESTED_SCREEN_SHARE_WIDTH,
|
|
height: REQUESTED_SCREEN_SHARE_HEIGHT,
|
|
frameRate: REQUESTED_SCREEN_SHARE_FRAMERATE,
|
|
|
|
onStart: () => {
|
|
isRunning = true;
|
|
|
|
// Repeat last frame every second to match "min" constraint above.
|
|
frameRepeater = setInterval(() => {
|
|
if (isRunning && track.readyState !== 'ended' && lastFrame != null) {
|
|
if (isOlderThan(lastFrameSentAt, SECOND)) {
|
|
drop(writer.write(lastFrame.clone()));
|
|
}
|
|
} else {
|
|
cleanup();
|
|
}
|
|
}, SECOND);
|
|
|
|
this.options.onMediaStream(mediaStream);
|
|
},
|
|
onStop() {
|
|
if (!isRunning) {
|
|
return;
|
|
}
|
|
isRunning = false;
|
|
|
|
if (track.readyState === 'ended') {
|
|
stream.stop();
|
|
return;
|
|
}
|
|
drop(writer.close());
|
|
},
|
|
onFrame(frame, width, height) {
|
|
if (!isRunning) {
|
|
return;
|
|
}
|
|
if (track.readyState === 'ended') {
|
|
stream.stop();
|
|
return;
|
|
}
|
|
|
|
lastFrame?.close();
|
|
lastFrameSentAt = Date.now();
|
|
lastFrame = new VideoFrame(frame, {
|
|
format: 'NV12',
|
|
codedWidth: width,
|
|
codedHeight: height,
|
|
timestamp: 0,
|
|
});
|
|
drop(writer.write(lastFrame.clone()));
|
|
},
|
|
});
|
|
|
|
return stream;
|
|
}
|
|
|
|
private translateSourceName(source: DesktopCapturerSource): string {
|
|
const { i18n } = this.options;
|
|
|
|
const { name } = source;
|
|
if (!isScreenSource(source)) {
|
|
return name;
|
|
}
|
|
|
|
if (name === 'Entire Screen') {
|
|
return i18n('icu:calling__SelectPresentingSourcesModal--entireScreen');
|
|
}
|
|
|
|
const match = name.match(/^Screen (\d+)$/);
|
|
if (match) {
|
|
return i18n('icu:calling__SelectPresentingSourcesModal--screen', {
|
|
id: match[1],
|
|
});
|
|
}
|
|
|
|
return name;
|
|
}
|
|
|
|
private static initialize(): void {
|
|
DesktopCapturer.isInitialized = true;
|
|
|
|
ipcRenderer.on(
|
|
'select-capture-sources',
|
|
async (_, { id, sources }: IpcResponseType) => {
|
|
let selected: DesktopCapturerSource | undefined;
|
|
try {
|
|
const { value: capturer, done } = liveCapturers.values().next();
|
|
strictAssert(!done, 'No capturer available for incoming sources');
|
|
liveCapturers.delete(capturer);
|
|
|
|
selected = await capturer.onSources(sources);
|
|
} catch (error) {
|
|
log.error(
|
|
'desktopCapturer: failed to get the source',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
}
|
|
ipcRenderer.send(`select-capture-sources:${id}:response`, selected);
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
function isScreenSource(source: DesktopCapturerSource): boolean {
|
|
return source.id.startsWith('screen');
|
|
}
|