Skip to content

Commit 56f60b5

Browse files
Merge 42d6241 into d29cf78
2 parents d29cf78 + 42d6241 commit 56f60b5

14 files changed

+242
-33
lines changed

injected/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ The exposed API is a global called contentScopeFeatures and has three methods:
2323
- 'allowlisted' true if the user has disabled protections.
2424
- 'domain' the hostname of the site in the URL bar
2525
- 'enabledFeatures' this is an array of features/ to enable
26+
- urlChanged
27+
- Called when the top frame URL is changed (for Single Page Apps)
28+
- Also ensures that path changes for config 'conditional matching' are applied.
2629
- update
2730
- Calls the update method on all the features
2831

injected/integration-test/pages.spec.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ test.describe('Test integration pages', () => {
2828
}
2929
}
3030

31+
test('Test infra', async ({ page }, testInfo) => {
32+
await testPage(
33+
page,
34+
testInfo,
35+
'/infra/pages/conditional-matching.html',
36+
'./integration-test/test-pages/infra/config/conditional-matching.json',
37+
);
38+
});
39+
40+
test('Test infra fallback', async ({ page }, testInfo) => {
41+
await page.addInitScript(() => {
42+
// This ensures that our fallback code applies and so we simulate other platforms than Chromium.
43+
delete globalThis.navigation;
44+
});
45+
await testPage(
46+
page,
47+
testInfo,
48+
'/infra/pages/conditional-matching.html',
49+
'./integration-test/test-pages/infra/config/conditional-matching.json',
50+
);
51+
});
52+
3153
test('Test manipulating APIs', async ({ page }, testInfo) => {
3254
await testPage(
3355
page,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"features": {
3+
"apiManipulation": {
4+
"state": "enabled",
5+
"settings": {
6+
"apiChanges": {
7+
"Navigator.prototype.hardwareConcurrency": {
8+
"type": "descriptor",
9+
"getterValue": {
10+
"type": "number",
11+
"value": 222
12+
}
13+
}
14+
},
15+
"conditionalChanges": [
16+
{
17+
"condition": {
18+
"urlPattern": "/test/*"
19+
},
20+
"patchSettings": [
21+
{
22+
"op": "replace",
23+
"path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value",
24+
"value": 333
25+
}
26+
]
27+
}
28+
]
29+
}
30+
}
31+
},
32+
"unprotectedTemporary": []
33+
}
34+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width">
6+
<title>API Interventions</title>
7+
<link rel="stylesheet" href="../shared/style.css">
8+
</head>
9+
<body>
10+
<p><a href="../../index.html">[Home]</a></p>
11+
<ul>
12+
<li><a href="./pages/conditional-matching.html">Conditional matching</a> - <a href="./config/conditional-matching.json">Config</a></li>
13+
</ul>
14+
</body>
15+
</html>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width">
6+
<title>Conditional Matching</title>
7+
<link rel="stylesheet" href="../../shared/style.css">
8+
</head>
9+
<body>
10+
<script src="../../shared/utils.js"></script>
11+
<p><a href="../index.html">[Infra]</a></p>
12+
13+
<p>This page verifies that APIs get modified</p>
14+
15+
<script>
16+
test('Conditional matching', async () => {
17+
const results = [
18+
{
19+
name: "APIs changing, expecting to always match",
20+
result: navigator.hardwareConcurrency,
21+
expected: 222
22+
}
23+
];
24+
const oldPathname = window.location.pathname;
25+
const newUrl = new URL(window.location.href);
26+
newUrl.pathname = "/test/test/path";
27+
window.history.pushState(null, '', newUrl.href);
28+
await new Promise(resolve => requestIdleCallback(resolve));
29+
results.push({
30+
name: "Expect URL to be changed",
31+
result: window.location.pathname,
32+
expected: '/test/test/path'
33+
})
34+
results.push({
35+
name: "APIs changing, expecting to match only when the URL is correct",
36+
result: navigator.hardwareConcurrency,
37+
expected: 333
38+
})
39+
const popStatePromise = new Promise(resolve => {
40+
window.addEventListener('popstate', resolve, { once: true });
41+
});
42+
// Call pop state to revert the URL
43+
window.history.back();
44+
await popStatePromise;
45+
results.push({
46+
name: "Expect URL to be reverted",
47+
result: window.location.pathname,
48+
expected: oldPathname
49+
})
50+
results.push({
51+
name: "APIs changing, expecting to match only when the URL is correct",
52+
result: navigator.hardwareConcurrency,
53+
expected: 222
54+
})
55+
56+
return results;
57+
});
58+
59+
60+
// eslint-disable-next-line no-undef
61+
renderResults();
62+
</script>
63+
</body>
64+
</html>

injected/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int",
1717
"test-int-snapshots": "playwright test --grep '@screenshots'",
1818
"test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed",
19-
"test": "npm run lint && npm run test-unit && npm run test-int && npm run playwright",
19+
"test": "npm run test-unit && npm run test-int && npm run playwright",
2020
"serve": "http-server -c-1 --port 3220 integration-test/test-pages",
2121
"playwright": "playwright test --grep-invert '@screenshots'",
2222
"playwright-screenshots": "playwright test --grep '@screenshots'",

injected/src/config-feature.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { immutableJSONPatch } from 'immutable-json-patch';
2-
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
2+
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings, computeLimitedSiteObject } from './utils.js';
33
import { URLPattern } from 'urlpattern-polyfill';
44

55
export default class ConfigFeature {
@@ -29,6 +29,16 @@ export default class ConfigFeature {
2929
}
3030
}
3131

32+
/**
33+
* Call this when the top URL has changed, to recompute the site object.
34+
* This is used to update the path matching for urlPattern.
35+
*/
36+
recomputeSiteObject() {
37+
if (this.#args) {
38+
this.#args.site = computeLimitedSiteObject();
39+
}
40+
}
41+
3242
get args() {
3343
return this.#args;
3444
}

injected/src/content-feature.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export default class ContentFeature extends ConfigFeature {
3030
#messaging;
3131
/** @type {boolean} */
3232
#isDebugFlagSet = false;
33+
/**
34+
* Set this to true if you wish to listen to top level URL changes for config matching.
35+
* @type {boolean}
36+
*/
37+
listenForUrlChanges = false;
3338

3439
/** @type {ImportMeta} */
3540
#importConfig;

injected/src/content-scope-features.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { initStringExemptionLists, isFeatureBroken, isGloballyDisabled, platform
22
import { platformSupport } from './features';
33
import { PerformanceMonitor } from './performance';
44
import platformFeatures from 'ddg:platformFeatures';
5+
import { registerForURLChanges } from './url-change';
56

67
let initArgs = null;
78
const updates = [];
@@ -74,6 +75,16 @@ export async function init(args) {
7475
resolvedFeatures.forEach(({ featureInstance, featureName }) => {
7576
if (!isFeatureBroken(args, featureName) || alwaysInitExtensionFeatures(args, featureName)) {
7677
featureInstance.callInit(args);
78+
// Either listenForUrlChanges or urlChanged ensures the feature listens.
79+
if (featureInstance.listenForUrlChanges || featureInstance.urlChanged) {
80+
registerForURLChanges(() => {
81+
// The rationale for the two separate call here is to ensure that
82+
// extensions to the class don't need to call super.urlChanged()
83+
featureInstance.recomputeSiteObject();
84+
// Called if the feature instance has a urlChanged method
85+
featureInstance?.urlChanged();
86+
});
87+
}
7788
}
7889
});
7990
// Fire off updates that came in faster than the init

injected/src/features/api-manipulation.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { processAttr } from '../utils';
1313
* @internal
1414
*/
1515
export default class ApiManipulation extends ContentFeature {
16+
listenForUrlChanges = true;
17+
1618
init() {
1719
const apiChanges = this.getFeatureSetting('apiChanges');
1820
if (apiChanges) {
@@ -26,6 +28,10 @@ export default class ApiManipulation extends ContentFeature {
2628
}
2729
}
2830

31+
urlChanged() {
32+
this.init();
33+
}
34+
2935
/**
3036
* Checks if the config API change is valid.
3137
* @param {any} change

injected/src/features/autofill-password-import.js

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ContentFeature from '../content-feature';
2-
import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils';
2+
import { isBeingFramed, withExponentialBackoff } from '../utils';
33

44
export const ANIMATION_DURATION_MS = 1000;
55
export const ANIMATION_ITERATIONS = Infinity;
@@ -490,23 +490,17 @@ export default class AutofillPasswordImport extends ContentFeature {
490490
this.#settingsButtonSettings = this.getFeatureSetting('settingsButton');
491491
}
492492

493+
urlChanged() {
494+
this.handlePath(window.location.pathname);
495+
}
496+
493497
init() {
498+
if (isBeingFramed()) {
499+
return;
500+
}
494501
this.setButtonSettings();
495502

496503
const handlePath = this.handlePath.bind(this);
497-
const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', {
498-
async apply(target, thisArg, args) {
499-
const path = args[1] === '' ? args[2].split('?')[0] : args[1];
500-
await handlePath(path);
501-
return DDGReflect.apply(target, thisArg, args);
502-
},
503-
});
504-
historyMethodProxy.overload();
505-
// listen for popstate events in order to run on back/forward navigations
506-
window.addEventListener('popstate', async () => {
507-
const path = window.location.pathname;
508-
await handlePath(path);
509-
});
510504

511505
this.#domLoaded = new Promise((resolve) => {
512506
if (document.readyState !== 'loading') {

injected/src/features/element-hiding.js

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ContentFeature from '../content-feature';
2-
import { isBeingFramed, DDGProxy, DDGReflect, injectGlobalStyles } from '../utils';
2+
import { isBeingFramed, injectGlobalStyles } from '../utils';
33

44
let adLabelStrings = [];
55
const parser = new DOMParser();
@@ -360,19 +360,13 @@ export default class ElementHiding extends ContentFeature {
360360
} else {
361361
applyRules(activeRules);
362362
}
363-
// single page applications don't have a DOMContentLoaded event on navigations, so
364-
// we use proxy/reflect on history.pushState to call applyRules on page navigations
365-
const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', {
366-
apply(target, thisArg, args) {
367-
applyRules(activeRules);
368-
return DDGReflect.apply(target, thisArg, args);
369-
},
370-
});
371-
historyMethodProxy.overload();
372-
// listen for popstate events in order to run on back/forward navigations
373-
window.addEventListener('popstate', () => {
374-
applyRules(activeRules);
375-
});
363+
this.activeRules = activeRules;
364+
}
365+
366+
urlChanged() {
367+
if (this.activeRules) {
368+
this.applyRules(this.activeRules);
369+
}
376370
}
377371

378372
/**

injected/src/url-change.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { DDGProxy, DDGReflect, isBeingFramed } from './utils.js';
2+
import ContentFeature from './content-feature.js';
3+
4+
const urlChangeListeners = new Set();
5+
/**
6+
* Register a listener to be called when the URL changes.
7+
* @param {function} listener
8+
*/
9+
export function registerForURLChanges(listener) {
10+
if (urlChangeListeners.size === 0) {
11+
listenForURLChanges();
12+
}
13+
urlChangeListeners.add(listener);
14+
}
15+
16+
function handleURLChange() {
17+
for (const listener of urlChangeListeners) {
18+
listener();
19+
}
20+
}
21+
22+
function listenForURLChanges() {
23+
const urlChangedInstance = new ContentFeature('urlChanged', {}, {});
24+
if ('navigation' in globalThis && 'addEventListener' in globalThis.navigation) {
25+
// if the browser supports the navigation API, we can use that to listen for URL changes
26+
// Listening to navigatesuccess instead of navigate to ensure the navigation is committed.
27+
globalThis.navigation.addEventListener('navigatesuccess', () => {
28+
handleURLChange();
29+
});
30+
// Exit early if the navigation API is supported
31+
return;
32+
}
33+
if (isBeingFramed()) {
34+
// don't run if we're in an iframe
35+
return;
36+
}
37+
// single page applications don't have a DOMContentLoaded event on navigations, so
38+
// we use proxy/reflect on history.pushState to call applyRules on page navigations
39+
const historyMethodProxy = new DDGProxy(urlChangedInstance, History.prototype, 'pushState', {
40+
apply(target, thisArg, args) {
41+
const changeResult = DDGReflect.apply(target, thisArg, args);
42+
handleURLChange();
43+
return changeResult;
44+
},
45+
});
46+
historyMethodProxy.overload();
47+
// listen for popstate events in order to run on back/forward navigations
48+
window.addEventListener('popstate', () => {
49+
handleURLChange();
50+
});
51+
}

injected/src/utils.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -546,10 +546,10 @@ export function isUnprotectedDomain(topLevelHostname, featureList) {
546546
* Used to inialize extension code in the load phase
547547
*/
548548
export function computeLimitedSiteObject() {
549-
const topLevelHostname = getTabHostname();
549+
const tabURL = getTabUrl();
550550
return {
551-
domain: topLevelHostname,
552-
url: getTabUrl()?.href || null,
551+
domain: tabURL?.hostname || null,
552+
url: tabURL?.href || null,
553553
};
554554
}
555555

0 commit comments

Comments
 (0)