Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions injected/integration-test/web-compat.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,167 @@ test.describe('Ensure Notification interface is injected', () => {
});
});

test.describe('webNotifications', () => {
/**
* @param {import("@playwright/test").Page} page
*/
async function beforeWebNotifications(page) {
await gotoAndWait(page, '/blank.html', {
site: { enabledFeatures: ['webCompat'] },
featureSettings: { webCompat: { webNotifications: 'enabled' } },
});
}

test('should override Notification API when enabled', async ({ page }) => {
await beforeWebNotifications(page);
const hasNotification = await page.evaluate(() => 'Notification' in window);
expect(hasNotification).toEqual(true);
});

test('should return granted for permission', async ({ page }) => {
await beforeWebNotifications(page);
const permission = await page.evaluate(() => window.Notification.permission);
expect(permission).toEqual('granted');
});

test('should return 2 for maxActions', async ({ page }) => {
await beforeWebNotifications(page);
const maxActions = await page.evaluate(() => {
// @ts-expect-error - maxActions is experimental
return window.Notification.maxActions;
});
expect(maxActions).toEqual(2);
});

test('should send showNotification message when constructing', async ({ page }) => {
await beforeWebNotifications(page);
await page.evaluate(() => {
globalThis.notifyCalls = [];
globalThis.cssMessaging.impl.notify = (msg) => {
globalThis.notifyCalls.push(msg);
};
});

await page.evaluate(() => new window.Notification('Test Title', { body: 'Test Body' }));

const calls = await page.evaluate(() => globalThis.notifyCalls);
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]).toMatchObject({
featureName: 'webCompat',
method: 'showNotification',
params: { title: 'Test Title', body: 'Test Body' },
});
});

test('should send closeNotification message on close()', async ({ page }) => {
await beforeWebNotifications(page);
await page.evaluate(() => {
globalThis.notifyCalls = [];
globalThis.cssMessaging.impl.notify = (msg) => {
globalThis.notifyCalls.push(msg);
};
});

await page.evaluate(() => {
const n = new window.Notification('Test');
n.close();
});

const calls = await page.evaluate(() => globalThis.notifyCalls);
const closeCall = calls.find((c) => c.method === 'closeNotification');
expect(closeCall).toBeDefined();
expect(closeCall).toMatchObject({
featureName: 'webCompat',
method: 'closeNotification',
});
expect(closeCall.params.id).toBeDefined();
});

test('should propagate requestPermission result from native', async ({ page }) => {
await beforeWebNotifications(page);
await page.evaluate(() => {
globalThis.cssMessaging.impl.request = () => {
return Promise.resolve({ permission: 'denied' });
};
});

const permission = await page.evaluate(() => window.Notification.requestPermission());
expect(permission).toEqual('denied');
});

test('should default to granted when native error occurs', async ({ page }) => {
await beforeWebNotifications(page);
await page.evaluate(() => {
globalThis.cssMessaging.impl.request = () => {
return Promise.reject(new Error('native error'));
};
});

const permission = await page.evaluate(() => window.Notification.requestPermission());
expect(permission).toEqual('granted');
});

test('should have native-looking toString()', async ({ page }) => {
await beforeWebNotifications(page);

const notificationToString = await page.evaluate(() => window.Notification.toString());
expect(notificationToString).toEqual('function Notification() { [native code] }');

const requestPermissionToString = await page.evaluate(() => window.Notification.requestPermission.toString());
expect(requestPermissionToString).toEqual('function requestPermission() { [native code] }');
});
});

test.describe('webNotifications with nativeEnabled: false', () => {
/**
* @param {import("@playwright/test").Page} page
*/
async function beforeWebNotificationsDisabled(page) {
await gotoAndWait(page, '/blank.html', {
site: { enabledFeatures: ['webCompat'] },
featureSettings: { webCompat: { webNotifications: { state: 'enabled', nativeEnabled: false } } },
});
}

test('should return denied for permission when nativeEnabled is false', async ({ page }) => {
await beforeWebNotificationsDisabled(page);
const permission = await page.evaluate(() => window.Notification.permission);
expect(permission).toEqual('denied');
});

test('should not send showNotification when nativeEnabled is false', async ({ page }) => {
await beforeWebNotificationsDisabled(page);
await page.evaluate(() => {
globalThis.notifyCalls = [];
globalThis.cssMessaging.impl.notify = (msg) => {
globalThis.notifyCalls.push(msg);
};
});

await page.evaluate(() => new window.Notification('Test Title'));

const calls = await page.evaluate(() => globalThis.notifyCalls);
expect(calls.length).toEqual(0);
});

test('should return denied from requestPermission without calling native', async ({ page }) => {
await beforeWebNotificationsDisabled(page);
await page.evaluate(() => {
globalThis.requestCalls = [];
globalThis.cssMessaging.impl.request = (msg) => {
globalThis.requestCalls.push(msg);
return Promise.resolve({ permission: 'granted' });
};
});

const permission = await page.evaluate(() => window.Notification.requestPermission());
const calls = await page.evaluate(() => globalThis.requestCalls);

expect(permission).toEqual('denied');
expect(calls.length).toEqual(0);
});
});

test.describe('Permissions API', () => {
// Fake the Permission API not existing in this browser
const removePermissionsScript = `
Expand Down
181 changes: 181 additions & 0 deletions injected/src/features/web-compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export class WebCompat extends ContentFeature {
/** @type {Promise<any> | null} */
#activeScreenLockRequest = null;

/** @type {Map<string, object>} */
#webNotifications = new Map();

// Opt in to receive configuration updates from initial ping responses
listenForConfigUpdates = true;

Expand All @@ -107,6 +110,9 @@ export class WebCompat extends ContentFeature {
if (this.getFeatureSettingEnabled('notification')) {
this.notificationFix();
}
if (this.getFeatureSettingEnabled('webNotifications')) {
this.webNotificationsFix();
}
if (this.getFeatureSettingEnabled('permissions')) {
const settings = this.getFeatureSetting('permissions');
this.permissionsFix(settings);
Expand Down Expand Up @@ -268,6 +274,181 @@ export class WebCompat extends ContentFeature {
});
}

/**
* Web Notifications polyfill that communicates with native code for permission
* management and notification display.
*/
webNotificationsFix() {
// Notification API is not supported in insecure contexts
if (!globalThis.isSecureContext) {
return;
}

// eslint-disable-next-line @typescript-eslint/no-this-alias
const feature = this;

// Check nativeEnabled setting - when false, install polyfill but skip native calls and return 'denied'
const settings = this.getFeatureSetting('webNotifications') || {};
const nativeEnabled = settings.nativeEnabled !== false;

// Wrap native calls - no-op when nativeEnabled is false
const nativeNotify = nativeEnabled ? (name, data) => feature.notify(name, data) : () => {};
const nativeRequest = nativeEnabled ? (name, data) => feature.request(name, data) : () => Promise.resolve({ permission: 'denied' });
const nativeSubscribe = nativeEnabled ? (name, cb) => feature.subscribe(name, cb) : () => () => {};
/** @type {NotificationPermission} */
const permission = nativeEnabled ? 'granted' : 'denied';

/**
* NotificationPolyfill - replaces the native Notification API
*/
class NotificationPolyfill {
/** @type {string} */
#id;
/** @type {string} */
title;
/** @type {string} */
body;
/** @type {string} */
icon;
/** @type {string} */
tag;
/** @type {any} */
data;

// Event handlers
/** @type {((this: Notification, ev: Event) => any) | null} */
onclick = null;
/** @type {((this: Notification, ev: Event) => any) | null} */
onclose = null;
/** @type {((this: Notification, ev: Event) => any) | null} */
onerror = null;
/** @type {((this: Notification, ev: Event) => any) | null} */
onshow = null;

/**
* @returns {'default' | 'denied' | 'granted'}
*/
static get permission() {
return permission;
}

/**
* @param {NotificationPermissionCallback} [deprecatedCallback]
* @returns {Promise<NotificationPermission>}
*/
static async requestPermission(deprecatedCallback) {
try {
const result = await nativeRequest('requestPermission', {});
const resultPermission = /** @type {NotificationPermission} */ (result?.permission || permission);
if (deprecatedCallback) {
deprecatedCallback(resultPermission);
}
return resultPermission;
} catch (e) {
if (deprecatedCallback) {
deprecatedCallback(permission);
}
return permission;
}
}

/**
* @returns {number}
*/
static get maxActions() {
return 2;
}

/**
* @param {string} title
* @param {NotificationOptions} [options]
*/
constructor(title, options = {}) {
this.#id = crypto.randomUUID();
this.title = String(title);
this.body = options.body ? String(options.body) : '';
this.icon = options.icon ? String(options.icon) : '';
this.tag = options.tag ? String(options.tag) : '';
this.data = options.data;

feature.#webNotifications.set(this.#id, this);

nativeNotify('showNotification', {
id: this.#id,
title: this.title,
body: this.body,
icon: this.icon,
tag: this.tag,
});
}

close() {
nativeNotify('closeNotification', { id: this.#id });
feature.#webNotifications.delete(this.#id);
}
}

// Wrap the constructor to make toString() look native
const wrappedNotification = wrapToString(NotificationPolyfill, NotificationPolyfill, 'function Notification() { [native code] }');

// Wrap static methods
const wrappedRequestPermission = wrapToString(
NotificationPolyfill.requestPermission.bind(NotificationPolyfill),
NotificationPolyfill.requestPermission,
'function requestPermission() { [native code] }',
);

// Subscribe to notification events from native
nativeSubscribe('notificationEvent', (data) => {
const notification = this.#webNotifications.get(data.id);
if (!notification) return;

const eventName = `on${data.event}`;
if (typeof notification[eventName] === 'function') {
try {
notification[eventName](new Event(data.event));
} catch (e) {
// Error in event handler - silently ignore
}
}

// Clean up on close event
if (data.event === 'close') {
this.#webNotifications.delete(data.id);
}
});

// Define the Notification property on globalThis
this.defineProperty(globalThis, 'Notification', {
value: wrappedNotification,
writable: true,
configurable: true,
enumerable: false,
});

// Define permission getter
this.defineProperty(globalThis.Notification, 'permission', {
get: () => permission,
configurable: true,
enumerable: true,
});

// Define maxActions getter
this.defineProperty(globalThis.Notification, 'maxActions', {
get: () => 2,
configurable: true,
enumerable: true,
});

// Define requestPermission
this.defineProperty(globalThis.Notification, 'requestPermission', {
value: wrappedRequestPermission,
writable: true,
configurable: true,
enumerable: true,
});
}

cleanIframeValue() {
function cleanValueData(val) {
const clone = Object.assign({}, val);
Expand Down
16 changes: 16 additions & 0 deletions injected/src/messages/web-compat/closeNotification.notify.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "CloseNotificationParams",
"description": "Parameters for closing a web notification",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"description": "Unique identifier of the notification to close",
"type": "string"
}
}
}


Loading