diff --git a/BUILD.gn b/BUILD.gn index 308850d15a061..58cb2dd678427 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -361,6 +361,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 7188faa2266eb..135f9e001d05a 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -180,6 +180,96 @@ 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) + * `frame` [WebFrameMain](web-frame-main.md) +* `callback` Function + * `deviceId` String | null (optional) + +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 no arguments 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() + + 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 + } + return false + }) + + // Optionally, retrieve previously persisted devices from a persistent store + const grantedDevices = fetchGrantedDevices() + + 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' + }) + callback(selectedPort?.deviceId) + }) +}) +``` + +#### Event: 'hid-device-added' + +Returns: + +* `event` Event +* `details` Object + * `device` [HIDDevice[]](structures/hid-device.md) + * `frame` [WebFrameMain](web-frame-main.md) + +Emitted when a new HID device becomes available. For example, when a new USB device is plugged in. + +This event will only be emitted after `navigator.hid.requestDevice` has been called and `select-hid-device` has fired. + +#### Event: 'hid-device-removed' + +Returns: + +* `event` Event +* `details` Object + * `device` [HIDDevice[]](structures/hid-device.md) + * `frame` [WebFrameMain](web-frame-main.md) + +Emitted when a HID device has been removed. For example, this event will fire when a USB device is unplugged. + +This event will only be emitted after `navigator.hid.requestDevice` has been called and `select-hid-device` has fired. + #### Event: 'select-serial-port' Returns: @@ -524,7 +614,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. @@ -552,6 +642,71 @@ 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. + * `frame` [WebFrameMain](web-frame-main.md) - WebFrameMain 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 { app, BrowserWindow } = require('electron') + +let win = null + +app.whenReady().then(() => { + win = new BrowserWindow() + + 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 + } + return false + }) + + // Optionally, retrieve previously persisted devices from a persistent store + const grantedDevices = fetchGrantedDevices() + + 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' + }) + callback(selectedPort?.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 5d3730b718f20..299b577b4a694 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 0246a0dba0f7e..65931a1d7929f 100644 --- a/filenames.gni +++ b/filenames.gni @@ -392,6 +392,14 @@ filenames = { "shell/browser/font/electron_font_access_delegate.h", "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/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/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc index 0f9d58fc342b0..1ebdd0686150c 100644 --- a/shell/browser/api/electron_api_web_contents.cc +++ b/shell/browser/api/electron_api_web_contents.cc @@ -918,6 +918,12 @@ void WebContents::InitWithWebContents(content::WebContents* web_contents, } WebContents::~WebContents() { + // clear out objects that have been granted permissions so that when + // WebContents::RenderFrameDeleted is called as a result of WebContents + // destruction it doesn't try to clear out a granted_devices_ + // on a destructed object. + granted_devices_.clear(); + if (!inspectable_web_contents_) { WebContentsDestroyed(); return; @@ -1453,6 +1459,12 @@ void WebContents::RenderFrameDeleted( // - Cross-origin navigation creates a new RFH in a separate process which // is swapped by content::RenderFrameHostManager. // + + // clear out objects that have been granted permissions + if (!granted_devices_.empty()) { + granted_devices_.erase(render_frame_host->GetFrameTreeNodeId()); + } + // WebFrameMain::FromRenderFrameHost(rfh) will use the RFH's FrameTreeNode ID // to find an existing instance of WebFrameMain. During a cross-origin // navigation, the deleted RFH will be the old host which was swapped out. In @@ -3273,6 +3285,42 @@ v8::Local WebContents::TakeHeapSnapshot( return handle; } +void WebContents::GrantDevicePermission( + const url::Origin& origin, + const base::Value* device, + content::PermissionType permissionType, + content::RenderFrameHost* render_frame_host) { + granted_devices_[render_frame_host->GetFrameTreeNodeId()][permissionType] + [origin] + .push_back( + std::make_unique(device->Clone())); +} + +std::vector WebContents::GetGrantedDevices( + const url::Origin& origin, + content::PermissionType permissionType, + content::RenderFrameHost* render_frame_host) { + const auto& devices_for_frame_host_it = + granted_devices_.find(render_frame_host->GetFrameTreeNodeId()); + if (devices_for_frame_host_it == granted_devices_.end()) + return {}; + + const auto& current_devices_it = + devices_for_frame_host_it->second.find(permissionType); + if (current_devices_it == devices_for_frame_host_it->second.end()) + return {}; + + const auto& origin_devices_it = current_devices_it->second.find(origin); + if (origin_devices_it == current_devices_it->second.end()) + return {}; + + std::vector results; + for (const auto& object : origin_devices_it->second) + results.push_back(object->Clone()); + + return results; +} + void WebContents::UpdatePreferredSize(content::WebContents* web_contents, const gfx::Size& pref_size) { Emit("preferred-size-changed", pref_size); diff --git a/shell/browser/api/electron_api_web_contents.h b/shell/browser/api/electron_api_web_contents.h index 7a98bbdc36018..1549a610852c8 100644 --- a/shell/browser/api/electron_api_web_contents.h +++ b/shell/browser/api/electron_api_web_contents.h @@ -20,6 +20,7 @@ #include "content/common/frame.mojom.h" #include "content/public/browser/devtools_agent_host.h" #include "content/public/browser/keyboard_event_processing_result.h" +#include "content/public/browser/permission_type.h" #include "content/public/browser/render_widget_host.h" #include "content/public/browser/web_contents.h" #include "content/public/browser/web_contents_delegate.h" @@ -92,6 +93,11 @@ class OffScreenWebContentsView; namespace api { +using DevicePermissionMap = std::map< + int, + std::map>>>>; + // Wrapper around the content::WebContents. class WebContents : public gin::Wrappable, public gin_helper::EventEmitterMixin, @@ -420,6 +426,21 @@ class WebContents : public gin::Wrappable, electron::mojom::ElectronBrowser::DoGetZoomLevelCallback callback); void SetImageAnimationPolicy(const std::string& new_policy); + // Grants |origin| access to |device|. + // To be used in place of ObjectPermissionContextBase::GrantObjectPermission. + void GrantDevicePermission(const url::Origin& origin, + const base::Value* device, + content::PermissionType permissionType, + content::RenderFrameHost* render_frame_host); + + // Returns the list of devices that |origin| has been granted permission to + // access. To be used in place of + // ObjectPermissionContextBase::GetGrantedObjects. + std::vector GetGrantedDevices( + const url::Origin& origin, + content::PermissionType permissionType, + content::RenderFrameHost* render_frame_host); + private: // Does not manage lifetime of |web_contents|. WebContents(v8::Isolate* isolate, content::WebContents* web_contents); @@ -775,6 +796,9 @@ class WebContents : public gin::Wrappable, service_manager::BinderRegistryWithArgs registry_; + // In-memory cache that holds objects that have been granted permissions. + DevicePermissionMap granted_devices_; + base::WeakPtrFactory weak_factory_{this}; DISALLOW_COPY_AND_ASSIGN(WebContents); diff --git a/shell/browser/electron_browser_client.cc b/shell/browser/electron_browser_client.cc index aa84b536f6746..cf22ec04e356d 100644 --- a/shell/browser/electron_browser_client.cc +++ b/shell/browser/electron_browser_client.cc @@ -1681,4 +1681,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 ccd4b6d706423..6bc8e3b482b58 100644 --- a/shell/browser/electron_browser_client.h +++ b/shell/browser/electron_browser_client.h @@ -21,6 +21,7 @@ #include "services/metrics/public/cpp/ukm_source_id.h" #include "shell/browser/bluetooth/electron_bluetooth_delegate.h" #include "shell/browser/font/electron_font_access_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" @@ -92,6 +93,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 font_access_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..cc38879d1d90f 100644 --- a/shell/browser/electron_permission_manager.cc +++ b/shell/browser/electron_permission_manager.cc @@ -17,9 +17,17 @@ #include "content/public/browser/render_process_host.h" #include "content/public/browser/render_view_host.h" #include "content/public/browser/web_contents.h" +#include "gin/data_object_builder.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/hid/hid_chooser_context.h" +#include "shell/browser/web_contents_permission_helper.h" #include "shell/browser/web_contents_preferences.h" +#include "shell/common/gin_converters/content_converter.h" +#include "shell/common/gin_converters/frame_converter.h" +#include "shell/common/gin_converters/value_converter.h" +#include "shell/common/gin_helper/event_emitter_caller.h" namespace electron { @@ -117,6 +125,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 +295,71 @@ bool ElectronPermissionManager::CheckPermissionWithDetails( mutable_details); } +bool ElectronPermissionManager::CheckDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device, + content::RenderFrameHost* render_frame_host) const { + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + api::WebContents* api_web_contents = api::WebContents::From(web_contents); + if (device_permission_handler_.is_null()) { + if (api_web_contents) { + std::vector granted_devices = + api_web_contents->GetGrantedDevices(origin, permission, + render_frame_host); + + for (const auto& granted_device : granted_devices) { + if (permission == + static_cast( + WebContentsPermissionHelper::PermissionType::HID)) { + if (device->FindIntKey(kHidVendorIdKey) != + granted_device.FindIntKey(kHidVendorIdKey) || + device->FindIntKey(kHidProductIdKey) != + granted_device.FindIntKey(kHidProductIdKey)) { + continue; + } + + const auto* serial_number = + granted_device.FindStringKey(kHidSerialNumberKey); + const auto* device_serial_number = + device->FindStringKey(kHidSerialNumberKey); + + if (serial_number && device_serial_number && + *device_serial_number == *serial_number) + return true; + } + } + } + return false; + } else { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local details = gin::DataObjectBuilder(isolate) + .Set("deviceType", permission) + .Set("origin", origin.Serialize()) + .Set("device", device->Clone()) + .Set("frame", render_frame_host) + .Build(); + return device_permission_handler_.Run(details); + } +} + +void ElectronPermissionManager::GrantDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device, + content::RenderFrameHost* render_frame_host) const { + if (device_permission_handler_.is_null()) { + auto* web_contents = + content::WebContents::FromRenderFrameHost(render_frame_host); + api::WebContents* api_web_contents = api::WebContents::From(web_contents); + if (api_web_contents) + api_web_contents->GrantDevicePermission(origin, device, permission, + render_frame_host); + } +} + 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..6015b4ca70a43 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, + const url::Origin& origin, + const base::Value* object, + content::RenderFrameHost* render_frame_host) const; + + void GrantDevicePermission(content::PermissionType permission, + const url::Origin& origin, + const base::Value* object, + content::RenderFrameHost* render_frame_host) 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..113f028c9dd5a --- /dev/null +++ b/shell/browser/hid/electron_hid_delegate.cc @@ -0,0 +1,163 @@ +// 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) { + electron::HidChooserContext* 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 false; +} + +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..e4e35d507db49 --- /dev/null +++ b/shell/browser/hid/electron_hid_delegate.h @@ -0,0 +1,84 @@ +// 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; + + // content::HidDelegate: + 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..a743048f336f8 --- /dev/null +++ b/shell/browser/hid/hid_chooser_context.cc @@ -0,0 +1,272 @@ +// 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 { + +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) {} + +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), render_frame_host); + } 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), render_frame_host); +} + +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..923f9f99fb8a1 --- /dev/null +++ b/shell/browser/hid/hid_chooser_context.h @@ -0,0 +1,136 @@ +// 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 { + +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. +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&) = 0; + virtual void OnDeviceRemoved(const device::mojom::HidDeviceInfo&) = 0; + virtual void OnDeviceChanged(const device::mojom::HidDeviceInfo&) = 0; + virtual void OnHidManagerConnectionError() = 0; + + // 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..7000999a0c1af --- /dev/null +++ b/shell/browser/hid/hid_chooser_controller.cc @@ -0,0 +1,366 @@ +// 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 "gin/data_object_builder.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 "shell/common/node_includes.h" +#include "shell/common/process_util.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), + render_frame_host_id_(render_frame_host->GetGlobalId()) { + 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) { + auto* rfh = content::RenderFrameHost::FromID(render_frame_host_id_); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local details = gin::DataObjectBuilder(isolate) + .Set("device", device.Clone()) + .Set("frame", rfh) + .Build(); + 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) { + auto* rfh = content::RenderFrameHost::FromID(render_frame_host_id_); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local details = gin::DataObjectBuilder(isolate) + .Set("device", device.Clone()) + .Set("frame", rfh) + .Build(); + 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(gin::Arguments* args) { + std::string device_id; + if (!args->GetNext(&device_id) || 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 { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + node::Environment* env = node::Environment::GetCurrent(isolate); + EmitWarning(env, "The device id " + device_id + " was not found.", + "UnknownHIDDeviceId"); + 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) { + auto* rfh = content::RenderFrameHost::FromID(render_frame_host_id_); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local details = gin::DataObjectBuilder(isolate) + .Set("deviceList", devicesToDisplay) + .Set("frame", rfh) + .Build(); + 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); + } +} + +} // 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..842cf31f9ceb2 --- /dev/null +++ b/shell/browser/hid/hid_chooser_controller.h @@ -0,0 +1,126 @@ +// 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/global_routing_id.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 "shell/common/gin_converters/frame_converter.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; + + 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(gin::Arguments* args); + + 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_; + + content::GlobalRenderFrameHostId render_frame_host_id_; + + 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..8ac0b5ca57949 100644 --- a/shell/browser/web_contents_permission_helper.cc +++ b/shell/browser/web_contents_permission_helper.cc @@ -94,6 +94,28 @@ bool WebContentsPermissionHelper::CheckPermission( details); } +bool WebContentsPermissionHelper::CheckDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device, + content::RenderFrameHost* render_frame_host) const { + auto* permission_manager = static_cast( + web_contents_->GetBrowserContext()->GetPermissionControllerDelegate()); + return permission_manager->CheckDevicePermission(permission, origin, device, + render_frame_host); +} + +void WebContentsPermissionHelper::GrantDevicePermission( + content::PermissionType permission, + const url::Origin& origin, + const base::Value* device, + content::RenderFrameHost* render_frame_host) const { + auto* permission_manager = static_cast( + web_contents_->GetBrowserContext()->GetPermissionControllerDelegate()); + permission_manager->GrantDevicePermission(permission, origin, device, + render_frame_host); +} + void WebContentsPermissionHelper::RequestFullscreenPermission( base::OnceCallback callback) { RequestPermission( @@ -168,6 +190,32 @@ 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, + content::RenderFrameHost* render_frame_host) const { + return CheckDevicePermission( + static_cast(PermissionType::HID), origin, + &device, render_frame_host); +} + +void WebContentsPermissionHelper::GrantHIDDevicePermission( + const url::Origin& origin, + base::Value device, + content::RenderFrameHost* render_frame_host) const { + return GrantDevicePermission( + static_cast(PermissionType::HID), origin, + &device, render_frame_host); +} + 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..9f7d7aa75e370 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,15 @@ 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, + content::RenderFrameHost* render_frame_host) const; + void GrantHIDDevicePermission( + const url::Origin& origin, + base::Value device, + content::RenderFrameHost* render_frame_host) const; private: explicit WebContentsPermissionHelper(content::WebContents* web_contents); @@ -54,6 +64,16 @@ 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, + content::RenderFrameHost* render_frame_host) const; + + void GrantDevicePermission(content::PermissionType permission, + const url::Origin& origin, + const base::Value* device, + content::RenderFrameHost* render_frame_host) 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 78062b3a49a67..4d6b250cfa166 100644 --- a/shell/common/gin_converters/content_converter.cc +++ b/shell/common/gin_converters/content_converter.cc @@ -209,6 +209,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..6652a6fff42e5 100644 --- a/spec-main/chromium-spec.ts +++ b/spec-main/chromium-spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { BrowserWindow, WebContents, session, ipcMain, app, protocol, webContents } from 'electron/main'; +import { BrowserWindow, WebContents, webFrameMain, session, ipcMain, app, protocol, webContents } from 'electron/main'; import { emittedOnce } from './events-helpers'; import { closeAllWindows } from './window-helpers'; import * as https from 'https'; @@ -1878,3 +1878,127 @@ 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; + 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(''); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://localhost:${(server.address() as any).port}`; + }); + + const getDevices: any = () => { + return w.webContents.executeJavaScript(` + navigator.hid.requestDevice({filters: []}).then(device => device.toString()).catch(err => err.toString()); + `, true); + }; + + after(() => { + server.close(); + 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) => { + 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 getDevices(); + expect(selectFired).to.be.true(); + if (haveDevices) { + expect(device).to.contain('[object HIDDevice]'); + } else { + expect(device).to.equal(''); + } + if (process.arch === 'arm64' || process.arch === 'arm') { + // arm CI returns HID devices - this block may need to change if CI hardware changes. + expect(haveDevices).to.be.true(); + // Verify that navigation will clear device permissions + const grantedDevices = await w.webContents.executeJavaScript('navigator.hid.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.hid.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-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(''); + } + }); +});