diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index 9c3bf17e90..b053f8e709 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -153,6 +153,206 @@ 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 default for permission initially', async ({ page }) => { + await beforeWebNotifications(page); + const permission = await page.evaluate(() => window.Notification.permission); + expect(permission).toEqual('default'); + }); + + 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 only fire onclose once when close() is called multiple times', async ({ page }) => { + await beforeWebNotifications(page); + + const closeCount = await page.evaluate(() => { + let count = 0; + const notification = new window.Notification('Test'); + notification.onclose = () => { + count++; + }; + + // Call close() multiple times - should only fire onclose once + notification.close(); + notification.close(); + notification.close(); + + return count; + }); + + expect(closeCount).toEqual(1); + }); + + 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 update Notification.permission after requestPermission resolves', async ({ page }) => { + await beforeWebNotifications(page); + + // Initially should be 'default' + const initialPermission = await page.evaluate(() => window.Notification.permission); + expect(initialPermission).toEqual('default'); + + // Mock native to return 'granted' + await page.evaluate(() => { + globalThis.cssMessaging.impl.request = () => { + return Promise.resolve({ permission: 'granted' }); + }; + }); + + await page.evaluate(() => window.Notification.requestPermission()); + + // After requestPermission, Notification.permission should reflect the new state + const updatedPermission = await page.evaluate(() => window.Notification.permission); + expect(updatedPermission).toEqual('granted'); + }); + + test('should return denied 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('denied'); + }); + + test('requestPermission should have native-looking toString()', async ({ page }) => { + await beforeWebNotifications(page); + + 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 = ` diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 47a9969de5..bd6c8a2d5e 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -2,7 +2,7 @@ import ContentFeature from '../content-feature.js'; // eslint-disable-next-line no-redeclare import { URL } from '../captured-globals.js'; import { DDGProxy, DDGReflect } from '../utils'; -import { wrapToString } from '../wrapper-utils.js'; +import { wrapToString, wrapFunction } from '../wrapper-utils.js'; /** * Fixes incorrect sizing value for outerHeight and outerWidth */ @@ -88,6 +88,9 @@ export class WebCompat extends ContentFeature { /** @type {Promise | null} */ #activeScreenLockRequest = null; + /** @type {Map} */ + #webNotifications = new Map(); + // Opt in to receive configuration updates from initial ping responses listenForConfigUpdates = true; @@ -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); @@ -268,6 +274,200 @@ 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) : () => () => {}; + // Permission is 'default' when enabled (not yet determined), 'denied' when disabled + /** @type {NotificationPermission} */ + let permission = nativeEnabled ? 'default' : '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} + */ + static async requestPermission(deprecatedCallback) { + try { + const result = await nativeRequest('requestPermission', {}); + const resultPermission = /** @type {NotificationPermission} */ (result?.permission || 'denied'); + // Update cached permission so Notification.permission reflects the new state + permission = resultPermission; + if (deprecatedCallback) { + deprecatedCallback(resultPermission); + } + return resultPermission; + } catch (e) { + // On error, set permission to denied + permission = 'denied'; + if (deprecatedCallback) { + deprecatedCallback('denied'); + } + return 'denied'; + } + } + + /** + * @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() { + // Guard against multiple close() calls - only fire onclose once + if (!feature.#webNotifications.has(this.#id)) { + return; + } + nativeNotify('closeNotification', { id: this.#id }); + // Remove from map first to prevent duplicate onclose from native event + feature.#webNotifications.delete(this.#id); + // Fire onclose handler + if (typeof this.onclose === 'function') { + try { + // @ts-expect-error - NotificationPolyfill doesn't fully implement Notification interface + this.onclose(new Event('close')); + } catch (e) { + // Error in event handler - silently ignore + } + } + } + } + + // Wrap the constructor + const wrappedNotification = wrapFunction(NotificationPolyfill, NotificationPolyfill); + + // 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); diff --git a/injected/src/messages/web-compat/closeNotification.notify.json b/injected/src/messages/web-compat/closeNotification.notify.json new file mode 100644 index 0000000000..7fd4645ec3 --- /dev/null +++ b/injected/src/messages/web-compat/closeNotification.notify.json @@ -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" + } + } +} + + diff --git a/injected/src/messages/web-compat/notificationEvent.subscribe.json b/injected/src/messages/web-compat/notificationEvent.subscribe.json new file mode 100644 index 0000000000..8b37fd937b --- /dev/null +++ b/injected/src/messages/web-compat/notificationEvent.subscribe.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "NotificationEventParams", + "description": "Subscription for notification lifecycle events from native", + "additionalProperties": false, + "required": ["id", "event"], + "properties": { + "id": { + "description": "Unique identifier of the notification", + "type": "string" + }, + "event": { + "description": "The event type that occurred", + "type": "string", + "enum": ["show", "close", "click", "error"] + } + } +} + + diff --git a/injected/src/messages/web-compat/requestPermission.request.json b/injected/src/messages/web-compat/requestPermission.request.json new file mode 100644 index 0000000000..a9642da2ee --- /dev/null +++ b/injected/src/messages/web-compat/requestPermission.request.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "RequestPermissionParams", + "description": "Parameters for requesting notification permission", + "additionalProperties": false, + "properties": {} +} + + diff --git a/injected/src/messages/web-compat/requestPermission.response.json b/injected/src/messages/web-compat/requestPermission.response.json new file mode 100644 index 0000000000..37a8cd959c --- /dev/null +++ b/injected/src/messages/web-compat/requestPermission.response.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "RequestPermissionResponse", + "description": "Response from notification permission request", + "additionalProperties": false, + "required": ["permission"], + "properties": { + "permission": { + "description": "The permission state", + "type": "string", + "enum": ["default", "denied", "granted"] + } + } +} + + diff --git a/injected/src/messages/web-compat/showNotification.notify.json b/injected/src/messages/web-compat/showNotification.notify.json new file mode 100644 index 0000000000..99f40c1bc5 --- /dev/null +++ b/injected/src/messages/web-compat/showNotification.notify.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "ShowNotificationParams", + "description": "Parameters for showing a web notification", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { + "description": "Unique identifier for the notification instance", + "type": "string" + }, + "title": { + "description": "The notification title", + "type": "string" + }, + "body": { + "description": "The notification body text", + "type": "string" + }, + "icon": { + "description": "URL of the notification icon", + "type": "string" + }, + "tag": { + "description": "Tag for grouping notifications", + "type": "string" + } + } +} + + diff --git a/injected/src/types/web-compat.ts b/injected/src/types/web-compat.ts index e4a641c18e..a6e1adb623 100644 --- a/injected/src/types/web-compat.ts +++ b/injected/src/types/web-compat.ts @@ -10,7 +10,57 @@ * Requests, Notifications and Subscriptions from the WebCompat feature */ export interface WebCompatMessages { - requests: DeviceEnumerationRequest | WebShareRequest; + notifications: CloseNotificationNotification | ShowNotificationNotification; + requests: DeviceEnumerationRequest | RequestPermissionRequest | WebShareRequest; + subscriptions: NotificationEventSubscription; +} +/** + * Generated from @see "../messages/web-compat/closeNotification.notify.json" + */ +export interface CloseNotificationNotification { + method: "closeNotification"; + params: CloseNotificationParams; +} +/** + * Parameters for closing a web notification + */ +export interface CloseNotificationParams { + /** + * Unique identifier of the notification to close + */ + id: string; +} +/** + * Generated from @see "../messages/web-compat/showNotification.notify.json" + */ +export interface ShowNotificationNotification { + method: "showNotification"; + params: ShowNotificationParams; +} +/** + * Parameters for showing a web notification + */ +export interface ShowNotificationParams { + /** + * Unique identifier for the notification instance + */ + id: string; + /** + * The notification title + */ + title: string; + /** + * The notification body text + */ + body?: string; + /** + * URL of the notification icon + */ + icon?: string; + /** + * Tag for grouping notifications + */ + tag?: string; } /** * Generated from @see "../messages/web-compat/deviceEnumeration.request.json" @@ -24,6 +74,27 @@ export interface DeviceEnumerationRequest { [k: string]: unknown; }; } +/** + * Generated from @see "../messages/web-compat/requestPermission.request.json" + */ +export interface RequestPermissionRequest { + method: "requestPermission"; + params: RequestPermissionParams; + result: RequestPermissionResponse; +} +/** + * Parameters for requesting notification permission + */ +export interface RequestPermissionParams {} +/** + * Response from notification permission request + */ +export interface RequestPermissionResponse { + /** + * The permission state + */ + permission: "default" | "denied" | "granted"; +} /** * Generated from @see "../messages/web-compat/webShare.request.json" */ @@ -48,9 +119,31 @@ export interface WebShareParams { */ text?: string; } +/** + * Generated from @see "../messages/web-compat/notificationEvent.subscribe.json" + */ +export interface NotificationEventSubscription { + subscriptionEvent: "notificationEvent"; + params: NotificationEventParams; +} +/** + * Subscription for notification lifecycle events from native + */ +export interface NotificationEventParams { + /** + * Unique identifier of the notification + */ + id: string; + /** + * The event type that occurred + */ + event: "show" | "close" | "click" | "error"; +} declare module "../features/web-compat.js" { export interface WebCompat { - request: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['request'] + notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'], + request: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['request'], + subscribe: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['subscribe'] } } \ No newline at end of file