diff --git a/injected/integration-test/device-enumeration.spec.js b/injected/integration-test/device-enumeration.spec.js new file mode 100644 index 0000000000..6428a61cc5 --- /dev/null +++ b/injected/integration-test/device-enumeration.spec.js @@ -0,0 +1,47 @@ +import { gotoAndWait, testContextForExtension } from './helpers/harness.js'; +import { test as base, expect } from '@playwright/test'; + +const test = testContextForExtension(base); + +test.describe('Device Enumeration Feature', () => { + test.describe('disabled feature', () => { + test('should not intercept enumerateDevices when disabled', async ({ page }) => { + await gotoAndWait(page, '/webcompat/pages/device-enumeration.html', { + site: { enabledFeatures: [] }, + }); + + // Should use native implementation + const results = await page.evaluate(() => { + // @ts-expect-error - results is set by renderResults() + return window.results; + }); + + // The test should pass with native behavior + expect(results).toBeDefined(); + }); + }); + + test.describe('enabled feature', () => { + test('should intercept enumerateDevices when enabled', async ({ page }) => { + await gotoAndWait(page, '/webcompat/pages/device-enumeration.html', { + site: { + enabledFeatures: ['webCompat'], + }, + featureSettings: { + webCompat: { + enumerateDevices: 'enabled', + }, + }, + }); + + // Should use our implementation + const results = await page.evaluate(() => { + // @ts-expect-error - results is set by renderResults() + return window.results; + }); + + // The test should pass with our implementation + expect(results).toBeDefined(); + }); + }); +}); diff --git a/injected/integration-test/pages.spec.js b/injected/integration-test/pages.spec.js index dee265bbf8..7bf7a5fc62 100644 --- a/injected/integration-test/pages.spec.js +++ b/injected/integration-test/pages.spec.js @@ -127,6 +127,15 @@ test.describe('Test integration pages', () => { ); }); + test('enumerateDevices API functionality', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + 'webcompat/pages/enumerate-devices-api-test.html', + './integration-test/test-pages/webcompat/config/enumerate-devices-api.json', + ); + }); + test('minSupportedVersion (string)', async ({ page }, testInfo) => { await testPage( page, diff --git a/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json b/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json new file mode 100644 index 0000000000..948e2ebae2 --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json @@ -0,0 +1,14 @@ +{ + "readme": "This config is used to test the enumerateDevices API proxy functionality.", + "version": 1, + "unprotectedTemporary": [], + "features": { + "webCompat": { + "state": "enabled", + "exceptions": [], + "settings": { + "enumerateDevices": "enabled" + } + } + } +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/webcompat/index.html b/injected/integration-test/test-pages/webcompat/index.html index c7210bcb7e..4bea178bd5 100644 --- a/injected/integration-test/test-pages/webcompat/index.html +++ b/injected/integration-test/test-pages/webcompat/index.html @@ -12,6 +12,8 @@
  • Message Handlers - Config
  • Shims - Config
  • Modify localStorage - Config
  • +
  • Device Enumeration
  • +
  • Enumerate Devices API Test - Config
  • diff --git a/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html b/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html new file mode 100644 index 0000000000..5d1f93d595 --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html @@ -0,0 +1,81 @@ + + + + + + Device Enumeration Test + + + + +

    [Webcompat shims]

    + +

    This page tests the device enumeration feature

    + + + + \ No newline at end of file diff --git a/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html b/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html new file mode 100644 index 0000000000..05234a668a --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html @@ -0,0 +1,236 @@ + + + + + + enumerateDevices API Test + + + + +

    [Webcompat API Tests]

    + +

    This page tests the enumerateDevices API proxy functionality

    + + + + \ No newline at end of file diff --git a/injected/package.json b/injected/package.json index 60050d1d13..de32cb26df 100644 --- a/injected/package.json +++ b/injected/package.json @@ -27,11 +27,11 @@ }, "type": "module", "dependencies": { + "minimist": "^1.2.8", "parse-address": "^1.1.2", "seedrandom": "^3.0.5", "sjcl": "^1.0.8", - "minimist": "^1.2.8", - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#10be120b4630107863ef6ffa228ccabc831be1c2", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#1752154773643", "esbuild": "^0.25.6", "urlpattern-polyfill": "^10.1.0" }, diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 6741b44b22..0d665fc4d2 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -1,7 +1,7 @@ import ContentFeature from '../content-feature.js'; // eslint-disable-next-line no-redeclare import { URL } from '../captured-globals.js'; -import { DDGProxy } from '../utils'; +import { DDGProxy, DDGReflect } from '../utils'; /** * Fixes incorrect sizing value for outerHeight and outerWidth */ @@ -17,6 +17,7 @@ const MSG_WEB_SHARE = 'webShare'; const MSG_PERMISSIONS_QUERY = 'permissionsQuery'; const MSG_SCREEN_LOCK = 'screenLock'; const MSG_SCREEN_UNLOCK = 'screenUnlock'; +const MSG_DEVICE_ENUMERATION = 'deviceEnumeration'; function canShare(data) { if (typeof data !== 'object') return false; @@ -129,6 +130,9 @@ export class WebCompat extends ContentFeature { if (this.getFeatureSettingEnabled('disableDeviceEnumeration') || this.getFeatureSettingEnabled('disableDeviceEnumerationFrames')) { this.preventDeviceEnumeration(); } + if (this.getFeatureSettingEnabled('enumerateDevices')) { + this.deviceEnumerationFix(); + } } /** Shim Web Share API in Android WebView */ @@ -757,6 +761,9 @@ export class WebCompat extends ContentFeature { } } + /** + * Prevents device enumeration by returning an empty array when enabled + */ preventDeviceEnumeration() { if (!window.MediaDevices) { return; @@ -770,6 +777,9 @@ export class WebCompat extends ContentFeature { } if (disableDeviceEnumeration) { const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { + /** + * @returns {Promise} + */ apply() { return Promise.resolve([]); }, @@ -777,6 +787,125 @@ export class WebCompat extends ContentFeature { enumerateDevicesProxy.overload(); } } + + /** + * Creates a valid MediaDeviceInfo or InputDeviceInfo object that passes instanceof checks + * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind + * @returns {MediaDeviceInfo | InputDeviceInfo} + */ + createMediaDeviceInfo(kind) { + // Create an empty object with the correct prototype + let deviceInfo; + if (kind === 'videoinput' || kind === 'audioinput') { + // Input devices should inherit from InputDeviceInfo.prototype if available + if (typeof InputDeviceInfo !== 'undefined' && InputDeviceInfo.prototype) { + deviceInfo = Object.create(InputDeviceInfo.prototype); + } else { + deviceInfo = Object.create(MediaDeviceInfo.prototype); + } + } else { + // Output devices inherit from MediaDeviceInfo.prototype + deviceInfo = Object.create(MediaDeviceInfo.prototype); + } + + // Define read-only properties from the start + Object.defineProperties(deviceInfo, { + deviceId: { + value: 'default', + writable: false, + configurable: false, + enumerable: true, + }, + kind: { + value: kind, + writable: false, + configurable: false, + enumerable: true, + }, + label: { + value: '', + writable: false, + configurable: false, + enumerable: true, + }, + groupId: { + value: 'default-group', + writable: false, + configurable: false, + enumerable: true, + }, + toJSON: { + value: function () { + return { + deviceId: this.deviceId, + kind: this.kind, + label: this.label, + groupId: this.groupId, + }; + }, + writable: false, + configurable: false, + enumerable: true, + }, + }); + + return deviceInfo; + } + + /** + * Fixes device enumeration to handle permission prompts gracefully + */ + deviceEnumerationFix() { + if (!window.MediaDevices) { + return; + } + + const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { + /** + * @param {MediaDevices['enumerateDevices']} target + * @param {MediaDevices} thisArg + * @param {Parameters} args + * @returns {Promise} + */ + apply: async (target, thisArg, args) => { + try { + // Request device enumeration information from native + /** @type {{willPrompt: boolean, videoInput: boolean, audioInput: boolean, audioOutput: boolean}} */ + const response = await this.messaging.request(MSG_DEVICE_ENUMERATION, {}); + + // Check if native indicates that prompts would be required + if (response.willPrompt) { + // If prompts would be required, return a manipulated response + // that includes the device types that are available + /** @type {MediaDeviceInfo[]} */ + const devices = []; + + if (response.videoInput) { + devices.push(this.createMediaDeviceInfo('videoinput')); + } + + if (response.audioInput) { + devices.push(this.createMediaDeviceInfo('audioinput')); + } + + if (response.audioOutput) { + devices.push(this.createMediaDeviceInfo('audiooutput')); + } + + return Promise.resolve(devices); + } else { + // If no prompts would be required, proceed with the regular device enumeration + return DDGReflect.apply(target, thisArg, args); + } + } catch (err) { + // If the native request fails, fall back to the original implementation + return DDGReflect.apply(target, thisArg, args); + } + }, + }); + + enumerateDevicesProxy.overload(); + } } /** @typedef {{title?: string, url?: string, text?: string}} ShareRequestData */ diff --git a/injected/src/messages/web-compat/deviceEnumeration.request.json b/injected/src/messages/web-compat/deviceEnumeration.request.json new file mode 100644 index 0000000000..4c490b2a13 --- /dev/null +++ b/injected/src/messages/web-compat/deviceEnumeration.request.json @@ -0,0 +1,27 @@ +{ + "description": "Request device enumeration information from native layer", + "params": {}, + "response": { + "description": "Device enumeration information from native layer", + "properties": { + "videoInput": { + "description": "Whether video input devices are available", + "type": "boolean" + }, + "audioInput": { + "description": "Whether audio input devices are available", + "type": "boolean" + }, + "audioOutput": { + "description": "Whether audio output devices are available", + "type": "boolean" + }, + "willPrompt": { + "description": "Whether the API would prompt for permissions", + "type": "boolean" + } + }, + "required": ["videoInput", "audioInput", "audioOutput", "willPrompt"], + "type": "object" + } +} \ No newline at end of file diff --git a/injected/src/types/web-compat.ts b/injected/src/types/web-compat.ts index ecf46f1b16..e4a641c18e 100644 --- a/injected/src/types/web-compat.ts +++ b/injected/src/types/web-compat.ts @@ -10,7 +10,19 @@ * Requests, Notifications and Subscriptions from the WebCompat feature */ export interface WebCompatMessages { - requests: WebShareRequest; + requests: DeviceEnumerationRequest | WebShareRequest; +} +/** + * Generated from @see "../messages/web-compat/deviceEnumeration.request.json" + */ +export interface DeviceEnumerationRequest { + method: "deviceEnumeration"; + /** + * Request device enumeration information from native layer + */ + params: { + [k: string]: unknown; + }; } /** * Generated from @see "../messages/web-compat/webShare.request.json" diff --git a/package-lock.json b/package-lock.json index 3d898d6b2a..583964cf8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "injected": { "hasInstallScript": true, "dependencies": { - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#10be120b4630107863ef6ffa228ccabc831be1c2", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#1752154773643", "esbuild": "^0.25.6", "minimist": "^1.2.8", "parse-address": "^1.1.2", @@ -615,8 +615,8 @@ }, "node_modules/@duckduckgo/privacy-configuration": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#10be120b4630107863ef6ffa228ccabc831be1c2", - "integrity": "sha512-ZpQQc1gbHrNBVtSZnhCz/zo9urLhgg+C4nQ4XaefgIsGHtlXH8WEZYLAKx/1HXl0uSKpX1RnE2sjDETffaIhMQ==", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#f8e6f16413398cda2b0509f3a635531b0f50f209", + "integrity": "sha512-IjwlCrrMZIrFKjqE9uNg9F1CdhWzdIYmlHoD5RAjQQwWxmk2kNNc+I/56KcddktsE/MWwKaTEf5rfs1s2V7URA==", "license": "Apache 2.0", "dependencies": { "eslint-plugin-json": "^4.0.1",