Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APIs for shimming standard APIs #956

Merged
merged 36 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
23872cd
Make defineProperty() strict to prevent descriptor mismatch
muodov Apr 22, 2024
2425160
First version of interface shimming API
muodov Apr 22, 2024
6e21078
first version of shimProperty()
muodov Apr 22, 2024
9a77d77
Fix toString wrapping in defineProperty() and handle toString.toString()
muodov Apr 23, 2024
ca3e1ed
Set up browser-based unit tests
muodov Apr 23, 2024
2bc30ae
Fix toString wrapper to work with already wrapped objects
muodov Apr 23, 2024
20d07b6
handle toString on shim class objects
muodov Apr 23, 2024
5eeb8c0
Handle toString on shim interface methods
muodov Apr 23, 2024
e19f7ae
Add tests for toString() wrapping
muodov Apr 23, 2024
eca5f5a
Mark shim classes
muodov Apr 24, 2024
fcf9b3b
mock the name property on shim classes
muodov Apr 24, 2024
95c22a7
API for shimming standard properties
muodov Apr 24, 2024
76a8510
Rename test file
muodov Apr 24, 2024
900f887
Minor
muodov Apr 24, 2024
5678d5e
Update src/content-feature.js
muodov Apr 25, 2024
bef5a56
Make toString wrapper less confusing
muodov Apr 25, 2024
2d938ad
Fix existing incomplete property descriptors
muodov Apr 25, 2024
102e396
Tiny lint fix
muodov Apr 25, 2024
c2ba146
Remove unused wrapConstructor
muodov Apr 25, 2024
923989b
Move shim implementation to wrapper-utils and convert types to jsdoc
muodov Apr 25, 2024
3b5214a
Split defineProperty wrapper from debug flag
muodov Apr 25, 2024
f12eff3
Lint fix in DDGProxy
muodov Apr 25, 2024
3ad47c3
Lint fix
muodov Apr 26, 2024
831294f
Fix file size tests
muodov Apr 26, 2024
693ff68
Add tet utils for webcompat shims
muodov Apr 28, 2024
4345985
Use shim API for the Presentation fix
muodov Apr 28, 2024
a3682f9
Fix descriptor properties for Notification
muodov Apr 29, 2024
34a020a
Merge branch 'main' into max/shim-apis
muodov Apr 29, 2024
feb7461
minor
muodov Apr 30, 2024
065ab7b
Convert shim API tests from WTR to Jasmine
muodov Apr 30, 2024
a61cb2b
Move out test pages logic
muodov May 1, 2024
9e4e014
Convert shim correctness tests to puppeteer
muodov May 1, 2024
2d4543b
Add some readme docs for the shim APIs
muodov May 14, 2024
a9ca257
Merge branch 'main' into max/shim-apis
muodov May 16, 2024
4cc8705
Apply shimMark only in test mode
muodov May 16, 2024
e3a5c5f
Fix unit test
muodov May 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4,687 changes: 4,441 additions & 246 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"lint-no-output-globals": "eslint --no-eslintrc --config=build-output.eslintrc --no-ignore Sources/ContentScopeScripts/dist/contentScope.js",
"lint-fix": "eslint . --fix && npm run tsc",
"generate-snapshots": "node scripts/generateOverloadSnapshots.js",
"test-wtr": "web-test-runner wtr-test/**/*.test.js --node-resolve",
"test-unit": "jasmine --config=unit-test/config.json",
"test-int": "npm run build-integration && jasmine --config=integration-test/config.js",
"test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int",
Expand All @@ -54,22 +55,25 @@
],
"dependencies": {
"immutable-json-patch": "^5.1.3",
"parse-address": "^1.1.2",
"seedrandom": "^3.0.5",
"sjcl": "^1.0.8",
"parse-address": "^1.1.2"
muodov marked this conversation as resolved.
Show resolved Hide resolved
"sjcl": "^1.0.8"
},
"devDependencies": {
"@canvas/image-data": "^1.0.0",
"@fingerprintjs/fingerprintjs": "^4.1.0",
"@playwright/test": "^1.38.1",
"@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.2",
"@types/chrome": "^0.0.248",
"@types/jasmine": "^4.3.1",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@web/test-runner": "^0.18.1",
"chai": "^5.1.0",
"config-builder": "github:duckduckgo/privacy-configuration#main",
"eslint": "^8.52.0",
"esbuild": "^0.19.5",
"eslint": "^8.52.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-node": "^11.1.0",
Expand All @@ -79,7 +83,6 @@
"json-schema-to-typescript": "^13.1.2",
"minimist": "^1.2.8",
"puppeteer": "^21.4.1",
"@playwright/test": "^1.38.1",
"rollup": "^3.29.4",
"rollup-plugin-import-css": "^3.3.5",
"rollup-plugin-svg-import": "^2.1.0",
Expand Down
7 changes: 7 additions & 0 deletions src/captured-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,12 @@ export const Reflect = globalThis.Reflect
export const customElementsGet = globalThis.customElements?.get.bind(globalThis.customElements)
export const customElementsDefine = globalThis.customElements?.define.bind(globalThis.customElements)
export const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor
export const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors
export const objectKeys = Object.keys
export const objectEntries = Object.entries
export const objectDefineProperty = Object.defineProperty
export const URL = globalThis.URL
export const Proxy = globalThis.Proxy
export const functionToString = Function.prototype.toString
export const TypeError = globalThis.TypeError
export const Symbol = globalThis.Symbol
231 changes: 214 additions & 17 deletions src/content-feature.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* global cloneInto, exportFunction */

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Cannot find name 'StrictPropertyDescriptor'. Did you mean 'PropertyDescriptor'?

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Cannot find name 'DefineInterfaceOptions'.

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Cannot find name 'DefineInterfaceOptions'.

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Cannot find name 'StrictDataDescriptor'.

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Unused '@ts-expect-error' directive.

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Cannot find name 'StrictPropertyDescriptor'. Did you mean 'PropertyDescriptor'?

Check failure on line 1 in src/content-feature.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'.

import { camelcase, matchHostname, processAttr, computeEnabledFeatures, parseFeatureSettings } from './utils.js'
import { immutableJSONPatch } from 'immutable-json-patch'
import { PerformanceMonitor } from './performance.js'
import { hasMozProxies, wrapToString } from './wrapper-utils.js'
import { getOwnPropertyDescriptor, objectKeys } from './captured-globals.js'
import { hasMozProxies, toStringProxyMixin, wrapToString } from './wrapper-utils.js'
import { getOwnPropertyDescriptor, getOwnPropertyDescriptors, objectDefineProperty, objectEntries, objectKeys, Proxy, Reflect, Symbol, TypeError } from './captured-globals.js'
import { Messaging, MessagingContext } from '../packages/messaging/index.js'
import { extensionConstructMessagingConfig } from './sendmessage-transport.js'

Expand All @@ -23,6 +23,8 @@
*/

const globalObj = typeof window === 'undefined' ? globalThis : window
// special proeprty that is set on classes used to shim standard interfaces
muodov marked this conversation as resolved.
Show resolved Hide resolved
export const ddgShimMark = Symbol('ddgShimMark')

export default class ContentFeature {
/** @type {import('./utils.js').RemoteConfig | undefined} */
Expand Down Expand Up @@ -317,9 +319,9 @@

/**
* Define a property descriptor. 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)
* @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype)
* @param {string} propertyName
* @param {PropertyDescriptor} descriptor
* @param {StrictPropertyDescriptor} 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
Expand All @@ -328,10 +330,13 @@
const descriptorProp = descriptor[k]
if (typeof descriptorProp === 'function') {
const addDebugFlag = this.addDebugFlag.bind(this)
descriptor[k] = function () {
addDebugFlag()
return Reflect.apply(descriptorProp, this, arguments)
}
const wrapper = new Proxy(descriptorProp, {
apply (target, thisArg, argumentsList) {
addDebugFlag()
return Reflect.apply(descriptorProp, thisArg, argumentsList)
}
})
descriptor[k] = wrapToString(wrapper, descriptorProp)
}
})

Expand All @@ -354,13 +359,13 @@
})
UsedObjectInterface.defineProperty(usedObj, propertyName, definedDescriptor)
} else {
Object.defineProperty(object, propertyName, descriptor)
objectDefineProperty(object, propertyName, descriptor)
}
}

/**
* Wrap a `get`/`set` or `value` property descriptor. Only for data properties. For methods, use wrapMethod(). For constructors, use wrapConstructor().
* @param {any} object - object whose property we are wrapping (most commonly a prototype)
* @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Screen.prototype)
* @param {string} propertyName
* @param {Partial<PropertyDescriptor>} descriptor
* @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found
Expand All @@ -383,10 +388,6 @@
('get' in origDescriptor && 'get' in descriptor) ||
('set' in origDescriptor && 'set' in descriptor)
) {
wrapToString(descriptor.value, origDescriptor.value)
wrapToString(descriptor.get, origDescriptor.get)
wrapToString(descriptor.set, origDescriptor.set)

this.defineProperty(object, propertyName, {
...origDescriptor,
...descriptor
Expand All @@ -400,7 +401,7 @@

/**
* Wrap a method descriptor. Only for function properties. For data properties, use wrapProperty(). For constructors, use wrapConstructor().
* @param {any} object - object whose property we are wrapping (most commonly a prototype)
* @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Bluetooth.prototype)
* @param {string} propertyName
* @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument
* @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found
Expand All @@ -424,15 +425,211 @@
throw new Error(`Property ${propertyName} does not look like a method`)
}

const newFn = function () {
const newFn = wrapToString(function () {
return wrapperFn.call(this, origFn, ...arguments)
}, origFn)

this.defineProperty(object, propertyName, {
...origDescriptor,
value: newFn
})
return origDescriptor
}

/**
* Wrap a constructor function descriptor. Only for constructor functions. For data properties, use wrapProperty(). For methods, use wrapMethod().
* @param {any} object - object whose property we are wrapping (most commonly the constructor function, e.g. globalThis.Audio)
* @param {string} propertyName
* @param {(originalConstructor, ...args) => any } wrapperFn - wrapper function receives the original constructor as the first argument
* @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found
*/
wrapConstructor (object, propertyName, wrapperFn) {
if (!object) {
return
}
const origDescriptor = getOwnPropertyDescriptor(object, propertyName)
if (!origDescriptor) {
// this happens if the property is not implemented in the browser
return
}

const origConstructor = origDescriptor.value
if (!origConstructor || typeof origConstructor !== 'function') {
// method properties are expected to be defined with a `value`
throw new Error(`Property ${propertyName} is not a function`)
}

/**
* @type ProxyHandler<Function>
*/
const handler = {
construct (target, argArray) {
return wrapperFn(origConstructor, ...argArray)
}
}
wrapToString(newFn, origFn)

const newFn = new Proxy(origConstructor, handler)

this.defineProperty(object, propertyName, {
...origDescriptor,
value: newFn
})

if (origConstructor.prototype?.constructor === origConstructor) {
// .prototype may be absent, e.g. in Proxy
// .prototype.constructor may be different, e.g. in Audio

const descriptor = getOwnPropertyDescriptor(origConstructor.prototype, 'constructor')
this.defineProperty(origConstructor.prototype, 'constructor', {
...descriptor,
value: newFn
})
}

return origDescriptor
}

/**
* @template {keyof typeof globalThis} StandardInterfaceName
* @param {StandardInterfaceName} interfaceName - the name of the interface to shim (must be some known standard API, e.g. 'MediaSession')
* @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation
* @param {Partial<DefineInterfaceOptions>} [options] - options for defining the interface
*/
shimInterface (
interfaceName,
ImplClass,
options
) {
// TODO: validate that it does not exist already?

/** @type {DefineInterfaceOptions} */
const defaultOptions = {
allowConstructorCall: false,
disallowConstructor: false,
constructorErrorMessage: 'Illegal constructor',
interfaceDescriptorOptions: { writable: true, enumerable: false, configurable: true, value: ImplClass },
wrapToString: true
}

const fullOptions = { ...defaultOptions, ...options }

// In some cases we can get away without a full proxy, but in many cases below we need it.
// For example, we can't redefine `prototype` property on ES6 classes.
// Se we just always wrap the class to make the code more maintaibnable

/** @type {ProxyHandler<Function>} */
const proxyHandler = {}

// handle the case where the constructor is called without new
if (fullOptions.allowConstructorCall) {
// make the constructor function callable without new
proxyHandler.apply = function (target, thisArg, argumentsList) {
return Reflect.construct(target, argumentsList, target)
}
}

// make the constructor function throw when called without new
if (fullOptions.disallowConstructor) {
proxyHandler.construct = function () {
throw new TypeError(fullOptions.constructorErrorMessage)
}
}

if (fullOptions.wrapToString) {
// mask toString() on class methods. `ImplClass.prototype` is non-configurable: we can't override or proxy it, so we have to wrap each method individually
for (const [prop, descriptor] of objectEntries(getOwnPropertyDescriptors(ImplClass.prototype))) {
if (prop !== 'constructor' && descriptor.writable && typeof descriptor.value === 'function') {
ImplClass.prototype[prop] = new Proxy(descriptor.value, toStringProxyMixin(descriptor.value, `function ${prop}() { [native code] }`))
}
}

// wrap toString on the constructor function itself
Object.assign(proxyHandler, toStringProxyMixin(ImplClass, `function ${interfaceName}() { [native code] }`))
muodov marked this conversation as resolved.
Show resolved Hide resolved
}

// Note that instanceof should still work, since the `.prototype` object is proxied too:
// Interface() instanceof Interface === true
// ImplClass() instanceof Interface === true
const Interface = new Proxy(ImplClass, proxyHandler)

// Make sure that Interface().constructor === Interface (not ImplClass)
if (ImplClass.prototype?.constructor === ImplClass) {
/** @type {StrictDataDescriptor} */
// @ts-expect-error - As long as ImplClass is a normal class, it should have the prototype property
const descriptor = getOwnPropertyDescriptor(ImplClass.prototype, 'constructor')
if (descriptor.writable) {
ImplClass.prototype.constructor = Interface
}
}

// mark the class as a shimmed class
objectDefineProperty(ImplClass, ddgShimMark, {
muodov marked this conversation as resolved.
Show resolved Hide resolved
value: true,
configurable: false,
enumerable: false,
writable: false
})

// mock the name property
objectDefineProperty(ImplClass, 'name', {
value: interfaceName,
configurable: true,
enumerable: false,
writable: false
})

// interfaces are exposed directly on the global object, not on its prototype
this.defineProperty(
globalThis,
interfaceName,
{ ...fullOptions.interfaceDescriptorOptions, value: Interface }
)
}

/**
* Define a missing standard property on a global (prototype) object. Only for data properties.
* For constructors, use shimInterface().
* Most of the time, you'd want to call shimInterface() first to shim the class itself (MediaSession), and then shimProperty() for the global singleton instance (Navigator.prototype.mediaSession).
* @template Base
* @template {keyof Base} K
* @param {Base} baseObject - object whose property we are shimming (most commonly a prototype object, e.g. Navigator.prototype)
* @param {K} propertyName - name of the property to shim (e.g. 'mediaSession')
* @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession())
* @param {boolean} [readOnly] - whether the property should be read-only (default: false)
*/
shimProperty (baseObject, propertyName, implInstance, readOnly = false) {
// @ts-expect-error - implInstance is a class instance
const ImplClass = implInstance.constructor
if (ImplClass[ddgShimMark] !== true) {
throw new TypeError('implInstance must be an instance of a shimmed class')
}

// mask toString() and toString.toString() on the instance
const proxiedInstance = new Proxy(implInstance, toStringProxyMixin(implInstance, `[object ${ImplClass.name}]`))

/** @type {StrictPropertyDescriptor} */
let descriptor

// Note that we only cover most common cases: a getter for "readonly" properties, and a value descriptor for writable properties.
// But there could be other cases, e.g. a property with both a getter and a setter. These could be defined with a raw defineProperty() call.
// Important: make sure to cover each new shim with a test that verifies that all descriptors match the standard API.
if (readOnly) {
const getter = function get () { return proxiedInstance }
const proxiedGetter = new Proxy(getter, toStringProxyMixin(getter, `function get ${propertyName}() { [native code] }`))
descriptor = {
configurable: true,
enumerable: true,
get: proxiedGetter
}
} else {
descriptor = {
configurable: true,
enumerable: true,
writable: true,
value: proxiedInstance
}
}

objectDefineProperty(baseObject, propertyName, descriptor)
}
}