Browse Source

feat: add support for WebUSB (#36289)

* feat: add support for WebUSB

* fixup for gn check

* fixup gn check on Windows

* Apply review feedback

Co-authored-by: Charles Kerr <[email protected]>

* chore: address review feedback

* chore: removed unneeded code

* Migrate non-default ScopedObservation<> instantiations to ScopedObservationTraits<> in chrome/browser/

https://chromium-review.googlesource.com/c/chromium/src/+/4016595

Co-authored-by: Charles Kerr <[email protected]>
John Kleinschmidt 2 years ago
parent
commit
629c54ba36

+ 117 - 3
docs/api/session.md

@@ -239,7 +239,7 @@ app.whenReady().then(() => {
     const selectedDevice = details.deviceList.find((device) => {
       return device.vendorId === '9025' && device.productId === '67'
     })
-    callback(selectedPort?.deviceId)
+    callback(selectedDevice?.deviceId)
   })
 })
 ```
@@ -429,6 +429,118 @@ const portConnect = async () => {
 }
 ```
 
+#### Event: 'select-usb-device'
+
+Returns:
+
+* `event` Event
+* `details` Object
+  * `deviceList` [USBDevice[]](structures/usb-device.md)
+  * `frame` [WebFrameMain](web-frame-main.md)
+* `callback` Function
+  * `deviceId` string (optional)
+
+Emitted when a USB device needs to be selected when a call to
+`navigator.usb.requestDevice` is made. `callback` should be called with
+`deviceId` to be selected; passing no arguments to `callback` will
+cancel the request.  Additionally, permissioning on `navigator.usb` can
+be further managed by using [ses.setPermissionCheckHandler(handler)](#sessetpermissioncheckhandlerhandler)
+and [ses.setDevicePermissionHandler(handler)`](#sessetdevicepermissionhandlerhandler).
+
+```javascript
+const { app, BrowserWindow } = require('electron')
+
+let win = null
+
+app.whenReady().then(() => {
+  win = new BrowserWindow()
+
+  win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
+    if (permission === 'usb') {
+      // Add logic here to determine if permission should be given to allow USB selection
+      return true
+    }
+    return false
+  })
+
+  // Optionally, retrieve previously persisted devices from a persistent store (fetchGrantedDevices needs to be implemented by developer to fetch persisted permissions)
+  const grantedDevices = fetchGrantedDevices()
+
+  win.webContents.session.setDevicePermissionHandler((details) => {
+    if (new URL(details.origin).hostname === 'some-host' && details.deviceType === 'usb') {
+      if (details.device.vendorId === 123 && details.device.productId === 345) {
+        // Always allow this type of device (this allows skipping the call to `navigator.usb.requestDevice` first)
+        return true
+      }
+
+      // Search through the list of devices that have previously been granted permission
+      return grantedDevices.some((grantedDevice) => {
+        return grantedDevice.vendorId === details.device.vendorId &&
+              grantedDevice.productId === details.device.productId &&
+              grantedDevice.serialNumber && grantedDevice.serialNumber === details.device.serialNumber
+      })
+    }
+    return false
+  })
+
+  win.webContents.session.on('select-usb-device', (event, details, callback) => {
+    event.preventDefault()
+    const selectedDevice = details.deviceList.find((device) => {
+      return device.vendorId === '9025' && device.productId === '67'
+    })
+    if (selectedDevice) {
+      // Optionally, add this to the persisted devices (updateGrantedDevices needs to be implemented by developer to persist permissions)
+      grantedDevices.push(selectedDevice)
+      updateGrantedDevices(grantedDevices)
+    }
+    callback(selectedDevice?.deviceId)
+  })
+})
+```
+
+#### Event: 'usb-device-added'
+
+Returns:
+
+* `event` Event
+* `details` Object
+  * `device` [USBDevice](structures/usb-device.md)
+  * `frame` [WebFrameMain](web-frame-main.md)
+
+Emitted after `navigator.usb.requestDevice` has been called and
+`select-usb-device` has fired if a new device becomes available before
+the callback from `select-usb-device` is called.  This event is intended for
+use when using a UI to ask users to pick a device so that the UI can be updated
+with the newly added device.
+
+#### Event: 'usb-device-removed'
+
+Returns:
+
+* `event` Event
+* `details` Object
+  * `device` [USBDevice](structures/usb-device.md)
+  * `frame` [WebFrameMain](web-frame-main.md)
+
+Emitted after `navigator.usb.requestDevice` has been called and
+`select-usb-device` has fired if a device has been removed before the callback
+from `select-usb-device` is called.  This event is intended for use when using
+a UI to ask users to pick a device so that the UI can be updated to remove the
+specified device.
+
+#### Event: 'usb-device-revoked'
+
+Returns:
+
+* `event` Event
+* `details` Object
+  * `device` [USBDevice[]](structures/usb-device.md)
+  * `origin` string (optional) - The origin that the device has been revoked from.
+
+Emitted after `USBDevice.forget()` has been called.  This event can be used
+to help maintain persistent storage of permissions when
+`setDevicePermissionHandler` is used.
+
 ### Instance Methods
 
 The following methods are available on instances of `Session`:
@@ -714,7 +826,7 @@ session.fromPartition('some-partition').setPermissionRequestHandler((webContents
 
 * `handler` Function\<boolean> | null
   * `webContents` ([WebContents](web-contents.md) | null) - WebContents checking the permission.  Please note that if the request comes from a subframe you should use `requestingUrl` to check the request origin.  All cross origin sub frames making permission checks will pass a `null` webContents to this handler, while certain other permission checks such as `notifications` checks will always pass `null`.  You should use `embeddingOrigin` and `requestingOrigin` to determine what origin the owning frame and the requesting frame are on respectively.
-  * `permission` string - Type of permission check.  Valid values are `midiSysex`, `notifications`, `geolocation`, `media`,`mediaKeySystem`,`midi`, `pointerLock`, `fullscreen`, `openExternal`, `hid`, or `serial`.
+  * `permission` string - Type of permission check.  Valid values are `midiSysex`, `notifications`, `geolocation`, `media`,`mediaKeySystem`,`midi`, `pointerLock`, `fullscreen`, `openExternal`, `hid`, `serial`, or `usb`.
   * `requestingOrigin` string - The origin URL of the permission check
   * `details` Object - Some properties are only available on certain permission types.
     * `embeddingOrigin` string (optional) - The origin of the frame embedding the frame that made the permission check.  Only set for cross-origin sub frames making permission checks.
@@ -800,7 +912,7 @@ Passing `null` instead of a function resets the handler to its default state.
 
 * `handler` Function\<boolean> | null
   * `details` Object
-    * `deviceType` string - The type of device that permission is being requested on, can be `hid` or `serial`.
+    * `deviceType` string - The type of device that permission is being requested on, can be `hid`, `serial`, or `usb`.
     * `origin` string - The origin URL of the device permission check.
     * `device` [HIDDevice](structures/hid-device.md) | [SerialPort](structures/serial-port.md)- the device that permission is being requested for.
 
@@ -828,6 +940,8 @@ app.whenReady().then(() => {
       return true
     } else if (permission === 'serial') {
       // Add logic here to determine if permission should be given to allow serial port selection
+    } else if (permission === 'usb') {
+      // Add logic here to determine if permission should be given to allow USB device selection
     }
     return false
   })

+ 17 - 0
docs/api/structures/usb-device.md

@@ -0,0 +1,17 @@
+# USBDevice Object
+
+* `deviceId` string - Unique identifier for the device.
+* `vendorId` Integer - The USB vendor ID.
+* `productId` Integer - The USB product ID.
+* `productName` string (optional) - Name of the device.
+* `serialNumber` string (optional) - The USB device serial number.
+* `manufacturerName` string (optional) - The manufacturer name of the device.
+* `usbVersionMajor` Integer - The USB protocol major version supported by the device
+* `usbVersionMinor` Integer - The USB protocol minor version supported by the device
+* `usbVersionSubminor` Integer - The USB protocol subminor version supported by the device
+* `deviceClass` Integer - The device class for the communication interface supported by the device
+* `deviceSubclass` Integer - The device subclass for the communication interface supported by the device
+* `deviceProtocol` Integer - The device protocol for the communication interface supported by the device
+* `deviceVersionMajor` Integer - The major version number of the device as defined by the device manufacturer.
+* `deviceVersionMinor` Integer - The minor version number of the device as defined by the device manufacturer.
+* `deviceVersionSubminor` Integer - The subminor version number of the device as defined by the device manufacturer.

+ 21 - 0
docs/fiddles/features/web-usb/index.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="UTF-8">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
+    <title>WebUSB API</title>
+  </head>
+  <body>
+    <h1>WebUSB API</h1>
+
+    <button id="clickme">Test WebUSB</button>
+
+    <h3>USB devices automatically granted access via <i>setDevicePermissionHandler</i></h3>
+    <div id="granted-devices"></div>
+    
+    <h3>USB devices automatically granted access via <i>select-usb-device</i></h3>
+    <div id="granted-devices2"></div>
+
+    <script src="./renderer.js"></script>
+  </body>
+</html>

+ 72 - 0
docs/fiddles/features/web-usb/main.js

@@ -0,0 +1,72 @@
+const {app, BrowserWindow} = require('electron')
+const e = require('express')
+const path = require('path')
+
+function createWindow () {
+  const mainWindow = new BrowserWindow({
+    width: 800,
+    height: 600
+  })
+  
+  let grantedDeviceThroughPermHandler
+
+  mainWindow.webContents.session.on('select-usb-device', (event, details, callback) => {
+    //Add events to handle devices being added or removed before the callback on
+    //`select-usb-device` is called.
+    mainWindow.webContents.session.on('usb-device-added', (event, device) => {    
+      console.log('usb-device-added FIRED WITH', device)
+      //Optionally update details.deviceList
+    })
+  
+    mainWindow.webContents.session.on('usb-device-removed', (event, device) => {
+      console.log('usb-device-removed FIRED WITH', device)
+      //Optionally update details.deviceList
+    })
+
+    event.preventDefault()
+    if (details.deviceList && details.deviceList.length > 0) {
+      const deviceToReturn  = details.deviceList.find((device) => {
+        if (!grantedDeviceThroughPermHandler || (device.deviceId != grantedDeviceThroughPermHandler.deviceId)) {
+          return true
+        }
+      })
+      if (deviceToReturn) {
+        callback(deviceToReturn.deviceId)        
+      } else {
+        callback()
+      }
+    }
+  })
+
+  mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
+    if (permission === 'usb' && details.securityOrigin === 'file:///') {
+      return true
+    }
+  })
+
+  
+  mainWindow.webContents.session.setDevicePermissionHandler((details) => {
+    if (details.deviceType === 'usb' && details.origin === 'file://') {
+      if (!grantedDeviceThroughPermHandler) {        
+        grantedDeviceThroughPermHandler = details.device
+        return true
+      } else {
+        return false
+      }
+    }
+  })
+  
+  mainWindow.loadFile('index.html')
+}
+
+app.whenReady().then(() => {
+  createWindow()
+  
+  app.on('activate', function () {
+    if (BrowserWindow.getAllWindows().length === 0) createWindow()
+  })
+})
+
+app.on('window-all-closed', function () {
+  if (process.platform !== 'darwin') app.quit()
+})

+ 33 - 0
docs/fiddles/features/web-usb/renderer.js

@@ -0,0 +1,33 @@
+function getDeviceDetails(device) {
+  return grantedDevice.productName || `Unknown device ${grantedDevice.deviceId}`
+}
+
+async function testIt() {
+  const noDevicesFoundMsg = 'No devices found'
+  const grantedDevices = await navigator.usb.getDevices()
+  let grantedDeviceList = ''
+  if (grantedDevices.length > 0) {    
+    grantedDevices.forEach(device => {
+      grantedDeviceList += `<hr>${getDeviceDetails(device)}</hr>`
+    })    
+  } else {
+    grantedDeviceList = noDevicesFoundMsg
+  }
+  document.getElementById('granted-devices').innerHTML = grantedDeviceList
+
+  grantedDeviceList = ''
+  try {
+    const grantedDevice = await navigator.usb.requestDevice({
+      filters: []
+    })
+    grantedDeviceList += `<hr>${getDeviceDetails(device)}</hr>`
+    
+  } catch (ex) {
+    if (ex.name === 'NotFoundError') {
+      grantedDeviceList = noDevicesFoundMsg
+    }
+  }
+  document.getElementById('granted-devices2').innerHTML = grantedDeviceList
+}
+
+document.getElementById('clickme').addEventListener('click',testIt)

+ 38 - 0
docs/tutorial/devices.md

@@ -115,3 +115,41 @@ when the `Test Web Serial` button is clicked.
 ```javascript fiddle='docs/fiddles/features/web-serial'
 
 ```
+
+## WebUSB API
+
+The [WebUSB API](https://web.dev/usb/) can be used to access USB devices.
+Electron provides several APIs for working with the WebUSB API:
+
+* The [`select-usb-device` event on the Session](../api/session.md#event-select-usb-device)
+  can be used to select a USB device when a call to
+  `navigator.usb.requestDevice` is made.  Additionally the [`usb-device-added`](../api/session.md#event-usb-device-added)
+  and [`usb-device-removed`](../api/session.md#event-usb-device-removed) events
+  on the Session can be used to handle devices being plugged in or unplugged
+  when handling the `select-usb-device` event.
+  **Note:** These two events only fire until the callback from `select-usb-device`
+  is called.  They are not intended to be used as a generic usb device listener.
+* The [`usb-device-revoked' event on the Session](../api/session.md#event-usb-device-revoked) can
+  be used to respond when [device.forget()](https://developer.chrome.com/articles/usb/#revoke-access)
+  is called on a USB device.
+* [`ses.setDevicePermissionHandler(handler)`](../api/session.md#sessetdevicepermissionhandlerhandler)
+  can be used to provide default permissioning to devices without first calling
+  for permission to devices via `navigator.usb.requestDevice`.  Additionally,
+  the default behavior of Electron is to store granted device permission through
+  the lifetime of the corresponding WebContents.  If longer term storage is
+  needed, a developer can store granted device permissions (eg when handling
+  the `select-usb-device` event) and then read from that storage with
+  `setDevicePermissionHandler`.
+* [`ses.setPermissionCheckHandler(handler)`](../api/session.md#sessetpermissioncheckhandlerhandler)
+  can be used to disable USB access for specific origins.
+
+### Example
+
+This example demonstrates an Electron application that automatically selects
+USB devices (if they are attached) through [`ses.setDevicePermissionHandler(handler)`](../api/session.md#sessetdevicepermissionhandlerhandler)
+and through [`select-usb-device` event on the Session](../api/session.md#event-select-usb-device)
+when the `Test WebUSB` button is clicked.
+
+```javascript fiddle='docs/fiddles/features/web-usb'
+
+```

+ 1 - 0
filenames.auto.gni

@@ -132,6 +132,7 @@ auto_filenames = {
     "docs/api/structures/upload-data.md",
     "docs/api/structures/upload-file.md",
     "docs/api/structures/upload-raw-data.md",
+    "docs/api/structures/usb-device.md",
     "docs/api/structures/user-default-types.md",
     "docs/api/structures/web-request-filter.md",
     "docs/api/structures/web-source.md",

+ 9 - 0
filenames.gni

@@ -505,6 +505,14 @@ filenames = {
     "shell/browser/ui/tray_icon_observer.h",
     "shell/browser/ui/webui/accessibility_ui.cc",
     "shell/browser/ui/webui/accessibility_ui.h",
+    "shell/browser/usb/electron_usb_delegate.cc",
+    "shell/browser/usb/electron_usb_delegate.h",
+    "shell/browser/usb/usb_chooser_context.cc",
+    "shell/browser/usb/usb_chooser_context.h",
+    "shell/browser/usb/usb_chooser_context_factory.cc",
+    "shell/browser/usb/usb_chooser_context_factory.h",
+    "shell/browser/usb/usb_chooser_controller.cc",
+    "shell/browser/usb/usb_chooser_controller.h",
     "shell/browser/web_contents_permission_helper.cc",
     "shell/browser/web_contents_permission_helper.h",
     "shell/browser/web_contents_preferences.cc",
@@ -586,6 +594,7 @@ filenames = {
     "shell/common/gin_converters/std_converter.h",
     "shell/common/gin_converters/time_converter.cc",
     "shell/common/gin_converters/time_converter.h",
+    "shell/common/gin_converters/usb_device_info_converter.h",
     "shell/common/gin_converters/value_converter.cc",
     "shell/common/gin_converters/value_converter.h",
     "shell/common/gin_helper/arguments.cc",

+ 6 - 0
shell/browser/electron_browser_client.cc

@@ -1715,6 +1715,12 @@ content::BluetoothDelegate* ElectronBrowserClient::GetBluetoothDelegate() {
   return bluetooth_delegate_.get();
 }
 
+content::UsbDelegate* ElectronBrowserClient::GetUsbDelegate() {
+  if (!usb_delegate_)
+    usb_delegate_ = std::make_unique<ElectronUsbDelegate>();
+  return usb_delegate_.get();
+}
+
 void BindBadgeServiceForServiceWorker(
     const content::ServiceWorkerVersionBaseInfo& info,
     mojo::PendingReceiver<blink::mojom::BadgeService> receiver) {

+ 3 - 0
shell/browser/electron_browser_client.h

@@ -22,6 +22,7 @@
 #include "shell/browser/bluetooth/electron_bluetooth_delegate.h"
 #include "shell/browser/hid/electron_hid_delegate.h"
 #include "shell/browser/serial/electron_serial_delegate.h"
+#include "shell/browser/usb/electron_usb_delegate.h"
 #include "third_party/blink/public/mojom/badging/badging.mojom-forward.h"
 
 namespace content {
@@ -103,6 +104,7 @@ class ElectronBrowserClient : public content::ContentBrowserClient,
   content::BluetoothDelegate* GetBluetoothDelegate() override;
 
   content::HidDelegate* GetHidDelegate() override;
+  content::UsbDelegate* GetUsbDelegate() override;
 
   content::WebAuthenticationDelegate* GetWebAuthenticationDelegate() override;
 
@@ -326,6 +328,7 @@ class ElectronBrowserClient : public content::ContentBrowserClient,
 
   std::unique_ptr<ElectronSerialDelegate> serial_delegate_;
   std::unique_ptr<ElectronBluetoothDelegate> bluetooth_delegate_;
+  std::unique_ptr<ElectronUsbDelegate> usb_delegate_;
   std::unique_ptr<ElectronHidDelegate> hid_delegate_;
   std::unique_ptr<ElectronWebAuthenticationDelegate>
       web_authentication_delegate_;

+ 12 - 8
shell/browser/electron_browser_context.cc

@@ -51,6 +51,7 @@
 #include "shell/browser/web_view_manager.h"
 #include "shell/browser/zoom_level_delegate.h"
 #include "shell/common/application_info.h"
+#include "shell/common/electron_constants.h"
 #include "shell/common/electron_paths.h"
 #include "shell/common/gin_converters/frame_converter.h"
 #include "shell/common/gin_helper/error_thrower.h"
@@ -583,19 +584,22 @@ bool ElectronBrowserContext::DoesDeviceMatch(
     const base::Value* device_to_compare,
     blink::PermissionType permission_type) {
   if (permission_type ==
-      static_cast<blink::PermissionType>(
-          WebContentsPermissionHelper::PermissionType::HID)) {
-    if (device.GetDict().FindInt(kHidVendorIdKey) !=
-            device_to_compare->GetDict().FindInt(kHidVendorIdKey) ||
-        device.GetDict().FindInt(kHidProductIdKey) !=
-            device_to_compare->GetDict().FindInt(kHidProductIdKey)) {
+          static_cast<blink::PermissionType>(
+              WebContentsPermissionHelper::PermissionType::HID) ||
+      permission_type ==
+          static_cast<blink::PermissionType>(
+              WebContentsPermissionHelper::PermissionType::USB)) {
+    if (device.GetDict().FindInt(kDeviceVendorIdKey) !=
+            device_to_compare->GetDict().FindInt(kDeviceVendorIdKey) ||
+        device.GetDict().FindInt(kDeviceProductIdKey) !=
+            device_to_compare->GetDict().FindInt(kDeviceProductIdKey)) {
       return false;
     }
 
     const auto* serial_number =
-        device_to_compare->GetDict().FindString(kHidSerialNumberKey);
+        device_to_compare->GetDict().FindString(kDeviceSerialNumberKey);
     const auto* device_serial_number =
-        device.GetDict().FindString(kHidSerialNumberKey);
+        device.GetDict().FindString(kDeviceSerialNumberKey);
 
     if (serial_number && device_serial_number &&
         *device_serial_number == *serial_number)

+ 9 - 0
shell/browser/feature_list.cc

@@ -17,6 +17,10 @@
 #include "net/base/features.h"
 #include "services/network/public/cpp/features.h"
 
+#if BUILDFLAG(IS_MAC)
+#include "device/base/features.h"  // nogncheck
+#endif
+
 namespace electron {
 
 void InitializeFeatureList() {
@@ -32,6 +36,11 @@ void InitializeFeatureList() {
   disable_features +=
       std::string(",") + features::kSpareRendererForSitePerProcess.name;
 
+#if BUILDFLAG(IS_MAC)
+  // Needed for WebUSB implementation
+  enable_features += std::string(",") + device::kNewUsbBackend.name;
+#endif
+
 #if !BUILDFLAG(ENABLE_PICTURE_IN_PICTURE)
   disable_features += std::string(",") + media::kPictureInPicture.name;
 #endif

+ 4 - 6
shell/browser/hid/hid_chooser_context.cc

@@ -22,6 +22,7 @@
 #include "shell/browser/api/electron_api_session.h"
 #include "shell/browser/electron_permission_manager.h"
 #include "shell/browser/web_contents_permission_helper.h"
+#include "shell/common/electron_constants.h"
 #include "shell/common/gin_converters/content_converter.h"
 #include "shell/common/gin_converters/frame_converter.h"
 #include "shell/common/gin_converters/hid_device_info_converter.h"
@@ -35,9 +36,6 @@ namespace electron {
 
 const char kHidDeviceNameKey[] = "name";
 const char kHidGuidKey[] = "guid";
-const char kHidVendorIdKey[] = "vendorId";
-const char kHidProductIdKey[] = "productId";
-const char kHidSerialNumberKey[] = "serialNumber";
 
 HidChooserContext::HidChooserContext(ElectronBrowserContext* context)
     : browser_context_(context) {}
@@ -76,12 +74,12 @@ base::Value HidChooserContext::DeviceInfoToValue(
   value.SetStringKey(
       kHidDeviceNameKey,
       base::UTF16ToUTF8(HidChooserContext::DisplayNameFromDeviceInfo(device)));
-  value.SetIntKey(kHidVendorIdKey, device.vendor_id);
-  value.SetIntKey(kHidProductIdKey, device.product_id);
+  value.SetIntKey(kDeviceVendorIdKey, device.vendor_id);
+  value.SetIntKey(kDeviceProductIdKey, device.product_id);
   if (HidChooserContext::CanStorePersistentEntry(device)) {
     // Use the USB serial number as a persistent identifier. If it is
     // unavailable, only ephemeral permissions may be granted.
-    value.SetStringKey(kHidSerialNumberKey, device.serial_number);
+    value.SetStringKey(kDeviceSerialNumberKey, device.serial_number);
   } else {
     // The GUID is a temporary ID created on connection that remains valid until
     // the device is disconnected. Ephemeral permissions are keyed by this ID

+ 0 - 2
shell/browser/hid/hid_chooser_context.h

@@ -33,9 +33,7 @@ namespace electron {
 
 extern const char kHidDeviceNameKey[];
 extern const char kHidGuidKey[];
-extern const char kHidVendorIdKey[];
 extern const char kHidProductIdKey[];
-extern const char kHidSerialNumberKey[];
 
 // Manages the internal state and connection to the device service for the
 // Human Interface Device (HID) chooser UI.

+ 0 - 3
shell/browser/serial/serial_chooser_context.h

@@ -29,9 +29,6 @@ class Value;
 
 namespace electron {
 
-extern const char kHidVendorIdKey[];
-extern const char kHidProductIdKey[];
-
 #if BUILDFLAG(IS_WIN)
 extern const char kDeviceInstanceIdKey[];
 #else

+ 317 - 0
shell/browser/usb/electron_usb_delegate.cc

@@ -0,0 +1,317 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/usb/electron_usb_delegate.h"
+
+#include <utility>
+
+#include "base/containers/contains.h"
+#include "base/containers/cxx20_erase.h"
+#include "base/observer_list.h"
+#include "base/observer_list_types.h"
+#include "base/scoped_observation.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/web_contents.h"
+#include "extensions/buildflags/buildflags.h"
+#include "services/device/public/mojom/usb_enumeration_options.mojom.h"
+#include "shell/browser/electron_permission_manager.h"
+#include "shell/browser/usb/usb_chooser_context.h"
+#include "shell/browser/usb/usb_chooser_context_factory.h"
+#include "shell/browser/usb/usb_chooser_controller.h"
+#include "shell/browser/web_contents_permission_helper.h"
+
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+#include "base/containers/fixed_flat_set.h"
+#include "chrome/common/chrome_features.h"
+#include "extensions/browser/extension_registry.h"
+#include "extensions/common/constants.h"
+#include "extensions/common/extension.h"
+#include "services/device/public/mojom/usb_device.mojom.h"
+#endif
+
+namespace {
+
+using ::content::UsbChooser;
+
+electron::UsbChooserContext* GetChooserContext(
+    content::BrowserContext* browser_context) {
+  return electron::UsbChooserContextFactory::GetForBrowserContext(
+      browser_context);
+}
+
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+// These extensions can claim the smart card USB class and automatically gain
+// permissions for devices that have an interface with this class.
+constexpr auto kSmartCardPrivilegedExtensionIds =
+    base::MakeFixedFlatSet<base::StringPiece>({
+        // Smart Card Connector Extension and its Beta version, see
+        // crbug.com/1233881.
+        "khpfeaanjngmcnplbdlpegiifgpfgdco",
+        "mockcojkppdndnhgonljagclgpkjbkek",
+    });
+
+bool DeviceHasInterfaceWithClass(
+    const device::mojom::UsbDeviceInfo& device_info,
+    uint8_t interface_class) {
+  for (const auto& configuration : device_info.configurations) {
+    for (const auto& interface : configuration->interfaces) {
+      for (const auto& alternate : interface->alternates) {
+        if (alternate->class_code == interface_class)
+          return true;
+      }
+    }
+  }
+  return false;
+}
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+
+bool IsDevicePermissionAutoGranted(
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device_info) {
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+  // Note: The `DeviceHasInterfaceWithClass()` call is made after checking the
+  // origin, since that method call is expensive.
+  if (origin.scheme() == extensions::kExtensionScheme &&
+      base::Contains(kSmartCardPrivilegedExtensionIds, origin.host()) &&
+      DeviceHasInterfaceWithClass(device_info,
+                                  device::mojom::kUsbSmartCardClass)) {
+    return true;
+  }
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+
+  return false;
+}
+
+}  // namespace
+
+namespace electron {
+
+// Manages the UsbDelegate observers for a single browser context.
+class ElectronUsbDelegate::ContextObservation
+    : public UsbChooserContext::DeviceObserver {
+ public:
+  ContextObservation(ElectronUsbDelegate* parent,
+                     content::BrowserContext* browser_context)
+      : parent_(parent), browser_context_(browser_context) {
+    auto* chooser_context = GetChooserContext(browser_context_);
+    device_observation_.Observe(chooser_context);
+  }
+  ContextObservation(ContextObservation&) = delete;
+  ContextObservation& operator=(ContextObservation&) = delete;
+  ~ContextObservation() override = default;
+
+  // UsbChooserContext::DeviceObserver:
+  void OnDeviceAdded(const device::mojom::UsbDeviceInfo& device_info) override {
+    for (auto& observer : observer_list_)
+      observer.OnDeviceAdded(device_info);
+  }
+
+  void OnDeviceRemoved(
+      const device::mojom::UsbDeviceInfo& device_info) override {
+    for (auto& observer : observer_list_)
+      observer.OnDeviceRemoved(device_info);
+  }
+
+  void OnDeviceManagerConnectionError() override {
+    for (auto& observer : observer_list_)
+      observer.OnDeviceManagerConnectionError();
+  }
+
+  void OnBrowserContextShutdown() override {
+    parent_->observations_.erase(browser_context_);
+    // Return since `this` is now deleted.
+  }
+
+  void AddObserver(content::UsbDelegate::Observer* observer) {
+    observer_list_.AddObserver(observer);
+  }
+
+  void RemoveObserver(content::UsbDelegate::Observer* observer) {
+    observer_list_.RemoveObserver(observer);
+  }
+
+ private:
+  // Safe because `parent_` owns `this`.
+  const raw_ptr<ElectronUsbDelegate> parent_;
+
+  // Safe because `this` is destroyed when the context is lost.
+  const raw_ptr<content::BrowserContext> browser_context_;
+
+  base::ScopedObservation<UsbChooserContext, UsbChooserContext::DeviceObserver>
+      device_observation_{this};
+  base::ObserverList<content::UsbDelegate::Observer> observer_list_;
+};
+
+ElectronUsbDelegate::ElectronUsbDelegate() = default;
+
+ElectronUsbDelegate::~ElectronUsbDelegate() = default;
+
+void ElectronUsbDelegate::AdjustProtectedInterfaceClasses(
+    content::BrowserContext* browser_context,
+    const url::Origin& origin,
+    content::RenderFrameHost* frame,
+    std::vector<uint8_t>& classes) {
+  // Isolated Apps have unrestricted access to any USB interface class.
+  if (frame && frame->GetWebExposedIsolationLevel() >=
+                   content::RenderFrameHost::WebExposedIsolationLevel::
+                       kMaybeIsolatedApplication) {
+    // TODO(https://crbug.com/1236706): Should the list of interface classes the
+    // app expects to claim be encoded in the Web App Manifest?
+    classes.clear();
+    return;
+  }
+
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+  // Don't enforce protected interface classes for Chrome Apps since the
+  // chrome.usb API has no such restriction.
+  if (origin.scheme() == extensions::kExtensionScheme) {
+    auto* extension_registry =
+        extensions::ExtensionRegistry::Get(browser_context);
+    if (extension_registry) {
+      const extensions::Extension* extension =
+          extension_registry->enabled_extensions().GetByID(origin.host());
+      if (extension && extension->is_platform_app()) {
+        classes.clear();
+        return;
+      }
+    }
+  }
+
+  if (origin.scheme() == extensions::kExtensionScheme &&
+      base::Contains(kSmartCardPrivilegedExtensionIds, origin.host())) {
+    base::Erase(classes, device::mojom::kUsbSmartCardClass);
+  }
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+}
+
+std::unique_ptr<UsbChooser> ElectronUsbDelegate::RunChooser(
+    content::RenderFrameHost& frame,
+    std::vector<device::mojom::UsbDeviceFilterPtr> filters,
+    blink::mojom::WebUsbService::GetPermissionCallback callback) {
+  UsbChooserController* controller = ControllerForFrame(&frame);
+  if (controller) {
+    DeleteControllerForFrame(&frame);
+  }
+  AddControllerForFrame(&frame, std::move(filters), std::move(callback));
+  // Return a nullptr because the return value isn't used for anything. The
+  // return value is simply used in Chromium to cleanup the chooser UI once the
+  // usb service is destroyed.
+  return nullptr;
+}
+
+bool ElectronUsbDelegate::CanRequestDevicePermission(
+    content::BrowserContext* browser_context,
+    const url::Origin& origin) {
+  base::Value::Dict details;
+  details.Set("securityOrigin", origin.GetURL().spec());
+  auto* permission_manager = static_cast<ElectronPermissionManager*>(
+      browser_context->GetPermissionControllerDelegate());
+  return permission_manager->CheckPermissionWithDetails(
+      static_cast<blink::PermissionType>(
+          WebContentsPermissionHelper::PermissionType::USB),
+      nullptr, origin.GetURL(), std::move(details));
+}
+
+void ElectronUsbDelegate::RevokeDevicePermissionWebInitiated(
+    content::BrowserContext* browser_context,
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device) {
+  GetChooserContext(browser_context)
+      ->RevokeDevicePermissionWebInitiated(origin, device);
+}
+
+const device::mojom::UsbDeviceInfo* ElectronUsbDelegate::GetDeviceInfo(
+    content::BrowserContext* browser_context,
+    const std::string& guid) {
+  return GetChooserContext(browser_context)->GetDeviceInfo(guid);
+}
+
+bool ElectronUsbDelegate::HasDevicePermission(
+    content::BrowserContext* browser_context,
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device) {
+  if (IsDevicePermissionAutoGranted(origin, device))
+    return true;
+
+  return GetChooserContext(browser_context)
+      ->HasDevicePermission(origin, device);
+}
+
+void ElectronUsbDelegate::GetDevices(
+    content::BrowserContext* browser_context,
+    blink::mojom::WebUsbService::GetDevicesCallback callback) {
+  GetChooserContext(browser_context)->GetDevices(std::move(callback));
+}
+
+void ElectronUsbDelegate::GetDevice(
+    content::BrowserContext* browser_context,
+    const std::string& guid,
+    base::span<const uint8_t> blocked_interface_classes,
+    mojo::PendingReceiver<device::mojom::UsbDevice> device_receiver,
+    mojo::PendingRemote<device::mojom::UsbDeviceClient> device_client) {
+  GetChooserContext(browser_context)
+      ->GetDevice(guid, blocked_interface_classes, std::move(device_receiver),
+                  std::move(device_client));
+}
+
+void ElectronUsbDelegate::AddObserver(content::BrowserContext* browser_context,
+                                      Observer* observer) {
+  GetContextObserver(browser_context)->AddObserver(observer);
+}
+
+void ElectronUsbDelegate::RemoveObserver(
+    content::BrowserContext* browser_context,
+    Observer* observer) {
+  GetContextObserver(browser_context)->RemoveObserver(observer);
+}
+
+ElectronUsbDelegate::ContextObservation*
+ElectronUsbDelegate::GetContextObserver(
+    content::BrowserContext* browser_context) {
+  if (!base::Contains(observations_, browser_context)) {
+    observations_.emplace(browser_context, std::make_unique<ContextObservation>(
+                                               this, browser_context));
+  }
+  return observations_[browser_context].get();
+}
+
+bool ElectronUsbDelegate::IsServiceWorkerAllowedForOrigin(
+    const url::Origin& origin) {
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+  // WebUSB is only available on extension service workers for now.
+  if (base::FeatureList::IsEnabled(
+          features::kEnableWebUsbOnExtensionServiceWorker) &&
+      origin.scheme() == extensions::kExtensionScheme) {
+    return true;
+  }
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+  return false;
+}
+
+UsbChooserController* ElectronUsbDelegate::ControllerForFrame(
+    content::RenderFrameHost* render_frame_host) {
+  auto mapping = controller_map_.find(render_frame_host);
+  return mapping == controller_map_.end() ? nullptr : mapping->second.get();
+}
+
+UsbChooserController* ElectronUsbDelegate::AddControllerForFrame(
+    content::RenderFrameHost* render_frame_host,
+    std::vector<device::mojom::UsbDeviceFilterPtr> filters,
+    blink::mojom::WebUsbService::GetPermissionCallback callback) {
+  auto* web_contents =
+      content::WebContents::FromRenderFrameHost(render_frame_host);
+  auto controller = std::make_unique<UsbChooserController>(
+      render_frame_host, std::move(filters), std::move(callback), web_contents,
+      weak_factory_.GetWeakPtr());
+  controller_map_.insert(
+      std::make_pair(render_frame_host, std::move(controller)));
+  return ControllerForFrame(render_frame_host);
+}
+
+void ElectronUsbDelegate::DeleteControllerForFrame(
+    content::RenderFrameHost* render_frame_host) {
+  controller_map_.erase(render_frame_host);
+}
+
+}  // namespace electron

+ 106 - 0
shell/browser/usb/electron_usb_delegate.h

@@ -0,0 +1,106 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_USB_ELECTRON_USB_DELEGATE_H_
+#define ELECTRON_SHELL_BROWSER_USB_ELECTRON_USB_DELEGATE_H_
+
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "base/containers/span.h"
+#include "base/memory/weak_ptr.h"
+#include "content/public/browser/usb_chooser.h"
+#include "content/public/browser/usb_delegate.h"
+#include "mojo/public/cpp/bindings/pending_receiver.h"
+#include "mojo/public/cpp/bindings/pending_remote.h"
+#include "services/device/public/mojom/usb_device.mojom-forward.h"
+#include "services/device/public/mojom/usb_enumeration_options.mojom-forward.h"
+#include "services/device/public/mojom/usb_manager.mojom-forward.h"
+#include "third_party/blink/public/mojom/usb/web_usb_service.mojom.h"
+#include "url/origin.h"
+
+namespace content {
+class BrowserContext;
+class RenderFrameHost;
+}  // namespace content
+
+namespace electron {
+
+class UsbChooserController;
+
+class ElectronUsbDelegate : public content::UsbDelegate {
+ public:
+  ElectronUsbDelegate();
+  ElectronUsbDelegate(ElectronUsbDelegate&&) = delete;
+  ElectronUsbDelegate& operator=(ElectronUsbDelegate&) = delete;
+  ~ElectronUsbDelegate() override;
+
+  // content::UsbDelegate:
+  void AdjustProtectedInterfaceClasses(content::BrowserContext* browser_context,
+                                       const url::Origin& origin,
+                                       content::RenderFrameHost* frame,
+                                       std::vector<uint8_t>& classes) override;
+  std::unique_ptr<content::UsbChooser> RunChooser(
+      content::RenderFrameHost& frame,
+      std::vector<device::mojom::UsbDeviceFilterPtr> filters,
+      blink::mojom::WebUsbService::GetPermissionCallback callback) override;
+  bool CanRequestDevicePermission(content::BrowserContext* browser_context,
+                                  const url::Origin& origin) override;
+  void RevokeDevicePermissionWebInitiated(
+      content::BrowserContext* browser_context,
+      const url::Origin& origin,
+      const device::mojom::UsbDeviceInfo& device) override;
+  const device::mojom::UsbDeviceInfo* GetDeviceInfo(
+      content::BrowserContext* browser_context,
+      const std::string& guid) override;
+  bool HasDevicePermission(content::BrowserContext* browser_context,
+                           const url::Origin& origin,
+                           const device::mojom::UsbDeviceInfo& device) override;
+  void GetDevices(
+      content::BrowserContext* browser_context,
+      blink::mojom::WebUsbService::GetDevicesCallback callback) override;
+  void GetDevice(
+      content::BrowserContext* browser_context,
+      const std::string& guid,
+      base::span<const uint8_t> blocked_interface_classes,
+      mojo::PendingReceiver<device::mojom::UsbDevice> device_receiver,
+      mojo::PendingRemote<device::mojom::UsbDeviceClient> device_client)
+      override;
+  void AddObserver(content::BrowserContext* browser_context,
+                   Observer* observer) override;
+  void RemoveObserver(content::BrowserContext* browser_context,
+                      Observer* observer) override;
+  bool IsServiceWorkerAllowedForOrigin(const url::Origin& origin) override;
+
+  void DeleteControllerForFrame(content::RenderFrameHost* render_frame_host);
+
+ private:
+  UsbChooserController* ControllerForFrame(
+      content::RenderFrameHost* render_frame_host);
+
+  UsbChooserController* AddControllerForFrame(
+      content::RenderFrameHost* render_frame_host,
+      std::vector<device::mojom::UsbDeviceFilterPtr> filters,
+      blink::mojom::WebUsbService::GetPermissionCallback callback);
+
+  class ContextObservation;
+
+  ContextObservation* GetContextObserver(
+      content::BrowserContext* browser_context);
+
+  base::flat_map<content::BrowserContext*, std::unique_ptr<ContextObservation>>
+      observations_;
+
+  std::unordered_map<content::RenderFrameHost*,
+                     std::unique_ptr<UsbChooserController>>
+      controller_map_;
+
+  base::WeakPtrFactory<ElectronUsbDelegate> weak_factory_{this};
+};
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_USB_ELECTRON_USB_DELEGATE_H_

+ 354 - 0
shell/browser/usb/usb_chooser_context.cc

@@ -0,0 +1,354 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/usb/usb_chooser_context.h"
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "base/bind.h"
+#include "base/containers/contains.h"
+#include "base/observer_list.h"
+#include "base/strings/string_number_conversions.h"
+#include "base/strings/string_util.h"
+#include "base/strings/stringprintf.h"
+#include "base/strings/utf_string_conversions.h"
+#include "base/threading/sequenced_task_runner_handle.h"
+#include "base/values.h"
+#include "build/build_config.h"
+#include "components/content_settings/core/common/content_settings.h"
+#include "content/public/browser/device_service.h"
+#include "services/device/public/cpp/usb/usb_ids.h"
+#include "services/device/public/mojom/usb_device.mojom.h"
+#include "shell/browser/api/electron_api_session.h"
+#include "shell/browser/electron_permission_manager.h"
+#include "shell/browser/web_contents_permission_helper.h"
+#include "shell/common/electron_constants.h"
+#include "shell/common/gin_converters/usb_device_info_converter.h"
+#include "shell/common/node_includes.h"
+#include "ui/base/l10n/l10n_util.h"
+
+namespace {
+
+constexpr char kDeviceNameKey[] = "productName";
+constexpr char kDeviceIdKey[] = "deviceId";
+constexpr int kUsbClassMassStorage = 0x08;
+
+bool CanStorePersistentEntry(const device::mojom::UsbDeviceInfo& device_info) {
+  return device_info.serial_number && !device_info.serial_number->empty();
+}
+
+bool IsMassStorageInterface(const device::mojom::UsbInterfaceInfo& interface) {
+  for (auto& alternate : interface.alternates) {
+    if (alternate->class_code == kUsbClassMassStorage)
+      return true;
+  }
+  return false;
+}
+
+bool ShouldExposeDevice(const device::mojom::UsbDeviceInfo& device_info) {
+  // blink::USBDevice::claimInterface() disallows claiming mass storage
+  // interfaces, but explicitly prevent access in the browser process as
+  // ChromeOS would allow these interfaces to be claimed.
+  for (auto& configuration : device_info.configurations) {
+    if (configuration->interfaces.size() == 0) {
+      return true;
+    }
+    for (auto& interface : configuration->interfaces) {
+      if (!IsMassStorageInterface(*interface))
+        return true;
+    }
+  }
+  return false;
+}
+
+}  // namespace
+
+namespace electron {
+
+void UsbChooserContext::DeviceObserver::OnDeviceAdded(
+    const device::mojom::UsbDeviceInfo& device_info) {}
+
+void UsbChooserContext::DeviceObserver::OnDeviceRemoved(
+    const device::mojom::UsbDeviceInfo& device_info) {}
+
+void UsbChooserContext::DeviceObserver::OnDeviceManagerConnectionError() {}
+
+UsbChooserContext::UsbChooserContext(ElectronBrowserContext* context)
+    : browser_context_(context) {}
+
+// static
+base::Value UsbChooserContext::DeviceInfoToValue(
+    const device::mojom::UsbDeviceInfo& device_info) {
+  base::Value device_value(base::Value::Type::DICTIONARY);
+  device_value.SetStringKey(kDeviceNameKey, device_info.product_name
+                                                ? *device_info.product_name
+                                                : base::StringPiece16());
+  device_value.SetIntKey(kDeviceVendorIdKey, device_info.vendor_id);
+  device_value.SetIntKey(kDeviceProductIdKey, device_info.product_id);
+
+  if (device_info.manufacturer_name) {
+    device_value.SetStringKey("manufacturerName",
+                              *device_info.manufacturer_name);
+  }
+
+  // CanStorePersistentEntry checks if |device_info.serial_number| is not empty.
+  if (CanStorePersistentEntry(device_info)) {
+    device_value.SetStringKey(kDeviceSerialNumberKey,
+                              *device_info.serial_number);
+  }
+
+  device_value.SetStringKey(kDeviceIdKey, device_info.guid);
+
+  device_value.SetIntKey("usbVersionMajor", device_info.usb_version_major);
+  device_value.SetIntKey("usbVersionMinor", device_info.usb_version_minor);
+  device_value.SetIntKey("usbVersionSubminor",
+                         device_info.usb_version_subminor);
+  device_value.SetIntKey("deviceClass", device_info.class_code);
+  device_value.SetIntKey("deviceSubclass", device_info.subclass_code);
+  device_value.SetIntKey("deviceProtocol", device_info.protocol_code);
+  device_value.SetIntKey("deviceVersionMajor",
+                         device_info.device_version_major);
+  device_value.SetIntKey("deviceVersionMinor",
+                         device_info.device_version_minor);
+  device_value.SetIntKey("deviceVersionSubminor",
+                         device_info.device_version_subminor);
+  return device_value;
+}
+
+void UsbChooserContext::InitDeviceList(
+    std::vector<device::mojom::UsbDeviceInfoPtr> devices) {
+  for (auto& device_info : devices) {
+    DCHECK(device_info);
+    if (ShouldExposeDevice(*device_info)) {
+      devices_.insert(
+          std::make_pair(device_info->guid, std::move(device_info)));
+    }
+  }
+  is_initialized_ = true;
+
+  while (!pending_get_devices_requests_.empty()) {
+    std::vector<device::mojom::UsbDeviceInfoPtr> device_list;
+    for (const auto& entry : devices_) {
+      device_list.push_back(entry.second->Clone());
+    }
+    std::move(pending_get_devices_requests_.front())
+        .Run(std::move(device_list));
+    pending_get_devices_requests_.pop();
+  }
+}
+
+void UsbChooserContext::EnsureConnectionWithDeviceManager() {
+  if (device_manager_)
+    return;
+
+  // Receive mojo::Remote<UsbDeviceManager> from DeviceService.
+  content::GetDeviceService().BindUsbDeviceManager(
+      device_manager_.BindNewPipeAndPassReceiver());
+
+  SetUpDeviceManagerConnection();
+}
+
+void UsbChooserContext::SetUpDeviceManagerConnection() {
+  DCHECK(device_manager_);
+  device_manager_.set_disconnect_handler(
+      base::BindOnce(&UsbChooserContext::OnDeviceManagerConnectionError,
+                     base::Unretained(this)));
+
+  // Listen for added/removed device events.
+  DCHECK(!client_receiver_.is_bound());
+  device_manager_->EnumerateDevicesAndSetClient(
+      client_receiver_.BindNewEndpointAndPassRemote(),
+      base::BindOnce(&UsbChooserContext::InitDeviceList,
+                     weak_factory_.GetWeakPtr()));
+}
+
+UsbChooserContext::~UsbChooserContext() {
+  OnDeviceManagerConnectionError();
+  for (auto& observer : device_observer_list_) {
+    observer.OnBrowserContextShutdown();
+    DCHECK(!device_observer_list_.HasObserver(&observer));
+  }
+}
+
+void UsbChooserContext::RevokeDevicePermissionWebInitiated(
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device) {
+  DCHECK(base::Contains(devices_, device.guid));
+  RevokeObjectPermissionInternal(origin, DeviceInfoToValue(device),
+                                 /*revoked_by_website=*/true);
+}
+
+void UsbChooserContext::RevokeObjectPermissionInternal(
+    const url::Origin& origin,
+    const base::Value& object,
+    bool revoked_by_website = false) {
+  if (object.FindStringKey(kDeviceSerialNumberKey)) {
+    auto* permission_manager = static_cast<ElectronPermissionManager*>(
+        browser_context_->GetPermissionControllerDelegate());
+    permission_manager->RevokeDevicePermission(
+        static_cast<blink::PermissionType>(
+            WebContentsPermissionHelper::PermissionType::USB),
+        origin, object, browser_context_);
+  } else {
+    const std::string* guid = object.FindStringKey(kDeviceIdKey);
+    auto it = ephemeral_devices_.find(origin);
+    if (it != ephemeral_devices_.end()) {
+      it->second.erase(*guid);
+      if (it->second.empty())
+        ephemeral_devices_.erase(it);
+    }
+  }
+
+  api::Session* session = api::Session::FromBrowserContext(browser_context_);
+  if (session) {
+    v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
+    v8::HandleScope scope(isolate);
+    gin_helper::Dictionary details =
+        gin_helper::Dictionary::CreateEmpty(isolate);
+    details.Set("device", object.Clone());
+    details.Set("origin", origin.Serialize());
+    session->Emit("usb-device-revoked", details);
+  }
+}
+
+void UsbChooserContext::GrantDevicePermission(
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device_info) {
+  if (CanStorePersistentEntry(device_info)) {
+    auto* permission_manager = static_cast<ElectronPermissionManager*>(
+        browser_context_->GetPermissionControllerDelegate());
+    permission_manager->GrantDevicePermission(
+        static_cast<blink::PermissionType>(
+            WebContentsPermissionHelper::PermissionType::USB),
+        origin, DeviceInfoToValue(device_info), browser_context_);
+  } else {
+    ephemeral_devices_[origin].insert(device_info.guid);
+  }
+}
+
+bool UsbChooserContext::HasDevicePermission(
+    const url::Origin& origin,
+    const device::mojom::UsbDeviceInfo& device_info) {
+  auto it = ephemeral_devices_.find(origin);
+  if (it != ephemeral_devices_.end() &&
+      base::Contains(it->second, device_info.guid)) {
+    return true;
+  }
+
+  auto* permission_manager = static_cast<ElectronPermissionManager*>(
+      browser_context_->GetPermissionControllerDelegate());
+
+  return permission_manager->CheckDevicePermission(
+      static_cast<blink::PermissionType>(
+          WebContentsPermissionHelper::PermissionType::USB),
+      origin, DeviceInfoToValue(device_info), browser_context_);
+}
+
+void UsbChooserContext::GetDevices(
+    device::mojom::UsbDeviceManager::GetDevicesCallback callback) {
+  if (!is_initialized_) {
+    EnsureConnectionWithDeviceManager();
+    pending_get_devices_requests_.push(std::move(callback));
+    return;
+  }
+
+  std::vector<device::mojom::UsbDeviceInfoPtr> device_list;
+  for (const auto& pair : devices_) {
+    device_list.push_back(pair.second->Clone());
+  }
+  base::SequencedTaskRunnerHandle::Get()->PostTask(
+      FROM_HERE, base::BindOnce(std::move(callback), std::move(device_list)));
+}
+
+void UsbChooserContext::GetDevice(
+    const std::string& guid,
+    base::span<const uint8_t> blocked_interface_classes,
+    mojo::PendingReceiver<device::mojom::UsbDevice> device_receiver,
+    mojo::PendingRemote<device::mojom::UsbDeviceClient> device_client) {
+  EnsureConnectionWithDeviceManager();
+  device_manager_->GetDevice(
+      guid,
+      std::vector<uint8_t>(blocked_interface_classes.begin(),
+                           blocked_interface_classes.end()),
+      std::move(device_receiver), std::move(device_client));
+}
+
+const device::mojom::UsbDeviceInfo* UsbChooserContext::GetDeviceInfo(
+    const std::string& guid) {
+  DCHECK(is_initialized_);
+  auto it = devices_.find(guid);
+  return it == devices_.end() ? nullptr : it->second.get();
+}
+
+void UsbChooserContext::AddObserver(DeviceObserver* observer) {
+  EnsureConnectionWithDeviceManager();
+  device_observer_list_.AddObserver(observer);
+}
+
+void UsbChooserContext::RemoveObserver(DeviceObserver* observer) {
+  device_observer_list_.RemoveObserver(observer);
+}
+
+base::WeakPtr<UsbChooserContext> UsbChooserContext::AsWeakPtr() {
+  return weak_factory_.GetWeakPtr();
+}
+
+void UsbChooserContext::OnDeviceAdded(
+    device::mojom::UsbDeviceInfoPtr device_info) {
+  DCHECK(device_info);
+  // Update the device list.
+  DCHECK(!base::Contains(devices_, device_info->guid));
+  if (!ShouldExposeDevice(*device_info))
+    return;
+  devices_.insert(std::make_pair(device_info->guid, device_info->Clone()));
+
+  // Notify all observers.
+  for (auto& observer : device_observer_list_)
+    observer.OnDeviceAdded(*device_info);
+}
+
+void UsbChooserContext::OnDeviceRemoved(
+    device::mojom::UsbDeviceInfoPtr device_info) {
+  DCHECK(device_info);
+
+  if (!ShouldExposeDevice(*device_info)) {
+    DCHECK(!base::Contains(devices_, device_info->guid));
+    return;
+  }
+
+  // Update the device list.
+  DCHECK(base::Contains(devices_, device_info->guid));
+  devices_.erase(device_info->guid);
+
+  // Notify all device observers.
+  for (auto& observer : device_observer_list_)
+    observer.OnDeviceRemoved(*device_info);
+
+  // If the device was persistent, return. Otherwise, notify all permission
+  // observers that its permissions were revoked.
+  if (device_info->serial_number &&
+      !device_info->serial_number.value().empty()) {
+    return;
+  }
+  for (auto& map_entry : ephemeral_devices_) {
+    map_entry.second.erase(device_info->guid);
+  }
+}
+
+void UsbChooserContext::OnDeviceManagerConnectionError() {
+  device_manager_.reset();
+  client_receiver_.reset();
+  devices_.clear();
+  is_initialized_ = false;
+
+  ephemeral_devices_.clear();
+
+  // Notify all device observers.
+  for (auto& observer : device_observer_list_)
+    observer.OnDeviceManagerConnectionError();
+}
+
+}  // namespace electron

+ 122 - 0
shell/browser/usb/usb_chooser_context.h

@@ -0,0 +1,122 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_H_
+#define ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_H_
+
+#include <map>
+#include <memory>
+#include <set>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "base/containers/queue.h"
+#include "base/observer_list.h"
+#include "base/values.h"
+#include "build/build_config.h"
+#include "components/keyed_service/core/keyed_service.h"
+#include "mojo/public/cpp/bindings/associated_receiver.h"
+#include "mojo/public/cpp/bindings/pending_receiver.h"
+#include "mojo/public/cpp/bindings/pending_remote.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "services/device/public/mojom/usb_manager.mojom.h"
+#include "services/device/public/mojom/usb_manager_client.mojom.h"
+#include "shell/browser/electron_browser_context.h"
+#include "url/origin.h"
+
+namespace electron {
+
+class UsbChooserContext : public KeyedService,
+                          public device::mojom::UsbDeviceManagerClient {
+ public:
+  explicit UsbChooserContext(ElectronBrowserContext* context);
+
+  UsbChooserContext(const UsbChooserContext&) = delete;
+  UsbChooserContext& operator=(const UsbChooserContext&) = delete;
+
+  ~UsbChooserContext() override;
+
+  // This observer can be used to be notified of changes to USB devices that are
+  // connected.
+  class DeviceObserver : public base::CheckedObserver {
+   public:
+    virtual void OnDeviceAdded(const device::mojom::UsbDeviceInfo&);
+    virtual void OnDeviceRemoved(const device::mojom::UsbDeviceInfo&);
+    virtual void OnDeviceManagerConnectionError();
+
+    // Called when the BrowserContext is shutting down. Observers must remove
+    // themselves before returning.
+    virtual void OnBrowserContextShutdown() = 0;
+  };
+
+  static base::Value DeviceInfoToValue(
+      const device::mojom::UsbDeviceInfo& device_info);
+
+  // Grants |origin| access to the USB device.
+  void GrantDevicePermission(const url::Origin& origin,
+                             const device::mojom::UsbDeviceInfo& device_info);
+
+  // Checks if |origin| has access to a device with |device_info|.
+  bool HasDevicePermission(const url::Origin& origin,
+                           const device::mojom::UsbDeviceInfo& device_info);
+
+  // Revokes |origin| access to the USB device ordered by website.
+  void RevokeDevicePermissionWebInitiated(
+      const url::Origin& origin,
+      const device::mojom::UsbDeviceInfo& device);
+
+  void AddObserver(DeviceObserver* observer);
+  void RemoveObserver(DeviceObserver* observer);
+
+  // Forward UsbDeviceManager methods.
+  void GetDevices(device::mojom::UsbDeviceManager::GetDevicesCallback callback);
+  void GetDevice(
+      const std::string& guid,
+      base::span<const uint8_t> blocked_interface_classes,
+      mojo::PendingReceiver<device::mojom::UsbDevice> device_receiver,
+      mojo::PendingRemote<device::mojom::UsbDeviceClient> device_client);
+
+  // This method should only be called when you are sure that |devices_| has
+  // been initialized. It will return nullptr if the guid cannot be found.
+  const device::mojom::UsbDeviceInfo* GetDeviceInfo(const std::string& guid);
+
+  base::WeakPtr<UsbChooserContext> AsWeakPtr();
+
+  void InitDeviceList(std::vector<::device::mojom::UsbDeviceInfoPtr> devices);
+
+ private:
+  // device::mojom::UsbDeviceManagerClient implementation.
+  void OnDeviceAdded(device::mojom::UsbDeviceInfoPtr device_info) override;
+  void OnDeviceRemoved(device::mojom::UsbDeviceInfoPtr device_info) override;
+
+  void RevokeObjectPermissionInternal(const url::Origin& origin,
+                                      const base::Value& object,
+                                      bool revoked_by_website);
+
+  void OnDeviceManagerConnectionError();
+  void EnsureConnectionWithDeviceManager();
+  void SetUpDeviceManagerConnection();
+
+  bool is_initialized_ = false;
+  base::queue<device::mojom::UsbDeviceManager::GetDevicesCallback>
+      pending_get_devices_requests_;
+
+  std::map<url::Origin, std::set<std::string>> ephemeral_devices_;
+  std::map<std::string, device::mojom::UsbDeviceInfoPtr> devices_;
+
+  // Connection to |device_manager_instance_|.
+  mojo::Remote<device::mojom::UsbDeviceManager> device_manager_;
+  mojo::AssociatedReceiver<device::mojom::UsbDeviceManagerClient>
+      client_receiver_{this};
+  base::ObserverList<DeviceObserver> device_observer_list_;
+
+  ElectronBrowserContext* browser_context_;
+
+  base::WeakPtrFactory<UsbChooserContext> weak_factory_{this};
+};
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_H_

+ 45 - 0
shell/browser/usb/usb_chooser_context_factory.cc

@@ -0,0 +1,45 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/usb/usb_chooser_context_factory.h"
+
+#include "components/keyed_service/content/browser_context_dependency_manager.h"
+#include "shell/browser/electron_browser_context.h"
+#include "shell/browser/usb/usb_chooser_context.h"
+
+namespace electron {
+
+UsbChooserContextFactory::UsbChooserContextFactory()
+    : BrowserContextKeyedServiceFactory(
+          "UsbChooserContext",
+          BrowserContextDependencyManager::GetInstance()) {}
+
+UsbChooserContextFactory::~UsbChooserContextFactory() {}
+
+KeyedService* UsbChooserContextFactory::BuildServiceInstanceFor(
+    content::BrowserContext* context) const {
+  auto* browser_context =
+      static_cast<electron::ElectronBrowserContext*>(context);
+  return new UsbChooserContext(browser_context);
+}
+
+// static
+UsbChooserContextFactory* UsbChooserContextFactory::GetInstance() {
+  return base::Singleton<UsbChooserContextFactory>::get();
+}
+
+// static
+UsbChooserContext* UsbChooserContextFactory::GetForBrowserContext(
+    content::BrowserContext* context) {
+  return static_cast<UsbChooserContext*>(
+      GetInstance()->GetServiceForBrowserContext(context, /*create=*/true));
+}
+
+UsbChooserContext* UsbChooserContextFactory::GetForBrowserContextIfExists(
+    content::BrowserContext* context) {
+  return static_cast<UsbChooserContext*>(
+      GetInstance()->GetServiceForBrowserContext(context, /*create=*/false));
+}
+
+}  // namespace electron

+ 39 - 0
shell/browser/usb/usb_chooser_context_factory.h

@@ -0,0 +1,39 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_FACTORY_H_
+#define ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_FACTORY_H_
+
+#include "base/memory/singleton.h"
+#include "components/keyed_service/content/browser_context_keyed_service_factory.h"
+
+namespace electron {
+
+class UsbChooserContext;
+
+class UsbChooserContextFactory : public BrowserContextKeyedServiceFactory {
+ public:
+  static UsbChooserContext* GetForBrowserContext(
+      content::BrowserContext* context);
+  static UsbChooserContext* GetForBrowserContextIfExists(
+      content::BrowserContext* context);
+  static UsbChooserContextFactory* GetInstance();
+
+  UsbChooserContextFactory(const UsbChooserContextFactory&) = delete;
+  UsbChooserContextFactory& operator=(const UsbChooserContextFactory&) = delete;
+
+ private:
+  friend struct base::DefaultSingletonTraits<UsbChooserContextFactory>;
+
+  UsbChooserContextFactory();
+  ~UsbChooserContextFactory() override;
+
+  // BrowserContextKeyedServiceFactory methods:
+  KeyedService* BuildServiceInstanceFor(
+      content::BrowserContext* profile) const override;
+};
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTEXT_FACTORY_H_

+ 165 - 0
shell/browser/usb/usb_chooser_controller.cc

@@ -0,0 +1,165 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/usb/usb_chooser_controller.h"
+
+#include <stddef.h>
+#include <utility>
+
+#include "base/bind.h"
+#include "base/strings/stringprintf.h"
+#include "base/strings/utf_string_conversions.h"
+#include "build/build_config.h"
+#include "components/strings/grit/components_strings.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/web_contents.h"
+#include "gin/data_object_builder.h"
+#include "services/device/public/cpp/usb/usb_utils.h"
+#include "services/device/public/mojom/usb_enumeration_options.mojom.h"
+#include "shell/browser/javascript_environment.h"
+#include "shell/browser/usb/usb_chooser_context_factory.h"
+#include "shell/common/gin_converters/callback_converter.h"
+#include "shell/common/gin_converters/content_converter.h"
+#include "shell/common/gin_converters/frame_converter.h"
+#include "shell/common/gin_converters/usb_device_info_converter.h"
+#include "shell/common/node_includes.h"
+#include "shell/common/process_util.h"
+#include "ui/base/l10n/l10n_util.h"
+#include "url/gurl.h"
+
+using content::RenderFrameHost;
+using content::WebContents;
+
+namespace electron {
+
+UsbChooserController::UsbChooserController(
+    RenderFrameHost* render_frame_host,
+    std::vector<device::mojom::UsbDeviceFilterPtr> device_filters,
+    blink::mojom::WebUsbService::GetPermissionCallback callback,
+    content::WebContents* web_contents,
+    base::WeakPtr<ElectronUsbDelegate> usb_delegate)
+    : WebContentsObserver(web_contents),
+      filters_(std::move(device_filters)),
+      callback_(std::move(callback)),
+      origin_(render_frame_host->GetMainFrame()->GetLastCommittedOrigin()),
+      usb_delegate_(usb_delegate),
+      render_frame_host_id_(render_frame_host->GetGlobalId()) {
+  chooser_context_ = UsbChooserContextFactory::GetForBrowserContext(
+                         web_contents->GetBrowserContext())
+                         ->AsWeakPtr();
+  DCHECK(chooser_context_);
+  chooser_context_->GetDevices(base::BindOnce(
+      &UsbChooserController::GotUsbDeviceList, weak_factory_.GetWeakPtr()));
+}
+
+UsbChooserController::~UsbChooserController() {
+  RunCallback(/*device=*/nullptr);
+}
+
+api::Session* UsbChooserController::GetSession() {
+  if (!web_contents()) {
+    return nullptr;
+  }
+  return api::Session::FromBrowserContext(web_contents()->GetBrowserContext());
+}
+
+void UsbChooserController::OnDeviceAdded(
+    const device::mojom::UsbDeviceInfo& device_info) {
+  if (DisplayDevice(device_info)) {
+    api::Session* session = GetSession();
+    if (session) {
+      session->Emit("usb-device-added", device_info.Clone(), web_contents());
+    }
+  }
+}
+
+void UsbChooserController::OnDeviceRemoved(
+    const device::mojom::UsbDeviceInfo& device_info) {
+  api::Session* session = GetSession();
+  if (session) {
+    session->Emit("usb-device-removed", device_info.Clone(), web_contents());
+  }
+}
+
+void UsbChooserController::OnDeviceChosen(gin::Arguments* args) {
+  std::string device_id;
+  if (!args->GetNext(&device_id) || device_id.empty()) {
+    RunCallback(/*device=*/nullptr);
+  } else {
+    auto* device_info = chooser_context_->GetDeviceInfo(device_id);
+    if (device_info) {
+      RunCallback(device_info->Clone());
+    } else {
+      v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
+      node::Environment* env = node::Environment::GetCurrent(isolate);
+      EmitWarning(env, "The device id " + device_id + " was not found.",
+                  "UnknownUsbDeviceId");
+      RunCallback(/*device=*/nullptr);
+    }
+  }
+}
+
+void UsbChooserController::OnBrowserContextShutdown() {
+  observation_.Reset();
+}
+
+// Get a list of devices that can be shown in the chooser bubble UI for
+// user to grant permsssion.
+void UsbChooserController::GotUsbDeviceList(
+    std::vector<::device::mojom::UsbDeviceInfoPtr> devices) {
+  // Listen to UsbChooserContext for OnDeviceAdded/Removed events after the
+  // enumeration.
+  if (chooser_context_)
+    observation_.Observe(chooser_context_.get());
+
+  bool prevent_default = false;
+  api::Session* session = GetSession();
+  if (session) {
+    auto* rfh = content::RenderFrameHost::FromID(render_frame_host_id_);
+    v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
+    v8::HandleScope scope(isolate);
+    v8::Local<v8::Object> details = gin::DataObjectBuilder(isolate)
+                                        .Set("deviceList", devices)
+                                        .Set("frame", rfh)
+                                        .Build();
+
+    prevent_default =
+        session->Emit("select-usb-device", details,
+                      base::AdaptCallbackForRepeating(
+                          base::BindOnce(&UsbChooserController::OnDeviceChosen,
+                                         weak_factory_.GetWeakPtr())));
+  }
+  if (!prevent_default) {
+    RunCallback(/*port=*/nullptr);
+  }
+}
+
+bool UsbChooserController::DisplayDevice(
+    const device::mojom::UsbDeviceInfo& device_info) const {
+  if (!device::UsbDeviceFilterMatchesAny(filters_, device_info))
+    return false;
+
+  return true;
+}
+
+void UsbChooserController::RenderFrameDeleted(
+    content::RenderFrameHost* render_frame_host) {
+  if (usb_delegate_) {
+    usb_delegate_->DeleteControllerForFrame(render_frame_host);
+  }
+}
+
+void UsbChooserController::RunCallback(
+    device::mojom::UsbDeviceInfoPtr device_info) {
+  if (callback_) {
+    if (!chooser_context_ || !device_info) {
+      std::move(callback_).Run(nullptr);
+    } else {
+      chooser_context_->GrantDevicePermission(origin_, *device_info);
+      std::move(callback_).Run(std::move(device_info));
+    }
+  }
+}
+
+}  // namespace electron

+ 81 - 0
shell/browser/usb/usb_chooser_controller.h

@@ -0,0 +1,81 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTROLLER_H_
+#define ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTROLLER_H_
+
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "base/memory/raw_ptr.h"
+#include "base/memory/ref_counted.h"
+#include "base/memory/weak_ptr.h"
+#include "base/scoped_observation.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/browser/web_contents_observer.h"
+#include "services/device/public/mojom/usb_device.mojom.h"
+#include "shell/browser/api/electron_api_session.h"
+#include "shell/browser/usb/electron_usb_delegate.h"
+#include "shell/browser/usb/usb_chooser_context.h"
+#include "third_party/blink/public/mojom/usb/web_usb_service.mojom.h"
+#include "url/origin.h"
+
+namespace content {
+class RenderFrameHost;
+}
+
+namespace electron {
+
+// UsbChooserController creates a chooser for WebUSB.
+class UsbChooserController final : public UsbChooserContext::DeviceObserver,
+                                   public content::WebContentsObserver {
+ public:
+  UsbChooserController(
+      content::RenderFrameHost* render_frame_host,
+      std::vector<device::mojom::UsbDeviceFilterPtr> device_filters,
+      blink::mojom::WebUsbService::GetPermissionCallback callback,
+      content::WebContents* web_contents,
+      base::WeakPtr<ElectronUsbDelegate> usb_delegate);
+
+  UsbChooserController(const UsbChooserController&) = delete;
+  UsbChooserController& operator=(const UsbChooserController&) = delete;
+
+  ~UsbChooserController() override;
+
+  // UsbChooserContext::DeviceObserver implementation:
+  void OnDeviceAdded(const device::mojom::UsbDeviceInfo& device_info) override;
+  void OnDeviceRemoved(
+      const device::mojom::UsbDeviceInfo& device_info) override;
+  void OnBrowserContextShutdown() override;
+
+  // content::WebContentsObserver:
+  void RenderFrameDeleted(content::RenderFrameHost* render_frame_host) override;
+
+ private:
+  api::Session* GetSession();
+  void GotUsbDeviceList(std::vector<device::mojom::UsbDeviceInfoPtr> devices);
+  bool DisplayDevice(const device::mojom::UsbDeviceInfo& device) const;
+  void RunCallback(device::mojom::UsbDeviceInfoPtr device_info);
+  void OnDeviceChosen(gin::Arguments* args);
+
+  std::vector<device::mojom::UsbDeviceFilterPtr> filters_;
+  blink::mojom::WebUsbService::GetPermissionCallback callback_;
+  url::Origin origin_;
+
+  base::WeakPtr<UsbChooserContext> chooser_context_;
+  base::ScopedObservation<UsbChooserContext, UsbChooserContext::DeviceObserver>
+      observation_{this};
+
+  base::WeakPtr<ElectronUsbDelegate> usb_delegate_;
+
+  content::GlobalRenderFrameHostId render_frame_host_id_;
+
+  base::WeakPtrFactory<UsbChooserController> weak_factory_{this};
+};
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_USB_USB_CHOOSER_CONTROLLER_H_

+ 2 - 1
shell/browser/web_contents_permission_helper.h

@@ -29,7 +29,8 @@ class WebContentsPermissionHelper
     FULLSCREEN,
     OPEN_EXTERNAL,
     SERIAL,
-    HID
+    HID,
+    USB
   };
 
   // Asynchronous Requests

+ 4 - 0
shell/common/electron_constants.cc

@@ -25,6 +25,10 @@ const char kSecureProtocolDescription[] =
     "The connection to this site is using a strong protocol version "
     "and cipher suite.";
 
+const char kDeviceVendorIdKey[] = "vendorId";
+const char kDeviceProductIdKey[] = "productId";
+const char kDeviceSerialNumberKey[] = "serialNumber";
+
 #if BUILDFLAG(ENABLE_RUN_AS_NODE)
 const char kRunAsNode[] = "ELECTRON_RUN_AS_NODE";
 #endif

+ 5 - 0
shell/common/electron_constants.h

@@ -25,6 +25,11 @@ extern const char kValidCertificateDescription[];
 extern const char kSecureProtocol[];
 extern const char kSecureProtocolDescription[];
 
+// Keys for Device APIs
+extern const char kDeviceVendorIdKey[];
+extern const char kDeviceProductIdKey[];
+extern const char kDeviceSerialNumberKey[];
+
 #if BUILDFLAG(ENABLE_RUN_AS_NODE)
 extern const char kRunAsNode[];
 #endif

+ 2 - 0
shell/common/gin_converters/content_converter.cc

@@ -209,6 +209,8 @@ v8::Local<v8::Value> Converter<blink::PermissionType>::ToV8(
       return StringToV8(isolate, "serial");
     case PermissionType::HID:
       return StringToV8(isolate, "hid");
+    case PermissionType::USB:
+      return StringToV8(isolate, "usb");
     default:
       return StringToV8(isolate, "unknown");
   }

+ 27 - 0
shell/common/gin_converters/usb_device_info_converter.h

@@ -0,0 +1,27 @@
+// Copyright (c) 2022 Microsoft, Inc.
+// 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_USB_DEVICE_INFO_CONVERTER_H_
+#define ELECTRON_SHELL_COMMON_GIN_CONVERTERS_USB_DEVICE_INFO_CONVERTER_H_
+
+#include "gin/converter.h"
+#include "services/device/public/mojom/usb_device.mojom.h"
+#include "shell/browser/usb/usb_chooser_context.h"
+#include "shell/common/gin_converters/value_converter.h"
+
+namespace gin {
+
+template <>
+struct Converter<device::mojom::UsbDeviceInfoPtr> {
+  static v8::Local<v8::Value> ToV8(
+      v8::Isolate* isolate,
+      const device::mojom::UsbDeviceInfoPtr& device) {
+    base::Value value = electron::UsbChooserContext::DeviceInfoToValue(*device);
+    return gin::ConvertToV8(isolate, value);
+  }
+};
+
+}  // namespace gin
+
+#endif  // ELECTRON_SHELL_COMMON_GIN_CONVERTERS_USB_DEVICE_INFO_CONVERTER_H_

+ 161 - 0
spec/chromium-spec.ts

@@ -2857,3 +2857,164 @@ describe('navigator.hid', () => {
     }
   });
 });
+
+describe('navigator.usb', () => {
+  let w: BrowserWindow;
+  let server: http.Server;
+  let serverUrl: string;
+  before(async () => {
+    w = new BrowserWindow({
+      show: false
+    });
+    await w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
+    server = http.createServer((req, res) => {
+      res.setHeader('Content-Type', 'text/html');
+      res.end('<body>');
+    });
+    await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
+    serverUrl = `http://localhost:${(server.address() as any).port}`;
+  });
+
+  const requestDevices: any = () => {
+    return w.webContents.executeJavaScript(`
+      navigator.usb.requestDevice({filters: []}).then(device => device.toString()).catch(err => err.toString());
+    `, true);
+  };
+
+  const notFoundError = 'NotFoundError: Failed to execute \'requestDevice\' on \'USB\': No device selected.';
+
+  after(() => {
+    server.close();
+    closeAllWindows();
+  });
+  afterEach(() => {
+    session.defaultSession.setPermissionCheckHandler(null);
+    session.defaultSession.setDevicePermissionHandler(null);
+    session.defaultSession.removeAllListeners('select-usb-device');
+  });
+
+  it('does not return a device if select-usb-device event is not defined', async () => {
+    w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
+    const device = await requestDevices();
+    expect(device).to.equal(notFoundError);
+  });
+
+  it('does not return a device when permission denied', async () => {
+    let selectFired = false;
+    w.webContents.session.on('select-usb-device', (event, details, callback) => {
+      selectFired = true;
+      callback();
+    });
+    session.defaultSession.setPermissionCheckHandler(() => false);
+    const device = await requestDevices();
+    expect(selectFired).to.be.false();
+    expect(device).to.equal(notFoundError);
+  });
+
+  it('returns a device when select-usb-device event is defined', async () => {
+    let haveDevices = false;
+    let selectFired = false;
+    w.webContents.session.on('select-usb-device', (event, details, callback) => {
+      expect(details.frame).to.have.ownProperty('frameTreeNodeId').that.is.a('number');
+      selectFired = true;
+      if (details.deviceList.length > 0) {
+        haveDevices = true;
+        callback(details.deviceList[0].deviceId);
+      } else {
+        callback();
+      }
+    });
+    const device = await requestDevices();
+    expect(selectFired).to.be.true();
+    if (haveDevices) {
+      expect(device).to.contain('[object USBDevice]');
+    } else {
+      expect(device).to.equal(notFoundError);
+    }
+    if (haveDevices) {
+      // Verify that navigation will clear device permissions
+      const grantedDevices = await w.webContents.executeJavaScript('navigator.usb.getDevices()');
+      expect(grantedDevices).to.not.be.empty();
+      w.loadURL(serverUrl);
+      const [,,,,, frameProcessId, frameRoutingId] = await emittedOnce(w.webContents, 'did-frame-navigate');
+      const frame = webFrameMain.fromId(frameProcessId, frameRoutingId);
+      expect(frame).to.not.be.empty();
+      if (frame) {
+        const grantedDevicesOnNewPage = await frame.executeJavaScript('navigator.usb.getDevices()');
+        expect(grantedDevicesOnNewPage).to.be.empty();
+      }
+    }
+  });
+
+  it('returns a device when DevicePermissionHandler is defined', async () => {
+    let haveDevices = false;
+    let selectFired = false;
+    let gotDevicePerms = false;
+    w.webContents.session.on('select-usb-device', (event, details, callback) => {
+      selectFired = true;
+      if (details.deviceList.length > 0) {
+        const foundDevice = details.deviceList.find((device) => {
+          if (device.productName && device.productName !== '' && device.serialNumber && device.serialNumber !== '') {
+            haveDevices = true;
+            return true;
+          }
+        });
+        if (foundDevice) {
+          callback(foundDevice.deviceId);
+          return;
+        }
+      }
+      callback();
+    });
+    session.defaultSession.setDevicePermissionHandler(() => {
+      gotDevicePerms = true;
+      return true;
+    });
+    await w.webContents.executeJavaScript('navigator.usb.getDevices();', true);
+    const device = await requestDevices();
+    expect(selectFired).to.be.true();
+    if (haveDevices) {
+      expect(device).to.contain('[object USBDevice]');
+      expect(gotDevicePerms).to.be.true();
+    } else {
+      expect(device).to.equal(notFoundError);
+    }
+  });
+
+  it('supports device.forget()', async () => {
+    let deletedDeviceFromEvent;
+    let haveDevices = false;
+    w.webContents.session.on('select-usb-device', (event, details, callback) => {
+      if (details.deviceList.length > 0) {
+        haveDevices = true;
+        callback(details.deviceList[0].deviceId);
+      } else {
+        callback();
+      }
+    });
+    w.webContents.session.on('usb-device-revoked', (event, details) => {
+      deletedDeviceFromEvent = details.device;
+    });
+    await requestDevices();
+    if (haveDevices) {
+      const grantedDevices = await w.webContents.executeJavaScript('navigator.usb.getDevices()');
+      if (grantedDevices.length > 0) {
+        const deletedDevice = await w.webContents.executeJavaScript(`
+          navigator.usb.getDevices().then(devices => {
+            devices[0].forget();
+            return {
+              vendorId: devices[0].vendorId,
+              productId: devices[0].productId,
+              productName: devices[0].productName
+            }
+          })
+        `);
+        const grantedDevices2 = await w.webContents.executeJavaScript('navigator.usb.getDevices()');
+        expect(grantedDevices2.length).to.be.lessThan(grantedDevices.length);
+        if (deletedDevice.name !== '' && deletedDevice.productId && deletedDevice.vendorId) {
+          expect(deletedDeviceFromEvent).to.include(deletedDevice);
+        }
+      }
+    }
+  });
+});