diff --git a/README.md b/README.md index d2cb49a..3c17b8b 100644 --- a/README.md +++ b/README.md @@ -34,30 +34,29 @@ To run this extension from the repo (and not from a pre-built package) on a loca ## Browser Support -| Web Browser | Platform | Web Push Notification Support | Background Notification Support (When Browser Closed) | -|---------------------|----------|-------------------------------|-------------------------------------------------------| -| **Google Chrome** | macOS | ✅ Yes | ❌ No (unless running in the background) | -| | Windows | ✅ Yes | ❌ No (unless running in the background) | -| | iOS | ❌ No | ❌ No | -| | Android | ✅ Yes | ✅ Yes | -| **Safari** | macOS | ✅ Yes | ✅ Yes | -| | iOS | ✅ Yes | ✅ Yes (site must be added to Home Screen first) | -| **Microsoft Edge** | macOS | ✅ Yes | ❌ No (unless running in the background) | -| | Windows | ✅ Yes | ❌ No (unless running in the background) | -| | iOS | ❌ No | ❌ No | -| | Android | ✅ Yes | ✅ Yes | -| **Mozilla Firefox** | macOS | ✅ Yes | ❌ No | -| | Windows | ✅ Yes | ❌ No | -| | iOS | ❌ No | ❌ No | -| | Android | ✅ Yes | ❌ No | -| **Opera** | macOS | ✅ Yes | ❌ No | -| | Windows | ✅ Yes | ❌ No | -| | iOS | ❌ No | ❌ No | -| | Android | ✅ Yes | ❌ No | - -*(unless running in the background)* means the browsers have background processes running (they’re not fully quit). - -More info here https://caniuse.com/push-api +The table covers current stable browsers using the standards-based [Push API](https://w3c.github.io/push-api/), [Notifications API](https://notifications.spec.whatwg.org/), and [Service Workers](https://w3c.github.io/ServiceWorker/). “Background delivery” means delivery when the site is not open. A fully quit desktop browser is called out separately. Last reviewed September 2026. + +| Web browser | Platform | Web Push support | Background delivery | +|-------------------------|---------------------------------------|-----------------------------------------------|------------------------------------------------------------------------------| +| **Google Chrome** | Windows, macOS, Linux | ✅ Yes | ⚠️ Only while Chrome or its background process is running | +| | ChromeOS | ✅ Yes | ✅ Yes, while the device is running | +| | Android | ✅ Yes | ✅ Yes, unless the app is force-stopped or background activity is restricted | +| **Safari** | macOS Ventura or later (Safari 16.1+) | ✅ Yes | ✅ Yes, even when Safari is not running | +| **Microsoft Edge** | Windows, macOS, Linux | ✅ Yes | ✅ Yes, subject to browser and OS background settings | +| | Android | ✅ Yes | ✅ Yes, unless the app is force-stopped or background activity is restricted | +| **Mozilla Firefox** | Windows, macOS, Linux | ✅ Yes | ❌ No after Firefox is fully quit | +| | Android | ✅ Yes | ✅ Yes, unless the app is force-stopped or background activity is restricted | +| **Opera** | Windows, macOS, Linux | ✅ Yes | ⚠️ Only while Opera or its background process is running | +| | Android | ✅ Yes | ✅ Yes, unless the app is force-stopped or background activity is restricted | +| **Samsung Internet** | Android | ✅ Yes | ✅ Yes, unless the app is force-stopped or background activity is restricted | +| **Home Screen web app** | iOS/iPadOS 16.4+ | ✅ Yes, after installation to the Home Screen | ✅ Yes | +| **Opera Mini** | Mobile | ❌ No | ❌ No | + +On iOS and iPadOS, Web Push is not available to a normal browser tab. It belongs to an installed Home Screen web app and works regardless of which browser added that app to the Home Screen. The extension provides the required `standalone` web app manifest. Notification permission must be requested from a direct user interaction. + +All supported platforms require user permission and a secure context (`https://`, with `http://localhost` allowed for development). Private browsing, enterprise policy, notification settings, Focus/Do Not Disturb modes, power-saving features, and OS background restrictions can prevent delivery. + +References: [Push API compatibility](https://caniuse.com/push-api), [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API), [Web Push on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/), [Web Push on macOS](https://webkit.org/blog/12945/meet-web-push/), [Edge website notifications](https://support.microsoft.com/en-us/edge/manage-website-notifications-in-microsoft-edge), and [Firefox Web Push notifications](https://support.mozilla.org/en-US/kb/push-notifications-firefox). ## Testing Push Notifications diff --git a/adm/style/pwa_acp.css b/adm/style/pwa_acp.css index 961f0ef..d6209f4 100644 --- a/adm/style/pwa_acp.css +++ b/adm/style/pwa_acp.css @@ -27,3 +27,13 @@ input[type="color"]::-moz-color-swatch { display: inline-block; width: 120px; } + +.pwa-colour-preview { + position: fixed; + left: -10000px; + top: 0; + visibility: hidden; + width: 1280px; + height: 800px; + border: 0; +} diff --git a/adm/style/pwa_acp.js b/adm/style/pwa_acp.js index 8705500..1237da6 100644 --- a/adm/style/pwa_acp.js +++ b/adm/style/pwa_acp.js @@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => { 'use strict'; const HEX_REGEX = /^#([A-Fa-f0-9]{6})$/; + const DETECTION_TIMEOUT = 30000; const colorPickers = document.querySelectorAll('input[type="color"]'); @@ -35,4 +36,116 @@ document.addEventListener('DOMContentLoaded', () => { syncColors(colorText, colorPicker); }); + + // Read an element's computed background colour as RGBA values. + const readColor = (view, element) => { + const value = view.getComputedStyle(element).backgroundColor; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = 1; + canvas.height = 1; + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + + return Array.from(context.getImageData(0, 0, 1, 1).data); + }; + + // Blend a foreground colour over a background colour. + const compositeColor = (foreground, background) => { + const alpha = foreground[3] / 255; + + return [ + Math.round(foreground[0] * alpha + background[0] * (1 - alpha)), + Math.round(foreground[1] * alpha + background[1] * (1 - alpha)), + Math.round(foreground[2] * alpha + background[2] * (1 - alpha)), + 255, + ]; + }; + + // Resolve the visible page colour from the preview's HTML and body backgrounds. + const detectPageColor = iframe => { + const previewDocument = iframe.contentDocument; + const previewWindow = iframe.contentWindow; + const htmlColor = readColor(previewWindow, previewDocument.documentElement); + const bodyColor = readColor(previewWindow, previewDocument.body); + + if (htmlColor[3] === 0 && bodyColor[3] === 0) { + return null; + } + + const canvasColor = compositeColor(htmlColor, [ 255, 255, 255, 255 ]); + return compositeColor(bodyColor, canvasColor); + }; + + // Convert RGB values to a six-digit hexadecimal colour. + const toHex = color => '#' + color.slice(0, 3) + .map(channel => channel.toString(16).padStart(2, '0')) + .join(''); + + // Update a style's text field and synchronize its colour picker. + const setColor = (styleId, name, value) => { + const colorText = document.getElementById(`pwa_${name}_color_${styleId}`); + + colorText.value = value; + colorText.dispatchEvent(new Event('input', { bubbles: true })); + }; + + // Attach colour detection to each installed style's button. + document.querySelectorAll('.pwa-detect-colours').forEach(button => { + button.addEventListener('click', () => { + const originalLabel = button.value; + const status = button.closest('dd').querySelector('.pwa-detect-status'); + const iframe = document.createElement('iframe'); + const previewUrl = new URL(button.dataset.previewUrl, window.location.href); + let finished = false; + + // Remove the preview and restore the detector button after completion. + const finish = error => { + if (finished) { + return; + } + + finished = true; + clearTimeout(timeout); + iframe.remove(); + button.disabled = false; + button.value = originalLabel; + status.textContent = error ? button.dataset.errorMessage : ''; + }; + + const timeout = setTimeout(() => finish(true), DETECTION_TIMEOUT); + + button.disabled = true; + button.value = button.dataset.detectingLabel; + status.textContent = ''; + status.title = ''; + iframe.className = 'pwa-colour-preview'; + iframe.setAttribute('aria-hidden', 'true'); + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.addEventListener('load', () => { + try { + const themeColor = detectPageColor(iframe); + + if (!themeColor) { + throw new Error(button.dataset.noBackgroundMessage); + } + + const pageColor = toHex(themeColor); + + setColor(button.dataset.styleId, 'theme', pageColor); + setColor(button.dataset.styleId, 'bg', pageColor); + finish(false); + } catch (error) { + status.title = error.message; + finish(true); + } + }); + iframe.addEventListener('error', () => finish(true)); + previewUrl.searchParams.set('style', button.dataset.styleId); + iframe.src = previewUrl.href; + document.body.appendChild(iframe); + }); + }); }); diff --git a/adm/style/wpn_acp_pwa.html b/adm/style/wpn_acp_pwa.html index d9b74a4..ce4e063 100644 --- a/adm/style/wpn_acp_pwa.html +++ b/adm/style/wpn_acp_pwa.html @@ -60,6 +60,16 @@

{{ lang('PWA_THEME_COLOURS') }}

+ + {% endfor %} diff --git a/event/listener.php b/event/listener.php index a16c49f..bad15d2 100644 --- a/event/listener.php +++ b/event/listener.php @@ -156,7 +156,6 @@ public function pwa_manifest() 'U_TOUCH_ICON' => $this->config['pwa_icon_small'] ? ext::PWA_ICON_DIR . '/' . $this->config['pwa_icon_small'] : null, 'SHORT_SITE_NAME' => $this->config['pwa_short_name'] ?: $this->trim_shortname($this->config['sitename']), 'PWA_THEME_COLOR' => $this->user->style['pwa_theme_color'] ?? '', - 'PWA_BG_COLOR' => $this->user->style['pwa_bg_color'] ?? '', 'S_PWA_SHOW_BANNER' => !empty($this->config['pwa_show_install_banner']) && $this->is_mobile_phone(), ]); } diff --git a/language/en/webpushnotifications_module_acp.php b/language/en/webpushnotifications_module_acp.php index 3550005..2747580 100644 --- a/language/en/webpushnotifications_module_acp.php +++ b/language/en/webpushnotifications_module_acp.php @@ -73,6 +73,10 @@ 'PWA_COLOURS' => 'Colours', 'PWA_THEME_COLOUR' => 'Theme colour', 'PWA_BACKGROUND_COLOUR' => 'Background colour', + 'PWA_DETECT_COLOURS' => 'Detect colours', + 'PWA_DETECTING_COLOURS' => 'Detecting…', + 'PWA_DETECT_COLOURS_FAILED' => 'Could not detect this style’s background colour.', + 'PWA_DETECT_COLOURS_NO_BG' => 'The style has no HTML or body background colour.', 'PWA_INVALID_COLOUR' => 'The colour code “%s” is not a valid hex code.', 'PWA_SHOW_INSTALL_BANNER' => 'Show app install banner', 'PWA_SHOW_INSTALL_BANNER_EXPLAIN' => 'Display a prompt on supported mobile devices inviting users to install your board as an app.', diff --git a/language/ru/webpushnotifications_module_acp.php b/language/ru/webpushnotifications_module_acp.php index 6927a27..b3d9ab8 100644 --- a/language/ru/webpushnotifications_module_acp.php +++ b/language/ru/webpushnotifications_module_acp.php @@ -73,6 +73,10 @@ 'PWA_COLOURS' => 'Цвета', 'PWA_THEME_COLOUR' => 'Цвет темы', 'PWA_BACKGROUND_COLOUR' => 'Цвет фона', + 'PWA_DETECT_COLOURS' => 'Определить цвета', + 'PWA_DETECTING_COLOURS' => 'Определение…', + 'PWA_DETECT_COLOURS_FAILED' => 'Не удалось определить цвет фона этого стиля.', + 'PWA_DETECT_COLOURS_NO_BG' => 'Для элементов HTML и body этого стиля не задан цвет фона.', 'PWA_INVALID_COLOUR' => 'Код цвета «%s» не является допустимым шестнадцатеричным кодом.', 'PWA_SHOW_INSTALL_BANNER' => 'Показывать баннер установки приложения', 'PWA_SHOW_INSTALL_BANNER_EXPLAIN' => 'Показывать на поддерживаемых мобильных устройствах приглашение установить конференцию как приложение.', diff --git a/styles/all/template/event/overall_header_head_append.html b/styles/all/template/event/overall_header_head_append.html index 2fc12ad..a7bd27f 100644 --- a/styles/all/template/event/overall_header_head_append.html +++ b/styles/all/template/event/overall_header_head_append.html @@ -1,19 +1,14 @@ - + + + + {% if PWA_THEME_COLOR %} {% endif %} -{% if PWA_BG_COLOR %} - -{% endif %} - - - - - {% if U_TOUCH_ICON %}{% endif %} {% if NOTIFICATIONS_WEBPUSH_ENABLE %} diff --git a/styles/all/template/push_worker.js.twig b/styles/all/template/push_worker.js.twig index 1d57d5c..3af01a9 100644 --- a/styles/all/template/push_worker.js.twig +++ b/styles/all/template/push_worker.js.twig @@ -39,7 +39,7 @@ self.addEventListener('push', event => { } event.waitUntil((async () => { - const getNotificationUrl = '{{ U_WEBPUSH_GET_NOTIFICATION }}'; + const getNotificationUrl = '{{ U_WEBPUSH_GET_NOTIFICATION|e("js") }}'; const assetsVersion = parseInt('{{ ASSETS_VERSION }}', 10); // Force update if versions differ @@ -83,7 +83,16 @@ self.addEventListener('push', event => { */ self.addEventListener('notificationclick', event => { event.notification.close(); - if (typeof event.notification.data !== 'undefined') { - event.waitUntil(self.clients.openWindow(event.notification.data.url)); + if (!event.notification.data || typeof event.notification.data.url !== 'string') { + return; + } + + try { + const url = new URL(event.notification.data.url, '{{ U_BOARD_URL|e("js") }}/'); + if (url.protocol === 'http:' || url.protocol === 'https:') { + event.waitUntil(self.clients.openWindow(url.href)); + } + } catch { + // Ignore malformed notification URLs } }); diff --git a/tests/controller/controller_webpush_test.php b/tests/controller/controller_webpush_test.php index 2283a23..648c27c 100644 --- a/tests/controller/controller_webpush_test.php +++ b/tests/controller/controller_webpush_test.php @@ -322,7 +322,20 @@ public function test_get_user_notification_legacy() */ public function test_worker() { - $this->template->method('render')->willReturn('rendered_content'); + $this->config['force_server_vars'] = true; + $this->config['server_protocol'] = 'https://'; + $this->config['server_name'] = 'example.com'; + $this->config['server_port'] = 443; + $this->config['script_path'] = '/forum'; + $this->config['cookie_secure'] = true; + $this->template->expects(self::once()) + ->method('render') + ->with('@phpbb_webpushnotifications/push_worker.js.twig', [ + 'U_WEBPUSH_GET_NOTIFICATION' => 'test_route', + 'U_BOARD_URL' => 'https://example.com/forum', + 'ASSETS_VERSION' => '1.0', + ]) + ->willReturn('rendered_content'); $this->controller_helper->method('route')->willReturn('test_route'); $this->config['assets_version'] = '1.0'; diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php index e343196..2ae9d29 100644 --- a/tests/event/listener_test.php +++ b/tests/event/listener_test.php @@ -348,7 +348,6 @@ public function test_pwa_manifest() 'U_TOUCH_ICON' => ext::PWA_ICON_DIR . '/icon-192x192.png', 'SHORT_SITE_NAME' => 'Test', 'PWA_THEME_COLOR' => '', - 'PWA_BG_COLOR' => '', 'S_PWA_SHOW_BANNER' => false, ]); @@ -381,7 +380,6 @@ public function test_pwa_manifest_builds_fallback_name_and_mobile_banner() 'U_TOUCH_ICON' => null, 'SHORT_SITE_NAME' => 'Long & Mobil', 'PWA_THEME_COLOR' => '#112233', - 'PWA_BG_COLOR' => '#445566', 'S_PWA_SHOW_BANNER' => true, ]); diff --git a/ucp/controller/webpush.php b/ucp/controller/webpush.php index 103688f..8f1575b 100644 --- a/ucp/controller/webpush.php +++ b/ucp/controller/webpush.php @@ -279,6 +279,7 @@ public function worker(): Response { $content = $this->template->render('@phpbb_webpushnotifications/push_worker.js.twig', [ 'U_WEBPUSH_GET_NOTIFICATION' => $this->controller_helper->route('phpbb_webpushnotifications_ucp_push_get_notification_controller'), + 'U_BOARD_URL' => generate_board_url(), 'ASSETS_VERSION' => $this->config['assets_version'], ]);