diff --git a/injected/README.md b/injected/README.md index 1f04842ed7..204346464d 100644 --- a/injected/README.md +++ b/injected/README.md @@ -23,6 +23,9 @@ The exposed API is a global called contentScopeFeatures and has three methods: - 'allowlisted' true if the user has disabled protections. - 'domain' the hostname of the site in the URL bar - 'enabledFeatures' this is an array of features/ to enable +- urlChanged + - Called when the top frame URL is changed (for Single Page Apps) + - Also ensures that path changes for config 'conditional matching' are applied. - update - Calls the update method on all the features diff --git a/injected/integration-test/pages.spec.js b/injected/integration-test/pages.spec.js index 982e5f7d4c..89d5828b2f 100644 --- a/injected/integration-test/pages.spec.js +++ b/injected/integration-test/pages.spec.js @@ -28,6 +28,28 @@ test.describe('Test integration pages', () => { } } + test('Test infra', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching.html', + './integration-test/test-pages/infra/config/conditional-matching.json', + ); + }); + + test('Test infra fallback', async ({ page }, testInfo) => { + await page.addInitScript(() => { + // This ensures that our fallback code applies and so we simulate other platforms than Chromium. + delete globalThis.navigation; + }); + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching.html', + './integration-test/test-pages/infra/config/conditional-matching.json', + ); + }); + test('Test manipulating APIs', async ({ page }, testInfo) => { await testPage( page, diff --git a/injected/integration-test/test-pages/infra/config/conditional-matching.json b/injected/integration-test/test-pages/infra/config/conditional-matching.json new file mode 100644 index 0000000000..d8f0c89b85 --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/conditional-matching.json @@ -0,0 +1,34 @@ +{ + "features": { + "apiManipulation": { + "state": "enabled", + "settings": { + "apiChanges": { + "Navigator.prototype.hardwareConcurrency": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 222 + } + } + }, + "conditionalChanges": [ + { + "condition": { + "urlPattern": "/test/*" + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value", + "value": 333 + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] + } + \ No newline at end of file diff --git a/injected/integration-test/test-pages/infra/index.html b/injected/integration-test/test-pages/infra/index.html new file mode 100644 index 0000000000..ff4a29bc5a --- /dev/null +++ b/injected/integration-test/test-pages/infra/index.html @@ -0,0 +1,15 @@ + + +
+ + +This page verifies that APIs get modified
+ + + + diff --git a/injected/package.json b/injected/package.json index 3ca4ccd8cd..6d19d31ced 100644 --- a/injected/package.json +++ b/injected/package.json @@ -16,7 +16,7 @@ "test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int", "test-int-snapshots": "playwright test --grep '@screenshots'", "test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed", - "test": "npm run lint && npm run test-unit && npm run test-int && npm run playwright", + "test": "npm run test-unit && npm run test-int && npm run playwright", "serve": "http-server -c-1 --port 3220 integration-test/test-pages", "playwright": "playwright test --grep-invert '@screenshots'", "playwright-screenshots": "playwright test --grep '@screenshots'", diff --git a/injected/src/config-feature.js b/injected/src/config-feature.js index 8021dd8351..c1fe935492 100644 --- a/injected/src/config-feature.js +++ b/injected/src/config-feature.js @@ -1,5 +1,5 @@ import { immutableJSONPatch } from 'immutable-json-patch'; -import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js'; +import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings, computeLimitedSiteObject } from './utils.js'; import { URLPattern } from 'urlpattern-polyfill'; export default class ConfigFeature { @@ -29,6 +29,16 @@ export default class ConfigFeature { } } + /** + * Call this when the top URL has changed, to recompute the site object. + * This is used to update the path matching for urlPattern. + */ + recomputeSiteObject() { + if (this.#args) { + this.#args.site = computeLimitedSiteObject(); + } + } + get args() { return this.#args; } diff --git a/injected/src/content-feature.js b/injected/src/content-feature.js index 5912cfa508..4745c4bcf8 100644 --- a/injected/src/content-feature.js +++ b/injected/src/content-feature.js @@ -30,6 +30,11 @@ export default class ContentFeature extends ConfigFeature { #messaging; /** @type {boolean} */ #isDebugFlagSet = false; + /** + * Set this to true if you wish to listen to top level URL changes for config matching. + * @type {boolean} + */ + listenForUrlChanges = false; /** @type {ImportMeta} */ #importConfig; diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 473e26adbb..ff4a561721 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -2,6 +2,7 @@ import { initStringExemptionLists, isFeatureBroken, isGloballyDisabled, platform import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; +import { registerForURLChanges } from './url-change'; let initArgs = null; const updates = []; @@ -74,6 +75,16 @@ export async function init(args) { resolvedFeatures.forEach(({ featureInstance, featureName }) => { if (!isFeatureBroken(args, featureName) || alwaysInitExtensionFeatures(args, featureName)) { featureInstance.callInit(args); + // Either listenForUrlChanges or urlChanged ensures the feature listens. + if (featureInstance.listenForUrlChanges || featureInstance.urlChanged) { + registerForURLChanges(() => { + // The rationale for the two separate call here is to ensure that + // extensions to the class don't need to call super.urlChanged() + featureInstance.recomputeSiteObject(); + // Called if the feature instance has a urlChanged method + featureInstance?.urlChanged(); + }); + } } }); // Fire off updates that came in faster than the init diff --git a/injected/src/features/api-manipulation.js b/injected/src/features/api-manipulation.js index 859c3a2186..824268cd5d 100644 --- a/injected/src/features/api-manipulation.js +++ b/injected/src/features/api-manipulation.js @@ -13,6 +13,8 @@ import { processAttr } from '../utils'; * @internal */ export default class ApiManipulation extends ContentFeature { + listenForUrlChanges = true; + init() { const apiChanges = this.getFeatureSetting('apiChanges'); if (apiChanges) { @@ -26,6 +28,10 @@ export default class ApiManipulation extends ContentFeature { } } + urlChanged() { + this.init(); + } + /** * Checks if the config API change is valid. * @param {any} change diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js index 8e8fc159c5..4d89d3305b 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-password-import.js @@ -1,5 +1,5 @@ import ContentFeature from '../content-feature'; -import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils'; +import { isBeingFramed, withExponentialBackoff } from '../utils'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -490,23 +490,17 @@ export default class AutofillPasswordImport extends ContentFeature { this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); } + urlChanged() { + this.handlePath(window.location.pathname); + } + init() { + if (isBeingFramed()) { + return; + } this.setButtonSettings(); const handlePath = this.handlePath.bind(this); - const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - async apply(target, thisArg, args) { - const path = args[1] === '' ? args[2].split('?')[0] : args[1]; - await handlePath(path); - return DDGReflect.apply(target, thisArg, args); - }, - }); - historyMethodProxy.overload(); - // listen for popstate events in order to run on back/forward navigations - window.addEventListener('popstate', async () => { - const path = window.location.pathname; - await handlePath(path); - }); this.#domLoaded = new Promise((resolve) => { if (document.readyState !== 'loading') { diff --git a/injected/src/features/element-hiding.js b/injected/src/features/element-hiding.js index 75d3afaabb..5c4aaf0cb4 100644 --- a/injected/src/features/element-hiding.js +++ b/injected/src/features/element-hiding.js @@ -1,5 +1,5 @@ import ContentFeature from '../content-feature'; -import { isBeingFramed, DDGProxy, DDGReflect, injectGlobalStyles } from '../utils'; +import { isBeingFramed, injectGlobalStyles } from '../utils'; let adLabelStrings = []; const parser = new DOMParser(); @@ -360,19 +360,13 @@ export default class ElementHiding extends ContentFeature { } else { applyRules(activeRules); } - // single page applications don't have a DOMContentLoaded event on navigations, so - // we use proxy/reflect on history.pushState to call applyRules on page navigations - const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - apply(target, thisArg, args) { - applyRules(activeRules); - return DDGReflect.apply(target, thisArg, args); - }, - }); - historyMethodProxy.overload(); - // listen for popstate events in order to run on back/forward navigations - window.addEventListener('popstate', () => { - applyRules(activeRules); - }); + this.activeRules = activeRules; + } + + urlChanged() { + if (this.activeRules) { + this.applyRules(this.activeRules); + } } /** diff --git a/injected/src/url-change.js b/injected/src/url-change.js new file mode 100644 index 0000000000..86cccb0eef --- /dev/null +++ b/injected/src/url-change.js @@ -0,0 +1,51 @@ +import { DDGProxy, DDGReflect, isBeingFramed } from './utils.js'; +import ContentFeature from './content-feature.js'; + +const urlChangeListeners = new Set(); +/** + * Register a listener to be called when the URL changes. + * @param {function} listener + */ +export function registerForURLChanges(listener) { + if (urlChangeListeners.size === 0) { + listenForURLChanges(); + } + urlChangeListeners.add(listener); +} + +function handleURLChange() { + for (const listener of urlChangeListeners) { + listener(); + } +} + +function listenForURLChanges() { + const urlChangedInstance = new ContentFeature('urlChanged', {}, {}); + if ('navigation' in globalThis && 'addEventListener' in globalThis.navigation) { + // if the browser supports the navigation API, we can use that to listen for URL changes + // Listening to navigatesuccess instead of navigate to ensure the navigation is committed. + globalThis.navigation.addEventListener('navigatesuccess', () => { + handleURLChange(); + }); + // Exit early if the navigation API is supported + return; + } + if (isBeingFramed()) { + // don't run if we're in an iframe + return; + } + // single page applications don't have a DOMContentLoaded event on navigations, so + // we use proxy/reflect on history.pushState to call applyRules on page navigations + const historyMethodProxy = new DDGProxy(urlChangedInstance, History.prototype, 'pushState', { + apply(target, thisArg, args) { + const changeResult = DDGReflect.apply(target, thisArg, args); + handleURLChange(); + return changeResult; + }, + }); + historyMethodProxy.overload(); + // listen for popstate events in order to run on back/forward navigations + window.addEventListener('popstate', () => { + handleURLChange(); + }); +} diff --git a/injected/src/utils.js b/injected/src/utils.js index ae71e2a9b4..dea8bef98b 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -546,10 +546,10 @@ export function isUnprotectedDomain(topLevelHostname, featureList) { * Used to inialize extension code in the load phase */ export function computeLimitedSiteObject() { - const topLevelHostname = getTabHostname(); + const tabURL = getTabUrl(); return { - domain: topLevelHostname, - url: getTabUrl()?.href || null, + domain: tabURL?.hostname || null, + url: tabURL?.href || null, }; }