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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions injected/src/content-scope-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
3 changes: 2 additions & 1 deletion injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ const otherFeatures = /** @type {const} */ ([
'favicon',
'webTelemetry',
'pageContext',
'webNotifications',
]);

/** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */
/** @type {Record<string, FeatureName[]>} */
export const platformSupport = {
apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'],
apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext', 'webNotifications'],
'apple-isolated': [
'duckPlayer',
'duckPlayerNative',
Expand Down
179 changes: 179 additions & 0 deletions injected/src/features/web-notifications.js
Original file line number Diff line number Diff line change
@@ -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<string, object>} */
#notifications = new Map();

init() {
console.log('[WebNotifications] init() called');
this.#initNotificationPolyfill();
console.log('[WebNotifications] Notification polyfill installed');
}

#initNotificationPolyfill() {

Check failure on line 18 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / snapshots

Private methods are currently unsupported in older WebKit and ESR Firefox

Check failure on line 18 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

Private methods are currently unsupported in older WebKit and ESR Firefox
const feature = this;

Check failure on line 19 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / snapshots

Unexpected aliasing of 'this' to local variable

Check failure on line 19 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

Unexpected aliasing of 'this' to local variable

/**
* 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<NotificationPermission>}
*/
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));

Check failure on line 136 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / snapshots

Unnecessary '.call()'

Check failure on line 136 in injected/src/features/web-notifications.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

Unnecessary '.call()'
} 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,
});
}
}

Original file line number Diff line number Diff line change
@@ -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"
}
}
}

Original file line number Diff line number Diff line change
@@ -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"]
}
}
}

Original file line number Diff line number Diff line change
@@ -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": {}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "RequestPermissionResponse",
"description": "Response from notification permission request",
"additionalProperties": false,
"required": ["permission"],
"properties": {
"permission": {
"description": "The permission state",
"type": "string",
"enum": ["default", "denied", "granted"]
}
}
}

Original file line number Diff line number Diff line change
@@ -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"
}
}
}

Loading
Loading