diff --git a/BUILD.gn b/BUILD.gn index a369924a97013..801cffea24092 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -379,6 +379,7 @@ source_set("electron_lib") { "//ppapi/shared_impl", "//printing/buildflags", "//services/device/public/cpp/geolocation", + "//services/device/public/cpp/hid", "//services/device/public/mojom", "//services/proxy_resolver:lib", "//services/video_capture/public/mojom:constants", diff --git a/docs/api/session.md b/docs/api/session.md index 79c65c406f083..715a65d1169e1 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -180,6 +180,99 @@ Emitted when a hunspell dictionary file download fails. For details on the failure you should collect a netlog and inspect the download request. +#### Event: 'select-hid-device' + +Returns: + +* `event` Event +* `details` Object + * `deviceList` [HIDDevice[]](structures/hid-device.md) + * `webContents` [WebContents](web-contents.md) +* `callback` Function + * `deviceId` String + +Emitted when a HID device needs to be selected when a call to +`navigator.hid.requestDevice` is made. `callback` should be called with +`deviceId` to be selected, passing an empty string to `callback` will +cancel the request. Additionally, permissioning on `navigator.hid` 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({ + width: 800, + height: 600 + }) + + win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + if (permission === 'hid') { + // Add logic here to determine if permission should be given to allow HID selection + return true + } + }) + + // Retrieve previously persisted devices from an (optional) store here + const grantedDevices = [] + + win.webContents.session.setDevicePermissionHandler((details) => { + if (new URL(details.origin).hostname === 'some-host' && details.deviceType === 'hid') { + if (details.device.vendorId === 123 && details.device.productId === 345) { + // Always allow this type of device (this allows skipping the call to `navigator.hid.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-hid-device', (event, details, callback) => { + event.preventDefault() + const selectedDevice = details.deviceList.find((device) => { + return device.vendorId === '9025' && device.productId === '67' + }) + if (!selectedDevice) { + callback('') + } else { + grantedDevices.push(device) + callback(selectedDevice.deviceId) + } + }) +}) +``` + +#### Event: 'hid-device-added' + +Returns: + +* `event` Event +* `details` Object + * `device` [HIDDevice[]](structures/hid-device.md) + * `webContents` [WebContents](web-contents.md) + +Emitted after `navigator.hid.requestDevice` has been called and `select-hid-device` has fired if a new HID device becomes available. For example, this event will fire when a new USB device is plugged in. + +#### Event: 'hid-device-removed' + +Returns: + +* `event` Event +* `details` Object + * `device` [HIDDevice[]](structures/hid-device.md) + * `webContents` [WebContents](web-contents.md) + +Emitted after `navigator.hid.requestDevice` has been called and `select-hid-device` has fired if a HID device has been removed. For example, this event will fire when a USB device is unplugged. + #### Event: 'select-serial-port' Returns: @@ -525,7 +618,7 @@ session.fromPartition('some-partition').setPermissionRequestHandler((webContents * `handler` Function\ | 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`, or `serial`. + * `permission` String - Type of permission check. Valid values are `midiSysex`, `notifications`, `geolocation`, `media`,`mediaKeySystem`,`midi`, `pointerLock`, `fullscreen`, `openExternal`, `hid`, or `serial`. * `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. @@ -553,6 +646,61 @@ session.fromPartition('some-partition').setPermissionCheckHandler((webContents, }) ``` +#### `ses.setDevicePermissionHandler(handler)` + +* `handler` Function\ | null + * `details` Object + * `deviceType` String - The type of device that permission is being requested on, can be `hid`. + * `origin` String - The origin URL of the device permission check. + * `device` [HIDDevice](structures/hid-device.md) - the device that permission is being requested for. + * `webContents` [WebContents](web-contents.md) - WebContents checking the device permission. + +Sets the handler which can be used to respond to device permission checks for the `session`. +Returning `true` will allow the device to be permitted and `false` will reject it. +To clear the handler, call `setDevicePermissionHandler(null)`. +This handler can be used to provide default permissioning to devices without first calling for permission +to devices (eg via `navigator.hid.requestDevice`). If this handler is not defined, the default device +permissions as granted through device selection (eg via `navigator.hid.requestDevice`) will be used. +Additionally, the default behavior of Electron is to store granted device permision 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-hid-device` event) and then read from that storage with `setDevicePermissionHandler`. + +```javascript +const { session } = require('electron') +const url = require('url') +const grantedDevices = // Retrieve previously persisted devices from an (optional) store here + +session.fromPartition('some-partition').setDevicePermissionHandler((details) => { + if (new URL(details.origin).hostname === 'some-host' && details.deviceType === 'hid') { + if (details.device.vendorId === 123 && details.device.productId === 345) { + // Always allow this type of device (this allows skipping the call to `navigator.hid.requestDevice` first) + return true // Approved permission + } + + // 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 // Denied permission +}) + +session.fromPartition('some-partition').on('select-hid-device', (event, details, callback) => { + event.preventDefault() + const selectedDevice = details.deviceList.find((device) => { + return device.vendorId === '9025' && device.productId === '67' + }) + if (!selectedDevice) { + callback('') + } else { + // Persist to grantedDevices here + callback(selectedDevice.deviceId) + } +}) +``` + #### `ses.clearHostResolverCache()` Returns `Promise` - Resolves when the operation is complete. diff --git a/docs/api/structures/hid-device.md b/docs/api/structures/hid-device.md new file mode 100644 index 0000000000000..fac2b6276a9df --- /dev/null +++ b/docs/api/structures/hid-device.md @@ -0,0 +1,8 @@ +# HIDDevice Object + +* `deviceId` String - Unique identifier for the device. +* `name` String - Name of the device. +* `vendorId` Integer - The USB vendor ID. +* `productId` Integer - The USB product ID. +* `serialNumber` String (optional) - The USB device serial number. +* `guid` String (optional) - Unique identifier for the HID interface. A device may have multiple HID interfaces. diff --git a/docs/fiddles/features/web-bluetooth/index.html b/docs/fiddles/features/web-bluetooth/index.html new file mode 100644 index 0000000000000..b2be53d400a6a --- /dev/null +++ b/docs/fiddles/features/web-bluetooth/index.html @@ -0,0 +1,17 @@ + + + + + + Web Bluetooth API + + +

Web Bluetooth API

+ + + +

Currently selected bluetooth device:

+ + + + diff --git a/docs/fiddles/features/web-bluetooth/main.js b/docs/fiddles/features/web-bluetooth/main.js new file mode 100644 index 0000000000000..b3cc55a438198 --- /dev/null +++ b/docs/fiddles/features/web-bluetooth/main.js @@ -0,0 +1,30 @@ +const {app, BrowserWindow} = require('electron') +const path = require('path') + +function createWindow () { + const mainWindow = new BrowserWindow({ + width: 800, + height: 600 + }) + + mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => { + event.preventDefault() + if (deviceList && deviceList.length > 0) { + callback(deviceList[0].deviceId) + } + }) + + 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() +}) diff --git a/docs/fiddles/features/web-bluetooth/renderer.js b/docs/fiddles/features/web-bluetooth/renderer.js new file mode 100644 index 0000000000000..e5830955599af --- /dev/null +++ b/docs/fiddles/features/web-bluetooth/renderer.js @@ -0,0 +1,8 @@ +async function testIt() { + const device = await navigator.bluetooth.requestDevice({ + acceptAllDevices: true + }) + document.getElementById('device-name').innerHTML = device.name || `ID: ${device.id}` +} + +document.getElementById('clickme').addEventListener('click',testIt) \ No newline at end of file diff --git a/docs/fiddles/features/web-hid/index.html b/docs/fiddles/features/web-hid/index.html new file mode 100644 index 0000000000000..659b5bc12395e --- /dev/null +++ b/docs/fiddles/features/web-hid/index.html @@ -0,0 +1,21 @@ + + + + + + WebHID API + + +

WebHID API

+ + + +

HID devices automatically granted access via setDevicePermissionHandler

+
+ +

HID devices automatically granted access via select-hid-device

+
+ + + + diff --git a/docs/fiddles/features/web-hid/main.js b/docs/fiddles/features/web-hid/main.js new file mode 100644 index 0000000000000..cb61e188a0fd0 --- /dev/null +++ b/docs/fiddles/features/web-hid/main.js @@ -0,0 +1,50 @@ +const {app, BrowserWindow} = require('electron') +const path = require('path') + +function createWindow () { + const mainWindow = new BrowserWindow({ + width: 800, + height: 600 + }) + + mainWindow.webContents.session.on('select-hid-device', (event, details, callback) => { + event.preventDefault() + if (details.deviceList && details.deviceList.length > 0) { + callback(details.deviceList[0].deviceId) + } + }) + + mainWindow.webContents.session.on('hid-device-added', (event, device) => { + console.log('hid-device-added FIRED WITH', device) + }) + + mainWindow.webContents.session.on('hid-device-removed', (event, device) => { + console.log('hid-device-removed FIRED WITH', device) + }) + + mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + if (permission === 'hid' && details.securityOrigin === 'file:///') { + return true + } + }) + + mainWindow.webContents.session.setDevicePermissionHandler((details) => { + if (details.deviceType === 'hid' && details.origin === 'file://') { + return true + } + }) + + 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() +}) diff --git a/docs/fiddles/features/web-hid/renderer.js b/docs/fiddles/features/web-hid/renderer.js new file mode 100644 index 0000000000000..54bda3f977106 --- /dev/null +++ b/docs/fiddles/features/web-hid/renderer.js @@ -0,0 +1,19 @@ +async function testIt() { + const grantedDevices = await navigator.hid.getDevices() + let grantedDeviceList = '' + grantedDevices.forEach(device => { + grantedDeviceList += `
${device.productName}` + }) + document.getElementById('granted-devices').innerHTML = grantedDeviceList + const grantedDevices2 = await navigator.hid.requestDevice({ + filters: [] + }) + + grantedDeviceList = '' + grantedDevices2.forEach(device => { + grantedDeviceList += `
${device.productName}` + }) + document.getElementById('granted-devices2').innerHTML = grantedDeviceList +} + +document.getElementById('clickme').addEventListener('click',testIt) diff --git a/docs/fiddles/features/web-serial/index.html b/docs/fiddles/features/web-serial/index.html new file mode 100644 index 0000000000000..013718c2931fd --- /dev/null +++ b/docs/fiddles/features/web-serial/index.html @@ -0,0 +1,16 @@ + + + + + + Web Serial API + +

Web Serial API

+ + + +

Matching Arduino Uno device:

+ + + + \ No newline at end of file diff --git a/docs/fiddles/features/web-serial/main.js b/docs/fiddles/features/web-serial/main.js new file mode 100644 index 0000000000000..c6bd996724d2e --- /dev/null +++ b/docs/fiddles/features/web-serial/main.js @@ -0,0 +1,54 @@ +const {app, BrowserWindow} = require('electron') +const path = require('path') + +function createWindow () { + const mainWindow = new BrowserWindow({ + width: 800, + height: 600 + }) + + mainWindow.webContents.session.on('select-serial-port', (event, portList, webContents, callback) => { + event.preventDefault() + if (portList && portList.length > 0) { + callback(portList[0].portId) + } else { + callback('') //Could not find any matching devices + } + }) + + mainWindow.webContents.session.on('serial-port-added', (event, port) => { + console.log('serial-port-added FIRED WITH', port) + }) + + mainWindow.webContents.session.on('serial-port-removed', (event, port) => { + console.log('serial-port-removed FIRED WITH', port) + }) + + mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + if (permission === 'serial' && details.securityOrigin === 'file:///') { + return true + } + }) + + mainWindow.webContents.session.setDevicePermissionHandler((details) => { + if (details.deviceType === 'serial' && details.origin === 'file://') { + return true + } + }) + + mainWindow.loadFile('index.html') + + mainWindow.webContents.openDevTools() +} + +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() +}) diff --git a/docs/fiddles/features/web-serial/renderer.js b/docs/fiddles/features/web-serial/renderer.js new file mode 100644 index 0000000000000..1d684d219252e --- /dev/null +++ b/docs/fiddles/features/web-serial/renderer.js @@ -0,0 +1,19 @@ +async function testIt() { + const filters = [ + { usbVendorId: 0x2341, usbProductId: 0x0043 }, + { usbVendorId: 0x2341, usbProductId: 0x0001 } + ]; + try { + const port = await navigator.serial.requestPort({filters}); + const portInfo = port.getInfo(); + document.getElementById('device-name').innerHTML = `vendorId: ${portInfo.usbVendorId} | productId: ${portInfo.usbProductId} ` + } catch (ex) { + if (ex.name === 'NotFoundError') { + document.getElementById('device-name').innerHTML = 'Device NOT found' + } else { + document.getElementById('device-name').innerHTML = ex + } + } +} + +document.getElementById('clickme').addEventListener('click',testIt) diff --git a/docs/tutorial/devices.md b/docs/tutorial/devices.md new file mode 100644 index 0000000000000..02194a79cae3b --- /dev/null +++ b/docs/tutorial/devices.md @@ -0,0 +1,99 @@ +# Device Access + +Like Chromium based browsers, Electron provides access to device hardware +through web APIs. For the most part these APIs work like they do in a browser, +but there are some differences that need to be taken into account. The primary +difference between Electron and browsers is what happens when device access is +requested. In a browser, users are presented with a popup where they can grant +access to an individual device. In Electron APIs are provided which can be +used by a developer to either automatically pick a device or prompt users to +pick a device via a developer created interface. + +## Web Bluetooth API + +The [Web Bluetooth API](https://web.dev/bluetooth/) can be used to communicate +with bluetooth devices. In order to use this API in Electron, developers will +need to handle the [`select-bluetooth-device` event on the webContents](../api/web-contents.md#event-select-bluetooth-device) +associated with the device request. + +### Example + +This example demonstrates an Electron application that automatically selects +the first available bluetooth device when the `Test Bluetooth` button is +clicked. + +```javascript fiddle='docs/fiddles/features/web-bluetooth' + +``` + +## WebHID API + +The [WebHID API](https://web.dev/hid/) can be used to access HID devices such +as keyboards and gamepads. Electron provides several APIs for working with +the WebHID API: + +* The [`select-hid-device` event on the Session](../api/session.md#event-select-hid-device) + can be used to select a HID device when a call to + `navigator.hid.requestDevice` is made. Additionally the [`hid-device-added`](../api/session.md#event-hid-device-added) + and [`hid-device-removed`](../api/session.md#event-hid-device-removed) events + on the Session can be used to handle devices being plugged in or unplugged during the + `navigator.hid.requestDevice` process. +* [`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.hid.requestDevice`. Additionally, + the default behavior of Electron is to store granted device permision 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-hid-device` event) and then read from that storage with + `setDevicePermissionHandler`. +* [`ses.setPermissionCheckHandler(handler)`](../api/session.md#sessetpermissioncheckhandlerhandler) + can be used to disable HID access for specific origins. + +### Blocklist + +By default Electron employs the same [blocklist](https://github.com/WICG/webhid/blob/main/blocklist.txt) +used by Chromium. If you wish to override this behavior, you can do so by +setting the `disable-hid-blocklist` flag: + +```javascript +app.commandLine.appendSwitch('disable-hid-blocklist') +``` + +### Example + +This example demonstrates an Electron application that automatically selects +HID devices through [`ses.setDevicePermissionHandler(handler)`](../api/session.md#sessetdevicepermissionhandlerhandler) +and through [`select-hid-device` event on the Session](../api/session.md#event-select-hid-device) +when the `Test WebHID` button is clicked. + +```javascript fiddle='docs/fiddles/features/web-hid' + +``` + +## Web Serial API + +The [Web Serial API](https://web.dev/serial/) can be used to access serial +devices that are connected via serial port, USB, or Bluetooth. In order to use +this API in Electron, developers will need to handle the +[`select-serial-port` event on the Session](../api/session.md#event-select-serial-port) +associated with the serial port request. + +There are several additional APIs for working with the Web Serial API: + +* The [`serial-port-added`](../api/session.md#event-serial-port-added) + and [`serial-port-removed`](../api/session.md#event-serial-port-removed) events + on the Session can be used to handle devices being plugged in or unplugged during the + `navigator.serial.requestPort` process. +* [`ses.setPermissionCheckHandler(handler)`](../api/session.md#sessetpermissioncheckhandlerhandler) + can be used to disable serial access for specific origins. + +### Example + +This example demonstrates an Electron application that automatically selects +the first available Arduino Uno serial device (if connected) through +[`select-serial-port` event on the Session](../api/session.md#event-select-serial-port) +when the `Test Web Serial` button is clicked. + +```javascript fiddle='docs/fiddles/features/web-serial' + +``` diff --git a/electron_strings.grdp b/electron_strings.grdp index 6fc83d90e5ea9..41265769a556a 100644 --- a/electron_strings.grdp +++ b/electron_strings.grdp @@ -125,5 +125,7 @@ {UNREAD_NOTIFICATIONS, plural, =1 {1 Unread Notification} other {# Unread Notifications}} - + + + Unknown Device ($11234:abcd) diff --git a/filenames.auto.gni b/filenames.auto.gni index 163f7be1d131c..aa2940d1d261a 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -84,6 +84,7 @@ auto_filenames = { "docs/api/structures/file-filter.md", "docs/api/structures/file-path-with-headers.md", "docs/api/structures/gpu-feature-status.md", + "docs/api/structures/hid-device.md", "docs/api/structures/input-event.md", "docs/api/structures/io-counters.md", "docs/api/structures/ipc-main-event.md", diff --git a/filenames.gni b/filenames.gni index d7f93a307b442..2000709912a53 100644 --- a/filenames.gni +++ b/filenames.gni @@ -389,6 +389,14 @@ filenames = { "shell/browser/file_select_helper_mac.mm", "shell/browser/font_defaults.cc", "shell/browser/font_defaults.h", + "shell/browser/hid/electron_hid_delegate.cc", + "shell/browser/hid/electron_hid_delegate.h", + "shell/browser/hid/hid_chooser_context.cc", + "shell/browser/hid/hid_chooser_context.h", + "shell/browser/hid/hid_chooser_context_factory.cc", + "shell/browser/hid/hid_chooser_context_factory.h", + "shell/browser/hid/hid_chooser_controller.cc", + "shell/browser/hid/hid_chooser_controller.h", "shell/browser/javascript_environment.cc", "shell/browser/javascript_environment.h", "shell/browser/lib/bluetooth_chooser.cc", diff --git a/lib/browser/api/web-contents.ts b/lib/browser/api/web-contents.ts index a553df6a8d2bb..9357db2f8f804 100644 --- a/lib/browser/api/web-contents.ts +++ b/lib/browser/api/web-contents.ts @@ -111,6 +111,8 @@ const defaultPrintingSetting = { url: undefined as string | undefined } as const; +const permissionMap = new Map>>(); + // JavaScript implementations of WebContents. const binding = process._linkedBinding('electron_browser_web_contents'); const printing = process._linkedBinding('electron_browser_printing'); @@ -760,6 +762,50 @@ WebContents.prototype._init = function () { }); }; +function _hasGrantedDevicePermission (grantedDevices: Array, type: string, device: any) { + const foundDevice = grantedDevices.find((grantedDevice: any) => { + switch (type) { + case 'hid': { + return grantedDevice.vendorId === device.vendorId && + grantedDevice.productId === device.productId && + grantedDevice.serialNumber && grantedDevice.serialNumber === device.serialNumber; + } + } + }); + if (foundDevice) { + return true; + } + return false; +} + +WebContents.prototype._defaultDevicePermissionHandler = function (details: { origin: string, deviceType: string, device: any }) { + const devicePermissionsForType = permissionMap.get(details.deviceType); + if (!devicePermissionsForType) { + return false; + } + const devicePermissionsForOrigin = devicePermissionsForType.get(details.origin); + if (!devicePermissionsForOrigin || devicePermissionsForOrigin.length === 0) { + return false; + } + return _hasGrantedDevicePermission(devicePermissionsForOrigin, details.deviceType, details.device); +}; + +WebContents.prototype._defaultGrantDevicePermissionHandler = function (details: { origin: string, deviceType: string, device: any }) { + let devicePermissionsForType = permissionMap.get(details.deviceType); + if (!devicePermissionsForType) { + devicePermissionsForType = new Map>(); + permissionMap.set(details.deviceType, devicePermissionsForType); + } + let devicePermissionsForOrigin = devicePermissionsForType.get(details.origin); + if (!devicePermissionsForOrigin) { + devicePermissionsForOrigin = []; + devicePermissionsForType.set(details.origin, devicePermissionsForOrigin); + } + if (!_hasGrantedDevicePermission(devicePermissionsForOrigin, details.deviceType, details.device)) { + devicePermissionsForOrigin.push(details.device); + } +}; + // Public APIs. export function create (options = {}): Electron.WebContents { return new (WebContents as any)(options); diff --git a/shell/browser/api/electron_api_session.cc b/shell/browser/api/electron_api_session.cc index e0097f01d760b..b5025e40d9fbd 100644 --- a/shell/browser/api/electron_api_session.cc +++ b/shell/browser/api/electron_api_session.cc @@ -644,6 +644,18 @@ void Session::SetPermissionCheckHandler(v8::Local val, permission_manager->SetPermissionCheckHandler(handler); } +void Session::SetDevicePermissionHandler(v8::Local val, + gin::Arguments* args) { + ElectronPermissionManager::DeviceCheckHandler handler; + if (!(val->IsNull() || gin::ConvertFromV8(args->isolate(), val, &handler))) { + args->ThrowTypeError("Must pass null or function"); + return; + } + auto* permission_manager = static_cast( + browser_context()->GetPermissionControllerDelegate()); + permission_manager->SetDevicePermissionHandler(handler); +} + v8::Local Session::ClearHostResolverCache(gin::Arguments* args) { v8::Isolate* isolate = args->isolate(); gin_helper::Promise promise(isolate); @@ -1147,6 +1159,8 @@ gin::ObjectTemplateBuilder Session::GetObjectTemplateBuilder( &Session::SetPermissionRequestHandler) .SetMethod("setPermissionCheckHandler", &Session::SetPermissionCheckHandler) + .SetMethod("setDevicePermissionHandler", + &Session::SetDevicePermissionHandler) .SetMethod("clearHostResolverCache", &Session::ClearHostResolverCache) .SetMethod("clearAuthCache", &Session::ClearAuthCache) .SetMethod("allowNTLMCredentialsForDomains", diff --git a/shell/browser/api/electron_api_session.h b/shell/browser/api/electron_api_session.h index 1e56fc357d6da..e98d784f13c84 100644 --- a/shell/browser/api/electron_api_session.h +++ b/shell/browser/api/electron_api_session.h @@ -104,6 +104,8 @@ class Session : public gin::Wrappable, gin::Arguments* args); void SetPermissionCheckHandler(v8::Local val, gin::Arguments* args); + void SetDevicePermissionHandler(v8::Local val, + gin::Arguments* args); v8::Local ClearHostResolverCache(gin::Arguments* args); v8::Local ClearAuthCache(); void AllowNTLMCredentialsForDomains(const std::string& domains); diff --git a/shell/browser/electron_browser_client.cc b/shell/browser/electron_browser_client.cc index 069c64c36104e..bc3f5532efc28 100644 --- a/shell/browser/electron_browser_client.cc +++ b/shell/browser/electron_browser_client.cc @@ -1699,4 +1699,10 @@ device::GeolocationManager* ElectronBrowserClient::GetGeolocationManager() { #endif } +content::HidDelegate* ElectronBrowserClient::GetHidDelegate() { + if (!hid_delegate_) + hid_delegate_ = std::make_unique(); + return hid_delegate_.get(); +} + } // namespace electron diff --git a/shell/browser/electron_browser_client.h b/shell/browser/electron_browser_client.h index 6b694d8796bca..7638595f98e1f 100644 --- a/shell/browser/electron_browser_client.h +++ b/shell/browser/electron_browser_client.h @@ -20,6 +20,7 @@ #include "net/ssl/client_cert_identity.h" #include "services/metrics/public/cpp/ukm_source_id.h" #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 "third_party/blink/public/mojom/badging/badging.mojom-forward.h" @@ -93,6 +94,8 @@ class ElectronBrowserClient : public content::ContentBrowserClient, content::BluetoothDelegate* GetBluetoothDelegate() override; + content::HidDelegate* GetHidDelegate() override; + device::GeolocationManager* GetGeolocationManager() override; protected: @@ -305,6 +308,7 @@ class ElectronBrowserClient : public content::ContentBrowserClient, std::unique_ptr serial_delegate_; std::unique_ptr bluetooth_delegate_; + std::unique_ptr hid_delegate_; #if defined(OS_MAC) ElectronBrowserMainParts* browser_main_parts_ = nullptr; diff --git a/shell/browser/electron_permission_manager.cc b/shell/browser/electron_permission_manager.cc index 78dd47fa2f46a..d27c800172494 100644 --- a/shell/browser/electron_permission_manager.cc +++ b/shell/browser/electron_permission_manager.cc @@ -17,9 +17,13 @@ #include "content/public/browser/render_process_host.h" #include "content/public/browser/render_view_host.h" #include "content/public/browser/web_contents.h" +#include "shell/browser/api/electron_api_web_contents.h" #include "shell/browser/electron_browser_client.h" #include "shell/browser/electron_browser_main_parts.h" #include "shell/browser/web_contents_preferences.h" +#include "shell/common/gin_converters/content_converter.h" +#include "shell/common/gin_converters/value_converter.h" +#include "shell/common/gin_helper/event_emitter_caller.h" namespace electron { @@ -117,6 +121,11 @@ void ElectronPermissionManager::SetPermissionCheckHandler( check_handler_ = handler; } +void ElectronPermissionManager::SetDevicePermissionHandler( + const DeviceCheckHandler& handler) { + device_permission_handler_ = handler; +} + void ElectronPermissionManager::RequestPermission( content::PermissionType permission, content::RenderFrameHost* render_frame_host, @@ -282,6 +291,48 @@ bool ElectronPermissionManager::CheckPermissionWithDetails( mutable_details); } +bool ElectronPermissionManager::CheckDevicePermission( + content::PermissionType permission, + content::WebContents* web_contents, + const url::Origin& origin, + const base::Value* device) const { + api::WebContents* api_web_contents = api::WebContents::From(web_contents); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + auto details = gin::Dictionary::CreateEmpty(isolate); + details.Set("deviceType", permission); + details.Set("origin", origin.Serialize()); + details.Set("device", device->Clone()); + details.Set("webContents", api_web_contents); + + if (device_permission_handler_.is_null()) { + bool ret = false; + v8::Local val = gin_helper::CallMethod( + isolate, api_web_contents, "_defaultDevicePermissionHandler", details); + gin::ConvertFromV8(isolate, val, &ret); + return ret; + } else { + return device_permission_handler_.Run(details); + } +} + +void ElectronPermissionManager::GrantDevicePermission( + content::PermissionType permission, + content::WebContents* web_contents, + const url::Origin& origin, + const base::Value* device) const { + api::WebContents* api_web_contents = api::WebContents::From(web_contents); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + auto details = gin::Dictionary::CreateEmpty(isolate); + details.Set("deviceType", permission); + details.Set("origin", origin.Serialize()); + details.Set("device", device->Clone()); + details.Set("webContents", api_web_contents); + gin_helper::CallMethod(isolate, api_web_contents, + "_defaultGrantDevicePermissionHandler", details); +} + blink::mojom::PermissionStatus ElectronPermissionManager::GetPermissionStatusForFrame( content::PermissionType permission, diff --git a/shell/browser/electron_permission_manager.h b/shell/browser/electron_permission_manager.h index c4d8b5bd7e5b7..727b98c5fbf5a 100644 --- a/shell/browser/electron_permission_manager.h +++ b/shell/browser/electron_permission_manager.h @@ -11,6 +11,7 @@ #include "base/callback.h" #include "base/containers/id_map.h" #include "content/public/browser/permission_controller_delegate.h" +#include "gin/dictionary.h" namespace base { class DictionaryValue; @@ -42,9 +43,13 @@ class ElectronPermissionManager : public content::PermissionControllerDelegate { const GURL& requesting_origin, const base::Value&)>; + using DeviceCheckHandler = + base::RepeatingCallback; + // Handler to dispatch permission requests in JS. void SetPermissionRequestHandler(const RequestHandler& handler); void SetPermissionCheckHandler(const CheckHandler& handler); + void SetDevicePermissionHandler(const DeviceCheckHandler& handler); // content::PermissionControllerDelegate: void RequestPermission(content::PermissionType permission, @@ -81,6 +86,16 @@ class ElectronPermissionManager : public content::PermissionControllerDelegate { const GURL& requesting_origin, const base::DictionaryValue* details) const; + bool CheckDevicePermission(content::PermissionType permission, + content::WebContents* web_contents, + const url::Origin& origin, + const base::Value* object) const; + + void GrantDevicePermission(content::PermissionType permission, + content::WebContents* web_contents, + const url::Origin& origin, + const base::Value* object) const; + protected: void OnPermissionResponse(int request_id, int permission_id, @@ -108,6 +123,7 @@ class ElectronPermissionManager : public content::PermissionControllerDelegate { RequestHandler request_handler_; CheckHandler check_handler_; + DeviceCheckHandler device_permission_handler_; PendingRequestsMap pending_requests_; diff --git a/shell/browser/hid/electron_hid_delegate.cc b/shell/browser/hid/electron_hid_delegate.cc new file mode 100644 index 0000000000000..ef82b4ff5e006 --- /dev/null +++ b/shell/browser/hid/electron_hid_delegate.cc @@ -0,0 +1,162 @@ +// Copyright (c) 2021 Microsoft, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/hid/electron_hid_delegate.h" + +#include +#include + +#include "content/public/browser/web_contents.h" +#include "shell/browser/hid/hid_chooser_context.h" +#include "shell/browser/hid/hid_chooser_context_factory.h" +#include "shell/browser/hid/hid_chooser_controller.h" +#include "shell/browser/web_contents_permission_helper.h" + +namespace { + +electron::HidChooserContext* GetChooserContext( + content::RenderFrameHost* frame) { + auto* web_contents = content::WebContents::FromRenderFrameHost(frame); + auto* browser_context = web_contents->GetBrowserContext(); + return electron::HidChooserContextFactory::GetForBrowserContext( + browser_context); +} + +} // namespace + +namespace electron { + +ElectronHidDelegate::ElectronHidDelegate() = default; + +ElectronHidDelegate::~ElectronHidDelegate() = default; + +std::unique_ptr ElectronHidDelegate::RunChooser( + content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback) { + auto* chooser_context = GetChooserContext(render_frame_host); + if (!device_observation_.IsObserving()) + device_observation_.Observe(chooser_context); + + HidChooserController* controller = ControllerForFrame(render_frame_host); + if (controller) { + DeleteControllerForFrame(render_frame_host); + } + AddControllerForFrame(render_frame_host, std::move(filters), + std::move(callback)); + + // Return a nullptr because the return value isn't used for anything, eg + // there is no mechanism to cancel navigator.hid.requestDevice(). The return + // value is simply used in Chromium to cleanup the chooser UI once the serial + // service is destroyed. + return nullptr; +} + +bool ElectronHidDelegate::CanRequestDevicePermission( + content::RenderFrameHost* render_frame_host) { + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + auto* permission_helper = + WebContentsPermissionHelper::FromWebContents(web_contents); + return permission_helper->CheckHIDAccessPermission( + web_contents->GetMainFrame()->GetLastCommittedOrigin()); +} + +bool ElectronHidDelegate::HasDevicePermission( + content::RenderFrameHost* render_frame_host, + const device::mojom::HidDeviceInfo& device) { + auto* chooser_context = GetChooserContext(render_frame_host); + const auto& origin = + render_frame_host->GetMainFrame()->GetLastCommittedOrigin(); + return chooser_context->HasDevicePermission(origin, device, + render_frame_host); +} + +device::mojom::HidManager* ElectronHidDelegate::GetHidManager( + content::RenderFrameHost* render_frame_host) { + auto* chooser_context = GetChooserContext(render_frame_host); + return chooser_context->GetHidManager(); +} + +void ElectronHidDelegate::AddObserver( + content::RenderFrameHost* render_frame_host, + Observer* observer) { + observer_list_.AddObserver(observer); + auto* chooser_context = GetChooserContext(render_frame_host); + if (!device_observation_.IsObserving()) + device_observation_.Observe(chooser_context); +} + +void ElectronHidDelegate::RemoveObserver( + content::RenderFrameHost* render_frame_host, + content::HidDelegate::Observer* observer) { + observer_list_.RemoveObserver(observer); +} + +const device::mojom::HidDeviceInfo* ElectronHidDelegate::GetDeviceInfo( + content::RenderFrameHost* render_frame_host, + const std::string& guid) { + auto* chooser_context = GetChooserContext(render_frame_host); + return chooser_context->GetDeviceInfo(guid); +} + +bool ElectronHidDelegate::IsFidoAllowedForOrigin(const url::Origin& origin) { + return true; +} + +void ElectronHidDelegate::OnDeviceAdded( + const device::mojom::HidDeviceInfo& device_info) { + for (auto& observer : observer_list_) + observer.OnDeviceAdded(device_info); +} + +void ElectronHidDelegate::OnDeviceRemoved( + const device::mojom::HidDeviceInfo& device_info) { + for (auto& observer : observer_list_) + observer.OnDeviceRemoved(device_info); +} + +void ElectronHidDelegate::OnDeviceChanged( + const device::mojom::HidDeviceInfo& device_info) { + for (auto& observer : observer_list_) + observer.OnDeviceChanged(device_info); +} + +void ElectronHidDelegate::OnHidManagerConnectionError() { + device_observation_.Reset(); + + for (auto& observer : observer_list_) + observer.OnHidManagerConnectionError(); +} + +void ElectronHidDelegate::OnHidChooserContextShutdown() { + device_observation_.Reset(); +} + +HidChooserController* ElectronHidDelegate::ControllerForFrame( + content::RenderFrameHost* render_frame_host) { + auto mapping = controller_map_.find(render_frame_host); + return mapping == controller_map_.end() ? nullptr : mapping->second.get(); +} + +HidChooserController* ElectronHidDelegate::AddControllerForFrame( + content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback) { + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + auto controller = std::make_unique( + 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 ElectronHidDelegate::DeleteControllerForFrame( + content::RenderFrameHost* render_frame_host) { + controller_map_.erase(render_frame_host); +} + +} // namespace electron diff --git a/shell/browser/hid/electron_hid_delegate.h b/shell/browser/hid/electron_hid_delegate.h new file mode 100644 index 0000000000000..4e8ad6b5c7ad0 --- /dev/null +++ b/shell/browser/hid/electron_hid_delegate.h @@ -0,0 +1,83 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef SHELL_BROWSER_HID_ELECTRON_HID_DELEGATE_H_ +#define SHELL_BROWSER_HID_ELECTRON_HID_DELEGATE_H_ + +#include +#include +#include +#include + +#include "base/observer_list.h" +#include "base/scoped_observation.h" +#include "content/public/browser/hid_delegate.h" +#include "shell/browser/hid/hid_chooser_context.h" + +namespace electron { + +class HidChooserController; + +class ElectronHidDelegate : public content::HidDelegate, + public HidChooserContext::DeviceObserver { + public: + ElectronHidDelegate(); + ElectronHidDelegate(ElectronHidDelegate&) = delete; + ElectronHidDelegate& operator=(ElectronHidDelegate&) = delete; + ~ElectronHidDelegate() override; + + std::unique_ptr RunChooser( + content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback) override; + bool CanRequestDevicePermission( + content::RenderFrameHost* render_frame_host) override; + bool HasDevicePermission(content::RenderFrameHost* render_frame_host, + const device::mojom::HidDeviceInfo& device) override; + device::mojom::HidManager* GetHidManager( + content::RenderFrameHost* render_frame_host) override; + void AddObserver(content::RenderFrameHost* render_frame_host, + content::HidDelegate::Observer* observer) override; + void RemoveObserver(content::RenderFrameHost* render_frame_host, + content::HidDelegate::Observer* observer) override; + const device::mojom::HidDeviceInfo* GetDeviceInfo( + content::RenderFrameHost* render_frame_host, + const std::string& guid) override; + bool IsFidoAllowedForOrigin(const url::Origin& origin) override; + + // HidChooserContext::DeviceObserver: + void OnDeviceAdded(const device::mojom::HidDeviceInfo&) override; + void OnDeviceRemoved(const device::mojom::HidDeviceInfo&) override; + void OnDeviceChanged(const device::mojom::HidDeviceInfo&) override; + void OnHidManagerConnectionError() override; + void OnHidChooserContextShutdown() override; + + void DeleteControllerForFrame(content::RenderFrameHost* render_frame_host); + + private: + HidChooserController* ControllerForFrame( + content::RenderFrameHost* render_frame_host); + + HidChooserController* AddControllerForFrame( + content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback); + + base::ScopedObservation + device_observation_{this}; + base::ObserverList observer_list_; + + std::unordered_map> + controller_map_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // SHELL_BROWSER_HID_ELECTRON_HID_DELEGATE_H_ diff --git a/shell/browser/hid/hid_chooser_context.cc b/shell/browser/hid/hid_chooser_context.cc new file mode 100644 index 0000000000000..c3ed70e0052b6 --- /dev/null +++ b/shell/browser/hid/hid_chooser_context.cc @@ -0,0 +1,283 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "shell/browser/hid/hid_chooser_context.h" + +#include + +#include "base/command_line.h" +#include "base/containers/contains.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/values.h" +#include "components/content_settings/core/common/content_settings_types.h" +#include "components/prefs/pref_service.h" +#include "content/public/browser/device_service.h" +#include "electron/grit/electron_resources.h" +#include "services/device/public/cpp/hid/hid_blocklist.h" +#include "services/device/public/cpp/hid/hid_switches.h" +#include "shell/browser/web_contents_permission_helper.h" +#include "ui/base/l10n/l10n_util.h" + +namespace electron { + +constexpr char kHidDeviceNameKey[] = "name"; +constexpr char kHidGuidKey[] = "guid"; +constexpr char kHidVendorIdKey[] = "vendorId"; +constexpr char kHidProductIdKey[] = "productId"; +constexpr char kHidSerialNumberKey[] = "serialNumber"; + +void HidChooserContext::DeviceObserver::OnDeviceAdded( + const device::mojom::HidDeviceInfo& device) {} + +void HidChooserContext::DeviceObserver::OnDeviceRemoved( + const device::mojom::HidDeviceInfo& device) {} + +void HidChooserContext::DeviceObserver::OnDeviceChanged( + const device::mojom::HidDeviceInfo& device) {} + +void HidChooserContext::DeviceObserver::OnHidManagerConnectionError() {} + +HidChooserContext::HidChooserContext(ElectronBrowserContext* context) + : browser_context_(context) {} + +HidChooserContext::~HidChooserContext() { + // Notify observers that the chooser context is about to be destroyed. + // Observers must remove themselves from the observer lists. + for (auto& observer : device_observer_list_) { + observer.OnHidChooserContextShutdown(); + DCHECK(!device_observer_list_.HasObserver(&observer)); + } +} + +// static +std::u16string HidChooserContext::DisplayNameFromDeviceInfo( + const device::mojom::HidDeviceInfo& device) { + if (device.product_name.empty()) { + auto device_id_string = base::ASCIIToUTF16( + base::StringPrintf("%04X:%04X", device.vendor_id, device.product_id)); + return l10n_util::GetStringFUTF16(IDS_HID_CHOOSER_ITEM_WITHOUT_NAME, + device_id_string); + } + return base::UTF8ToUTF16(device.product_name); +} + +// static +bool HidChooserContext::CanStorePersistentEntry( + const device::mojom::HidDeviceInfo& device) { + return !device.serial_number.empty() && !device.product_name.empty(); +} + +// static +base::Value HidChooserContext::DeviceInfoToValue( + const device::mojom::HidDeviceInfo& device) { + base::Value value(base::Value::Type::DICTIONARY); + value.SetStringKey( + kHidDeviceNameKey, + base::UTF16ToUTF8(HidChooserContext::DisplayNameFromDeviceInfo(device))); + value.SetIntKey(kHidVendorIdKey, device.vendor_id); + value.SetIntKey(kHidProductIdKey, 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); + } 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 + // and must be granted again each time the device is connected. + value.SetStringKey(kHidGuidKey, device.guid); + } + return value; +} + +void HidChooserContext::GrantDevicePermission( + const url::Origin& origin, + const device::mojom::HidDeviceInfo& device, + content::RenderFrameHost* render_frame_host) { + DCHECK(base::Contains(devices_, device.guid)); + if (CanStorePersistentEntry(device)) { + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + auto* permission_helper = + WebContentsPermissionHelper::FromWebContents(web_contents); + permission_helper->GrantHIDDevicePermission(origin, + DeviceInfoToValue(device)); + } else { + ephemeral_devices_[origin].insert(device.guid); + } +} + +bool HidChooserContext::HasDevicePermission( + const url::Origin& origin, + const device::mojom::HidDeviceInfo& device, + content::RenderFrameHost* render_frame_host) { + if (!base::CommandLine::ForCurrentProcess()->HasSwitch( + switches::kDisableHidBlocklist) && + device::HidBlocklist::IsDeviceExcluded(device)) + return false; + + auto it = ephemeral_devices_.find(origin); + if (it != ephemeral_devices_.end() && + base::Contains(it->second, device.guid)) { + return true; + } + + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + auto* permission_helper = + WebContentsPermissionHelper::FromWebContents(web_contents); + return permission_helper->CheckHIDDevicePermission(origin, + DeviceInfoToValue(device)); +} + +void HidChooserContext::AddDeviceObserver(DeviceObserver* observer) { + EnsureHidManagerConnection(); + device_observer_list_.AddObserver(observer); +} + +void HidChooserContext::RemoveDeviceObserver(DeviceObserver* observer) { + device_observer_list_.RemoveObserver(observer); +} + +void HidChooserContext::GetDevices( + device::mojom::HidManager::GetDevicesCallback callback) { + if (!is_initialized_) { + EnsureHidManagerConnection(); + pending_get_devices_requests_.push(std::move(callback)); + return; + } + + std::vector device_list; + device_list.reserve(devices_.size()); + 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))); +} + +const device::mojom::HidDeviceInfo* HidChooserContext::GetDeviceInfo( + const std::string& guid) { + DCHECK(is_initialized_); + auto it = devices_.find(guid); + return it == devices_.end() ? nullptr : it->second.get(); +} + +device::mojom::HidManager* HidChooserContext::GetHidManager() { + EnsureHidManagerConnection(); + return hid_manager_.get(); +} + +base::WeakPtr HidChooserContext::AsWeakPtr() { + return weak_factory_.GetWeakPtr(); +} + +void HidChooserContext::DeviceAdded(device::mojom::HidDeviceInfoPtr device) { + DCHECK(device); + + // Update the device list. + if (!base::Contains(devices_, device->guid)) + devices_.insert({device->guid, device->Clone()}); + + // Notify all observers. + for (auto& observer : device_observer_list_) + observer.OnDeviceAdded(*device); +} + +void HidChooserContext::DeviceRemoved(device::mojom::HidDeviceInfoPtr device) { + DCHECK(device); + DCHECK(base::Contains(devices_, device->guid)); + + // Update the device list. + devices_.erase(device->guid); + + // Notify all device observers. + for (auto& observer : device_observer_list_) + observer.OnDeviceRemoved(*device); + + // Next we'll notify observers for revoked permissions. If the device does not + // support persistent permissions then device permissions are revoked on + // disconnect. + if (CanStorePersistentEntry(*device)) + return; + + std::vector revoked_origins; + for (auto& map_entry : ephemeral_devices_) { + if (map_entry.second.erase(device->guid) > 0) + revoked_origins.push_back(map_entry.first); + } + if (revoked_origins.empty()) + return; +} + +void HidChooserContext::DeviceChanged(device::mojom::HidDeviceInfoPtr device) { + DCHECK(device); + DCHECK(base::Contains(devices_, device->guid)); + + // Update the device list. + devices_[device->guid] = device->Clone(); + + // Notify all observers. + for (auto& observer : device_observer_list_) + observer.OnDeviceChanged(*device); +} + +void HidChooserContext::EnsureHidManagerConnection() { + if (hid_manager_) + return; + + mojo::PendingRemote manager; + content::GetDeviceService().BindHidManager( + manager.InitWithNewPipeAndPassReceiver()); + SetUpHidManagerConnection(std::move(manager)); +} + +void HidChooserContext::SetUpHidManagerConnection( + mojo::PendingRemote manager) { + hid_manager_.Bind(std::move(manager)); + hid_manager_.set_disconnect_handler(base::BindOnce( + &HidChooserContext::OnHidManagerConnectionError, base::Unretained(this))); + + hid_manager_->GetDevicesAndSetClient( + client_receiver_.BindNewEndpointAndPassRemote(), + base::BindOnce(&HidChooserContext::InitDeviceList, + weak_factory_.GetWeakPtr())); +} + +void HidChooserContext::InitDeviceList( + std::vector devices) { + for (auto& device : devices) + devices_.insert({device->guid, std::move(device)}); + + is_initialized_ = true; + + while (!pending_get_devices_requests_.empty()) { + std::vector device_list; + device_list.reserve(devices.size()); + 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 HidChooserContext::OnHidManagerConnectionError() { + hid_manager_.reset(); + client_receiver_.reset(); + devices_.clear(); + + std::vector revoked_origins; + revoked_origins.reserve(ephemeral_devices_.size()); + for (const auto& map_entry : ephemeral_devices_) + revoked_origins.push_back(map_entry.first); + ephemeral_devices_.clear(); + + // Notify all device observers. + for (auto& observer : device_observer_list_) + observer.OnHidManagerConnectionError(); +} + +} // namespace electron diff --git a/shell/browser/hid/hid_chooser_context.h b/shell/browser/hid/hid_chooser_context.h new file mode 100644 index 0000000000000..81bb387c47d04 --- /dev/null +++ b/shell/browser/hid/hid_chooser_context.h @@ -0,0 +1,130 @@ +// Copyright (c) 2021 Microsoft, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_H_ +#define SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_H_ + +#include +#include +#include +#include +#include +#include + +#include "base/containers/queue.h" +#include "base/memory/weak_ptr.h" +#include "base/observer_list.h" +#include "base/unguessable_token.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "mojo/public/cpp/bindings/associated_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "services/device/public/mojom/hid.mojom.h" +#include "shell/browser/electron_browser_context.h" +#include "url/origin.h" + +namespace base { +class Value; +} + +namespace electron { + +// Manages the internal state and connection to the device service for the +// Human Interface Device (HID) chooser UI. +class HidChooserContext : public KeyedService, + public device::mojom::HidManagerClient { + public: + // This observer can be used to be notified when HID devices are connected or + // disconnected. + class DeviceObserver : public base::CheckedObserver { + public: + virtual void OnDeviceAdded(const device::mojom::HidDeviceInfo&); + virtual void OnDeviceRemoved(const device::mojom::HidDeviceInfo&); + virtual void OnDeviceChanged(const device::mojom::HidDeviceInfo&); + virtual void OnHidManagerConnectionError(); + + // Called when the HidChooserContext is shutting down. Observers must remove + // themselves before returning. + virtual void OnHidChooserContextShutdown() = 0; + }; + + explicit HidChooserContext(ElectronBrowserContext* context); + HidChooserContext(const HidChooserContext&) = delete; + HidChooserContext& operator=(const HidChooserContext&) = delete; + ~HidChooserContext() override; + + // Returns a human-readable string identifier for |device|. + static std::u16string DisplayNameFromDeviceInfo( + const device::mojom::HidDeviceInfo& device); + + // Returns true if a persistent permission can be granted for |device|. + static bool CanStorePersistentEntry( + const device::mojom::HidDeviceInfo& device); + + static base::Value DeviceInfoToValue( + const device::mojom::HidDeviceInfo& device); + + // HID-specific interface for granting and checking permissions. + void GrantDevicePermission(const url::Origin& origin, + const device::mojom::HidDeviceInfo& device, + content::RenderFrameHost* render_frame_host); + bool HasDevicePermission(const url::Origin& origin, + const device::mojom::HidDeviceInfo& device, + content::RenderFrameHost* render_frame_host); + + // For ScopedObserver. + void AddDeviceObserver(DeviceObserver* observer); + void RemoveDeviceObserver(DeviceObserver* observer); + + // Forward HidManager::GetDevices. + void GetDevices(device::mojom::HidManager::GetDevicesCallback callback); + + // Only call this if you're sure |devices_| has been initialized before-hand. + // The returned raw pointer is owned by |devices_| and will be destroyed when + // the device is removed. + const device::mojom::HidDeviceInfo* GetDeviceInfo(const std::string& guid); + + device::mojom::HidManager* GetHidManager(); + + base::WeakPtr AsWeakPtr(); + + private: + // device::mojom::HidManagerClient implementation: + void DeviceAdded(device::mojom::HidDeviceInfoPtr device_info) override; + void DeviceRemoved(device::mojom::HidDeviceInfoPtr device_info) override; + void DeviceChanged(device::mojom::HidDeviceInfoPtr device_info) override; + + void EnsureHidManagerConnection(); + void SetUpHidManagerConnection( + mojo::PendingRemote manager); + void InitDeviceList(std::vector devices); + void OnHidManagerInitializedForTesting( + device::mojom::HidManager::GetDevicesCallback callback, + std::vector devices); + void OnHidManagerConnectionError(); + + ElectronBrowserContext* browser_context_; + + bool is_initialized_ = false; + base::queue + pending_get_devices_requests_; + + // Tracks the set of devices to which an origin has access to. + std::map> ephemeral_devices_; + + // Map from device GUID to device info. + std::map devices_; + + mojo::Remote hid_manager_; + mojo::AssociatedReceiver client_receiver_{ + this}; + base::ObserverList device_observer_list_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_H_ diff --git a/shell/browser/hid/hid_chooser_context_factory.cc b/shell/browser/hid/hid_chooser_context_factory.cc new file mode 100644 index 0000000000000..cec317b2714be --- /dev/null +++ b/shell/browser/hid/hid_chooser_context_factory.cc @@ -0,0 +1,55 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "shell/browser/hid/hid_chooser_context_factory.h" + +#include "components/keyed_service/content/browser_context_dependency_manager.h" +#include "shell/browser/electron_browser_context.h" +#include "shell/browser/hid/hid_chooser_context.h" + +namespace electron { + +// static +HidChooserContextFactory* HidChooserContextFactory::GetInstance() { + static base::NoDestructor factory; + return factory.get(); +} + +// static +HidChooserContext* HidChooserContextFactory::GetForBrowserContext( + content::BrowserContext* context) { + return static_cast( + GetInstance()->GetServiceForBrowserContext(context, true)); +} + +// static +HidChooserContext* HidChooserContextFactory::GetForBrowserContextIfExists( + content::BrowserContext* context) { + return static_cast( + GetInstance()->GetServiceForBrowserContext(context, /*create=*/false)); +} + +HidChooserContextFactory::HidChooserContextFactory() + : BrowserContextKeyedServiceFactory( + "HidChooserContext", + BrowserContextDependencyManager::GetInstance()) {} + +HidChooserContextFactory::~HidChooserContextFactory() = default; + +KeyedService* HidChooserContextFactory::BuildServiceInstanceFor( + content::BrowserContext* context) const { + auto* browser_context = + static_cast(context); + return new HidChooserContext(browser_context); +} + +content::BrowserContext* HidChooserContextFactory::GetBrowserContextToUse( + content::BrowserContext* context) const { + return context; +} + +void HidChooserContextFactory::BrowserContextShutdown( + content::BrowserContext* context) {} + +} // namespace electron diff --git a/shell/browser/hid/hid_chooser_context_factory.h b/shell/browser/hid/hid_chooser_context_factory.h new file mode 100644 index 0000000000000..8520a6718d1a7 --- /dev/null +++ b/shell/browser/hid/hid_chooser_context_factory.h @@ -0,0 +1,42 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_FACTORY_H_ +#define SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_FACTORY_H_ + +#include "base/macros.h" +#include "base/no_destructor.h" +#include "components/keyed_service/content/browser_context_keyed_service_factory.h" + +namespace electron { + +class HidChooserContext; + +class HidChooserContextFactory : public BrowserContextKeyedServiceFactory { + public: + static HidChooserContext* GetForBrowserContext( + content::BrowserContext* context); + static HidChooserContext* GetForBrowserContextIfExists( + content::BrowserContext* context); + static HidChooserContextFactory* GetInstance(); + + private: + friend base::NoDestructor; + + HidChooserContextFactory(); + ~HidChooserContextFactory() override; + + // BrowserContextKeyedBaseFactory: + KeyedService* BuildServiceInstanceFor( + content::BrowserContext* profile) const override; + content::BrowserContext* GetBrowserContextToUse( + content::BrowserContext* context) const override; + void BrowserContextShutdown(content::BrowserContext* context) override; + + DISALLOW_COPY_AND_ASSIGN(HidChooserContextFactory); +}; + +} // namespace electron + +#endif // SHELL_BROWSER_HID_HID_CHOOSER_CONTEXT_FACTORY_H_ diff --git a/shell/browser/hid/hid_chooser_controller.cc b/shell/browser/hid/hid_chooser_controller.cc new file mode 100644 index 0000000000000..b1b99aa708d7f --- /dev/null +++ b/shell/browser/hid/hid_chooser_controller.cc @@ -0,0 +1,357 @@ +// Copyright (c) 2021 Microsoft, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/hid/hid_chooser_controller.h" + +#include + +#include "base/bind.h" +#include "base/command_line.h" +#include "base/containers/contains.h" +#include "base/ranges/algorithm.h" +#include "base/stl_util.h" +#include "services/device/public/cpp/hid/hid_blocklist.h" +#include "services/device/public/cpp/hid/hid_switches.h" +#include "shell/browser/api/electron_api_session.h" +#include "shell/browser/hid/hid_chooser_context.h" +#include "shell/browser/hid/hid_chooser_context_factory.h" +#include "shell/browser/javascript_environment.h" +#include "shell/common/gin_converters/callback_converter.h" +#include "shell/common/gin_converters/content_converter.h" +#include "shell/common/gin_converters/value_converter.h" +#include "shell/common/gin_helper/dictionary.h" +#include "ui/base/l10n/l10n_util.h" + +namespace { + +std::string PhysicalDeviceIdFromDeviceInfo( + const device::mojom::HidDeviceInfo& device) { + // A single physical device may expose multiple HID interfaces, each + // represented by a HidDeviceInfo object. When a device exposes multiple + // HID interfaces, the HidDeviceInfo objects will share a common + // |physical_device_id|. Group these devices so that a single chooser item + // is shown for each physical device. If a device's physical device ID is + // empty, use its GUID instead. + return device.physical_device_id.empty() ? device.guid + : device.physical_device_id; +} + +} // namespace + +namespace gin { + +template <> +struct Converter { + static v8::Local ToV8( + v8::Isolate* isolate, + const device::mojom::HidDeviceInfoPtr& device) { + base::Value value = electron::HidChooserContext::DeviceInfoToValue(*device); + value.SetStringKey("deviceId", PhysicalDeviceIdFromDeviceInfo(*device)); + return gin::ConvertToV8(isolate, value); + } +}; + +} // namespace gin + +namespace electron { + +HidChooserController::HidChooserController( + content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback, + content::WebContents* web_contents, + base::WeakPtr hid_delegate) + : WebContentsObserver(web_contents), + filters_(std::move(filters)), + callback_(std::move(callback)), + origin_(content::WebContents::FromRenderFrameHost(render_frame_host) + ->GetMainFrame() + ->GetLastCommittedOrigin()), + frame_tree_node_id_(render_frame_host->GetFrameTreeNodeId()), + hid_delegate_(hid_delegate) { + chooser_context_ = HidChooserContextFactory::GetForBrowserContext( + web_contents->GetBrowserContext()) + ->AsWeakPtr(); + DCHECK(chooser_context_); + + chooser_context_->GetHidManager()->GetDevices(base::BindOnce( + &HidChooserController::OnGotDevices, weak_factory_.GetWeakPtr())); +} + +HidChooserController::~HidChooserController() { + if (callback_) + std::move(callback_).Run(std::vector()); +} + +api::Session* HidChooserController::GetSession() { + if (!web_contents()) { + return nullptr; + } + return api::Session::FromBrowserContext(web_contents()->GetBrowserContext()); +} + +void HidChooserController::OnDeviceAdded( + const device::mojom::HidDeviceInfo& device) { + if (!DisplayDevice(device)) + return; + if (AddDeviceInfo(device)) { + api::Session* session = GetSession(); + if (session) { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + auto details = gin::Dictionary::CreateEmpty(isolate); + details.Set("device", device.Clone()); + details.Set("webContents", web_contents()); + session->Emit("hid-device-added", details); + } + } + + return; +} + +void HidChooserController::OnDeviceRemoved( + const device::mojom::HidDeviceInfo& device) { + auto id = PhysicalDeviceIdFromDeviceInfo(device); + auto items_it = std::find(items_.begin(), items_.end(), id); + if (items_it == items_.end()) + return; + api::Session* session = GetSession(); + if (session) { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + auto details = gin::Dictionary::CreateEmpty(isolate); + details.Set("device", device.Clone()); + details.Set("webContents", web_contents()); + session->Emit("hid-device-removed", details); + } + RemoveDeviceInfo(device); +} + +void HidChooserController::OnDeviceChanged( + const device::mojom::HidDeviceInfo& device) { + bool has_chooser_item = + base::Contains(items_, PhysicalDeviceIdFromDeviceInfo(device)); + if (!DisplayDevice(device)) { + if (has_chooser_item) + OnDeviceRemoved(device); + return; + } + + if (!has_chooser_item) { + OnDeviceAdded(device); + return; + } + + // Update the item to replace the old device info with |device|. + UpdateDeviceInfo(device); +} + +void HidChooserController::OnDeviceChosen(const std::string& device_id) { + if (device_id.empty()) { + RunCallback({}); + } else { + auto find_it = device_map_.find(device_id); + if (find_it != device_map_.end()) { + auto& device_infos = find_it->second; + std::vector devices; + devices.reserve(device_infos.size()); + for (auto& device : device_infos) { + chooser_context_->GrantDevicePermission(origin_, *device, + web_contents()->GetMainFrame()); + devices.push_back(device->Clone()); + } + RunCallback(std::move(devices)); + } else { + RunCallback({}); + } + } +} + +void HidChooserController::OnHidManagerConnectionError() { + observation_.Reset(); +} + +void HidChooserController::OnHidChooserContextShutdown() { + observation_.Reset(); +} + +void HidChooserController::OnGotDevices( + std::vector devices) { + std::vector devicesToDisplay; + devicesToDisplay.reserve(devices.size()); + + for (auto& device : devices) { + if (DisplayDevice(*device)) { + if (AddDeviceInfo(*device)) { + devicesToDisplay.push_back(device->Clone()); + } + } + } + + // Listen to HidChooserContext 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) { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + auto details = gin::Dictionary::CreateEmpty(isolate); + details.Set("deviceList", devicesToDisplay); + details.Set("webContents", web_contents()); + prevent_default = + session->Emit("select-hid-device", details, + base::AdaptCallbackForRepeating( + base::BindOnce(&HidChooserController::OnDeviceChosen, + weak_factory_.GetWeakPtr()))); + } + if (!prevent_default) { + RunCallback({}); + } +} + +bool HidChooserController::DisplayDevice( + const device::mojom::HidDeviceInfo& device) const { + if (!base::CommandLine::ForCurrentProcess()->HasSwitch( + switches::kDisableHidBlocklist)) { + // Do not pass the device to the chooser if it is excluded by the blocklist. + if (device::HidBlocklist::IsDeviceExcluded(device)) + return false; + + // Do not pass the device to the chooser if it has a top-level collection + // with the FIDO usage page. + // + // Note: The HID blocklist also blocks top-level collections with the FIDO + // usage page, but will not block the device if it has other (non-FIDO) + // collections. The check below will exclude the device from the chooser + // if it has any top-level FIDO collection. + auto find_it = + std::find_if(device.collections.begin(), device.collections.end(), + [](const device::mojom::HidCollectionInfoPtr& c) { + return c->usage->usage_page == device::mojom::kPageFido; + }); + if (find_it != device.collections.end()) + return false; + } + + return FilterMatchesAny(device); +} + +bool HidChooserController::FilterMatchesAny( + const device::mojom::HidDeviceInfo& device) const { + if (filters_.empty()) + return true; + + for (const auto& filter : filters_) { + if (filter->device_ids) { + if (filter->device_ids->is_vendor()) { + if (filter->device_ids->get_vendor() != device.vendor_id) + continue; + } else if (filter->device_ids->is_vendor_and_product()) { + const auto& vendor_and_product = + filter->device_ids->get_vendor_and_product(); + if (vendor_and_product->vendor != device.vendor_id) + continue; + if (vendor_and_product->product != device.product_id) + continue; + } + } + + if (filter->usage) { + if (filter->usage->is_page()) { + const uint16_t usage_page = filter->usage->get_page(); + auto find_it = + std::find_if(device.collections.begin(), device.collections.end(), + [=](const device::mojom::HidCollectionInfoPtr& c) { + return usage_page == c->usage->usage_page; + }); + if (find_it == device.collections.end()) + continue; + } else if (filter->usage->is_usage_and_page()) { + const auto& usage_and_page = filter->usage->get_usage_and_page(); + auto find_it = std::find_if( + device.collections.begin(), device.collections.end(), + [&usage_and_page](const device::mojom::HidCollectionInfoPtr& c) { + return usage_and_page->usage_page == c->usage->usage_page && + usage_and_page->usage == c->usage->usage; + }); + if (find_it == device.collections.end()) + continue; + } + } + + return true; + } + + return false; +} + +bool HidChooserController::AddDeviceInfo( + const device::mojom::HidDeviceInfo& device) { + auto id = PhysicalDeviceIdFromDeviceInfo(device); + auto find_it = device_map_.find(id); + if (find_it != device_map_.end()) { + find_it->second.push_back(device.Clone()); + return false; + } + // A new device was connected. Append it to the end of the chooser list. + device_map_[id].push_back(device.Clone()); + items_.push_back(id); + return true; +} + +bool HidChooserController::RemoveDeviceInfo( + const device::mojom::HidDeviceInfo& device) { + auto id = PhysicalDeviceIdFromDeviceInfo(device); + auto find_it = device_map_.find(id); + DCHECK(find_it != device_map_.end()); + auto& device_infos = find_it->second; + base::EraseIf(device_infos, + [&device](const device::mojom::HidDeviceInfoPtr& d) { + return d->guid == device.guid; + }); + if (!device_infos.empty()) + return false; + // A device was disconnected. Remove it from the chooser list. + device_map_.erase(find_it); + base::Erase(items_, id); + return true; +} + +void HidChooserController::UpdateDeviceInfo( + const device::mojom::HidDeviceInfo& device) { + auto id = PhysicalDeviceIdFromDeviceInfo(device); + auto physical_device_it = device_map_.find(id); + DCHECK(physical_device_it != device_map_.end()); + auto& device_infos = physical_device_it->second; + auto device_it = base::ranges::find_if( + device_infos, [&device](const device::mojom::HidDeviceInfoPtr& d) { + return d->guid == device.guid; + }); + DCHECK(device_it != device_infos.end()); + *device_it = device.Clone(); +} + +void HidChooserController::RunCallback( + std::vector devices) { + if (callback_) { + std::move(callback_).Run(std::move(devices)); + } +} + +void HidChooserController::RenderFrameDeleted( + content::RenderFrameHost* render_frame_host) { + if (hid_delegate_) { + hid_delegate_->DeleteControllerForFrame(render_frame_host); + } +} + +void HidChooserController::PrimaryPageChanged(content::Page& page) { + if (hid_delegate_) { + hid_delegate_->DeleteControllerForFrame(web_contents()->GetMainFrame()); + } +} + +} // namespace electron diff --git a/shell/browser/hid/hid_chooser_controller.h b/shell/browser/hid/hid_chooser_controller.h new file mode 100644 index 0000000000000..585a45ca8d110 --- /dev/null +++ b/shell/browser/hid/hid_chooser_controller.h @@ -0,0 +1,123 @@ +// Copyright (c) 2021 Microsoft, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef SHELL_BROWSER_HID_HID_CHOOSER_CONTROLLER_H_ +#define SHELL_BROWSER_HID_HID_CHOOSER_CONTROLLER_H_ + +#include +#include +#include + +#include "base/macros.h" +#include "base/memory/weak_ptr.h" +#include "content/public/browser/hid_chooser.h" +#include "content/public/browser/web_contents.h" +#include "content/public/browser/web_contents_observer.h" +#include "services/device/public/mojom/hid.mojom-forward.h" +#include "shell/browser/api/electron_api_session.h" +#include "shell/browser/hid/electron_hid_delegate.h" +#include "shell/browser/hid/hid_chooser_context.h" +#include "third_party/blink/public/mojom/hid/hid.mojom.h" + +namespace content { +class RenderFrameHost; +} // namespace content + +namespace electron { + +class ElectronHidDelegate; + +class HidChooserContext; + +// HidChooserController provides data for the WebHID API permission prompt. +class HidChooserController + : public content::WebContentsObserver, + public electron::HidChooserContext::DeviceObserver { + public: + // Construct a chooser controller for Human Interface Devices (HID). + // |render_frame_host| is used to initialize the chooser strings and to access + // the requesting and embedding origins. |callback| is called when the chooser + // is closed, either by selecting an item or by dismissing the chooser dialog. + // The callback is called with the selected device, or nullptr if no device is + // selected. + HidChooserController(content::RenderFrameHost* render_frame_host, + std::vector filters, + content::HidChooser::Callback callback, + content::WebContents* web_contents, + base::WeakPtr hid_delegate); + HidChooserController(HidChooserController&) = delete; + HidChooserController& operator=(HidChooserController&) = delete; + ~HidChooserController() override; + + // HidChooserContext::DeviceObserver: + void OnDeviceAdded(const device::mojom::HidDeviceInfo& device_info) override; + void OnDeviceRemoved( + const device::mojom::HidDeviceInfo& device_info) override; + void OnDeviceChanged( + const device::mojom::HidDeviceInfo& device_info) override; + void OnHidManagerConnectionError() override; + void OnHidChooserContextShutdown() override; + + // content::WebContentsObserver: + void RenderFrameDeleted(content::RenderFrameHost* render_frame_host) override; + void PrimaryPageChanged(content::Page& page) override; + + private: + api::Session* GetSession(); + void OnGotDevices(std::vector devices); + bool DisplayDevice(const device::mojom::HidDeviceInfo& device) const; + bool FilterMatchesAny(const device::mojom::HidDeviceInfo& device) const; + + // Add |device_info| to |device_map_|. The device is added to the chooser item + // representing the physical device. If the chooser item does not yet exist, a + // new item is appended. Returns true if an item was appended. + bool AddDeviceInfo(const device::mojom::HidDeviceInfo& device_info); + + // Remove |device_info| from |device_map_|. The device info is removed from + // the chooser item representing the physical device. If this would cause the + // item to be empty, the chooser item is removed. Does nothing if the device + // is not in the chooser item. Returns true if an item was removed. + bool RemoveDeviceInfo(const device::mojom::HidDeviceInfo& device_info); + + // Update the information for the device described by |device_info| in the + // |device_map_|. + void UpdateDeviceInfo(const device::mojom::HidDeviceInfo& device_info); + + void RunCallback(std::vector devices); + void OnDeviceChosen(const std::string& device_id); + + std::vector filters_; + content::HidChooser::Callback callback_; + const url::Origin origin_; + const int frame_tree_node_id_; + + // The lifetime of the chooser context is tied to the browser context used to + // create it, and may be destroyed while the chooser is still active. + base::WeakPtr chooser_context_; + + // Information about connected devices and their HID interfaces. A single + // physical device may expose multiple HID interfaces. Keys are physical + // device IDs, values are collections of HidDeviceInfo objects representing + // the HID interfaces hosted by the physical device. + std::map> + device_map_; + + // An ordered list of physical device IDs that determines the order of items + // in the chooser. + std::vector items_; + + base::ScopedObservation + observation_{this}; + + base::WeakPtr hid_delegate_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // SHELL_BROWSER_HID_HID_CHOOSER_CONTROLLER_H_ diff --git a/shell/browser/web_contents_permission_helper.cc b/shell/browser/web_contents_permission_helper.cc index 14df76c83e407..1f39258b9f7e5 100644 --- a/shell/browser/web_contents_permission_helper.cc +++ b/shell/browser/web_contents_permission_helper.cc @@ -94,6 +94,26 @@ bool WebContentsPermissionHelper::CheckPermission( details); } +bool WebContentsPermissionHelper::CheckDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device) const { + auto* permission_manager = static_cast( + web_contents_->GetBrowserContext()->GetPermissionControllerDelegate()); + return permission_manager->CheckDevicePermission(permission, web_contents_, + origin, device); +} + +void WebContentsPermissionHelper::GrantDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device) const { + auto* permission_manager = static_cast( + web_contents_->GetBrowserContext()->GetPermissionControllerDelegate()); + permission_manager->GrantDevicePermission(permission, web_contents_, origin, + device); +} + void WebContentsPermissionHelper::RequestFullscreenPermission( base::OnceCallback callback) { RequestPermission( @@ -168,6 +188,30 @@ bool WebContentsPermissionHelper::CheckSerialAccessPermission( static_cast(PermissionType::SERIAL), &details); } +bool WebContentsPermissionHelper::CheckHIDAccessPermission( + const url::Origin& embedding_origin) const { + base::DictionaryValue details; + details.SetString("securityOrigin", embedding_origin.GetURL().spec()); + return CheckPermission( + static_cast(PermissionType::HID), &details); +} + +bool WebContentsPermissionHelper::CheckHIDDevicePermission( + const url::Origin& origin, + base::Value device) const { + return CheckDevicePermission( + static_cast(PermissionType::HID), origin, + &device); +} + +void WebContentsPermissionHelper::GrantHIDDevicePermission( + const url::Origin& origin, + base::Value device) const { + return GrantDevicePermission( + static_cast(PermissionType::HID), origin, + &device); +} + WEB_CONTENTS_USER_DATA_KEY_IMPL(WebContentsPermissionHelper) } // namespace electron diff --git a/shell/browser/web_contents_permission_helper.h b/shell/browser/web_contents_permission_helper.h index 79a587b395a88..375524027b046 100644 --- a/shell/browser/web_contents_permission_helper.h +++ b/shell/browser/web_contents_permission_helper.h @@ -23,7 +23,8 @@ class WebContentsPermissionHelper POINTER_LOCK = static_cast(content::PermissionType::NUM) + 1, FULLSCREEN, OPEN_EXTERNAL, - SERIAL + SERIAL, + HID }; // Asynchronous Requests @@ -41,6 +42,11 @@ class WebContentsPermissionHelper bool CheckMediaAccessPermission(const GURL& security_origin, blink::mojom::MediaStreamType type) const; bool CheckSerialAccessPermission(const url::Origin& embedding_origin) const; + bool CheckHIDAccessPermission(const url::Origin& embedding_origin) const; + bool CheckHIDDevicePermission(const url::Origin& origin, + base::Value device) const; + void GrantHIDDevicePermission(const url::Origin& origin, + base::Value device) const; private: explicit WebContentsPermissionHelper(content::WebContents* web_contents); @@ -54,6 +60,14 @@ class WebContentsPermissionHelper bool CheckPermission(content::PermissionType permission, const base::DictionaryValue* details) const; + bool CheckDevicePermission(content::PermissionType permission, + const url::Origin& origin, + const base::Value* device) const; + + void GrantDevicePermission(content::PermissionType permission, + const url::Origin& origin, + const base::Value* device) const; + content::WebContents* web_contents_; WEB_CONTENTS_USER_DATA_KEY_DECL(); diff --git a/shell/common/gin_converters/content_converter.cc b/shell/common/gin_converters/content_converter.cc index a9979d7f599c7..e9852e4096c22 100644 --- a/shell/common/gin_converters/content_converter.cc +++ b/shell/common/gin_converters/content_converter.cc @@ -206,6 +206,8 @@ v8::Local Converter::ToV8( return StringToV8(isolate, "openExternal"); case PermissionType::SERIAL: return StringToV8(isolate, "serial"); + case PermissionType::HID: + return StringToV8(isolate, "hid"); default: return StringToV8(isolate, "unknown"); } diff --git a/spec-main/chromium-spec.ts b/spec-main/chromium-spec.ts index 8f9661e4e2a10..a2004fd90c78f 100644 --- a/spec-main/chromium-spec.ts +++ b/spec-main/chromium-spec.ts @@ -1878,3 +1878,100 @@ describe('navigator.bluetooth', () => { expect(bluetooth).to.be.oneOf(['Found a device!', 'Bluetooth adapter not available.', 'User cancelled the requestDevice() chooser.']); }); }); + +describe('navigator.hid', () => { + let w: BrowserWindow; + before(async () => { + w = new BrowserWindow({ + show: false + }); + await w.loadFile(path.join(fixturesPath, 'pages', 'blank.html')); + }); + + const getDevices: any = () => { + return w.webContents.executeJavaScript(` + navigator.hid.requestDevice({filters: []}).then(device => device.toString()).catch(err => err.toString()); + `, true); + }; + + after(closeAllWindows); + afterEach(() => { + session.defaultSession.setPermissionCheckHandler(null); + session.defaultSession.setDevicePermissionHandler(null); + session.defaultSession.removeAllListeners('select-hid-device'); + }); + + it('does not return a device if select-hid-device event is not defined', async () => { + w.loadFile(path.join(fixturesPath, 'pages', 'blank.html')); + const device = await getDevices(); + expect(device).to.equal(''); + }); + + it('does not return a device when permission denied', async () => { + let selectFired = false; + w.webContents.session.on('select-hid-device', (event, details, callback) => { + selectFired = true; + callback(''); + }); + session.defaultSession.setPermissionCheckHandler(() => false); + const device = await getDevices(); + expect(selectFired).to.be.false(); + expect(device).to.equal(''); + }); + + it('returns a device when select-hid-device event is defined', async () => { + let haveDevices = false; + let selectFired = false; + w.webContents.session.on('select-hid-device', (event, details, callback) => { + selectFired = true; + if (details.deviceList.length > 0) { + haveDevices = true; + callback(details.deviceList[0].deviceId); + } else { + callback(''); + } + }); + const device = await getDevices(); + expect(selectFired).to.be.true(); + if (haveDevices) { + expect(device).to.contain('[object HIDDevice]'); + } else { + expect(device).to.equal(''); + } + }); + + it('returns a device when DevicePermissionHandler is defined', async () => { + let haveDevices = false; + let selectFired = false; + let gotDevicePerms = false; + w.webContents.session.on('select-hid-device', (event, details, callback) => { + selectFired = true; + if (details.deviceList.length > 0) { + const foundDevice = details.deviceList.find((device) => { + if (device.name && device.name !== '' && 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.hid.getDevices();', true); + const device = await getDevices(); + expect(selectFired).to.be.true(); + if (haveDevices) { + expect(device).to.contain('[object HIDDevice]'); + expect(gotDevicePerms).to.be.true(); + } else { + expect(device).to.equal(''); + } + }); +}); diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index 09ab0adc1ce49..420086436a4af 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -71,6 +71,8 @@ declare namespace Electron { _print(options: any, callback?: (success: boolean, failureReason: string) => void): void; _getPrinters(): Electron.PrinterInfo[]; _init(): void; + _defaultDevicePermissionHandler(details: { origin: string, deviceType: string, device: any }): boolean; + _defaultGrantDevicePermissionHandler(details: { origin: string, deviceType: string, device: any }): void; canGoToIndex(index: number): boolean; getActiveIndex(): number; length(): number;