Skip to content
Merged
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
47 changes: 23 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions adm/style/pwa_acp.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
113 changes: 113 additions & 0 deletions adm/style/pwa_acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');

Expand Down Expand Up @@ -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);
});
});
});
10 changes: 10 additions & 0 deletions adm/style/wpn_acp_pwa.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ <h1>{{ lang('PWA_THEME_COLOURS') }}</h1>
<input type="text" id="pwa_bg_color_{{ style.style_id }}" name="pwa_bg_color_{{ style.style_id }}" size="8" value="{{ style.pwa_bg_color }}" placeholder="#ffffff">
<input type="color" id="pwa_bg_color_picker_{{ style.style_id }}" aria-label="{{ lang('PWA_BACKGROUND_COLOUR') }}" value="{{ style.pwa_bg_color|default(default_color) }}">
</span>
<input
type="button"
class="button2 pwa-detect-colours"
value="{{ lang('PWA_DETECT_COLOURS')|e('html_attr') }}"
data-style-id="{{ style.style_id|e('html_attr') }}"
data-preview-url="{{ U_INDEX }}"
data-detecting-label="{{ lang('PWA_DETECTING_COLOURS')|e('html_attr') }}"
data-no-background-message="{{ lang('PWA_DETECT_COLOURS_NO_BG')|e('html_attr') }}"
data-error-message="{{ lang('PWA_DETECT_COLOURS_FAILED')|e('html_attr') }}">
<span class="pwa-detect-status error" role="status"></span>
</dd>
</dl>
{% endfor %}
Expand Down
1 change: 0 additions & 1 deletion event/listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
Expand Down
4 changes: 4 additions & 0 deletions language/en/webpushnotifications_module_acp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 “<samp>%s</samp>” 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.',
Expand Down
4 changes: 4 additions & 0 deletions language/ru/webpushnotifications_module_acp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => 'Код цвета «<samp>%s</samp>» не является допустимым шестнадцатеричным кодом.',
'PWA_SHOW_INSTALL_BANNER' => 'Показывать баннер установки приложения',
'PWA_SHOW_INSTALL_BANNER_EXPLAIN' => 'Показывать на поддерживаемых мобильных устройствах приглашение установить конференцию как приложение.',
Expand Down
13 changes: 4 additions & 9 deletions styles/all/template/event/overall_header_head_append.html
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
<!-- Android / Chrome PWA support -->

<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{{ SHORT_SITE_NAME }}">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
{% if PWA_THEME_COLOR %}
<meta name="theme-color" content="{{ PWA_THEME_COLOR }}">
<style>:root { --pwa-theme-colour: {{ PWA_THEME_COLOR }}; }</style>
{% endif %}
{% if PWA_BG_COLOR %}
<meta name="background-color" content="{{ PWA_BG_COLOR }}">
{% endif %}
<!-- iOS PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{{ SHORT_SITE_NAME }}">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

<link rel="manifest" href="{{ U_MANIFEST_URL }}" crossorigin="use-credentials">

{% if U_TOUCH_ICON %}<link rel="apple-touch-icon" href="{{ ROOT_PATH ~ U_TOUCH_ICON }}">{% endif %}

{% if NOTIFICATIONS_WEBPUSH_ENABLE %}
Expand Down
15 changes: 12 additions & 3 deletions styles/all/template/push_worker.js.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
});
15 changes: 14 additions & 1 deletion tests/controller/controller_webpush_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 0 additions & 2 deletions tests/event/listener_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);

Expand Down Expand Up @@ -381,7 +380,6 @@ public function test_pwa_manifest_builds_fallback_name_and_mobile_banner()
'U_TOUCH_ICON' => null,
'SHORT_SITE_NAME' => 'Long &amp; Mobil',
'PWA_THEME_COLOR' => '#112233',
'PWA_BG_COLOR' => '#445566',
'S_PWA_SHOW_BANNER' => true,
]);

Expand Down
1 change: 1 addition & 0 deletions ucp/controller/webpush.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);

Expand Down
Loading