feat: make desktopCapturer main-process-only (#30720)

* feat: make desktopCapturer main-process-only

* remove --enable-api-filtering-logging

* remove test

* merge lib/browser/api/desktop-capturer.ts with lib/browser/desktop-capturer.ts

* remove desktop-capturer-get-sources event

* fix specs

* getSources needs to be async

Co-authored-by: Milan Burda <milan.burda@gmail.com>
This commit is contained in:
Jeremy Rose 2021-10-03 20:16:00 -07:00 committed by GitHub
parent 6db8d7918d
commit 4fd7c2adcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 149 additions and 312 deletions

View file

@ -500,16 +500,6 @@ gets emitted.
**Note:** Extra command line arguments might be added by Chromium,
such as `--original-process-start-time`.
### Event: 'desktop-capturer-get-sources'
Returns:
* `event` Event
* `webContents` [WebContents](web-contents.md)
Emitted when `desktopCapturer.getSources()` is called in the renderer process of `webContents`.
Calling `event.preventDefault()` will make it return empty sources.
## Methods
The `app` object has the following methods:

View file

@ -61,12 +61,6 @@ throttling in one window, you can take the hack of
Forces the maximum disk space to be used by the disk cache, in bytes.
### --enable-api-filtering-logging
Enables caller stack logging for the following APIs (filtering events):
* `desktopCapturer.getSources()` / `desktop-capturer-get-sources`
### --enable-logging[=file]
Prints Chromium's logging to stderr (or a log file).

View file

@ -3,40 +3,49 @@
> Access information about media sources that can be used to capture audio and
> video from the desktop using the [`navigator.mediaDevices.getUserMedia`] API.
Process: [Main](../glossary.md#main-process), [Renderer](../glossary.md#renderer-process)
Process: [Main](../glossary.md#main-process)
The following example shows how to capture video from a desktop window whose
title is `Electron`:
```javascript
// In the renderer process.
// In the main process.
const { desktopCapturer } = require('electron')
desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
for (const source of sources) {
if (source.name === 'Electron') {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: source.id,
minWidth: 1280,
maxWidth: 1280,
minHeight: 720,
maxHeight: 720
}
}
})
handleStream(stream)
} catch (e) {
handleError(e)
}
mainWindow.webContents.send('SET_SOURCE', source.id)
return
}
}
})
```
```javascript
// In the preload script.
const { ipcRenderer } = require('electron')
ipcRenderer.on('SET_SOURCE', async (event, sourceId) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
minWidth: 1280,
maxWidth: 1280,
minHeight: 720,
maxHeight: 720
}
}
})
handleStream(stream)
} catch (e) {
handleError(e)
}
})
function handleStream (stream) {
const video = document.querySelector('video')

View file

@ -856,15 +856,6 @@ Returns:
Emitted when the renderer process sends a synchronous message via `ipcRenderer.sendSync()`.
#### Event: 'desktop-capturer-get-sources'
Returns:
* `event` Event
Emitted when `desktopCapturer.getSources()` is called in the renderer process.
Calling `event.preventDefault()` will make it return empty sources.
#### Event: 'preferred-size-changed'
Returns:

View file

@ -142,7 +142,6 @@ auto_filenames = {
"lib/common/web-view-methods.ts",
"lib/renderer/api/context-bridge.ts",
"lib/renderer/api/crash-reporter.ts",
"lib/renderer/api/desktop-capturer.ts",
"lib/renderer/api/ipc-renderer.ts",
"lib/renderer/api/web-frame.ts",
"lib/renderer/inspector.ts",
@ -224,7 +223,6 @@ auto_filenames = {
"lib/browser/api/web-contents.ts",
"lib/browser/api/web-frame-main.ts",
"lib/browser/default-menu.ts",
"lib/browser/desktop-capturer.ts",
"lib/browser/devtools.ts",
"lib/browser/guest-view-manager.ts",
"lib/browser/guest-window-manager.ts",
@ -271,7 +269,6 @@ auto_filenames = {
"lib/common/webpack-provider.ts",
"lib/renderer/api/context-bridge.ts",
"lib/renderer/api/crash-reporter.ts",
"lib/renderer/api/desktop-capturer.ts",
"lib/renderer/api/exports/electron.ts",
"lib/renderer/api/ipc-renderer.ts",
"lib/renderer/api/module-list.ts",
@ -309,7 +306,6 @@ auto_filenames = {
"lib/common/webpack-provider.ts",
"lib/renderer/api/context-bridge.ts",
"lib/renderer/api/crash-reporter.ts",
"lib/renderer/api/desktop-capturer.ts",
"lib/renderer/api/exports/electron.ts",
"lib/renderer/api/ipc-renderer.ts",
"lib/renderer/api/module-list.ts",

View file

@ -1,5 +1,72 @@
import { getSourcesImpl } from '@electron/internal/browser/desktop-capturer';
const { createDesktopCapturer } = process._linkedBinding('electron_browser_desktop_capturer');
export async function getSources (options: Electron.SourcesOptions) {
return getSourcesImpl(null, options);
const deepEqual = (a: ElectronInternal.GetSourcesOptions, b: ElectronInternal.GetSourcesOptions) => JSON.stringify(a) === JSON.stringify(b);
let currentlyRunning: {
options: ElectronInternal.GetSourcesOptions;
getSources: Promise<ElectronInternal.GetSourcesResult[]>;
}[] = [];
// |options.types| can't be empty and must be an array
function isValid (options: Electron.SourcesOptions) {
const types = options ? options.types : undefined;
return Array.isArray(types);
}
export async function getSources (args: Electron.SourcesOptions) {
if (!isValid(args)) throw new Error('Invalid options');
const captureWindow = args.types.includes('window');
const captureScreen = args.types.includes('screen');
const { thumbnailSize = { width: 150, height: 150 } } = args;
const { fetchWindowIcons = false } = args;
const options = {
captureWindow,
captureScreen,
thumbnailSize,
fetchWindowIcons
};
for (const running of currentlyRunning) {
if (deepEqual(running.options, options)) {
// If a request is currently running for the same options
// return that promise
return running.getSources;
}
}
const getSources = new Promise<ElectronInternal.GetSourcesResult[]>((resolve, reject) => {
let capturer: ElectronInternal.DesktopCapturer | null = createDesktopCapturer();
const stopRunning = () => {
if (capturer) {
delete capturer._onerror;
delete capturer._onfinished;
capturer = null;
}
// Remove from currentlyRunning once we resolve or reject
currentlyRunning = currentlyRunning.filter(running => running.options !== options);
};
capturer._onerror = (error: string) => {
stopRunning();
reject(error);
};
capturer._onfinished = (sources: Electron.DesktopCapturerSource[]) => {
stopRunning();
resolve(sources);
};
capturer.startHandling(captureWindow, captureScreen, thumbnailSize, fetchWindowIcons);
});
currentlyRunning.push({
options,
getSources
});
return getSources;
}

View file

@ -1,82 +0,0 @@
const { createDesktopCapturer } = process._linkedBinding('electron_browser_desktop_capturer');
const deepEqual = (a: ElectronInternal.GetSourcesOptions, b: ElectronInternal.GetSourcesOptions) => JSON.stringify(a) === JSON.stringify(b);
let currentlyRunning: {
options: ElectronInternal.GetSourcesOptions;
getSources: Promise<ElectronInternal.GetSourcesResult[]>;
}[] = [];
// |options.types| can't be empty and must be an array
function isValid (options: Electron.SourcesOptions) {
const types = options ? options.types : undefined;
return Array.isArray(types);
}
export const getSourcesImpl = (sender: Electron.WebContents | null, args: Electron.SourcesOptions) => {
if (!isValid(args)) throw new Error('Invalid options');
const captureWindow = args.types.includes('window');
const captureScreen = args.types.includes('screen');
const { thumbnailSize = { width: 150, height: 150 } } = args;
const { fetchWindowIcons = false } = args;
const options = {
captureWindow,
captureScreen,
thumbnailSize,
fetchWindowIcons
};
for (const running of currentlyRunning) {
if (deepEqual(running.options, options)) {
// If a request is currently running for the same options
// return that promise
return running.getSources;
}
}
const getSources = new Promise<ElectronInternal.GetSourcesResult[]>((resolve, reject) => {
let capturer: ElectronInternal.DesktopCapturer | null = createDesktopCapturer();
const stopRunning = () => {
if (capturer) {
delete capturer._onerror;
delete capturer._onfinished;
capturer = null;
}
// Remove from currentlyRunning once we resolve or reject
currentlyRunning = currentlyRunning.filter(running => running.options !== options);
if (sender) {
sender.removeListener('destroyed', stopRunning);
}
};
capturer._onerror = (error: string) => {
stopRunning();
reject(error);
};
capturer._onfinished = (sources: Electron.DesktopCapturerSource[]) => {
stopRunning();
resolve(sources);
};
capturer.startHandling(captureWindow, captureScreen, thumbnailSize, fetchWindowIcons);
// If the WebContents is destroyed before receiving result, just remove the
// reference to emit and the capturer itself so that it never dispatches
// back to the renderer
if (sender) {
sender.once('destroyed', stopRunning);
}
});
currentlyRunning.push({
options,
getSources
});
return getSources;
};

View file

@ -1,30 +1,9 @@
import { app } from 'electron/main';
import type { WebContents } from 'electron/main';
import { clipboard } from 'electron/common';
import * as fs from 'fs';
import { ipcMainInternal } from '@electron/internal/browser/ipc-main-internal';
import * as ipcMainUtils from '@electron/internal/browser/ipc-main-internal-utils';
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
import type * as desktopCapturerModule from '@electron/internal/browser/desktop-capturer';
const eventBinding = process._linkedBinding('electron_browser_event');
const emitCustomEvent = function (contents: WebContents, eventName: string, ...args: any[]) {
const event = eventBinding.createWithSender(contents);
app.emit(eventName, event, contents, ...args);
contents.emit(eventName, event, ...args);
return event;
};
const logStack = function (contents: WebContents, code: string, stack: string) {
if (stack) {
console.warn(`WebContents (${contents.id}): ${code}`, stack);
}
};
// Implements window.close()
ipcMainInternal.on(IPC_MESSAGES.BROWSER_WINDOW_CLOSE, function (event) {
const window = event.sender.getOwnerBrowserWindow();
@ -58,22 +37,6 @@ ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_CLIPBOARD_SYNC, function (event, me
return (clipboard as any)[method](...args);
});
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
const desktopCapturer = require('@electron/internal/browser/desktop-capturer') as typeof desktopCapturerModule;
ipcMainInternal.handle(IPC_MESSAGES.DESKTOP_CAPTURER_GET_SOURCES, async function (event, options: Electron.SourcesOptions, stack: string) {
logStack(event.sender, 'desktopCapturer.getSources()', stack);
const customEvent = emitCustomEvent(event.sender, 'desktop-capturer-get-sources');
if (customEvent.defaultPrevented) {
console.error('Blocked desktopCapturer.getSources()');
return [];
}
return await desktopCapturer.getSourcesImpl(event.sender, options);
});
}
const getPreloadScript = async function (preloadPath: string) {
let preloadSrc = null;
let preloadError = null;

View file

@ -28,6 +28,4 @@ export const enum IPC_MESSAGES {
INSPECTOR_CONFIRM = 'INSPECTOR_CONFIRM',
INSPECTOR_CONTEXT_MENU = 'INSPECTOR_CONTEXT_MENU',
INSPECTOR_SELECT_FILE = 'INSPECTOR_SELECT_FILE',
DESKTOP_CAPTURER_GET_SOURCES = 'DESKTOP_CAPTURER_GET_SOURCES',
}

View file

@ -1,24 +0,0 @@
import { ipcRendererInternal } from '@electron/internal/renderer/ipc-renderer-internal';
import deprecate from '@electron/internal/common/api/deprecate';
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
const { hasSwitch } = process._linkedBinding('electron_common_command_line');
const enableStacks = hasSwitch('enable-api-filtering-logging');
function getCurrentStack () {
const target = {};
if (enableStacks) {
Error.captureStackTrace(target, getCurrentStack);
}
return (target as any).stack;
}
let warned = process.noDeprecation;
export async function getSources (options: Electron.SourcesOptions) {
if (!warned) {
deprecate.log('The use of \'desktopCapturer.getSources\' in the renderer process is deprecated and will be removed. See https://www.electronjs.org/docs/breaking-changes#removed-desktopcapturergetsources-in-the-renderer for more details.');
warned = true;
}
return await ipcRendererInternal.invoke(IPC_MESSAGES.DESKTOP_CAPTURER_GET_SOURCES, options, getCurrentStack());
}

View file

@ -5,10 +5,3 @@ export const rendererModuleList: ElectronInternal.ModuleEntry[] = [
{ name: 'ipcRenderer', loader: () => require('./ipc-renderer') },
{ name: 'webFrame', loader: () => require('./web-frame') }
];
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
rendererModuleList.push({
name: 'desktopCapturer',
loader: () => require('@electron/internal/renderer/api/desktop-capturer')
});
}

View file

@ -26,10 +26,3 @@ export const moduleList: ElectronInternal.ModuleEntry[] = [
private: true
}
];
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
moduleList.push({
name: 'desktopCapturer',
loader: () => require('@electron/internal/renderer/api/desktop-capturer')
});
}

View file

@ -592,8 +592,7 @@ void ElectronBrowserClient::AppendExtraCommandLineSwitches(
switches::kStandardSchemes, switches::kEnableSandbox,
switches::kSecureSchemes, switches::kBypassCSPSchemes,
switches::kCORSSchemes, switches::kFetchSchemes,
switches::kServiceWorkerSchemes, switches::kEnableApiFilteringLogging,
switches::kStreamingSchemes};
switches::kServiceWorkerSchemes, switches::kStreamingSchemes};
command_line->CopySwitchesFrom(*base::CommandLine::ForCurrentProcess(),
kCommonSwitchNames,
base::size(kCommonSwitchNames));

View file

@ -241,8 +241,6 @@ const char kAppUserModelId[] = "app-user-model-id";
// The application path
const char kAppPath[] = "app-path";
const char kEnableApiFilteringLogging[] = "enable-api-filtering-logging";
// The command line switch versions of the options.
const char kScrollBounce[] = "scroll-bounce";

View file

@ -118,7 +118,6 @@ extern const char kCORSSchemes[];
extern const char kStreamingSchemes[];
extern const char kAppUserModelId[];
extern const char kAppPath[];
extern const char kEnableApiFilteringLogging[];
extern const char kScrollBounce[];
extern const char kNodeIntegrationInWorker[];

View file

@ -12,8 +12,6 @@ import { closeWindow, closeAllWindows } from './window-helpers';
import { ifdescribe, ifit } from './spec-helpers';
import split = require('split')
const features = process._linkedBinding('electron_common_features');
const fixturesPath = path.resolve(__dirname, '../spec/fixtures');
describe('electron module', () => {
@ -462,25 +460,6 @@ describe('app module', () => {
expect(webContents).to.equal(w.webContents);
expect(details.reason).to.be.oneOf(['crashed', 'abnormal-exit']);
});
ifdescribe(features.isDesktopCapturerEnabled())('desktopCapturer module filtering', () => {
it('should emit desktop-capturer-get-sources event when desktopCapturer.getSources() is invoked', async () => {
w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
await w.loadURL('about:blank');
const promise = emittedOnce(app, 'desktop-capturer-get-sources');
w.webContents.executeJavaScript('require(\'electron\').desktopCapturer.getSources({ types: [\'screen\'] })');
const [, webContents] = await promise;
expect(webContents).to.equal(w.webContents);
});
});
});
describe('app.badgeCount', () => {

View file

@ -1,6 +1,5 @@
import { expect } from 'chai';
import { screen, BrowserWindow, SourcesOptions } from 'electron/main';
import { desktopCapturer } from 'electron/common';
import { screen, desktopCapturer, BrowserWindow } from 'electron/main';
import { emittedOnce } from './events-helpers';
import { ifdescribe, ifit } from './spec-helpers';
import { closeAllWindows } from './window-helpers';
@ -23,76 +22,55 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
after(closeAllWindows);
const getSources: typeof desktopCapturer.getSources = (options: SourcesOptions) => {
return w.webContents.executeJavaScript(`
require('electron').desktopCapturer.getSources(${JSON.stringify(options)}).then(m => JSON.parse(JSON.stringify(m)))
`);
};
// TODO(nornagon): figure out why this test is failing on Linux and re-enable it.
ifit(process.platform !== 'linux')('should return a non-empty array of sources', async () => {
const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources).to.be.an('array').that.is.not.empty();
});
const generateSpecs = (description: string, getSources: typeof desktopCapturer.getSources) => {
describe(description, () => {
// TODO(nornagon): figure out why this test is failing on Linux and re-enable it.
ifit(process.platform !== 'linux')('should return a non-empty array of sources', async () => {
const sources = await getSources({ types: ['window', 'screen'] });
expect(sources).to.be.an('array').that.is.not.empty();
});
it('throws an error for invalid options', async () => {
const promise = desktopCapturer.getSources(['window', 'screen'] as any);
await expect(promise).to.be.eventually.rejectedWith(Error, 'Invalid options');
});
it('throws an error for invalid options', async () => {
const promise = getSources(['window', 'screen'] as any);
await expect(promise).to.be.eventually.rejectedWith(Error, 'Invalid options');
});
// TODO(nornagon): figure out why this test is failing on Linux and re-enable it.
ifit(process.platform !== 'linux')('does not throw an error when called more than once (regression)', async () => {
const sources1 = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources1).to.be.an('array').that.is.not.empty();
// TODO(nornagon): figure out why this test is failing on Linux and re-enable it.
ifit(process.platform !== 'linux')('does not throw an error when called more than once (regression)', async () => {
const sources1 = await getSources({ types: ['window', 'screen'] });
expect(sources1).to.be.an('array').that.is.not.empty();
const sources2 = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources2).to.be.an('array').that.is.not.empty();
});
const sources2 = await getSources({ types: ['window', 'screen'] });
expect(sources2).to.be.an('array').that.is.not.empty();
});
ifit(process.platform !== 'linux')('responds to subsequent calls of different options', async () => {
const promise1 = desktopCapturer.getSources({ types: ['window'] });
await expect(promise1).to.eventually.be.fulfilled();
ifit(process.platform !== 'linux')('responds to subsequent calls of different options', async () => {
const promise1 = getSources({ types: ['window'] });
await expect(promise1).to.eventually.be.fulfilled();
const promise2 = desktopCapturer.getSources({ types: ['screen'] });
await expect(promise2).to.eventually.be.fulfilled();
});
const promise2 = getSources({ types: ['screen'] });
await expect(promise2).to.eventually.be.fulfilled();
});
// Linux doesn't return any window sources.
ifit(process.platform !== 'linux')('returns an empty display_id for window sources on Windows and Mac', async () => {
const w = new BrowserWindow({ width: 200, height: 200 });
await w.loadURL('about:blank');
// Linux doesn't return any window sources.
ifit(process.platform !== 'linux')('returns an empty display_id for window sources on Windows and Mac', async () => {
const w = new BrowserWindow({ width: 200, height: 200 });
await w.loadURL('about:blank');
const sources = await desktopCapturer.getSources({ types: ['window'] });
w.destroy();
expect(sources).to.be.an('array').that.is.not.empty();
for (const { display_id: displayId } of sources) {
expect(displayId).to.be.a('string').and.be.empty();
}
});
const sources = await getSources({ types: ['window'] });
w.destroy();
expect(sources).to.be.an('array').that.is.not.empty();
for (const { display_id: displayId } of sources) {
expect(displayId).to.be.a('string').and.be.empty();
}
});
ifit(process.platform !== 'linux')('returns display_ids matching the Screen API on Windows and Mac', async () => {
const displays = screen.getAllDisplays();
const sources = await desktopCapturer.getSources({ types: ['screen'] });
expect(sources).to.be.an('array').of.length(displays.length);
ifit(process.platform !== 'linux')('returns display_ids matching the Screen API on Windows and Mac', async () => {
const displays = screen.getAllDisplays();
const sources = await getSources({ types: ['screen'] });
expect(sources).to.be.an('array').of.length(displays.length);
for (let i = 0; i < sources.length; i++) {
expect(sources[i].display_id).to.equal(displays[i].id.toString());
}
});
});
};
generateSpecs('in renderer process', getSources);
generateSpecs('in main process', desktopCapturer.getSources);
ifit(process.platform !== 'linux')('returns an empty source list if blocked by the main process', async () => {
w.webContents.once('desktop-capturer-get-sources', (event) => {
event.preventDefault();
});
const sources = await getSources({ types: ['screen'] });
expect(sources).to.be.empty();
for (let i = 0; i < sources.length; i++) {
expect(sources[i].display_id).to.equal(displays[i].id.toString());
}
});
it('disabling thumbnail should return empty images', async () => {
@ -101,14 +79,10 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
w2.show();
await wShown;
const isEmpties: boolean[] = await w.webContents.executeJavaScript(`
require('electron').desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 0, height: 0 }
}).then((sources) => {
return sources.map(s => s.thumbnail.constructor.name === 'NativeImage' && s.thumbnail.isEmpty())
})
`);
const isEmpties: boolean[] = (await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 0, height: 0 }
})).map(s => s.thumbnail.constructor.name === 'NativeImage' && s.thumbnail.isEmpty());
w2.destroy();
expect(isEmpties).to.be.an('array').that.is.not.empty();
@ -125,7 +99,7 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
await wFocused;
const mediaSourceId = w.getMediaSourceId();
const sources = await getSources({
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
@ -161,7 +135,7 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
const ids = mediaSourceId.split(':');
expect(ids[1]).to.not.equal(ids[2]);
const sources = await getSources({
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
@ -206,7 +180,7 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
// DesktopCapturer.getSources() returns sources sorted from foreground to
// background, i.e. top to bottom.
let sources = await getSources({
let sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
@ -252,7 +226,7 @@ ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('deskt
}
});
sources = await getSources({
sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});