Skip to content

Commit d8efb0c

Browse files
committed
add favicon feature
1 parent cd85139 commit d8efb0c

File tree

15 files changed

+288
-5
lines changed

15 files changed

+288
-5
lines changed

injected/docs/favicon.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
title: Favicon Monitor
3+
---
4+
5+
# Favicon Monitor
6+
7+
Reports the presence of favicons on page-load, and optionally when they change.
8+
9+
## Notifications
10+
11+
### `faviconFound`
12+
- {@link "Favicon Messages".FaviconFoundNotification}
13+
- Sent on page load, sends {@link "Favicon Messages".FaviconFound}
14+
15+
**Example**
16+
17+
```json
18+
{
19+
"favicons": [
20+
{
21+
"href": "favicon.png",
22+
"rel": "stylesheet"
23+
}
24+
],
25+
"documentUrl": "https://example.com"
26+
}
27+
```
28+
29+
## Remote Config
30+
31+
## Enabled (default)
32+
{@includeCode ../integration-test/test-pages/favicon/config/favicon-enabled.json}
33+
34+
### Disable the monitor only.
35+
36+
To only receive the initial payload and nothing more (to mimic the old behavior),
37+
you can set `monitor: false` in the remote config, and it will not install the Mutation Observer.
38+
39+
{@includeCode ../integration-test/test-pages/favicon/config/favicon-monitor-disabled.json}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ResultsCollector } from './page-objects/results-collector.js';
3+
4+
const HTML = '/favicon/index.html';
5+
const CONFIG = './integration-test/test-pages/favicon/config/favicon-enabled.json';
6+
7+
test('favicon feature absent', async ({ page }, testInfo) => {
8+
const CONFIG = './integration-test/test-pages/favicon/config/favicon-absent.json';
9+
const favicon = ResultsCollector.create(page, testInfo.project.use);
10+
await favicon.load(HTML, CONFIG);
11+
12+
// ensure first favicon item was sent
13+
const messages = await favicon.waitForMessage('faviconFound', 1);
14+
15+
expect(messages[0].payload.params).toStrictEqual({
16+
favicons: [{ href: './favicon.png', rel: 'shortcut icon' }],
17+
documentUrl: 'http://localhost:3220/favicon/index.html',
18+
});
19+
});
20+
21+
test('favicon + monitor', async ({ page }, testInfo) => {
22+
const favicon = ResultsCollector.create(page, testInfo.project.use);
23+
await favicon.load(HTML, CONFIG);
24+
25+
// ensure first favicon item was sent
26+
await favicon.waitForMessage('faviconFound', 1);
27+
28+
// now update it
29+
await page.getByRole('button', { name: 'Set override' }).click();
30+
31+
// wait for the second message
32+
const messages = await favicon.waitForMessage('faviconFound', 2);
33+
34+
expect(messages[0].payload.params).toStrictEqual({
35+
favicons: [{ href: './favicon.png', rel: 'shortcut icon' }],
36+
documentUrl: 'http://localhost:3220/favicon/index.html',
37+
});
38+
39+
expect(messages[1].payload.params).toStrictEqual({
40+
favicons: [{ href: './new_favicon.png', rel: 'shortcut icon' }],
41+
documentUrl: 'http://localhost:3220/favicon/index.html',
42+
});
43+
});
44+
45+
test('favicon + monitor disabled', async ({ page }, testInfo) => {
46+
const CONFIG = './integration-test/test-pages/favicon/config/favicon-monitor-disabled.json';
47+
const favicon = ResultsCollector.create(page, testInfo.project.use);
48+
49+
await page.clock.install();
50+
51+
await favicon.load(HTML, CONFIG);
52+
53+
// ensure first favicon item was sent
54+
await favicon.waitForMessage('faviconFound', 1);
55+
56+
// now update it
57+
await page.getByRole('button', { name: 'Set override' }).click();
58+
59+
//
60+
await expect(page.locator('link')).toHaveAttribute('href', './new_favicon.png');
61+
62+
// account for the debounce
63+
await page.clock.fastForward(200);
64+
65+
// ensure only 1 message was still sent (ie: the monitor is disabled)
66+
const messages = await favicon.outgoingMessages();
67+
expect(messages).toHaveLength(1);
68+
});

injected/integration-test/page-objects/results-collector.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,14 +268,15 @@ export class ResultsCollector {
268268

269269
/**
270270
* @param {string} method
271-
* @return {Promise<object>}
271+
* @param {number} [count=1]
272+
* @return {Promise<Record<string, any>[]>}
272273
*/
273-
async waitForMessage(method) {
274+
async waitForMessage(method, count = 1) {
274275
await this.page.waitForFunction(
275276
waitForCallCount,
276277
{
277278
method,
278-
count: 1,
279+
count,
279280
},
280281
{ timeout: 5000, polling: 100 },
281282
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"features": {
3+
4+
},
5+
"unprotectedTemporary": []
6+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"features": {
3+
"favicon": {
4+
"state": "enabled",
5+
"exceptions": [],
6+
"settings": {
7+
"monitor": true
8+
}
9+
}
10+
},
11+
"unprotectedTemporary": []
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"features": {
3+
"favicon": {
4+
"state": "enabled",
5+
"exceptions": [],
6+
"settings": {
7+
"monitor": false
8+
}
9+
}
10+
},
11+
"unprotectedTemporary": []
12+
}
Loading
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Document</title>
7+
<link rel="shortcut icon" href="./favicon.png">
8+
</head>
9+
<body>
10+
<button onclick="setOverride()">Set override</button>
11+
<script>
12+
function setOverride() {
13+
document.querySelector("link[rel='shortcut icon']").href = "./new_favicon.png";
14+
}
15+
</script>
16+
</body>
17+
</html>
Loading

injected/playwright.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineConfig({
2121
'integration-test/duckplayer.spec.js',
2222
'integration-test/duckplayer-remote-config.spec.js',
2323
'integration-test/broker-protection.spec.js',
24+
'integration-test/favicon.spec.js',
2425
],
2526
use: { injectName: 'apple-isolated', platform: 'macos' },
2627
},

injected/src/features.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ const otherFeatures = /** @type {const} */ ([
2626
'performanceMetrics',
2727
'breakageReporting',
2828
'autofillPasswordImport',
29+
'favicon'
2930
]);
3031

3132
/** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */
3233
/** @type {Record<string, FeatureName[]>} */
3334
export const platformSupport = {
3435
apple: ['webCompat', ...baseFeatures],
35-
'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'],
36+
'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'],
3637
android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'],
3738
'android-broker-protection': ['brokerProtection'],
3839
'android-autofill-password-import': ['autofillPasswordImport'],

injected/src/features/favicon.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import ContentFeature from '../content-feature.js';
2+
3+
export class Favicon extends ContentFeature {
4+
/** @type {undefined|number} */
5+
#debounce = undefined;
6+
init() {
7+
window.addEventListener('DOMContentLoaded', () => this.setup());
8+
}
9+
10+
setup() {
11+
this.send();
12+
13+
// was there an explicit opt-out?
14+
if (this.getFeatureSetting('monitor') !== false) {
15+
monitor(() => this.send());
16+
}
17+
}
18+
19+
send() {
20+
clearTimeout(this.#debounce);
21+
const id = setTimeout(() => {
22+
const favicons = getFaviconList();
23+
this.notify('faviconFound', { favicons, documentUrl: document.URL });
24+
}, 100);
25+
// todo(shane): fix this timer id type
26+
this.#debounce = /** @type {any} */ (id);
27+
}
28+
}
29+
30+
export default Favicon;
31+
32+
/**
33+
* @param {()=>void} changeObservedCallback
34+
* @param {Element} [target]
35+
*/
36+
function monitor(changeObservedCallback, target = document.head) {
37+
const observer = new MutationObserver((mutations) => {
38+
for (const mutation of mutations) {
39+
if (mutation.type === 'attributes' && mutation.target instanceof HTMLLinkElement) {
40+
changeObservedCallback();
41+
break;
42+
}
43+
}
44+
});
45+
observer.observe(target, { attributeFilter: ['rel', 'href'], attributes: true, subtree: true });
46+
}
47+
48+
/**
49+
* @returns {import('../types/favicon.js').FaviconAttrs[]}
50+
*/
51+
function getFaviconList() {
52+
const selectors = [
53+
"link[href][rel='favicon']",
54+
"link[href][rel*='icon']",
55+
"link[href][rel='apple-touch-icon']",
56+
"link[href][rel='apple-touch-icon-precomposed']",
57+
];
58+
const elements = document.head.querySelectorAll(selectors.join(','));
59+
return Array.from(elements).map((x) => {
60+
const href = x.getAttribute('href') || '';
61+
const rel = x.getAttribute('rel') || '';
62+
return { href, rel };
63+
});
64+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"title": "FaviconFound",
5+
"required": ["favicons", "documentUrl"],
6+
"properties": {
7+
"favicons": {
8+
"type": "array",
9+
"items": {
10+
"type": "object",
11+
"required": ["rel", "href"],
12+
"title": "Favicon Attrs",
13+
"properties": {
14+
"href": {
15+
"type": "string"
16+
},
17+
"rel": {
18+
"type": "string"
19+
}
20+
}
21+
}
22+
},
23+
"documentUrl": {
24+
"type": "string"
25+
}
26+
}
27+
}

injected/src/types/favicon.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* These types are auto-generated from schema files.
3+
* scripts/build-types.mjs is responsible for type generation.
4+
* **DO NOT** edit this file directly as your changes will be lost.
5+
*
6+
* @module Favicon Messages
7+
*/
8+
9+
/**
10+
* Requests, Notifications and Subscriptions from the Favicon feature
11+
*/
12+
export interface FaviconMessages {
13+
notifications: FaviconFoundNotification;
14+
}
15+
/**
16+
* Generated from @see "../messages/favicon/faviconFound.notify.json"
17+
*/
18+
export interface FaviconFoundNotification {
19+
method: "faviconFound";
20+
params: FaviconFound;
21+
}
22+
export interface FaviconFound {
23+
favicons: FaviconAttrs[];
24+
documentUrl: string;
25+
}
26+
export interface FaviconAttrs {
27+
href: string;
28+
rel: string;
29+
}
30+
31+
declare module "../features/favicon.js" {
32+
export interface Favicon {
33+
notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase<FaviconMessages>['notify']
34+
}
35+
}

injected/src/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ export function isGloballyDisabled(args) {
703703
* @import {FeatureName} from "./features";
704704
* @type {FeatureName[]}
705705
*/
706-
export const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge'];
706+
export const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge', 'favicon'];
707707

708708
export function isPlatformSpecificFeature(featureName) {
709709
return platformSpecificFeatures.includes(featureName);

0 commit comments

Comments
 (0)