diff --git a/docs/api/session.md b/docs/api/session.md index f405d133ba523..d80e2831b0f26 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -698,6 +698,60 @@ session.fromPartition('some-partition').setPermissionCheckHandler((webContents, }) ``` +#### `ses.setDisplayMediaRequestHandler(handler)` + +* `handler` Function | null + * `request` Object + * `frame` [WebFrameMain](web-frame-main.md) - Frame that is requesting access to media. + * `securityOrigin` String - Origin of the page making the request. + * `videoRequested` Boolean - true if the web content requested a video stream. + * `audioRequested` Boolean - true if the web content requested an audio stream. + * `userGesture` Boolean - Whether a user gesture was active when this request was triggered. + * `callback` Function + * `streams` Object + * `video` Object | [WebFrameMain](web-frame-main.md) (optional) + * `id` String - The id of the stream being granted. This will usually + come from a [DesktopCapturerSource](structures/desktop-capturer-source.md) + object. + * `name` String - The name of the stream being granted. This will + usually come from a [DesktopCapturerSource](structures/desktop-capturer-source.md) + object. + * `audio` String | [WebFrameMain](web-frame-main.md) (optional) - If + a string is specified, can be `loopback` or `loopbackWithMute`. + Specifying a loopback device will capture system audio, and is + currently only supported on Windows. If a WebFrameMain is specified, + will capture audio from that frame. + +This handler will be called when web content requests access to display media +via the `navigator.mediaDevices.getDisplayMedia` API. Use the +[desktopCapturer](desktop-capturer.md) API to choose which stream(s) to grant +access to. + +```javascript +const { session, desktopCapturer } = require('electron') + +session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { + desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { + // Grant access to the first screen found. + callback({ video: sources[0] }) + }) +}) +``` + +Passing a [WebFrameMain](web-frame-main.md) object as a video or audio stream +will capture the video or audio stream from that frame. + +```javascript +const { session } = require('electron') + +session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { + // Allow the tab to capture itself. + callback({ video: request.frame }) +}) +``` + +Passing `null` instead of a function resets the handler to its default state. + #### `ses.setDevicePermissionHandler(handler)` * `handler` Function\ | null diff --git a/filenames.gni b/filenames.gni index 9fda05b41f983..5b579ebe8a057 100644 --- a/filenames.gni +++ b/filenames.gni @@ -577,6 +577,8 @@ filenames = { "shell/common/gin_converters/hid_device_info_converter.h", "shell/common/gin_converters/image_converter.cc", "shell/common/gin_converters/image_converter.h", + "shell/common/gin_converters/media_converter.cc", + "shell/common/gin_converters/media_converter.h", "shell/common/gin_converters/message_box_converter.cc", "shell/common/gin_converters/message_box_converter.h", "shell/common/gin_converters/native_window_converter.h", diff --git a/package.json b/package.json index 128a200cd8a67..8bd2ec7d85745 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "devDependencies": { "@azure/storage-blob": "^12.9.0", "@electron/docs-parser": "^0.12.4", - "@electron/typescript-definitions": "^8.9.5", + "@electron/typescript-definitions": "^8.9.6", "@octokit/auth-app": "^2.10.0", "@octokit/rest": "^18.0.3", "@primer/octicons": "^10.0.0", diff --git a/shell/browser/api/electron_api_session.cc b/shell/browser/api/electron_api_session.cc index 3b691b06bdbc3..f7be2cc0a35bb 100644 --- a/shell/browser/api/electron_api_session.cc +++ b/shell/browser/api/electron_api_session.cc @@ -52,6 +52,7 @@ #include "shell/browser/api/electron_api_net_log.h" #include "shell/browser/api/electron_api_protocol.h" #include "shell/browser/api/electron_api_service_worker_context.h" +#include "shell/browser/api/electron_api_web_frame_main.h" #include "shell/browser/api/electron_api_web_request.h" #include "shell/browser/browser.h" #include "shell/browser/electron_browser_context.h" @@ -65,6 +66,7 @@ #include "shell/common/gin_converters/content_converter.h" #include "shell/common/gin_converters/file_path_converter.h" #include "shell/common/gin_converters/gurl_converter.h" +#include "shell/common/gin_converters/media_converter.h" #include "shell/common/gin_converters/net_converter.h" #include "shell/common/gin_converters/value_converter.h" #include "shell/common/gin_helper/dictionary.h" @@ -73,6 +75,7 @@ #include "shell/common/options_switches.h" #include "shell/common/process_util.h" #include "third_party/blink/public/common/storage_key/storage_key.h" +#include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h" #include "ui/base/l10n/l10n_util.h" #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) @@ -643,6 +646,22 @@ void Session::SetPermissionCheckHandler(v8::Local val, permission_manager->SetPermissionCheckHandler(handler); } +void Session::SetDisplayMediaRequestHandler(v8::Isolate* isolate, + v8::Local val) { + if (val->IsNull()) { + browser_context_->SetDisplayMediaRequestHandler( + DisplayMediaRequestHandler()); + return; + } + DisplayMediaRequestHandler handler; + if (!gin::ConvertFromV8(isolate, val, &handler)) { + gin_helper::ErrorThrower(isolate).ThrowTypeError( + "Display media request handler must be null or a function"); + return; + } + browser_context_->SetDisplayMediaRequestHandler(handler); +} + void Session::SetDevicePermissionHandler(v8::Local val, gin::Arguments* args) { ElectronPermissionManager::DeviceCheckHandler handler; @@ -1198,6 +1217,8 @@ gin::ObjectTemplateBuilder Session::GetObjectTemplateBuilder( &Session::SetPermissionRequestHandler) .SetMethod("setPermissionCheckHandler", &Session::SetPermissionCheckHandler) + .SetMethod("setDisplayMediaRequestHandler", + &Session::SetDisplayMediaRequestHandler) .SetMethod("setDevicePermissionHandler", &Session::SetDevicePermissionHandler) .SetMethod("clearHostResolverCache", &Session::ClearHostResolverCache) diff --git a/shell/browser/api/electron_api_session.h b/shell/browser/api/electron_api_session.h index c5f21920dcc6e..164b4c292b644 100644 --- a/shell/browser/api/electron_api_session.h +++ b/shell/browser/api/electron_api_session.h @@ -179,6 +179,9 @@ class Session : public gin::Wrappable, #endif private: + void SetDisplayMediaRequestHandler(v8::Isolate* isolate, + v8::Local val); + // Cached gin_helper::Wrappable objects. v8::Global cookies_; v8::Global protocol_; diff --git a/shell/browser/electron_browser_context.cc b/shell/browser/electron_browser_context.cc index 432ea24c9ea22..71c88ab13fa89 100644 --- a/shell/browser/electron_browser_context.cc +++ b/shell/browser/electron_browser_context.cc @@ -31,8 +31,11 @@ #include "content/browser/blob_storage/chrome_blob_storage_context.h" // nogncheck #include "content/public/browser/browser_thread.h" #include "content/public/browser/cors_origin_pattern_setter.h" +#include "content/public/browser/render_process_host.h" #include "content/public/browser/shared_cors_origin_access_list.h" #include "content/public/browser/storage_partition.h" +#include "content/public/browser/web_contents_media_capture_id.h" +#include "media/audio/audio_device_description.h" #include "services/network/public/cpp/features.h" #include "services/network/public/cpp/wrapper_shared_url_loader_factory.h" #include "services/network/public/mojom/network_context.mojom.h" @@ -51,7 +54,10 @@ #include "shell/browser/zoom_level_delegate.h" #include "shell/common/application_info.h" #include "shell/common/electron_paths.h" +#include "shell/common/gin_converters/frame_converter.h" +#include "shell/common/gin_helper/error_thrower.h" #include "shell/common/options_switches.h" +#include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h" #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) #include "extensions/browser/browser_context_keyed_service_factories.h" @@ -412,6 +418,131 @@ void ElectronBrowserContext::SetSSLConfigClient( ssl_config_client_ = std::move(client); } +void ElectronBrowserContext::SetDisplayMediaRequestHandler( + DisplayMediaRequestHandler handler) { + display_media_request_handler_ = handler; +} + +void ElectronBrowserContext::DisplayMediaDeviceChosen( + const content::MediaStreamRequest& request, + content::MediaResponseCallback callback, + gin::Arguments* args) { + blink::mojom::StreamDevicesSetPtr stream_devices_set = + blink::mojom::StreamDevicesSet::New(); + v8::Local result; + if (!args->GetNext(&result) || result->IsNullOrUndefined()) { + std::move(callback).Run( + blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::CAPTURE_FAILURE, nullptr); + return; + } + gin_helper::Dictionary result_dict; + if (!gin::ConvertFromV8(args->isolate(), result, &result_dict)) { + gin_helper::ErrorThrower(args->isolate()) + .ThrowTypeError( + "Display Media Request streams callback must be called with null " + "or a valid object"); + std::move(callback).Run( + blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::CAPTURE_FAILURE, nullptr); + return; + } + stream_devices_set->stream_devices.emplace_back( + blink::mojom::StreamDevices::New()); + blink::mojom::StreamDevices& devices = *stream_devices_set->stream_devices[0]; + bool video_requested = + request.video_type != blink::mojom::MediaStreamType::NO_SERVICE; + bool audio_requested = + request.audio_type != blink::mojom::MediaStreamType::NO_SERVICE; + bool has_video = false; + if (video_requested && result_dict.Has("video")) { + gin_helper::Dictionary video_dict; + std::string id; + std::string name; + content::RenderFrameHost* rfh; + if (result_dict.Get("video", &video_dict) && video_dict.Get("id", &id) && + video_dict.Get("name", &name)) { + devices.video_device = + blink::MediaStreamDevice(request.video_type, id, name); + } else if (result_dict.Get("video", &rfh)) { + devices.video_device = blink::MediaStreamDevice( + request.video_type, + content::WebContentsMediaCaptureId(rfh->GetProcess()->GetID(), + rfh->GetRoutingID()) + .ToString(), + base::UTF16ToUTF8( + content::WebContents::FromRenderFrameHost(rfh)->GetTitle())); + } else { + gin_helper::ErrorThrower(args->isolate()) + .ThrowTypeError( + "video must be a WebFrameMain or DesktopCapturerSource"); + std::move(callback).Run( + blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::CAPTURE_FAILURE, nullptr); + return; + } + has_video = true; + } + if (audio_requested && result_dict.Has("audio")) { + gin_helper::Dictionary audio_dict; + std::string id; + std::string name; + content::RenderFrameHost* rfh; + // NB. this is not permitted by the documentation, but is left here as an + // "escape hatch" for providing an arbitrary name/id if needed in the + // future. + if (result_dict.Get("audio", &audio_dict) && audio_dict.Get("id", &id) && + audio_dict.Get("name", &name)) { + devices.audio_device = + blink::MediaStreamDevice(request.audio_type, id, name); + } else if (result_dict.Get("audio", &rfh)) { + devices.audio_device = blink::MediaStreamDevice( + request.audio_type, + content::WebContentsMediaCaptureId(rfh->GetProcess()->GetID(), + rfh->GetRoutingID(), + /* disable_local_echo= */ true) + .ToString(), + "Tab audio"); + } else if (result_dict.Get("audio", &id)) { + devices.audio_device = + blink::MediaStreamDevice(request.audio_type, id, "System audio"); + } else { + gin_helper::ErrorThrower(args->isolate()) + .ThrowTypeError( + "audio must be a WebFrameMain, \"loopback\" or " + "\"loopbackWithMute\""); + std::move(callback).Run( + blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::CAPTURE_FAILURE, nullptr); + return; + } + } + + if ((video_requested && !has_video)) { + gin_helper::ErrorThrower(args->isolate()) + .ThrowTypeError( + "Video was requested, but no video stream was provided"); + std::move(callback).Run( + blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::CAPTURE_FAILURE, nullptr); + return; + } + + std::move(callback).Run(*stream_devices_set, + blink::mojom::MediaStreamRequestResult::OK, nullptr); +} + +bool ElectronBrowserContext::ChooseDisplayMediaDevice( + const content::MediaStreamRequest& request, + content::MediaResponseCallback callback) { + if (!display_media_request_handler_) + return false; + DisplayMediaResponseCallbackJs callbackJs = + base::BindOnce(&DisplayMediaDeviceChosen, request, std::move(callback)); + display_media_request_handler_.Run(request, std::move(callbackJs)); + return true; +} + void ElectronBrowserContext::GrantDevicePermission( const url::Origin& origin, const base::Value& device, diff --git a/shell/browser/electron_browser_context.h b/shell/browser/electron_browser_context.h index b1fa4715cb758..7117aecdb1dc7 100644 --- a/shell/browser/electron_browser_context.h +++ b/shell/browser/electron_browser_context.h @@ -13,8 +13,10 @@ #include "base/memory/weak_ptr.h" #include "chrome/browser/predictors/preconnect_manager.h" #include "content/public/browser/browser_context.h" +#include "content/public/browser/media_stream_request.h" #include "content/public/browser/resource_context.h" #include "electron/buildflags/buildflags.h" +#include "gin/arguments.h" #include "mojo/public/cpp/bindings/remote.h" #include "services/network/public/mojom/network_context.mojom.h" #include "services/network/public/mojom/url_loader_factory.mojom.h" @@ -38,6 +40,13 @@ class ElectronExtensionSystem; } #endif +namespace v8 { +template +class Local; +class Isolate; +class Value; +} // namespace v8 + namespace electron { using DevicePermissionMap = @@ -51,6 +60,12 @@ class ResolveProxyHelper; class WebViewManager; class ProtocolRegistry; +using DisplayMediaResponseCallbackJs = + base::OnceCallback; +using DisplayMediaRequestHandler = + base::RepeatingCallback; + class ElectronBrowserContext : public content::BrowserContext { public: // disable copy @@ -150,6 +165,10 @@ class ElectronBrowserContext : public content::BrowserContext { network::mojom::SSLConfigPtr GetSSLConfig(); void SetSSLConfigClient(mojo::Remote client); + bool ChooseDisplayMediaDevice(const content::MediaStreamRequest& request, + content::MediaResponseCallback callback); + void SetDisplayMediaRequestHandler(DisplayMediaRequestHandler handler); + ~ElectronBrowserContext() override; // Grants |origin| access to |device|. @@ -176,6 +195,11 @@ class ElectronBrowserContext : public content::BrowserContext { bool in_memory, base::Value::Dict options); + static void DisplayMediaDeviceChosen( + const content::MediaStreamRequest& request, + content::MediaResponseCallback callback, + gin::Arguments* args); + // Initialize pref registry. void InitPrefs(); @@ -214,6 +238,8 @@ class ElectronBrowserContext : public content::BrowserContext { network::mojom::SSLConfigPtr ssl_config_; mojo::Remote ssl_config_client_; + DisplayMediaRequestHandler display_media_request_handler_; + // In-memory cache that holds objects that have been granted permissions. DevicePermissionMap granted_devices_; diff --git a/shell/browser/web_contents_permission_helper.cc b/shell/browser/web_contents_permission_helper.cc index 49dcfc1a1c6cd..712558823dc7d 100644 --- a/shell/browser/web_contents_permission_helper.cc +++ b/shell/browser/web_contents_permission_helper.cc @@ -111,19 +111,43 @@ void MediaAccessAllowed(const content::MediaStreamRequest& request, request.video_type == blink::mojom::MediaStreamType::GUM_TAB_VIDEO_CAPTURE || request.audio_type == - blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE) + blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE) { HandleUserMediaRequest(request, std::move(callback)); - else if (request.video_type == - blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE || - request.audio_type == - blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE) + } else if (request.video_type == + blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE || + request.audio_type == + blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE) { webrtc::MediaStreamDevicesController::RequestPermissions( request, MediaCaptureDevicesDispatcher::GetInstance(), base::BindOnce(&OnMediaStreamRequestResponse, std::move(callback))); - else + } else if (request.video_type == + blink::mojom::MediaStreamType::DISPLAY_VIDEO_CAPTURE || + request.video_type == blink::mojom::MediaStreamType:: + DISPLAY_VIDEO_CAPTURE_THIS_TAB || + request.video_type == + blink::mojom::MediaStreamType::DISPLAY_VIDEO_CAPTURE_SET || + request.audio_type == + blink::mojom::MediaStreamType::DISPLAY_AUDIO_CAPTURE) { + content::RenderFrameHost* rfh = content::RenderFrameHost::FromID( + request.render_process_id, request.render_frame_id); + if (!rfh) + return; + + content::BrowserContext* browser_context = rfh->GetBrowserContext(); + ElectronBrowserContext* electron_browser_context = + static_cast(browser_context); + auto split_callback = base::SplitOnceCallback(std::move(callback)); + if (electron_browser_context->ChooseDisplayMediaDevice( + request, std::move(split_callback.second))) + return; + std::move(split_callback.first) + .Run(blink::mojom::StreamDevicesSet(), + blink::mojom::MediaStreamRequestResult::NOT_SUPPORTED, nullptr); + } else { std::move(callback).Run( blink::mojom::StreamDevicesSet(), blink::mojom::MediaStreamRequestResult::NOT_SUPPORTED, nullptr); + } } else { std::move(callback).Run( blink::mojom::StreamDevicesSet(), diff --git a/shell/common/gin_converters/frame_converter.cc b/shell/common/gin_converters/frame_converter.cc index 6be7d3b681c65..35ae7f2fccb3e 100644 --- a/shell/common/gin_converters/frame_converter.cc +++ b/shell/common/gin_converters/frame_converter.cc @@ -26,6 +26,19 @@ v8::Local Converter::ToV8( return electron::api::WebFrameMain::From(isolate, val).ToV8(); } +// static +bool Converter::FromV8( + v8::Isolate* isolate, + v8::Local val, + content::RenderFrameHost** out) { + electron::api::WebFrameMain* web_frame_main = nullptr; + if (!ConvertFromV8(isolate, val, &web_frame_main)) + return false; + *out = web_frame_main->render_frame_host(); + + return true; +} + // static v8::Local Converter>::ToV8( diff --git a/shell/common/gin_converters/frame_converter.h b/shell/common/gin_converters/frame_converter.h index 56f87f598b2cd..732cb16d47b13 100644 --- a/shell/common/gin_converters/frame_converter.h +++ b/shell/common/gin_converters/frame_converter.h @@ -18,6 +18,9 @@ template <> struct Converter { static v8::Local ToV8(v8::Isolate* isolate, content::RenderFrameHost* val); + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + content::RenderFrameHost** out); }; template <> diff --git a/shell/common/gin_converters/media_converter.cc b/shell/common/gin_converters/media_converter.cc new file mode 100644 index 0000000000000..0da6206feb7b3 --- /dev/null +++ b/shell/common/gin_converters/media_converter.cc @@ -0,0 +1,36 @@ +// Copyright (c) 2021 Slack Technologies, LLC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/common/gin_converters/media_converter.h" + +#include +#include + +#include "content/public/browser/media_stream_request.h" +#include "content/public/browser/render_frame_host.h" +#include "gin/data_object_builder.h" +#include "shell/common/gin_converters/frame_converter.h" +#include "shell/common/gin_converters/gurl_converter.h" +#include "shell/common/gin_helper/dictionary.h" +#include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h" + +namespace gin { + +v8::Local Converter::ToV8( + v8::Isolate* isolate, + const content::MediaStreamRequest& request) { + content::RenderFrameHost* rfh = content::RenderFrameHost::FromID( + request.render_process_id, request.render_frame_id); + return gin::DataObjectBuilder(isolate) + .Set("frame", rfh) + .Set("securityOrigin", request.security_origin) + .Set("userGesture", request.user_gesture) + .Set("videoRequested", + request.video_type != blink::mojom::MediaStreamType::NO_SERVICE) + .Set("audioRequested", + request.audio_type != blink::mojom::MediaStreamType::NO_SERVICE) + .Build(); +} + +} // namespace gin diff --git a/shell/common/gin_converters/media_converter.h b/shell/common/gin_converters/media_converter.h new file mode 100644 index 0000000000000..7e2b22073fd89 --- /dev/null +++ b/shell/common/gin_converters/media_converter.h @@ -0,0 +1,26 @@ +// Copyright (c) 2021 Slack Technologies, LLC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_COMMON_GIN_CONVERTERS_MEDIA_CONVERTER_H_ +#define ELECTRON_SHELL_COMMON_GIN_CONVERTERS_MEDIA_CONVERTER_H_ + +#include "gin/converter.h" +#include "third_party/blink/public/common/mediastream/media_stream_request.h" +#include "third_party/blink/public/mojom/mediastream/media_stream.mojom-forward.h" + +namespace content { +struct MediaStreamRequest; +} + +namespace gin { + +template <> +struct Converter { + static v8::Local ToV8(v8::Isolate* isolate, + const content::MediaStreamRequest& request); +}; + +} // namespace gin + +#endif // ELECTRON_SHELL_COMMON_GIN_CONVERTERS_MEDIA_CONVERTER_H_ diff --git a/spec-main/api-media-handler-spec.ts b/spec-main/api-media-handler-spec.ts new file mode 100644 index 0000000000000..65dab3690f55a --- /dev/null +++ b/spec-main/api-media-handler-spec.ts @@ -0,0 +1,361 @@ +import { expect } from 'chai'; +import { BrowserWindow, session, desktopCapturer } from 'electron/main'; +import { closeAllWindows } from './window-helpers'; +import * as http from 'http'; +import { ifdescribe, ifit } from './spec-helpers'; + +const features = process._linkedBinding('electron_common_features'); + +ifdescribe(features.isDesktopCapturerEnabled())('setDisplayMediaRequestHandler', () => { + afterEach(closeAllWindows); + // These tests are done on an http server because navigator.userAgentData + // requires a secure context. + let server: http.Server; + let serverUrl: string; + before(async () => { + server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(''); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://localhost:${(server.address() as any).port}`; + }); + after(() => { + server.close(); + }); + + // NOTE(nornagon): this test fails on our macOS CircleCI runners with the + // error message: + // [ERROR:video_capture_device_client.cc(659)] error@ OnStart@content/browser/media/capture/desktop_capture_device_mac.cc:98, CGDisplayStreamCreate failed, OS message: Value too large to be stored in data type (84) + // This is possibly related to the OS/VM setup that CircleCI uses for macOS. + // Our arm64 runners are in @jkleinsc's office, and are real machines, so the + // test works there. + ifit(!(process.platform === 'darwin' && process.arch === 'x64'))('works when calling getDisplayMedia', async function () { + if ((await desktopCapturer.getSources({ types: ['screen'] })).length === 0) { return this.skip(); } + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + let mediaRequest: any = null; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + mediaRequest = request; + desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { + // Grant access to the first screen found. + const { id, name } = sources[0]; + callback({ + video: { id, name } + // TODO: 'loopback' and 'loopbackWithMute' are currently only supported on Windows. + // audio: { id: 'loopback', name: 'System Audio' } + }); + }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(mediaRequest.videoRequested).to.be.true(); + expect(mediaRequest.audioRequested).to.be.false(); + expect(ok).to.be.true(message); + }); + + it('does not crash when using a bogus ID', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + callback({ + video: { id: 'bogus', name: 'whatever' } + }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.false(); + expect(message).to.equal('Could not start video source'); + }); + + it('does not crash when providing only audio for a video request', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + let callbackError: any; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + try { + callback({ + audio: 'loopback' + }); + } catch (e) { + callbackError = e; + } + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.false(); + expect(callbackError?.message).to.equal('Video was requested, but no video stream was provided'); + }); + + it('does not crash when providing only an audio stream for an audio+video request', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + let callbackError: any; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + try { + callback({ + audio: 'loopback' + }); + } catch (e) { + callbackError = e; + } + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.false(); + expect(callbackError?.message).to.equal('Video was requested, but no video stream was provided'); + }); + + it('does not crash when providing a non-loopback audio stream', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + callback({ + video: w.webContents.mainFrame, + audio: 'default' as any + }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.true(); + }); + + it('does not crash when providing no streams', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + let callbackError: any; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + try { + callback({}); + } catch (e) { + callbackError = e; + } + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.false(); + expect(callbackError.message).to.equal('Video was requested, but no video stream was provided'); + }); + + it('does not crash when using a bogus web-contents-media-stream:// ID', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + callback({ + video: { id: 'web-contents-media-stream://9999:9999', name: 'whatever' } + }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + // This is a little surprising... apparently chrome will generate a stream + // for this non-existent web contents? + expect(ok).to.be.true(); + }); + + it('is not called when calling getUserMedia', async () => { + const ses = session.fromPartition('' + Math.random()); + ses.setDisplayMediaRequestHandler(() => { + throw new Error('bad'); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(ok).to.be.true(message); + }); + + it('works when calling getDisplayMedia with preferCurrentTab', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler((request, callback) => { + requestHandlerCalled = true; + callback({ video: w.webContents.mainFrame }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + preferCurrentTab: true, + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.true(message); + }); + + ifit(!(process.platform === 'darwin' && process.arch === 'x64'))('can supply a screen response to preferCurrentTab', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler(async (request, callback) => { + requestHandlerCalled = true; + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + callback({ video: sources[0] }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + preferCurrentTab: true, + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.true(message); + }); + + it('can supply a frame response', async () => { + const ses = session.fromPartition('' + Math.random()); + let requestHandlerCalled = false; + ses.setDisplayMediaRequestHandler(async (request, callback) => { + requestHandlerCalled = true; + callback({ video: w.webContents.mainFrame }); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(requestHandlerCalled).to.be.true(); + expect(ok).to.be.true(message); + }); + + it('is not called when calling legacy getUserMedia', async () => { + const ses = session.fromPartition('' + Math.random()); + ses.setDisplayMediaRequestHandler(() => { + throw new Error('bad'); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + new Promise((resolve, reject) => navigator.getUserMedia({ + video: true, + audio: true, + }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message}))) + `); + expect(ok).to.be.true(message); + }); + + it('is not called when calling legacy getUserMedia with desktop capture constraint', async () => { + const ses = session.fromPartition('' + Math.random()); + ses.setDisplayMediaRequestHandler(() => { + throw new Error('bad'); + }); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + new Promise((resolve, reject) => navigator.getUserMedia({ + video: { + mandatory: { + chromeMediaSource: 'desktop' + } + }, + }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message}))) + `); + expect(ok).to.be.true(message); + }); + + it('works when calling getUserMedia without a media request handler', async () => { + const w = new BrowserWindow({ show: false }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(ok).to.be.true(message); + }); + + it('works when calling legacy getUserMedia without a media request handler', async () => { + const w = new BrowserWindow({ show: false }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + new Promise((resolve, reject) => navigator.getUserMedia({ + video: true, + audio: true, + }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message}))) + `); + expect(ok).to.be.true(message); + }); + + it('can remove a displayMediaRequestHandler', async () => { + const ses = session.fromPartition('' + Math.random()); + + ses.setDisplayMediaRequestHandler(() => { + throw new Error('bad'); + }); + ses.setDisplayMediaRequestHandler(null); + const w = new BrowserWindow({ show: false, webPreferences: { session: ses } }); + await w.loadURL(serverUrl); + const { ok, message } = await w.webContents.executeJavaScript(` + navigator.mediaDevices.getDisplayMedia({ + video: true, + }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message})) + `); + expect(ok).to.be.false(); + expect(message).to.equal('Not supported'); + }); +}); diff --git a/yarn.lock b/yarn.lock index c9adc494cf72f..0d299493375e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -126,10 +126,10 @@ ora "^4.0.3" pretty-ms "^5.1.0" -"@electron/typescript-definitions@^8.9.5": - version "8.9.5" - resolved "https://registry.yarnpkg.com/@electron/typescript-definitions/-/typescript-definitions-8.9.5.tgz#e6cb08e0e7c9656e178b892eab50866a8a80bf7a" - integrity sha512-xDLFl6joGpA8c9cGSPWC3DFHyIGf9+OWZmDrPbGJW1URt6C1ukdQWKSmjb1Rttb94QQxBrGuUlSyz27IQgLFsw== +"@electron/typescript-definitions@^8.9.6": + version "8.9.6" + resolved "https://registry.yarnpkg.com/@electron/typescript-definitions/-/typescript-definitions-8.9.6.tgz#99575209b12ae00784190282e5b636a44f1beabc" + integrity sha512-Hlvzo0A5iuRFICOB/xIADKKc1axCA4G13vsCC5ZcG6VVvJPsmPrjr2/npb9Aebfzm4OUbdoPHS952lqPXFLFXQ== dependencies: "@types/node" "^11.13.7" chalk "^2.4.2"