diff --git a/.gitignore b/.gitignore index d77e539..f1ff4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ pip-log.txt sftp-config.json node_modules/ +nodebb-plugin-web-push-1.xml diff --git a/library.js b/library.js index ce9cea6..13ed45b 100644 --- a/library.js +++ b/library.js @@ -244,7 +244,7 @@ async function constructPayload(notification, uid, lang) { tag, lang, dir, - data: { url, icon, badge }, + data: { url, icon, badge, mergeId }, }; } diff --git a/plugin.json b/plugin.json index 85ee949..713949e 100644 --- a/plugin.json +++ b/plugin.json @@ -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" diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 2e8f3ab..295c83d 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -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." } \ No newline at end of file diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json new file mode 100644 index 0000000..f82bc76 --- /dev/null +++ b/public/languages/he/web-push.json @@ -0,0 +1,15 @@ +{ + "profile.label": "התראות דחיפה", + "profile.introduction": "בנוסף להתראות בתוך האפליקציה ולהתראות בדוא״ל, ניתן לבחור לקבל גם התראות דחיפה. כך תוכלו לקבל התראות גם כשהאפליקציה אינה פתוחה במכשיר.", + "profile.option": "הפעלת התראות דחיפה במכשיר זה", + "profile.devices": "כרגע נשלחות התראות ל־ %1 מכשיר(ים).", + "profile.permissionBlocked": "המכשיר שלך אינו מאפשר כרגע לקבל התראות מאתר זה. יש לאשר את הרשאת ההתראות כדי להמשיך.", + "profile.send-test": "שליחת התראת בדיקה", + + "toast.test_success": "התראת הבדיקה נשלחה.", + "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", + "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", + "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.", + "toast.unsupported": "הדפדפן הזה אינו תומך בהתראות דחיפה.", + "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית." +} diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index ab3abcd..34f098d 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -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 上,请先将本站添加到主屏幕。" } \ No newline at end of file diff --git a/public/lib/main.js b/public/lib/main.js index d51436f..fc6e500 100644 --- a/public/lib/main.js +++ b/public/lib/main.js @@ -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); + } }); })(); diff --git a/public/lib/settings.js b/public/lib/settings.js index 5c5cee4..d97f9d2 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -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); @@ -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, @@ -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(); diff --git a/static/web-push.js b/static/web-push.js index baa1f34..a2967d7 100644 --- a/static/web-push.js +++ b/static/web-push.js @@ -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(