Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,4 @@ pip-log.txt

sftp-config.json
node_modules/
nodebb-plugin-web-push-1.xml
2 changes: 1 addition & 1 deletion library.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ async function constructPayload(notification, uid, lang) {
tag,
lang,
dir,
data: { url, icon, badge },
data: { url, icon, badge, mergeId },
};
}

Expand Down
3 changes: 3 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
{ "hook": "filter:service-worker.scripts", "method": "registerServiceWorker" }
],
"languages": "public/languages",
"scripts": [
"public/lib/main.js"
],
"modules": {
"../client/account/web-push.js": "./public/lib/settings.js",
"../admin/plugins/web-push.js": "./public/lib/admin.js"
Expand Down
6 changes: 5 additions & 1 deletion public/languages/en-GB/web-push.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
"profile.send-test": "Send Test Notification",

"toast.test_success": "Test notification sent.",
"toast.test_unavailable": "Cannot send test notification as push notifications are not enabled on this device."
"toast.test_unavailable": "Cannot send test notification as push notifications are not enabled on this device.",
"toast.permission_denied": "Notification permission was denied. Please enable notifications for this site in your browser settings.",
"toast.subscribe_failed": "Could not enable push notifications on this device. Please try again.",
"toast.unsupported": "This browser does not support push notifications.",
"toast.sw_not_registered": "Push notifications are unavailable: the background service is not registered. On iOS, install this site to your Home Screen first."
}
15 changes: 15 additions & 0 deletions public/languages/he/web-push.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"profile.label": "התראות דחיפה",
"profile.introduction": "בנוסף להתראות בתוך האפליקציה ולהתראות בדוא״ל, ניתן לבחור לקבל גם התראות דחיפה. כך תוכלו לקבל התראות גם כשהאפליקציה אינה פתוחה במכשיר.",
"profile.option": "הפעלת התראות דחיפה במכשיר זה",
"profile.devices": "כרגע נשלחות התראות ל־ <strong>%1</strong> מכשיר(ים).",
"profile.permissionBlocked": "המכשיר שלך אינו מאפשר כרגע לקבל התראות מאתר זה. יש לאשר את הרשאת ההתראות כדי להמשיך.",
"profile.send-test": "שליחת התראת בדיקה",

"toast.test_success": "התראת הבדיקה נשלחה.",
"toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.",
"toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.",
"toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.",
"toast.unsupported": "הדפדפן הזה אינו תומך בהתראות דחיפה.",
"toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית."
}
6 changes: 5 additions & 1 deletion public/languages/zh-CN/web-push.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
"profile.send-test": "发送测试通知",

"toast.test_success": "测试通知已发送。",
"toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。"
"toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。",
"toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。",
"toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。",
"toast.unsupported": "此浏览器不支持推送通知。",
"toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。"
}
78 changes: 75 additions & 3 deletions public/lib/main.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,83 @@
// this file here as placeholder in case needed. Add back to plugin.json to use

'use strict';

// NodeBB core skips serviceWorker.register() on Safari (see public/src/app.js).
// That predates iOS 16.4, where Safari/PWA does support Web Push. Without an SW,
// navigator.serviceWorker.ready hangs forever and our settings page silently fails.
// This script registers the SW ourselves on Safari/iOS, and logs every lifecycle
// event so failures surface in Web Inspector instead of disappearing.

(async () => {
const [hooks] = await app.require(['hooks']);

hooks.on('action:app.load', async () => {
// ...
if (!('serviceWorker' in navigator)) {
console.warn('[web-push] serviceWorker not supported');
return;
}

const ua = navigator.userAgent;
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
const isIOS = /iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

if (!isSafari && !isIOS) {
// Core handles registration on non-Safari browsers.
return;
}

// Core already tried (and skipped) registration by this point. If something
// is registered, leave it alone.
const existing = await navigator.serviceWorker.getRegistration();
if (existing) {
console.info('[web-push] SW already registered:', existing);
return;
}

const swUrl = (config.relative_path || '') + '/service-worker.js';
const scope = (config.relative_path || '') + '/';

console.info('[web-push] Registering service worker (Safari/iOS fallback):', { swUrl, scope });

let registration;
try {
registration = await navigator.serviceWorker.register(swUrl, { scope });
console.info('[web-push] register() resolved:', registration);
} catch (err) {
console.error('[web-push] register() FAILED:', err);
console.error('[web-push] Failure name:', err && err.name);
console.error('[web-push] Failure message:', err && err.message);
return;
}

const logState = (worker, label) => {
if (!worker) return;
console.info(`[web-push] ${label} initial state:`, worker.state);
worker.addEventListener('statechange', () => {
console.info(`[web-push] ${label} state →`, worker.state);
});
worker.addEventListener('error', (e) => {
console.error(`[web-push] ${label} error event:`, e);
});
};

logState(registration.installing, 'installing');
logState(registration.waiting, 'waiting');
logState(registration.active, 'active');

registration.addEventListener('updatefound', () => {
console.info('[web-push] updatefound — new worker installing');
logState(registration.installing, 'installing(updatefound)');
});

navigator.serviceWorker.addEventListener('error', (e) => {
console.error('[web-push] navigator.serviceWorker error:', e);
});

try {
const ready = await navigator.serviceWorker.ready;
console.info('[web-push] serviceWorker.ready resolved:', ready);
} catch (err) {
console.error('[web-push] serviceWorker.ready rejected:', err);
}
});
})();
59 changes: 57 additions & 2 deletions public/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,31 @@ export async function init() {
console.error('Web Push form container not found');
return;
}
const registration = await navigator.serviceWorker.ready;

if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.error('[web-push] Service workers or Push API not supported in this browser');
warning('[[web-push:toast.unsupported]]');
return;
}

// navigator.serviceWorker.ready never rejects — it hangs forever if no SW is registered.
// On iOS this is a common failure mode (PWA not installed, scope mismatch, core SW failed
// to register). Race it against a timeout so we surface the problem instead of hanging.
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) => setTimeout(
() => reject(new Error('Service worker not ready after 5s — likely not registered')),
5000
)),
]).catch((err) => {
console.error('[web-push]', err);
warning('[[web-push:toast.sw_not_registered]]');
return null;
});
if (!registration) {
return;
}

let subscription = await registration.pushManager.getSubscription();
const convertedVapidKey = urlBase64ToUint8Array(config['web-push'].vapidKey);

Expand All @@ -33,7 +57,30 @@ export async function init() {
case 'toggle': {
const countEl = document.querySelector('#deviceCount strong');
if (!subscription) {
// iOS Safari is strict about user activation: subscribe() must be
// called from the same synchronous task as the click. We branch BEFORE
// any await: if permission is already granted, call subscribe() first
// (no awaits in between). Otherwise request permission, which itself
// preserves activation on Chrome but may lose it on iOS — the user can
// just tap again.
if (Notification.permission === 'denied') {
subselector.checked = false;
warning('[[web-push:toast.permission_denied]]');
document.getElementById('permission-warning').classList.remove('d-none');
break;
}

try {
if (Notification.permission !== 'granted') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
subselector.checked = false;
warning('[[web-push:toast.permission_denied]]');
document.getElementById('permission-warning').classList.remove('d-none');
break;
}
}

subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey,
Expand All @@ -45,8 +92,16 @@ export async function init() {
let count = parseInt(countEl.textContent, 10);
count += 1;
countEl.innerText = count;
} catch (e) {
} catch (err) {
console.error('[web-push] subscribe failed:', err);
subselector.checked = false;
// Roll back any browser-level subscription created before the failure.
const stale = await registration.pushManager.getSubscription();
if (stale) {
await stale.unsubscribe();
}
subscription = null;
warning('[[web-push:toast.subscribe_failed]]');
}
} else {
await subscription.unsubscribe();
Expand Down
18 changes: 16 additions & 2 deletions static/web-push.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@ self.addEventListener('push', (event) => {
const { title, body, tag, data } = event.data.json();

if (title && body) {
const { icon } = data;
const { icon, mergeId } = data;
delete data.icon;
const { badge } = data;
delete data.badge;

// Close any existing notifications with the same mergeId (for Safari compatibility)
// Safari doesn't properly support the 'tag' property for replacing notifications
const closePromise = mergeId
? self.registration.getNotifications().then((notifications) => {
notifications.forEach((notification) => {
if (notification.data && notification.data.mergeId === mergeId) {
notification.close();
}
});
})
: Promise.resolve();

event.waitUntil(
self.registration.showNotification(title, { body, tag, data, icon, badge })
closePromise.then(() => {
return self.registration.showNotification(title, { body, tag, data, icon, badge });
})
);
} else if (tag) {
event.waitUntil(
Expand Down