Browse Source

feat: session.setDisplayMediaRequestHandler (#30702)

Jeremy Rose 2 years ago
parent
commit
221bb51326

+ 54 - 0
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\<boolean> | null

+ 2 - 0
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",

+ 1 - 1
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",

+ 21 - 0
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<v8::Value> val,
   permission_manager->SetPermissionCheckHandler(handler);
 }
 
+void Session::SetDisplayMediaRequestHandler(v8::Isolate* isolate,
+                                            v8::Local<v8::Value> 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<v8::Value> 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)

+ 3 - 0
shell/browser/api/electron_api_session.h

@@ -179,6 +179,9 @@ class Session : public gin::Wrappable<Session>,
 #endif
 
  private:
+  void SetDisplayMediaRequestHandler(v8::Isolate* isolate,
+                                     v8::Local<v8::Value> val);
+
   // Cached gin_helper::Wrappable objects.
   v8::Global<v8::Value> cookies_;
   v8::Global<v8::Value> protocol_;

+ 131 - 0
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<v8::Value> 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,

+ 26 - 0
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 <typename T>
+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<void(gin::Arguments* args)>;
+using DisplayMediaRequestHandler =
+    base::RepeatingCallback<void(const content::MediaStreamRequest&,
+                                 DisplayMediaResponseCallbackJs)>;
+
 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<network::mojom::SSLConfigClient> 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<network::mojom::SSLConfigClient> ssl_config_client_;
 
+  DisplayMediaRequestHandler display_media_request_handler_;
+
   // In-memory cache that holds objects that have been granted permissions.
   DevicePermissionMap granted_devices_;
 

+ 30 - 6
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<ElectronBrowserContext*>(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(),

+ 13 - 0
shell/common/gin_converters/frame_converter.cc

@@ -26,6 +26,19 @@ v8::Local<v8::Value> Converter<content::RenderFrameHost*>::ToV8(
   return electron::api::WebFrameMain::From(isolate, val).ToV8();
 }
 
+// static
+bool Converter<content::RenderFrameHost*>::FromV8(
+    v8::Isolate* isolate,
+    v8::Local<v8::Value> 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<v8::Value>
 Converter<gin_helper::AccessorValue<content::RenderFrameHost*>>::ToV8(

+ 3 - 0
shell/common/gin_converters/frame_converter.h

@@ -18,6 +18,9 @@ template <>
 struct Converter<content::RenderFrameHost*> {
   static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
                                    content::RenderFrameHost* val);
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> val,
+                     content::RenderFrameHost** out);
 };
 
 template <>

+ 36 - 0
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 <string>
+#include <utility>
+
+#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<v8::Value> Converter<content::MediaStreamRequest>::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

+ 26 - 0
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<content::MediaStreamRequest> {
+  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
+                                   const content::MediaStreamRequest& request);
+};
+
+}  // namespace gin
+
+#endif  // ELECTRON_SHELL_COMMON_GIN_CONVERTERS_MEDIA_CONVERTER_H_

+ 361 - 0
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<void>(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');
+  });
+});

+ 4 - 4
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"