Skip to content

add favicon feature #1561

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

Merged
merged 14 commits into from
Mar 21, 2025
39 changes: 39 additions & 0 deletions injected/docs/favicon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Favicon Monitor
---

# Favicon Monitor

Reports the presence of favicons on page-load, and optionally when they change.

## Notifications

### `faviconFound`
- {@link "Favicon Messages".FaviconFoundNotification}
- Sent on page load, sends {@link "Favicon Messages".FaviconFound}

**Example**

```json
{
"favicons": [
{
"href": "favicon.png",
"rel": "stylesheet"
}
],
"documentUrl": "https://example.com"
}
```

## Remote Config

## Enabled (default)
{@includeCode ../integration-test/test-pages/favicon/config/favicon-enabled.json}

### Disable the monitor only.

To only receive the initial payload and nothing more (to mimic the old behavior),
you can set `monitor: false` in the remote config, and it will not install the Mutation Observer.

{@includeCode ../integration-test/test-pages/favicon/config/favicon-monitor-disabled.json}
117 changes: 117 additions & 0 deletions injected/integration-test/favicon.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test, expect } from '@playwright/test';
import { ResultsCollector } from './page-objects/results-collector.js';

const HTML = '/favicon/index.html';
const CONFIG = './integration-test/test-pages/favicon/config/favicon-enabled.json';

test('favicon feature absent', async ({ page, baseURL }, testInfo) => {
const CONFIG = './integration-test/test-pages/favicon/config/favicon-absent.json';
const favicon = ResultsCollector.create(page, testInfo.project.use);
await favicon.load(HTML, CONFIG);

// ensure first favicon item was sent
const messages = await favicon.waitForMessage('faviconFound', 1);
const url = new URL('/favicon/favicon.png', baseURL);

expect(messages[0].payload.params).toStrictEqual({
favicons: [{ href: url.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
});
});

test('favicon + monitor', async ({ page, baseURL }, testInfo) => {
const favicon = ResultsCollector.create(page, testInfo.project.use);
await favicon.load(HTML, CONFIG);

// ensure first favicon item was sent
await favicon.waitForMessage('faviconFound', 1);

// now update it
await page.getByRole('button', { name: 'Set override' }).click();

// wait for the second message
const messages = await favicon.waitForMessage('faviconFound', 2);

const url1 = new URL('/favicon/favicon.png', baseURL);
const url2 = new URL('/favicon/new_favicon.png', baseURL);

expect(messages[0].payload.params).toStrictEqual({
favicons: [{ href: url1.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
});

expect(messages[1].payload.params).toStrictEqual({
favicons: [{ href: url2.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
});
});

test('favicon + monitor (many updates)', async ({ page, baseURL }, testInfo) => {
const favicon = ResultsCollector.create(page, testInfo.project.use);
await page.clock.install();
await favicon.load(HTML, CONFIG);

// ensure first favicon item was sent
await favicon.waitForMessage('faviconFound', 1);

// now update it
await page.getByRole('button', { name: 'Set many overrides' }).click();
await page.clock.fastForward(20);

const messages = await favicon.outgoingMessages();
expect(messages).toHaveLength(1);

await page.clock.fastForward(60);
await page.clock.fastForward(100);

{
const messages = await favicon.outgoingMessages();
expect(messages).toHaveLength(3);
}

{
const url1 = new URL('/favicon/favicon.png', baseURL);
const url2 = new URL('/favicon/new_favicon.png?count=0', baseURL);
const url3 = new URL('/favicon/new_favicon.png?count=1', baseURL);

const messages = await favicon.outgoingMessages();
expect(messages.map((x) => /** @type {{params: any}} */ (x.payload).params)).toStrictEqual([
{
favicons: [{ href: url1.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
},
{
favicons: [{ href: url2.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
},
{
favicons: [{ href: url3.href, rel: 'shortcut icon' }],
documentUrl: 'http://localhost:3220/favicon/index.html',
},
]);
}
});

test('favicon + monitor disabled', async ({ page }, testInfo) => {
const CONFIG = './integration-test/test-pages/favicon/config/favicon-monitor-disabled.json';
const favicon = ResultsCollector.create(page, testInfo.project.use);

await page.clock.install();

await favicon.load(HTML, CONFIG);

// ensure first favicon item was sent
await favicon.waitForMessage('faviconFound', 1);

// now update it
await page.getByRole('button', { name: 'Set override' }).click();

await expect(page.locator('link')).toHaveAttribute('href', './new_favicon.png');

// account for the debounce
await page.clock.fastForward(200);

// ensure only 1 message was still sent (ie: the monitor is disabled)
const messages = await favicon.outgoingMessages();
expect(messages).toHaveLength(1);
});
7 changes: 4 additions & 3 deletions injected/integration-test/page-objects/results-collector.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,14 +268,15 @@ export class ResultsCollector {

/**
* @param {string} method
* @return {Promise<object>}
* @param {number} [count=1]
* @return {Promise<Record<string, any>[]>}
*/
async waitForMessage(method) {
async waitForMessage(method, count = 1) {
await this.page.waitForFunction(
waitForCallCount,
{
method,
count: 1,
count,
},
{ timeout: 5000, polling: 100 },
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"features": {

},
"unprotectedTemporary": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"features": {
"favicon": {
"state": "enabled",
"exceptions": [],
"settings": {
"monitor": true
}
}
},
"unprotectedTemporary": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"features": {
"favicon": {
"state": "enabled",
"exceptions": [],
"settings": {
"monitor": false
}
}
},
"unprotectedTemporary": []
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions injected/integration-test/test-pages/favicon/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<link rel="shortcut icon" href="./favicon.png">
</head>
<body>
<button onclick="setOverride()">Set override</button>
<button onclick="setManyOverrides()">Set many overrides</button>
<script>
function setOverride() {
document.querySelector("link[rel='shortcut icon']").href = "./new_favicon.png";
}
async function setManyOverrides() {
const elem = document.querySelector("link[rel='shortcut icon']");
for (let i = 0; i < 100; i++) {
await new Promise(resolve => {
setTimeout(resolve, 40);
})
const path = `./new_favicon.png?count=${i}`
elem.href = path
}
}
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"unprotectedTemporary": [],
"features": {
"favicon": {
"state": "disabled",
"exceptions": []
},
"navigatorInterface": {
"state": "enabled",
"exceptions": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"state": "enabled",
"exceptions": []
},
"favicon": {
"state": "disabled",
"exceptions": []
},
"messageBridge": {
"exceptions": [],
"state": "enabled",
Expand Down
1 change: 1 addition & 0 deletions injected/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineConfig({
'integration-test/duckplayer.spec.js',
'integration-test/duckplayer-remote-config.spec.js',
'integration-test/broker-protection-tests/**/*.spec.js',
'integration-test/favicon.spec.js',
],
use: { injectName: 'apple-isolated', platform: 'macos' },
},
Expand Down
3 changes: 2 additions & 1 deletion injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ const otherFeatures = /** @type {const} */ ([
'performanceMetrics',
'breakageReporting',
'autofillPasswordImport',
'favicon',
]);

/** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */
/** @type {Record<string, FeatureName[]>} */
export const platformSupport = {
apple: ['webCompat', ...baseFeatures],
'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'],
'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'],
android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'],
'android-broker-protection': ['brokerProtection'],
'android-autofill-password-import': ['autofillPasswordImport'],
Expand Down
84 changes: 84 additions & 0 deletions injected/src/features/favicon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import ContentFeature from '../content-feature.js';
import { isBeingFramed } from '../utils.js';

export class Favicon extends ContentFeature {
init() {
/**
* This feature never operates in a frame
*/
if (isBeingFramed()) return;

window.addEventListener('DOMContentLoaded', () => {
// send once, immediately
this.send();

// then optionally watch for changes
this.monitorChanges();
});
}

monitorChanges() {
// if there was an explicit opt-out, do nothing
// this allows the remote config to be absent for this feature
if (this.getFeatureSetting('monitor') === false) return;

let trailing;
let lastEmitTime = performance.now();
const interval = 50;

monitor(() => {
clearTimeout(trailing);
const currentTime = performance.now();
const delta = currentTime - lastEmitTime;
if (delta >= interval) {
this.send();
} else {
trailing = setTimeout(() => {
this.send();
}, 50);
}
lastEmitTime = currentTime;
});
}

send() {
const favicons = getFaviconList();
this.notify('faviconFound', { favicons, documentUrl: document.URL });
}
}

export default Favicon;

/**
* @param {()=>void} changeObservedCallback
* @param {Element} [target]
*/
function monitor(changeObservedCallback, target = document.head) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target instanceof HTMLLinkElement) {
changeObservedCallback();
break;
}
}
});
observer.observe(target, { attributeFilter: ['rel', 'href'], attributes: true, subtree: true });
}

/**
* @returns {import('../types/favicon.js').FaviconAttrs[]}
*/
function getFaviconList() {
const selectors = [
"link[href][rel='favicon']",
"link[href][rel*='icon']",
"link[href][rel='apple-touch-icon']",
"link[href][rel='apple-touch-icon-precomposed']",
];
const elements = document.head.querySelectorAll(selectors.join(','));
return Array.from(elements).map((/** @type {HTMLLinkElement} */ link) => {
const href = link.href || '';
const rel = link.getAttribute('rel') || '';
return { href, rel };
});
}
27 changes: 27 additions & 0 deletions injected/src/messages/favicon/faviconFound.notify.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "FaviconFound",
"required": ["favicons", "documentUrl"],
"properties": {
"favicons": {
"type": "array",
"items": {
"type": "object",
"required": ["rel", "href"],
"title": "Favicon Attrs",
"properties": {
"href": {
"type": "string"
},
"rel": {
"type": "string"
}
}
}
},
"documentUrl": {
"type": "string"
}
}
}
Loading
Loading