From e5edc069434a739e9a75def8405d98c9bae1638a Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Thu, 27 Nov 2025 18:46:54 +0100 Subject: [PATCH 01/10] Add webNotifications feature for macOS - Add web-notifications.js feature with Notification API polyfill - Add message schema for showNotification, closeNotification, requestPermission - Add unit tests - Register feature for apple platform - Add debug logging for feature loading --- injected/src/content-scope-features.js | 6 + injected/src/features.js | 3 +- injected/src/features/web-notifications.js | 179 +++++++++++++ .../closeNotification.notify.json | 15 ++ .../notificationEvent.subscribe.json | 20 ++ .../requestPermission.request.json | 9 + .../requestPermission.response.json | 16 ++ .../showNotification.notify.json | 31 +++ injected/src/types/web-notifications.ts | 113 ++++++++ injected/unit-test/web-notifications.js | 241 ++++++++++++++++++ 10 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 injected/src/features/web-notifications.js create mode 100644 injected/src/messages/web-notifications/closeNotification.notify.json create mode 100644 injected/src/messages/web-notifications/notificationEvent.subscribe.json create mode 100644 injected/src/messages/web-notifications/requestPermission.request.json create mode 100644 injected/src/messages/web-notifications/requestPermission.response.json create mode 100644 injected/src/messages/web-notifications/showNotification.notify.json create mode 100644 injected/src/types/web-notifications.ts create mode 100644 injected/unit-test/web-notifications.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 2b062f6c23..c7a77d4abd 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -52,6 +52,12 @@ export function load(args) { // point, which is why we fall back to `bundledFeatureNames`. : args.site.enabledFeatures || bundledFeatureNames; + // DEBUG: Log feature loading info + console.log('[CSS DEBUG] bundledFeatureNames:', bundledFeatureNames); + console.log('[CSS DEBUG] featuresToLoad:', featuresToLoad); + console.log('[CSS DEBUG] webNotifications in featuresToLoad:', featuresToLoad.includes('webNotifications')); + console.log('[CSS DEBUG] site.enabledFeatures:', args.site?.enabledFeatures); + for (const featureName of bundledFeatureNames) { if (featuresToLoad.includes(featureName)) { const ContentFeature = platformFeatures['ddg_feature_' + featureName]; diff --git a/injected/src/features.js b/injected/src/features.js index 3d0995ad7d..76f00f7abe 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -33,12 +33,13 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', + 'webNotifications', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext', 'webNotifications'], 'apple-isolated': [ 'duckPlayer', 'duckPlayerNative', diff --git a/injected/src/features/web-notifications.js b/injected/src/features/web-notifications.js new file mode 100644 index 0000000000..b3200a45f9 --- /dev/null +++ b/injected/src/features/web-notifications.js @@ -0,0 +1,179 @@ +import ContentFeature from '../content-feature.js'; +import { wrapToString } from '../wrapper-utils.js'; + +/** + * Web Notifications feature - provides a polyfill for the Web Notifications API + * that communicates with native code for permission management and notification display. + */ +export default class WebNotifications extends ContentFeature { + /** @type {Map} */ + #notifications = new Map(); + + init() { + console.log('[WebNotifications] init() called'); + this.#initNotificationPolyfill(); + console.log('[WebNotifications] Notification polyfill installed'); + } + + #initNotificationPolyfill() { + const feature = this; + + /** + * 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() { + // For now, always return 'granted' - Project 5 will query native + return 'granted'; + } + + /** + * @param {NotificationPermissionCallback} [deprecatedCallback] + * @returns {Promise} + */ + static async requestPermission(deprecatedCallback) { + try { + const result = await feature.request('requestPermission', {}); + const permission = result?.permission || 'granted'; + if (deprecatedCallback) { + deprecatedCallback(permission); + } + return permission; + } catch (e) { + feature.log.error('requestPermission failed:', e); + const fallback = 'granted'; + if (deprecatedCallback) { + deprecatedCallback(fallback); + } + return fallback; + } + } + + /** + * @returns {number} + */ + static get maxActions() { + return 2; + } + + /** + * @param {string} title + * @param {NotificationOptions} [options] + */ + constructor(title, options = {}) { + this.#id = crypto.randomUUID(); + this.title = title; + this.body = options.body || ''; + this.icon = options.icon || ''; + this.tag = options.tag || ''; + this.data = options.data; + + feature.#notifications.set(this.#id, this); + + feature.notify('showNotification', { + id: this.#id, + title: this.title, + body: this.body, + icon: this.icon, + tag: this.tag, + }); + } + + close() { + feature.notify('closeNotification', { id: this.#id }); + feature.#notifications.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 + this.subscribe('notificationEvent', (data) => { + const notification = this.#notifications.get(data.id); + if (!notification) return; + + const eventName = `on${data.event}`; + if (typeof notification[eventName] === 'function') { + try { + notification[eventName].call(notification, new Event(data.event)); + } catch (e) { + feature.log.error(`Error in ${eventName} handler:`, e); + } + } + + // Clean up on close event + if (data.event === 'close') { + this.#notifications.delete(data.id); + } + }); + + // Define the Notification property on globalThis + this.defineProperty(globalThis, 'Notification', { + value: wrappedNotification, + writable: true, + configurable: true, + enumerable: false, + }); + + // Define permission getter (return value directly to avoid recursion) + this.defineProperty(globalThis.Notification, 'permission', { + get: () => 'granted', // For now, always return 'granted' - Project 5 will query native + configurable: true, + enumerable: true, + }); + + // Define maxActions getter (return value directly to avoid recursion) + 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, + }); + } +} + diff --git a/injected/src/messages/web-notifications/closeNotification.notify.json b/injected/src/messages/web-notifications/closeNotification.notify.json new file mode 100644 index 0000000000..d03f11cf4a --- /dev/null +++ b/injected/src/messages/web-notifications/closeNotification.notify.json @@ -0,0 +1,15 @@ +{ + "$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-notifications/notificationEvent.subscribe.json b/injected/src/messages/web-notifications/notificationEvent.subscribe.json new file mode 100644 index 0000000000..6d46080792 --- /dev/null +++ b/injected/src/messages/web-notifications/notificationEvent.subscribe.json @@ -0,0 +1,20 @@ +{ + "$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-notifications/requestPermission.request.json b/injected/src/messages/web-notifications/requestPermission.request.json new file mode 100644 index 0000000000..c998baf26f --- /dev/null +++ b/injected/src/messages/web-notifications/requestPermission.request.json @@ -0,0 +1,9 @@ +{ + "$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-notifications/requestPermission.response.json b/injected/src/messages/web-notifications/requestPermission.response.json new file mode 100644 index 0000000000..547881adaa --- /dev/null +++ b/injected/src/messages/web-notifications/requestPermission.response.json @@ -0,0 +1,16 @@ +{ + "$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-notifications/showNotification.notify.json b/injected/src/messages/web-notifications/showNotification.notify.json new file mode 100644 index 0000000000..d5213f1bc1 --- /dev/null +++ b/injected/src/messages/web-notifications/showNotification.notify.json @@ -0,0 +1,31 @@ +{ + "$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-notifications.ts b/injected/src/types/web-notifications.ts new file mode 100644 index 0000000000..dd2858cee5 --- /dev/null +++ b/injected/src/types/web-notifications.ts @@ -0,0 +1,113 @@ +/** + * These types are auto-generated from schema files. + * scripts/build-types.mjs is responsible for type generation. + * **DO NOT** edit this file directly as your changes will be lost. + * + * @module WebNotifications Messages + */ + +/** + * Requests, Notifications and Subscriptions from the WebNotifications feature + */ +export interface WebNotificationsMessages { + notifications: CloseNotificationNotification | ShowNotificationNotification; + requests: RequestPermissionRequest; + subscriptions: NotificationEventSubscription; +} +/** + * Generated from @see "../messages/web-notifications/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-notifications/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-notifications/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-notifications/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-notifications.js" { + export interface WebNotifications { + 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 diff --git a/injected/unit-test/web-notifications.js b/injected/unit-test/web-notifications.js new file mode 100644 index 0000000000..4069ecd814 --- /dev/null +++ b/injected/unit-test/web-notifications.js @@ -0,0 +1,241 @@ +import WebNotifications from '../src/features/web-notifications.js'; + +describe('WebNotifications feature', () => { + /** @type {WebNotifications} */ + let feature; + let mockMessaging; + let mockSubscribeCallback; + + beforeEach(() => { + // Reset globalThis.Notification if it exists + if (globalThis.Notification) { + delete globalThis.Notification; + } + + // Create mock messaging + mockMessaging = { + notify: jasmine.createSpy('notify'), + request: jasmine.createSpy('request').and.returnValue(Promise.resolve({ permission: 'granted' })), + subscribe: jasmine.createSpy('subscribe').and.callFake((name, cb) => { + mockSubscribeCallback = cb; + return () => {}; + }), + }; + + feature = new WebNotifications( + 'webNotifications', + {}, + { + site: { domain: 'example.com', url: 'https://example.com' }, + platform: { name: 'apple', internal: true }, + }, + ); + + // Override messaging getter + Object.defineProperty(feature, 'messaging', { + get: () => mockMessaging, + }); + + // Override _messaging + feature._messaging = mockMessaging; + + feature.callInit({ + site: { domain: 'example.com', url: 'https://example.com' }, + platform: { name: 'apple', internal: true }, + }); + }); + + afterEach(() => { + if (globalThis.Notification) { + delete globalThis.Notification; + } + }); + + describe('Notification polyfill setup', () => { + it('should define Notification on window', () => { + expect(globalThis.Notification).toBeDefined(); + }); + + it('should define Notification.permission as granted by default', () => { + expect(globalThis.Notification.permission).toBe('granted'); + }); + + it('should define Notification.maxActions as 2', () => { + expect(globalThis.Notification.maxActions).toBe(2); + }); + + it('should define Notification.requestPermission as a function', () => { + expect(typeof globalThis.Notification.requestPermission).toBe('function'); + }); + }); + + describe('Notification constructor', () => { + it('should create a notification with title', () => { + const notification = new globalThis.Notification('Test Title'); + expect(notification.title).toBe('Test Title'); + }); + + it('should create a notification with options', () => { + const notification = new globalThis.Notification('Test', { + body: 'Test body', + icon: 'https://example.com/icon.png', + tag: 'test-tag', + }); + expect(notification.body).toBe('Test body'); + expect(notification.icon).toBe('https://example.com/icon.png'); + expect(notification.tag).toBe('test-tag'); + }); + + it('should call notify with showNotification message', () => { + new globalThis.Notification('Test Title', { body: 'Test body' }); + expect(mockMessaging.notify).toHaveBeenCalledWith('showNotification', jasmine.objectContaining({ + title: 'Test Title', + body: 'Test body', + })); + }); + + it('should generate a unique id for each notification', () => { + new globalThis.Notification('Test 1'); + new globalThis.Notification('Test 2'); + const calls = mockMessaging.notify.calls.allArgs(); + const id1 = calls[0][1].id; + const id2 = calls[1][1].id; + expect(id1).not.toBe(id2); + }); + + it('should have null event handlers by default', () => { + const notification = new globalThis.Notification('Test'); + expect(notification.onclick).toBeNull(); + expect(notification.onclose).toBeNull(); + expect(notification.onerror).toBeNull(); + expect(notification.onshow).toBeNull(); + }); + }); + + describe('Notification.close()', () => { + it('should call notify with closeNotification message', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + notification.close(); + + expect(mockMessaging.notify).toHaveBeenCalledWith('closeNotification', { + id: notificationId, + }); + }); + }); + + describe('Notification.requestPermission()', () => { + it('should return a promise that resolves to permission', async () => { + const result = await globalThis.Notification.requestPermission(); + expect(result).toBe('granted'); + }); + + it('should call the deprecated callback if provided', async () => { + const callback = jasmine.createSpy('callback'); + await globalThis.Notification.requestPermission(callback); + expect(callback).toHaveBeenCalledWith('granted'); + }); + + it('should call request with requestPermission message', async () => { + await globalThis.Notification.requestPermission(); + expect(mockMessaging.request).toHaveBeenCalledWith('requestPermission', {}); + }); + + it('should handle request errors gracefully', async () => { + mockMessaging.request.and.returnValue(Promise.reject(new Error('Network error'))); + const result = await globalThis.Notification.requestPermission(); + expect(result).toBe('granted'); // Falls back to granted + }); + }); + + describe('Notification events', () => { + it('should subscribe to notificationEvent', () => { + expect(mockMessaging.subscribe).toHaveBeenCalledWith('notificationEvent', jasmine.any(Function)); + }); + + it('should call onclick handler when click event is received', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + const clickHandler = jasmine.createSpy('onclick'); + notification.onclick = clickHandler; + + mockSubscribeCallback({ id: notificationId, event: 'click' }); + + expect(clickHandler).toHaveBeenCalled(); + }); + + it('should call onshow handler when show event is received', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + const showHandler = jasmine.createSpy('onshow'); + notification.onshow = showHandler; + + mockSubscribeCallback({ id: notificationId, event: 'show' }); + + expect(showHandler).toHaveBeenCalled(); + }); + + it('should call onclose handler when close event is received', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + const closeHandler = jasmine.createSpy('onclose'); + notification.onclose = closeHandler; + + mockSubscribeCallback({ id: notificationId, event: 'close' }); + + expect(closeHandler).toHaveBeenCalled(); + }); + + it('should call onerror handler when error event is received', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + const errorHandler = jasmine.createSpy('onerror'); + notification.onerror = errorHandler; + + mockSubscribeCallback({ id: notificationId, event: 'error' }); + + expect(errorHandler).toHaveBeenCalled(); + }); + + it('should not throw if event handler is not set', () => { + const notification = new globalThis.Notification('Test'); + const showCall = mockMessaging.notify.calls.mostRecent(); + const notificationId = showCall.args[1].id; + + expect(() => { + mockSubscribeCallback({ id: notificationId, event: 'click' }); + }).not.toThrow(); + }); + + it('should ignore events for unknown notification ids', () => { + const notification = new globalThis.Notification('Test'); + const clickHandler = jasmine.createSpy('onclick'); + notification.onclick = clickHandler; + + mockSubscribeCallback({ id: 'unknown-id', event: 'click' }); + + expect(clickHandler).not.toHaveBeenCalled(); + }); + }); + + describe('toString behavior', () => { + it('should have native-like toString for Notification', () => { + expect(globalThis.Notification.toString()).toBe('function Notification() { [native code] }'); + }); + + it('should have native-like toString for requestPermission', () => { + expect(globalThis.Notification.requestPermission.toString()).toBe('function requestPermission() { [native code] }'); + }); + }); +}); + From 9f55e0a03f2ef5a6655bb22919ca548a1ee0a3af Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Thu, 27 Nov 2025 20:32:23 +0100 Subject: [PATCH 02/10] Fix: Coerce notification options to strings for Swift decoding - Convert title, body, icon, tag to String() in constructor - Add tests for non-string option coercion - Ensures Swift Decodable can parse numeric tag values --- injected/src/features/web-notifications.js | 8 ++++---- injected/unit-test/web-notifications.js | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/injected/src/features/web-notifications.js b/injected/src/features/web-notifications.js index b3200a45f9..24bbbca1f8 100644 --- a/injected/src/features/web-notifications.js +++ b/injected/src/features/web-notifications.js @@ -88,10 +88,10 @@ export default class WebNotifications extends ContentFeature { */ constructor(title, options = {}) { this.#id = crypto.randomUUID(); - this.title = title; - this.body = options.body || ''; - this.icon = options.icon || ''; - this.tag = options.tag || ''; + 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.#notifications.set(this.#id, this); diff --git a/injected/unit-test/web-notifications.js b/injected/unit-test/web-notifications.js index 4069ecd814..2072acb436 100644 --- a/injected/unit-test/web-notifications.js +++ b/injected/unit-test/web-notifications.js @@ -110,6 +110,26 @@ describe('WebNotifications feature', () => { expect(notification.onerror).toBeNull(); expect(notification.onshow).toBeNull(); }); + + it('should coerce non-string options to strings', () => { + const notification = new globalThis.Notification(123, { + body: 456, + icon: 789, + tag: 42, + }); + expect(notification.title).toBe('123'); + expect(notification.body).toBe('456'); + expect(notification.icon).toBe('789'); + expect(notification.tag).toBe('42'); + }); + + it('should send coerced string values to native', () => { + new globalThis.Notification(123, { tag: 42 }); + expect(mockMessaging.notify).toHaveBeenCalledWith('showNotification', jasmine.objectContaining({ + title: '123', + tag: '42', + })); + }); }); describe('Notification.close()', () => { From 5aab99ce9d45086c7f203d83bea04500a83016e7 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 28 Nov 2025 16:55:49 +0100 Subject: [PATCH 03/10] Move webNotifications to webCompat setting - Add webNotificationsFix() to web-compat.js as independent setting - Move message definitions from web-notifications/ to web-compat/ - Remove webNotifications from standalone feature registration - Delete standalone web-notifications.js and types - Replace unit tests with integration tests in web-compat.spec.js - Regenerate web-compat.ts types for new messages --- injected/integration-test/web-compat.spec.js | 111 ++++++++ injected/src/content-scope-features.js | 6 - injected/src/features.js | 3 +- injected/src/features/web-compat.js | 175 ++++++++++++ injected/src/features/web-notifications.js | 179 ------------ .../closeNotification.notify.json | 0 .../notificationEvent.subscribe.json | 0 .../requestPermission.request.json | 0 .../requestPermission.response.json | 0 .../showNotification.notify.json | 0 injected/src/types/web-compat.ts | 97 ++++++- injected/src/types/web-notifications.ts | 113 -------- injected/unit-test/web-notifications.js | 261 ------------------ 13 files changed, 382 insertions(+), 563 deletions(-) delete mode 100644 injected/src/features/web-notifications.js rename injected/src/messages/{web-notifications => web-compat}/closeNotification.notify.json (100%) rename injected/src/messages/{web-notifications => web-compat}/notificationEvent.subscribe.json (100%) rename injected/src/messages/{web-notifications => web-compat}/requestPermission.request.json (100%) rename injected/src/messages/{web-notifications => web-compat}/requestPermission.response.json (100%) rename injected/src/messages/{web-notifications => web-compat}/showNotification.notify.json (100%) delete mode 100644 injected/src/types/web-notifications.ts delete mode 100644 injected/unit-test/web-notifications.js diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index 9c3bf17e90..79b18d8b01 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -153,6 +153,117 @@ 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('Permissions API', () => { // Fake the Permission API not existing in this browser const removePermissionsScript = ` diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index c7a77d4abd..2b062f6c23 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -52,12 +52,6 @@ export function load(args) { // point, which is why we fall back to `bundledFeatureNames`. : args.site.enabledFeatures || bundledFeatureNames; - // DEBUG: Log feature loading info - console.log('[CSS DEBUG] bundledFeatureNames:', bundledFeatureNames); - console.log('[CSS DEBUG] featuresToLoad:', featuresToLoad); - console.log('[CSS DEBUG] webNotifications in featuresToLoad:', featuresToLoad.includes('webNotifications')); - console.log('[CSS DEBUG] site.enabledFeatures:', args.site?.enabledFeatures); - for (const featureName of bundledFeatureNames) { if (featuresToLoad.includes(featureName)) { const ContentFeature = platformFeatures['ddg_feature_' + featureName]; diff --git a/injected/src/features.js b/injected/src/features.js index 76f00f7abe..3d0995ad7d 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -33,13 +33,12 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', - 'webNotifications', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext', 'webNotifications'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'], 'apple-isolated': [ 'duckPlayer', 'duckPlayerNative', diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 47a9969de5..cb7a03a6c1 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -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,175 @@ export class WebCompat extends ContentFeature { }); } + /** + * Web Notifications polyfill that communicates with native code for permission + * management and notification display. + */ + webNotificationsFix() { + console.log('[webNotificationsFix] Starting - will override Notification API'); + console.log('[webNotificationsFix] Current Notification exists:', !!globalThis.Notification); + // eslint-disable-next-line @typescript-eslint/no-this-alias + const feature = this; + + /** + * 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() { + console.log('[webNotificationsFix] permission getter called, returning granted'); + return 'granted'; + } + + /** + * @param {NotificationPermissionCallback} [deprecatedCallback] + * @returns {Promise} + */ + static async requestPermission(deprecatedCallback) { + console.log('[webNotificationsFix] requestPermission called'); + try { + const result = await feature.request('requestPermission', {}); + console.log('[webNotificationsFix] requestPermission result from native:', result); + const permission = result?.permission || 'granted'; + console.log('[webNotificationsFix] requestPermission returning:', permission); + if (deprecatedCallback) { + deprecatedCallback(permission); + } + return permission; + } catch (e) { + console.log('[webNotificationsFix] requestPermission error:', e); + const fallback = 'granted'; + if (deprecatedCallback) { + deprecatedCallback(fallback); + } + return fallback; + } + } + + /** + * @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); + + feature.notify('showNotification', { + id: this.#id, + title: this.title, + body: this.body, + icon: this.icon, + tag: this.tag, + }); + } + + close() { + feature.notify('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 + this.subscribe('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: () => 'granted', + 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, + }); + + console.log('[webNotificationsFix] Polyfill installed. Notification.permission:', globalThis.Notification.permission); + } + cleanIframeValue() { function cleanValueData(val) { const clone = Object.assign({}, val); diff --git a/injected/src/features/web-notifications.js b/injected/src/features/web-notifications.js deleted file mode 100644 index 24bbbca1f8..0000000000 --- a/injected/src/features/web-notifications.js +++ /dev/null @@ -1,179 +0,0 @@ -import ContentFeature from '../content-feature.js'; -import { wrapToString } from '../wrapper-utils.js'; - -/** - * Web Notifications feature - provides a polyfill for the Web Notifications API - * that communicates with native code for permission management and notification display. - */ -export default class WebNotifications extends ContentFeature { - /** @type {Map} */ - #notifications = new Map(); - - init() { - console.log('[WebNotifications] init() called'); - this.#initNotificationPolyfill(); - console.log('[WebNotifications] Notification polyfill installed'); - } - - #initNotificationPolyfill() { - const feature = this; - - /** - * 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() { - // For now, always return 'granted' - Project 5 will query native - return 'granted'; - } - - /** - * @param {NotificationPermissionCallback} [deprecatedCallback] - * @returns {Promise} - */ - static async requestPermission(deprecatedCallback) { - try { - const result = await feature.request('requestPermission', {}); - const permission = result?.permission || 'granted'; - if (deprecatedCallback) { - deprecatedCallback(permission); - } - return permission; - } catch (e) { - feature.log.error('requestPermission failed:', e); - const fallback = 'granted'; - if (deprecatedCallback) { - deprecatedCallback(fallback); - } - return fallback; - } - } - - /** - * @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.#notifications.set(this.#id, this); - - feature.notify('showNotification', { - id: this.#id, - title: this.title, - body: this.body, - icon: this.icon, - tag: this.tag, - }); - } - - close() { - feature.notify('closeNotification', { id: this.#id }); - feature.#notifications.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 - this.subscribe('notificationEvent', (data) => { - const notification = this.#notifications.get(data.id); - if (!notification) return; - - const eventName = `on${data.event}`; - if (typeof notification[eventName] === 'function') { - try { - notification[eventName].call(notification, new Event(data.event)); - } catch (e) { - feature.log.error(`Error in ${eventName} handler:`, e); - } - } - - // Clean up on close event - if (data.event === 'close') { - this.#notifications.delete(data.id); - } - }); - - // Define the Notification property on globalThis - this.defineProperty(globalThis, 'Notification', { - value: wrappedNotification, - writable: true, - configurable: true, - enumerable: false, - }); - - // Define permission getter (return value directly to avoid recursion) - this.defineProperty(globalThis.Notification, 'permission', { - get: () => 'granted', // For now, always return 'granted' - Project 5 will query native - configurable: true, - enumerable: true, - }); - - // Define maxActions getter (return value directly to avoid recursion) - 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, - }); - } -} - diff --git a/injected/src/messages/web-notifications/closeNotification.notify.json b/injected/src/messages/web-compat/closeNotification.notify.json similarity index 100% rename from injected/src/messages/web-notifications/closeNotification.notify.json rename to injected/src/messages/web-compat/closeNotification.notify.json diff --git a/injected/src/messages/web-notifications/notificationEvent.subscribe.json b/injected/src/messages/web-compat/notificationEvent.subscribe.json similarity index 100% rename from injected/src/messages/web-notifications/notificationEvent.subscribe.json rename to injected/src/messages/web-compat/notificationEvent.subscribe.json diff --git a/injected/src/messages/web-notifications/requestPermission.request.json b/injected/src/messages/web-compat/requestPermission.request.json similarity index 100% rename from injected/src/messages/web-notifications/requestPermission.request.json rename to injected/src/messages/web-compat/requestPermission.request.json diff --git a/injected/src/messages/web-notifications/requestPermission.response.json b/injected/src/messages/web-compat/requestPermission.response.json similarity index 100% rename from injected/src/messages/web-notifications/requestPermission.response.json rename to injected/src/messages/web-compat/requestPermission.response.json diff --git a/injected/src/messages/web-notifications/showNotification.notify.json b/injected/src/messages/web-compat/showNotification.notify.json similarity index 100% rename from injected/src/messages/web-notifications/showNotification.notify.json rename to injected/src/messages/web-compat/showNotification.notify.json 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 diff --git a/injected/src/types/web-notifications.ts b/injected/src/types/web-notifications.ts deleted file mode 100644 index dd2858cee5..0000000000 --- a/injected/src/types/web-notifications.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * These types are auto-generated from schema files. - * scripts/build-types.mjs is responsible for type generation. - * **DO NOT** edit this file directly as your changes will be lost. - * - * @module WebNotifications Messages - */ - -/** - * Requests, Notifications and Subscriptions from the WebNotifications feature - */ -export interface WebNotificationsMessages { - notifications: CloseNotificationNotification | ShowNotificationNotification; - requests: RequestPermissionRequest; - subscriptions: NotificationEventSubscription; -} -/** - * Generated from @see "../messages/web-notifications/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-notifications/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-notifications/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-notifications/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-notifications.js" { - export interface WebNotifications { - 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 diff --git a/injected/unit-test/web-notifications.js b/injected/unit-test/web-notifications.js deleted file mode 100644 index 2072acb436..0000000000 --- a/injected/unit-test/web-notifications.js +++ /dev/null @@ -1,261 +0,0 @@ -import WebNotifications from '../src/features/web-notifications.js'; - -describe('WebNotifications feature', () => { - /** @type {WebNotifications} */ - let feature; - let mockMessaging; - let mockSubscribeCallback; - - beforeEach(() => { - // Reset globalThis.Notification if it exists - if (globalThis.Notification) { - delete globalThis.Notification; - } - - // Create mock messaging - mockMessaging = { - notify: jasmine.createSpy('notify'), - request: jasmine.createSpy('request').and.returnValue(Promise.resolve({ permission: 'granted' })), - subscribe: jasmine.createSpy('subscribe').and.callFake((name, cb) => { - mockSubscribeCallback = cb; - return () => {}; - }), - }; - - feature = new WebNotifications( - 'webNotifications', - {}, - { - site: { domain: 'example.com', url: 'https://example.com' }, - platform: { name: 'apple', internal: true }, - }, - ); - - // Override messaging getter - Object.defineProperty(feature, 'messaging', { - get: () => mockMessaging, - }); - - // Override _messaging - feature._messaging = mockMessaging; - - feature.callInit({ - site: { domain: 'example.com', url: 'https://example.com' }, - platform: { name: 'apple', internal: true }, - }); - }); - - afterEach(() => { - if (globalThis.Notification) { - delete globalThis.Notification; - } - }); - - describe('Notification polyfill setup', () => { - it('should define Notification on window', () => { - expect(globalThis.Notification).toBeDefined(); - }); - - it('should define Notification.permission as granted by default', () => { - expect(globalThis.Notification.permission).toBe('granted'); - }); - - it('should define Notification.maxActions as 2', () => { - expect(globalThis.Notification.maxActions).toBe(2); - }); - - it('should define Notification.requestPermission as a function', () => { - expect(typeof globalThis.Notification.requestPermission).toBe('function'); - }); - }); - - describe('Notification constructor', () => { - it('should create a notification with title', () => { - const notification = new globalThis.Notification('Test Title'); - expect(notification.title).toBe('Test Title'); - }); - - it('should create a notification with options', () => { - const notification = new globalThis.Notification('Test', { - body: 'Test body', - icon: 'https://example.com/icon.png', - tag: 'test-tag', - }); - expect(notification.body).toBe('Test body'); - expect(notification.icon).toBe('https://example.com/icon.png'); - expect(notification.tag).toBe('test-tag'); - }); - - it('should call notify with showNotification message', () => { - new globalThis.Notification('Test Title', { body: 'Test body' }); - expect(mockMessaging.notify).toHaveBeenCalledWith('showNotification', jasmine.objectContaining({ - title: 'Test Title', - body: 'Test body', - })); - }); - - it('should generate a unique id for each notification', () => { - new globalThis.Notification('Test 1'); - new globalThis.Notification('Test 2'); - const calls = mockMessaging.notify.calls.allArgs(); - const id1 = calls[0][1].id; - const id2 = calls[1][1].id; - expect(id1).not.toBe(id2); - }); - - it('should have null event handlers by default', () => { - const notification = new globalThis.Notification('Test'); - expect(notification.onclick).toBeNull(); - expect(notification.onclose).toBeNull(); - expect(notification.onerror).toBeNull(); - expect(notification.onshow).toBeNull(); - }); - - it('should coerce non-string options to strings', () => { - const notification = new globalThis.Notification(123, { - body: 456, - icon: 789, - tag: 42, - }); - expect(notification.title).toBe('123'); - expect(notification.body).toBe('456'); - expect(notification.icon).toBe('789'); - expect(notification.tag).toBe('42'); - }); - - it('should send coerced string values to native', () => { - new globalThis.Notification(123, { tag: 42 }); - expect(mockMessaging.notify).toHaveBeenCalledWith('showNotification', jasmine.objectContaining({ - title: '123', - tag: '42', - })); - }); - }); - - describe('Notification.close()', () => { - it('should call notify with closeNotification message', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - notification.close(); - - expect(mockMessaging.notify).toHaveBeenCalledWith('closeNotification', { - id: notificationId, - }); - }); - }); - - describe('Notification.requestPermission()', () => { - it('should return a promise that resolves to permission', async () => { - const result = await globalThis.Notification.requestPermission(); - expect(result).toBe('granted'); - }); - - it('should call the deprecated callback if provided', async () => { - const callback = jasmine.createSpy('callback'); - await globalThis.Notification.requestPermission(callback); - expect(callback).toHaveBeenCalledWith('granted'); - }); - - it('should call request with requestPermission message', async () => { - await globalThis.Notification.requestPermission(); - expect(mockMessaging.request).toHaveBeenCalledWith('requestPermission', {}); - }); - - it('should handle request errors gracefully', async () => { - mockMessaging.request.and.returnValue(Promise.reject(new Error('Network error'))); - const result = await globalThis.Notification.requestPermission(); - expect(result).toBe('granted'); // Falls back to granted - }); - }); - - describe('Notification events', () => { - it('should subscribe to notificationEvent', () => { - expect(mockMessaging.subscribe).toHaveBeenCalledWith('notificationEvent', jasmine.any(Function)); - }); - - it('should call onclick handler when click event is received', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - const clickHandler = jasmine.createSpy('onclick'); - notification.onclick = clickHandler; - - mockSubscribeCallback({ id: notificationId, event: 'click' }); - - expect(clickHandler).toHaveBeenCalled(); - }); - - it('should call onshow handler when show event is received', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - const showHandler = jasmine.createSpy('onshow'); - notification.onshow = showHandler; - - mockSubscribeCallback({ id: notificationId, event: 'show' }); - - expect(showHandler).toHaveBeenCalled(); - }); - - it('should call onclose handler when close event is received', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - const closeHandler = jasmine.createSpy('onclose'); - notification.onclose = closeHandler; - - mockSubscribeCallback({ id: notificationId, event: 'close' }); - - expect(closeHandler).toHaveBeenCalled(); - }); - - it('should call onerror handler when error event is received', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - const errorHandler = jasmine.createSpy('onerror'); - notification.onerror = errorHandler; - - mockSubscribeCallback({ id: notificationId, event: 'error' }); - - expect(errorHandler).toHaveBeenCalled(); - }); - - it('should not throw if event handler is not set', () => { - const notification = new globalThis.Notification('Test'); - const showCall = mockMessaging.notify.calls.mostRecent(); - const notificationId = showCall.args[1].id; - - expect(() => { - mockSubscribeCallback({ id: notificationId, event: 'click' }); - }).not.toThrow(); - }); - - it('should ignore events for unknown notification ids', () => { - const notification = new globalThis.Notification('Test'); - const clickHandler = jasmine.createSpy('onclick'); - notification.onclick = clickHandler; - - mockSubscribeCallback({ id: 'unknown-id', event: 'click' }); - - expect(clickHandler).not.toHaveBeenCalled(); - }); - }); - - describe('toString behavior', () => { - it('should have native-like toString for Notification', () => { - expect(globalThis.Notification.toString()).toBe('function Notification() { [native code] }'); - }); - - it('should have native-like toString for requestPermission', () => { - expect(globalThis.Notification.requestPermission.toString()).toBe('function requestPermission() { [native code] }'); - }); - }); -}); - From 6b6a3327ca05d2cfcac5e37158bdeece91e97c0d Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 28 Nov 2025 17:20:20 +0100 Subject: [PATCH 04/10] Add nativeEnabled setting and isSecureContext gate to webNotifications - Add isSecureContext check (crypto.randomUUID requires secure context) - Add nativeEnabled setting (defaults true, when false returns denied and skips native calls) - Add integration tests for nativeEnabled: false behavior --- injected/integration-test/web-compat.spec.js | 50 ++++++++++++++++++++ injected/src/features/web-compat.js | 43 +++++++++++------ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index 79b18d8b01..2e2c22d639 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -264,6 +264,56 @@ test.describe('webNotifications', () => { }); }); +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 cb7a03a6c1..bb239733b7 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -279,11 +279,27 @@ export class WebCompat extends ContentFeature { * management and notification display. */ webNotificationsFix() { + // crypto.randomUUID() requires secure context + if (!globalThis.isSecureContext) { + return; + } + console.log('[webNotificationsFix] Starting - will override Notification API'); console.log('[webNotificationsFix] Current Notification exists:', !!globalThis.Notification); // 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 */ @@ -315,8 +331,8 @@ export class WebCompat extends ContentFeature { * @returns {'default' | 'denied' | 'granted'} */ static get permission() { - console.log('[webNotificationsFix] permission getter called, returning granted'); - return 'granted'; + console.log('[webNotificationsFix] permission getter called, returning', permission); + return permission; } /** @@ -326,21 +342,20 @@ export class WebCompat extends ContentFeature { static async requestPermission(deprecatedCallback) { console.log('[webNotificationsFix] requestPermission called'); try { - const result = await feature.request('requestPermission', {}); + const result = await nativeRequest('requestPermission', {}); console.log('[webNotificationsFix] requestPermission result from native:', result); - const permission = result?.permission || 'granted'; - console.log('[webNotificationsFix] requestPermission returning:', permission); + const resultPermission = result?.permission || permission; + console.log('[webNotificationsFix] requestPermission returning:', resultPermission); if (deprecatedCallback) { - deprecatedCallback(permission); + deprecatedCallback(resultPermission); } - return permission; + return resultPermission; } catch (e) { console.log('[webNotificationsFix] requestPermission error:', e); - const fallback = 'granted'; if (deprecatedCallback) { - deprecatedCallback(fallback); + deprecatedCallback(permission); } - return fallback; + return permission; } } @@ -365,7 +380,7 @@ export class WebCompat extends ContentFeature { feature.#webNotifications.set(this.#id, this); - feature.notify('showNotification', { + nativeNotify('showNotification', { id: this.#id, title: this.title, body: this.body, @@ -375,7 +390,7 @@ export class WebCompat extends ContentFeature { } close() { - feature.notify('closeNotification', { id: this.#id }); + nativeNotify('closeNotification', { id: this.#id }); feature.#webNotifications.delete(this.#id); } } @@ -391,7 +406,7 @@ export class WebCompat extends ContentFeature { ); // Subscribe to notification events from native - this.subscribe('notificationEvent', (data) => { + nativeSubscribe('notificationEvent', (data) => { const notification = this.#webNotifications.get(data.id); if (!notification) return; @@ -420,7 +435,7 @@ export class WebCompat extends ContentFeature { // Define permission getter this.defineProperty(globalThis.Notification, 'permission', { - get: () => 'granted', + get: () => permission, configurable: true, enumerable: true, }); From f091fc8ec49e9fbb49c6bdd57e8d175d0e147484 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 28 Nov 2025 17:29:16 +0100 Subject: [PATCH 05/10] Remove debug logging from webNotificationsFix --- injected/src/features/web-compat.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index bb239733b7..4793db135f 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -284,8 +284,6 @@ export class WebCompat extends ContentFeature { return; } - console.log('[webNotificationsFix] Starting - will override Notification API'); - console.log('[webNotificationsFix] Current Notification exists:', !!globalThis.Notification); // eslint-disable-next-line @typescript-eslint/no-this-alias const feature = this; @@ -331,7 +329,6 @@ export class WebCompat extends ContentFeature { * @returns {'default' | 'denied' | 'granted'} */ static get permission() { - console.log('[webNotificationsFix] permission getter called, returning', permission); return permission; } @@ -340,18 +337,14 @@ export class WebCompat extends ContentFeature { * @returns {Promise} */ static async requestPermission(deprecatedCallback) { - console.log('[webNotificationsFix] requestPermission called'); try { const result = await nativeRequest('requestPermission', {}); - console.log('[webNotificationsFix] requestPermission result from native:', result); - const resultPermission = result?.permission || permission; - console.log('[webNotificationsFix] requestPermission returning:', resultPermission); + const resultPermission = /** @type {NotificationPermission} */ (result?.permission || permission); if (deprecatedCallback) { deprecatedCallback(resultPermission); } return resultPermission; } catch (e) { - console.log('[webNotificationsFix] requestPermission error:', e); if (deprecatedCallback) { deprecatedCallback(permission); } @@ -454,8 +447,6 @@ export class WebCompat extends ContentFeature { configurable: true, enumerable: true, }); - - console.log('[webNotificationsFix] Polyfill installed. Notification.permission:', globalThis.Notification.permission); } cleanIframeValue() { From 3a8dc54b1df275964d6148c6eae756eee1968ab4 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 1 Dec 2025 10:45:27 +0100 Subject: [PATCH 06/10] Address PR feedback for webNotificationsFix - Update secure context comment to clarify Notification API requirement - Replace wrapToString with shimInterface for consistent constructor wrapping --- injected/src/features/web-compat.js | 18 +++++++----------- .../web-compat/closeNotification.notify.json | 1 + .../notificationEvent.subscribe.json | 1 + .../web-compat/requestPermission.request.json | 1 + .../web-compat/requestPermission.response.json | 1 + .../web-compat/showNotification.notify.json | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 4793db135f..f50d1ace95 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -279,7 +279,7 @@ export class WebCompat extends ContentFeature { * management and notification display. */ webNotificationsFix() { - // crypto.randomUUID() requires secure context + // Notification API is not supported in insecure contexts if (!globalThis.isSecureContext) { return; } @@ -388,8 +388,12 @@ export class WebCompat extends ContentFeature { } } - // Wrap the constructor to make toString() look native - const wrappedNotification = wrapToString(NotificationPolyfill, NotificationPolyfill, 'function Notification() { [native code] }'); + // Use shimInterface for consistent constructor wrapping + this.shimInterface('Notification', NotificationPolyfill, { + disallowConstructor: false, + allowConstructorCall: false, + wrapToString: true, + }); // Wrap static methods const wrappedRequestPermission = wrapToString( @@ -418,14 +422,6 @@ export class WebCompat extends ContentFeature { } }); - // 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, diff --git a/injected/src/messages/web-compat/closeNotification.notify.json b/injected/src/messages/web-compat/closeNotification.notify.json index d03f11cf4a..7fd4645ec3 100644 --- a/injected/src/messages/web-compat/closeNotification.notify.json +++ b/injected/src/messages/web-compat/closeNotification.notify.json @@ -13,3 +13,4 @@ } } + diff --git a/injected/src/messages/web-compat/notificationEvent.subscribe.json b/injected/src/messages/web-compat/notificationEvent.subscribe.json index 6d46080792..8b37fd937b 100644 --- a/injected/src/messages/web-compat/notificationEvent.subscribe.json +++ b/injected/src/messages/web-compat/notificationEvent.subscribe.json @@ -18,3 +18,4 @@ } } + diff --git a/injected/src/messages/web-compat/requestPermission.request.json b/injected/src/messages/web-compat/requestPermission.request.json index c998baf26f..a9642da2ee 100644 --- a/injected/src/messages/web-compat/requestPermission.request.json +++ b/injected/src/messages/web-compat/requestPermission.request.json @@ -7,3 +7,4 @@ "properties": {} } + diff --git a/injected/src/messages/web-compat/requestPermission.response.json b/injected/src/messages/web-compat/requestPermission.response.json index 547881adaa..37a8cd959c 100644 --- a/injected/src/messages/web-compat/requestPermission.response.json +++ b/injected/src/messages/web-compat/requestPermission.response.json @@ -14,3 +14,4 @@ } } + diff --git a/injected/src/messages/web-compat/showNotification.notify.json b/injected/src/messages/web-compat/showNotification.notify.json index d5213f1bc1..99f40c1bc5 100644 --- a/injected/src/messages/web-compat/showNotification.notify.json +++ b/injected/src/messages/web-compat/showNotification.notify.json @@ -29,3 +29,4 @@ } } + From cac0a60e0ca12c2622acc77c7f2a561641b73e18 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 1 Dec 2025 10:54:44 +0100 Subject: [PATCH 07/10] Revert shimInterface change due to TypeScript constraints shimInterface requires full interface implementation including EventTarget, which is impractical for the Notification polyfill. Reverting to wrapToString. --- injected/src/features/web-compat.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index f50d1ace95..bbaee581e2 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -388,12 +388,8 @@ export class WebCompat extends ContentFeature { } } - // Use shimInterface for consistent constructor wrapping - this.shimInterface('Notification', NotificationPolyfill, { - disallowConstructor: false, - allowConstructorCall: false, - wrapToString: true, - }); + // Wrap the constructor to make toString() look native + const wrappedNotification = wrapToString(NotificationPolyfill, NotificationPolyfill, 'function Notification() { [native code] }'); // Wrap static methods const wrappedRequestPermission = wrapToString( @@ -422,6 +418,14 @@ export class WebCompat extends ContentFeature { } }); + // 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, From ac47e29f2db5c7a5f8609b3755bc867c773775f7 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 1 Dec 2025 11:50:01 +0100 Subject: [PATCH 08/10] Use wrapFunction for Notification constructor, fix close() onclose handler, improve permission flow --- injected/integration-test/web-compat.spec.js | 13 ++++------- injected/src/features/web-compat.js | 24 ++++++++++++++------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index 2e2c22d639..c48a6df67f 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -170,10 +170,10 @@ test.describe('webNotifications', () => { expect(hasNotification).toEqual(true); }); - test('should return granted for permission', async ({ page }) => { + test('should return default for permission initially', async ({ page }) => { await beforeWebNotifications(page); const permission = await page.evaluate(() => window.Notification.permission); - expect(permission).toEqual('granted'); + expect(permission).toEqual('default'); }); test('should return 2 for maxActions', async ({ page }) => { @@ -241,7 +241,7 @@ test.describe('webNotifications', () => { expect(permission).toEqual('denied'); }); - test('should default to granted when native error occurs', async ({ page }) => { + test('should return denied when native error occurs', async ({ page }) => { await beforeWebNotifications(page); await page.evaluate(() => { globalThis.cssMessaging.impl.request = () => { @@ -250,15 +250,12 @@ test.describe('webNotifications', () => { }); const permission = await page.evaluate(() => window.Notification.requestPermission()); - expect(permission).toEqual('granted'); + expect(permission).toEqual('denied'); }); - test('should have native-looking toString()', async ({ page }) => { + test('requestPermission 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] }'); }); diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index bbaee581e2..4c768c7baa 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 */ @@ -295,8 +295,9 @@ export class WebCompat extends ContentFeature { 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} */ - const permission = nativeEnabled ? 'granted' : 'denied'; + const permission = nativeEnabled ? 'default' : 'denied'; /** * NotificationPolyfill - replaces the native Notification API @@ -339,16 +340,16 @@ export class WebCompat extends ContentFeature { static async requestPermission(deprecatedCallback) { try { const result = await nativeRequest('requestPermission', {}); - const resultPermission = /** @type {NotificationPermission} */ (result?.permission || permission); + const resultPermission = /** @type {NotificationPermission} */ (result?.permission || 'denied'); if (deprecatedCallback) { deprecatedCallback(resultPermission); } return resultPermission; } catch (e) { if (deprecatedCallback) { - deprecatedCallback(permission); + deprecatedCallback('denied'); } - return permission; + return 'denied'; } } @@ -384,12 +385,21 @@ export class WebCompat extends ContentFeature { close() { nativeNotify('closeNotification', { id: this.#id }); + // Fire onclose handler before removing from map + 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 + } + } feature.#webNotifications.delete(this.#id); } } - // Wrap the constructor to make toString() look native - const wrappedNotification = wrapToString(NotificationPolyfill, NotificationPolyfill, 'function Notification() { [native code] }'); + // Wrap the constructor + const wrappedNotification = wrapFunction(NotificationPolyfill, NotificationPolyfill); // Wrap static methods const wrappedRequestPermission = wrapToString( From 631608c15c39ef6814b5a861d9b65b90a6f0017e Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 1 Dec 2025 12:08:59 +0100 Subject: [PATCH 09/10] Update Notification.permission after requestPermission resolves --- injected/integration-test/web-compat.spec.js | 21 ++++++++++++++++++++ injected/src/features/web-compat.js | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index c48a6df67f..dd558a2d10 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -241,6 +241,27 @@ test.describe('webNotifications', () => { 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(() => { diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 4c768c7baa..902cb05783 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -297,7 +297,7 @@ export class WebCompat extends ContentFeature { const nativeSubscribe = nativeEnabled ? (name, cb) => feature.subscribe(name, cb) : () => () => {}; // Permission is 'default' when enabled (not yet determined), 'denied' when disabled /** @type {NotificationPermission} */ - const permission = nativeEnabled ? 'default' : 'denied'; + let permission = nativeEnabled ? 'default' : 'denied'; /** * NotificationPolyfill - replaces the native Notification API @@ -341,11 +341,15 @@ export class WebCompat extends ContentFeature { 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'); } From a5993e5fc70ba1d687ac7db8392c8ac122e3b4e6 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 1 Dec 2025 12:12:19 +0100 Subject: [PATCH 10/10] Make close() idempotent to prevent multiple onclose events --- injected/integration-test/web-compat.spec.js | 21 ++++++++++++++++++++ injected/src/features/web-compat.js | 9 +++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index dd558a2d10..b053f8e709 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -229,6 +229,27 @@ test.describe('webNotifications', () => { 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(() => { diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 902cb05783..bd6c8a2d5e 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -388,8 +388,14 @@ export class WebCompat extends ContentFeature { } close() { + // Guard against multiple close() calls - only fire onclose once + if (!feature.#webNotifications.has(this.#id)) { + return; + } nativeNotify('closeNotification', { id: this.#id }); - // Fire onclose handler before removing from map + // 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 @@ -398,7 +404,6 @@ export class WebCompat extends ContentFeature { // Error in event handler - silently ignore } } - feature.#webNotifications.delete(this.#id); } }