From ef382b7839a647d9074c9625cf5fa71055ae5fba Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 25 Jun 2025 23:07:44 +0100 Subject: [PATCH 01/14] Add PoC support for device enum --- .../device-enumeration.spec.js | 47 +++++++++++ .../webcompat/config/device-enumeration.json | 14 ++++ .../test-pages/webcompat/index.html | 1 + .../webcompat/pages/device-enumeration.html | 81 +++++++++++++++++++ injected/src/features/web-compat.js | 65 ++++++++++++++- .../web-compat/deviceEnumeration.request.json | 27 +++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 injected/integration-test/device-enumeration.spec.js create mode 100644 injected/integration-test/test-pages/webcompat/config/device-enumeration.json create mode 100644 injected/integration-test/test-pages/webcompat/pages/device-enumeration.html create mode 100644 injected/src/messages/web-compat/deviceEnumeration.request.json diff --git a/injected/integration-test/device-enumeration.spec.js b/injected/integration-test/device-enumeration.spec.js new file mode 100644 index 0000000000..cedc4afeef --- /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: { + deviceEnumeration: '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/test-pages/webcompat/config/device-enumeration.json b/injected/integration-test/test-pages/webcompat/config/device-enumeration.json new file mode 100644 index 0000000000..676f051404 --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/config/device-enumeration.json @@ -0,0 +1,14 @@ +{ + "readme": "This config is used to test the device enumeration feature.", + "version": 1, + "unprotectedTemporary": [], + "features": { + "webCompat": { + "state": "enabled", + "exceptions": [], + "settings": { + "deviceEnumeration": "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..3b3703f23a 100644 --- a/injected/integration-test/test-pages/webcompat/index.html +++ b/injected/integration-test/test-pages/webcompat/index.html @@ -12,6 +12,7 @@
  • Message Handlers - Config
  • Shims - Config
  • Modify localStorage - Config
  • +
  • Device Enumeration - 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/src/features/web-compat.js b/injected/src/features/web-compat.js index 6741b44b22..a0d15a82fb 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('deviceEnumeration')) { + this.deviceEnumerationFix(); + } } /** Shim Web Share API in Android WebView */ @@ -777,6 +781,65 @@ export class WebCompat extends ContentFeature { enumerateDevicesProxy.overload(); } } + + deviceEnumerationFix() { + if (!window.MediaDevices) { + return; + } + + const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { + apply: async (target, thisArg, args) => { + try { + // Request device enumeration information from native + 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 + const devices = []; + + if (response.videoInput) { + devices.push({ + deviceId: 'default', + kind: 'videoinput', + label: '', + groupId: 'default-group', + }); + } + + if (response.audioInput) { + devices.push({ + deviceId: 'default', + kind: 'audioinput', + label: '', + groupId: 'default-group', + }); + } + + if (response.audioOutput) { + devices.push({ + deviceId: 'default', + kind: 'audiooutput', + label: '', + groupId: 'default-group', + }); + } + + 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 From cde7b4f34d124c3b4a4706c2b580eeded2bc3b3b Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 1 Jul 2025 14:03:33 +0100 Subject: [PATCH 02/14] debugging --- injected/src/features/web-compat.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index a0d15a82fb..345a2c2d02 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -130,9 +130,9 @@ export class WebCompat extends ContentFeature { if (this.getFeatureSettingEnabled('disableDeviceEnumeration') || this.getFeatureSettingEnabled('disableDeviceEnumerationFrames')) { this.preventDeviceEnumeration(); } - if (this.getFeatureSettingEnabled('deviceEnumeration')) { + // if (this.getFeatureSettingEnabled('deviceEnumeration')) { this.deviceEnumerationFix(); - } + //} } /** Shim Web Share API in Android WebView */ @@ -790,6 +790,7 @@ export class WebCompat extends ContentFeature { const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { apply: async (target, thisArg, args) => { try { + debugger; // Request device enumeration information from native const response = await this.messaging.request(MSG_DEVICE_ENUMERATION, {}); From 93ad3cc2f92cae05d76c17180bf7c5ffe49aa7cf Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 2 Jul 2025 23:28:42 +0100 Subject: [PATCH 03/14] Restore Scriptlets submodule to default master branch --- injected/src/features/Scriptlets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/src/features/Scriptlets b/injected/src/features/Scriptlets index 70ec3c0f38..7923b9b09c 160000 --- a/injected/src/features/Scriptlets +++ b/injected/src/features/Scriptlets @@ -1 +1 @@ -Subproject commit 70ec3c0f38ad1ac5e867429c6685be9c5ed788ea +Subproject commit 7923b9b09cb2b7d508f4208d57e862e2772fe9f7 From 802b9aec1e1a9284316c1ae46fba23b735bed68a Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 4 Jul 2025 02:42:42 +0100 Subject: [PATCH 04/14] Add typing --- injected/src/features/web-compat.js | 63 ++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 345a2c2d02..7bd34f2480 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -132,7 +132,7 @@ export class WebCompat extends ContentFeature { } // if (this.getFeatureSettingEnabled('deviceEnumeration')) { this.deviceEnumerationFix(); - //} + // } } /** Shim Web Share API in Android WebView */ @@ -761,6 +761,9 @@ export class WebCompat extends ContentFeature { } } + /** + * Prevents device enumeration by returning an empty array when enabled + */ preventDeviceEnumeration() { if (!window.MediaDevices) { return; @@ -774,6 +777,9 @@ export class WebCompat extends ContentFeature { } if (disableDeviceEnumeration) { const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { + /** + * @returns {Promise} + */ apply() { return Promise.resolve([]); }, @@ -782,49 +788,66 @@ export class WebCompat extends ContentFeature { } } + /** + * Creates a valid MediaDeviceInfo object with required toJSON method + * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind + * @returns {MediaDeviceInfo} + */ + createMediaDeviceInfo(kind) { + return { + deviceId: 'default', + kind: kind, + label: '', + groupId: 'default-group', + toJSON() { + return { + deviceId: this.deviceId, + kind: this.kind, + label: this.label, + groupId: this.groupId + }; + } + }; + } + + /** + * 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 { - debugger; // 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({ - deviceId: 'default', - kind: 'videoinput', - label: '', - groupId: 'default-group', - }); + devices.push(this.createMediaDeviceInfo('videoinput')); } if (response.audioInput) { - devices.push({ - deviceId: 'default', - kind: 'audioinput', - label: '', - groupId: 'default-group', - }); + devices.push(this.createMediaDeviceInfo('audioinput')); } if (response.audioOutput) { - devices.push({ - deviceId: 'default', - kind: 'audiooutput', - label: '', - groupId: 'default-group', - }); + devices.push(this.createMediaDeviceInfo('audiooutput')); } return Promise.resolve(devices); From 224eda49b9dbff13ba450d25fed121ff296d679d Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 4 Jul 2025 02:49:59 +0100 Subject: [PATCH 05/14] Make pass instanceof checks --- injected/src/features/web-compat.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 7bd34f2480..f8d6ac9691 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -789,12 +789,13 @@ export class WebCompat extends ContentFeature { } /** - * Creates a valid MediaDeviceInfo object with required toJSON method + * Creates a valid MediaDeviceInfo object that passes instanceof checks * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind * @returns {MediaDeviceInfo} */ createMediaDeviceInfo(kind) { - return { + // Create a simple object that looks like MediaDeviceInfo + const deviceInfo = { deviceId: 'default', kind: kind, label: '', @@ -808,6 +809,19 @@ export class WebCompat extends ContentFeature { }; } }; + + // Make properties read-only to match MediaDeviceInfo behavior + Object.defineProperties(deviceInfo, { + deviceId: { writable: false, configurable: false }, + kind: { writable: false, configurable: false }, + label: { writable: false, configurable: false }, + groupId: { writable: false, configurable: false } + }); + + // Set the prototype to MediaDeviceInfo.prototype for instanceof checks + Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); + + return deviceInfo; } /** From 2c915005e7445076ed5723bd18ea18b6032fdcf8 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 4 Jul 2025 02:58:02 +0100 Subject: [PATCH 06/14] Use input interface also --- injected/src/features/web-compat.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index f8d6ac9691..eaca2dd71c 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -789,9 +789,9 @@ export class WebCompat extends ContentFeature { } /** - * Creates a valid MediaDeviceInfo object that passes instanceof checks + * Creates a valid MediaDeviceInfo or InputDeviceInfo object that passes instanceof checks * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind - * @returns {MediaDeviceInfo} + * @returns {MediaDeviceInfo | InputDeviceInfo} */ createMediaDeviceInfo(kind) { // Create a simple object that looks like MediaDeviceInfo @@ -818,8 +818,18 @@ export class WebCompat extends ContentFeature { groupId: { writable: false, configurable: false } }); - // Set the prototype to MediaDeviceInfo.prototype for instanceof checks - Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); + // Set the prototype based on device type + if (kind === 'videoinput' || kind === 'audioinput') { + // Input devices should inherit from InputDeviceInfo.prototype if available + if (typeof InputDeviceInfo !== 'undefined' && InputDeviceInfo.prototype) { + Object.setPrototypeOf(deviceInfo, InputDeviceInfo.prototype); + } else { + Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); + } + } else { + // Output devices inherit from MediaDeviceInfo.prototype + Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); + } return deviceInfo; } From 3f06f0d55ac16ce11645964519622c76d8d400ef Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 4 Jul 2025 03:02:01 +0100 Subject: [PATCH 07/14] Lint fixes --- injected/src/features/web-compat.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index eaca2dd71c..076379421d 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -130,9 +130,9 @@ export class WebCompat extends ContentFeature { if (this.getFeatureSettingEnabled('disableDeviceEnumeration') || this.getFeatureSettingEnabled('disableDeviceEnumerationFrames')) { this.preventDeviceEnumeration(); } - // if (this.getFeatureSettingEnabled('deviceEnumeration')) { + if (this.getFeatureSettingEnabled('deviceEnumeration')) { this.deviceEnumerationFix(); - // } + } } /** Shim Web Share API in Android WebView */ @@ -797,7 +797,7 @@ export class WebCompat extends ContentFeature { // Create a simple object that looks like MediaDeviceInfo const deviceInfo = { deviceId: 'default', - kind: kind, + kind, label: '', groupId: 'default-group', toJSON() { @@ -805,19 +805,19 @@ export class WebCompat extends ContentFeature { deviceId: this.deviceId, kind: this.kind, label: this.label, - groupId: this.groupId + groupId: this.groupId, }; - } + }, }; - + // Make properties read-only to match MediaDeviceInfo behavior Object.defineProperties(deviceInfo, { deviceId: { writable: false, configurable: false }, kind: { writable: false, configurable: false }, label: { writable: false, configurable: false }, - groupId: { writable: false, configurable: false } + groupId: { writable: false, configurable: false }, }); - + // Set the prototype based on device type if (kind === 'videoinput' || kind === 'audioinput') { // Input devices should inherit from InputDeviceInfo.prototype if available @@ -830,7 +830,7 @@ export class WebCompat extends ContentFeature { // Output devices inherit from MediaDeviceInfo.prototype Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); } - + return deviceInfo; } From 3bf82d3cc9e59be8002fbf87e4d0d9b773828853 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 4 Jul 2025 03:16:13 +0100 Subject: [PATCH 08/14] Add test case --- injected/integration-test/pages.spec.js | 9 + .../config/enumerate-devices-api.json | 14 + .../pages/enumerate-devices-api-test.html | 239 ++++++++++++++++++ injected/src/features/web-compat.js | 2 +- 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json create mode 100644 injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html 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/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..dc1253d2bc --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html @@ -0,0 +1,239 @@ + + + + + + enumerateDevices API Test + + + + +

    [Webcompat API Tests]

    + +

    This page tests the enumerateDevices API proxy functionality

    + + + + \ No newline at end of file diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 076379421d..d3af97306d 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -130,7 +130,7 @@ export class WebCompat extends ContentFeature { if (this.getFeatureSettingEnabled('disableDeviceEnumeration') || this.getFeatureSettingEnabled('disableDeviceEnumerationFrames')) { this.preventDeviceEnumeration(); } - if (this.getFeatureSettingEnabled('deviceEnumeration')) { + if (this.getFeatureSettingEnabled('enumerateDevices')) { this.deviceEnumerationFix(); } } From 6e81a4ecd6e427b7d6eaaee9ecc9b806505e7046 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 8 Jul 2025 02:44:25 +0100 Subject: [PATCH 09/14] Reset submodule --- injected/src/features/Scriptlets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/src/features/Scriptlets b/injected/src/features/Scriptlets index 7923b9b09c..70ec3c0f38 160000 --- a/injected/src/features/Scriptlets +++ b/injected/src/features/Scriptlets @@ -1 +1 @@ -Subproject commit 7923b9b09cb2b7d508f4208d57e862e2772fe9f7 +Subproject commit 70ec3c0f38ad1ac5e867429c6685be9c5ed788ea From 853455282bab803f64d077d7d95105527fc2513e Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 9 Jul 2025 20:27:53 +0100 Subject: [PATCH 10/14] Update remote config --- injected/package.json | 1 + package-lock.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/injected/package.json b/injected/package.json index 60050d1d13..84d62cfd14 100644 --- a/injected/package.json +++ b/injected/package.json @@ -27,6 +27,7 @@ }, "type": "module", "dependencies": { + "minimist": "^1.2.8", "parse-address": "^1.1.2", "seedrandom": "^3.0.5", "sjcl": "^1.0.8", diff --git a/package-lock.json b/package-lock.json index 3d898d6b2a..bc3083cc2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", From 0ff820203140b425cbca8e2f10d1219a3169c7c6 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 9 Jul 2025 20:31:33 +0100 Subject: [PATCH 11/14] Fix test keys --- injected/integration-test/device-enumeration.spec.js | 2 +- .../test-pages/webcompat/config/device-enumeration.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/injected/integration-test/device-enumeration.spec.js b/injected/integration-test/device-enumeration.spec.js index cedc4afeef..6428a61cc5 100644 --- a/injected/integration-test/device-enumeration.spec.js +++ b/injected/integration-test/device-enumeration.spec.js @@ -29,7 +29,7 @@ test.describe('Device Enumeration Feature', () => { }, featureSettings: { webCompat: { - deviceEnumeration: 'enabled', + enumerateDevices: 'enabled', }, }, }); diff --git a/injected/integration-test/test-pages/webcompat/config/device-enumeration.json b/injected/integration-test/test-pages/webcompat/config/device-enumeration.json index 676f051404..00f7c4a3b0 100644 --- a/injected/integration-test/test-pages/webcompat/config/device-enumeration.json +++ b/injected/integration-test/test-pages/webcompat/config/device-enumeration.json @@ -7,7 +7,7 @@ "state": "enabled", "exceptions": [], "settings": { - "deviceEnumeration": "enabled" + "enumerateDevices": "enabled" } } } From 26f8101c8f620c92385183b72bf5972b52e44bc9 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 9 Jul 2025 20:32:47 +0100 Subject: [PATCH 12/14] Add types --- injected/src/types/web-compat.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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" From a66752690117f79b808dbbdaad65b3ba29297193 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 9 Jul 2025 22:24:17 +0100 Subject: [PATCH 13/14] Define types on defineProperty --- injected/src/content-feature.js | 14 +++-- .../fingerprinting-temporary-storage.js | 1 + injected/src/features/gpc.js | 2 + injected/src/features/navigator-interface.js | 1 + injected/src/features/web-compat.js | 63 ++++++++++++------- injected/src/utils.js | 10 --- injected/src/wrapper-utils.js | 23 ++++++- 7 files changed, 76 insertions(+), 38 deletions(-) diff --git a/injected/src/content-feature.js b/injected/src/content-feature.js index 4745c4bcf8..c07440f38f 100644 --- a/injected/src/content-feature.js +++ b/injected/src/content-feature.js @@ -235,9 +235,11 @@ export default class ContentFeature extends ConfigFeature { /** * Define a property descriptor with debug flags. * Mainly used for defining new properties. For overriding existing properties, consider using wrapProperty(), wrapMethod() and wrapConstructor(). - * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) - * @param {string} propertyName - * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types + * @template Obj + * @template {keyof Obj} Key + * @param {Obj} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) + * @param {Key} propertyName + * @param {import('./wrapper-utils.js').StrictPropertyDescriptorGeneric} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types */ defineProperty(object, propertyName, descriptor) { // make sure to send a debug flag when the property is used @@ -247,16 +249,16 @@ export default class ContentFeature extends ConfigFeature { if (typeof descriptorProp === 'function') { const addDebugFlag = this.addDebugFlag.bind(this); const wrapper = new Proxy(descriptorProp, { - apply(_, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { addDebugFlag(); - return Reflect.apply(descriptorProp, thisArg, argumentsList); + return target.apply(thisArg, argumentsList); }, }); descriptor[k] = wrapToString(wrapper, descriptorProp); } }); - return defineProperty(object, propertyName, descriptor); + return defineProperty(object, String(propertyName), /** @type {any} */ (descriptor)); } /** diff --git a/injected/src/features/fingerprinting-temporary-storage.js b/injected/src/features/fingerprinting-temporary-storage.js index e8f57d0846..306752a310 100644 --- a/injected/src/features/fingerprinting-temporary-storage.js +++ b/injected/src/features/fingerprinting-temporary-storage.js @@ -26,6 +26,7 @@ export default class FingerprintingTemporaryStorage extends ContentFeature { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f org.call(navigator.webkitTemporaryStorage, modifiedCallback, err); }; + // @ts-expect-error This doesn't exist in the DOM lib this.defineProperty(Navigator.prototype, 'webkitTemporaryStorage', { get: () => tStorage, enumerable: true, diff --git a/injected/src/features/gpc.js b/injected/src/features/gpc.js index 2a75dc9a29..fddab8ede3 100644 --- a/injected/src/features/gpc.js +++ b/injected/src/features/gpc.js @@ -8,6 +8,7 @@ export default class GlobalPrivacyControl extends ContentFeature { if (args.globalPrivacyControlValue) { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (navigator.globalPrivacyControl) return; + // @ts-expect-error This doesn't exist in the DOM lib this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => true, configurable: true, @@ -18,6 +19,7 @@ export default class GlobalPrivacyControl extends ContentFeature { // this may be overwritten by the user agent or other extensions // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (typeof navigator.globalPrivacyControl !== 'undefined') return; + // @ts-expect-error This doesn't exist in the DOM lib this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => false, configurable: true, diff --git a/injected/src/features/navigator-interface.js b/injected/src/features/navigator-interface.js index bc57dfccc7..5c669a6bdc 100644 --- a/injected/src/features/navigator-interface.js +++ b/injected/src/features/navigator-interface.js @@ -22,6 +22,7 @@ export default class NavigatorInterface extends ContentFeature { if (!args.platform || !args.platform.name) { return; } + // @ts-expect-error This doesn't exist in the DOM lib this.defineProperty(Navigator.prototype, 'duckduckgo', { value: { platform: args.platform.name, diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index d3af97306d..6e3f33aa6c 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -1,3 +1,5 @@ +// TypeScript is disabled for this file due to intentional DOM polyfills (e.g., Notification) that are incompatible with the DOM lib types. + import ContentFeature from '../content-feature.js'; // eslint-disable-next-line no-redeclare import { URL } from '../captured-globals.js'; @@ -193,6 +195,7 @@ export class WebCompat extends ContentFeature { } // Expose the API this.defineProperty(window, 'Notification', { + // @ts-expect-error window.Notification polyfill is intentionally incompatible with DOM lib types value: () => { // noop }, @@ -200,8 +203,8 @@ export class WebCompat extends ContentFeature { configurable: true, enumerable: false, }); - - this.defineProperty(window.Notification, 'requestPermission', { + // window.Notification polyfill is intentionally incompatible with DOM lib types + this.defineProperty(/** @type {any} */ (window.Notification), 'requestPermission', { value: () => { return Promise.resolve('denied'); }, @@ -210,13 +213,13 @@ export class WebCompat extends ContentFeature { enumerable: true, }); - this.defineProperty(window.Notification, 'permission', { + this.defineProperty(/** @type {any} */ (window.Notification), 'permission', { get: () => 'denied', configurable: true, enumerable: false, }); - this.defineProperty(window.Notification, 'maxActions', { + this.defineProperty(/** @type {any} */ (window.Notification), 'maxActions', { get: () => 2, configurable: true, enumerable: true, @@ -400,6 +403,7 @@ export class WebCompat extends ContentFeature { }; // TODO: original property is an accessor descriptor this.defineProperty(Navigator.prototype, 'credentials', { + // @ts-expect-error validate this value, configurable: true, enumerable: true, @@ -416,6 +420,7 @@ export class WebCompat extends ContentFeature { if (window.safari) { return; } + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window, 'safari', { value: {}, writable: true, @@ -791,16 +796,37 @@ export class WebCompat extends ContentFeature { /** * Creates a valid MediaDeviceInfo or InputDeviceInfo object that passes instanceof checks * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind - * @returns {MediaDeviceInfo | InputDeviceInfo} + * @returns {MediaDeviceInfo} */ createMediaDeviceInfo(kind) { - // Create a simple object that looks like MediaDeviceInfo - const deviceInfo = { - deviceId: 'default', - kind, - label: '', - groupId: 'default-group', - toJSON() { + const deviceInfo = /** @type {MediaDeviceInfo} */ ({}); + + this.defineProperty(deviceInfo, 'deviceId', { + value: 'default', + writable: false, + configurable: false, + enumerable: true + }); + this.defineProperty(deviceInfo, 'kind', { + value: kind, + writable: false, + configurable: false, + enumerable: true + }); + this.defineProperty(deviceInfo, 'label', { + value: '', + writable: false, + configurable: false, + enumerable: true + }); + this.defineProperty(deviceInfo, 'groupId', { + value: 'default-group', + writable: false, + configurable: false, + enumerable: true + }); + this.defineProperty(deviceInfo, 'toJSON', { + value: function () { return { deviceId: this.deviceId, kind: this.kind, @@ -808,14 +834,9 @@ export class WebCompat extends ContentFeature { groupId: this.groupId, }; }, - }; - - // Make properties read-only to match MediaDeviceInfo behavior - Object.defineProperties(deviceInfo, { - deviceId: { writable: false, configurable: false }, - kind: { writable: false, configurable: false }, - label: { writable: false, configurable: false }, - groupId: { writable: false, configurable: false }, + writable: false, + configurable: false, + enumerable: false }); // Set the prototype based on device type @@ -831,7 +852,7 @@ export class WebCompat extends ContentFeature { Object.setPrototypeOf(deviceInfo, MediaDeviceInfo.prototype); } - return deviceInfo; + return /** @type {MediaDeviceInfo} */ (deviceInfo); } /** diff --git a/injected/src/utils.js b/injected/src/utils.js index 8e2c000cf2..6165fdf715 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -458,16 +458,6 @@ export class DDGProxy { overload() { this.objectScope[this.property] = this.internal; } - - overloadDescriptor() { - // TODO: this is not always correct! Use wrap* or shim* methods instead - this.feature.defineProperty(this.objectScope, this.property, { - value: this.internal, - writable: true, - enumerable: true, - configurable: true, - }); - } } const maxCounter = new Map(); diff --git a/injected/src/wrapper-utils.js b/injected/src/wrapper-utils.js index c543edd687..9557cd5177 100644 --- a/injected/src/wrapper-utils.js +++ b/injected/src/wrapper-utils.js @@ -366,16 +366,37 @@ export function shimProperty(baseObject, propertyName, implInstance, readOnly, d */ /** - * @typedef {Object} BaseStrictPropertyDescriptor + * A generic property descriptor for a property of an object, with correct `this` context for accessors. + * + * @template Obj The object type + * @template {keyof Obj} Key The property key + * @typedef {Object} StrictPropertyDescriptorGeneric * @property {boolean} configurable * @property {boolean} enumerable + * @property {boolean} [writable] + * @property {(function(this: Obj): Obj[Key]) |Obj[Key]} [value] + * @property {(function(this: Obj): Obj[Key])} [get] + * @property {(function(this: Obj, Obj[Key]): void)} [set] */ +/** + * @typedef {Object} BaseStrictPropertyDescriptor + * @property {boolean} configurable + * @property {boolean} enumerable + */ /** * @typedef {BaseStrictPropertyDescriptor & { value: any; writable: boolean }} StrictDataDescriptor + */ +/** * @typedef {BaseStrictPropertyDescriptor & { get: () => any; set: (v: any) => void }} StrictAccessorDescriptor + */ +/** * @typedef {BaseStrictPropertyDescriptor & { get: () => any }} StrictGetDescriptor + */ +/** * @typedef {BaseStrictPropertyDescriptor & { set: (v: any) => void }} StrictSetDescriptor + */ +/** * @typedef {StrictDataDescriptor | StrictAccessorDescriptor | StrictGetDescriptor | StrictSetDescriptor} StrictPropertyDescriptor */ From 8250a5d236b3d8d3a384c3422bee54d9c8628fd5 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 10 Jul 2025 22:24:44 +0100 Subject: [PATCH 14/14] Add test link --- injected/integration-test/test-pages/index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/injected/integration-test/test-pages/index.html b/injected/integration-test/test-pages/index.html index d82db682e0..14adffe636 100644 --- a/injected/integration-test/test-pages/index.html +++ b/injected/integration-test/test-pages/index.html @@ -3,5 +3,8 @@

    Integration page

    This loads the injection file as if it were loaded through the content script.

    +