feat: add webchat pwa push subscription support

This commit is contained in:
William Valentin
2026-02-18 10:46:55 -08:00
parent 02fa604c7c
commit 8234cc93f3
17 changed files with 743 additions and 2 deletions
+2
View File
@@ -4,6 +4,7 @@
* Hash-based routing with page lifecycle management.
*/
import { getClient } from './lib/ws-client.js';
import { registerPwaServiceWorker } from './lib/pwa.js';
const routes = new Map();
let currentPage = null;
@@ -55,6 +56,7 @@ async function render() {
export function initRouter() {
contentEl = document.getElementById('content');
window.addEventListener('hashchange', render);
void registerPwaServiceWorker().catch(() => undefined);
render();
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Flynn icon">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#25c2a0"/>
<stop offset="100%" stop-color="#1877f2"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="#0b0f14"/>
<path d="M58 72h140v28H90v28h90v28H90v52H58z" fill="url(#g)"/>
<circle cx="188" cy="188" r="16" fill="#25c2a0"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#101216">
<title>Flynn</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="flynn-icon.svg">
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
+158
View File
@@ -0,0 +1,158 @@
function readGatewayToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
return token && token.trim() ? token.trim() : null;
}
function withToken(path) {
const token = readGatewayToken();
if (!token) {
return path;
}
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}token=${encodeURIComponent(token)}`;
}
async function requestJson(path, options) {
const response = await fetch(withToken(path), {
...(options ?? {}),
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
});
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message = typeof body?.error === 'string'
? body.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return body;
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function isPushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
export async function registerPwaServiceWorker() {
if (!('serviceWorker' in navigator)) {
return null;
}
return navigator.serviceWorker.register(withToken('/sw.js'));
}
export async function getPushStatus() {
const supported = isPushSupported();
if (!supported) {
return {
supported: false,
permission: 'unsupported',
subscribed: false,
enabled: false,
configured: false,
message: 'Push is not supported by this browser.',
};
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' });
const configured = Boolean(publicKeyPayload?.vapidPublicKey);
return {
supported: true,
permission: Notification.permission,
subscribed: Boolean(subscription),
enabled: Boolean(publicKeyPayload?.enabled),
configured,
message: null,
};
}
export async function enablePushNotifications() {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported by this browser.');
}
const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' });
if (!publicKeyPayload?.enabled) {
throw new Error('WebChat push is disabled on the gateway.');
}
if (!publicKeyPayload?.vapidPublicKey) {
throw new Error('Gateway is missing server.webchat_push.vapid_public_key.');
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission was not granted.');
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKeyPayload.vapidPublicKey),
});
}
const json = subscription.toJSON();
await requestJson('/webchat/push/subscriptions', {
method: 'POST',
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: json.keys ?? {},
userAgent: navigator.userAgent,
}),
});
return getPushStatus();
}
export async function disablePushNotifications() {
if (!('serviceWorker' in navigator)) {
return {
supported: false,
permission: 'unsupported',
subscribed: false,
enabled: false,
configured: false,
message: 'Push is not supported by this browser.',
};
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await requestJson('/webchat/push/subscriptions', {
method: 'DELETE',
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
return getPushStatus();
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Flynn WebChat",
"short_name": "Flynn",
"start_url": "/#/chat",
"display": "standalone",
"background_color": "#101216",
"theme_color": "#101216",
"description": "Flynn WebChat companion UI",
"icons": [
{
"src": "/flynn-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+88
View File
@@ -4,6 +4,12 @@
* Read-only config view (redacted), editable hook patterns,
* tool list, and channel overview.
*/
import {
isPushSupported,
getPushStatus,
enablePushNotifications,
disablePushNotifications,
} from '../lib/pwa.js';
function escapeHtml(text) {
const div = document.createElement('div');
@@ -14,6 +20,75 @@ function escapeHtml(text) {
let _client = null;
let _el = null;
function describePushStatus(status) {
if (!status.supported) {
return status.message || 'Push notifications are not supported in this browser.';
}
if (!status.enabled) {
return 'Gateway push is disabled (`server.webchat_push.enabled: false`).';
}
if (!status.configured) {
return 'Gateway push key is missing (`server.webchat_push.vapid_public_key`).';
}
if (status.permission === 'denied') {
return 'Browser notifications are blocked. Allow notifications in browser settings.';
}
if (status.subscribed) {
return 'Push notifications are enabled for this browser.';
}
return 'Push is configured. Click Enable to subscribe this browser.';
}
async function renderPushStatus() {
const statusEl = _el.querySelector('#push-status');
const enableBtn = _el.querySelector('#push-enable');
const disableBtn = _el.querySelector('#push-disable');
if (!statusEl || !enableBtn || !disableBtn) {
return;
}
try {
const status = await getPushStatus();
statusEl.textContent = describePushStatus(status);
statusEl.className = status.subscribed ? 'text-sm text-success' : 'text-sm text-muted';
enableBtn.disabled = !status.supported || !status.enabled || !status.configured || status.subscribed;
disableBtn.disabled = !status.supported || !status.subscribed;
} catch (err) {
statusEl.textContent = `Push status error: ${err.message}`;
statusEl.className = 'text-sm text-error';
enableBtn.disabled = true;
disableBtn.disabled = true;
}
}
async function onEnablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Enabling push notifications...';
statusEl.className = 'text-sm text-muted';
try {
await enablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Enable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
}
}
async function onDisablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Disabling push notifications...';
statusEl.className = 'text-sm text-muted';
try {
await disablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Disable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
}
}
async function loadSettings() {
if (!_client || !_el) {return;}
@@ -52,6 +127,16 @@ async function loadSettings() {
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<h2 class="section-title">WebChat Push Notifications</h2>
<div class="settings-section">
${isPushSupported() ? '' : '<div class="text-sm text-muted">This browser does not support PushManager APIs.</div>'}
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button id="push-enable" class="btn btn-primary" type="button">Enable Push</button>
<button id="push-disable" class="btn btn-secondary" type="button">Disable Push</button>
</div>
<div id="push-status" class="text-sm text-muted"></div>
</div>
<h2 class="section-title">Hook Patterns</h2>
<div class="settings-section">
<div class="hook-editor">
@@ -133,6 +218,9 @@ async function loadSettings() {
// Bind save hooks
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
_el.querySelector('#push-enable').addEventListener('click', onEnablePush);
_el.querySelector('#push-disable').addEventListener('click', onDisablePush);
await renderPushStatus();
}
async function saveHooks() {
+92
View File
@@ -0,0 +1,92 @@
const CACHE_NAME = 'flynn-webchat-v1';
const token = new URL(self.location.href).searchParams.get('token');
const withToken = (path) => {
if (!token) {
return path;
}
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}token=${encodeURIComponent(token)}`;
};
const OFFLINE_ASSETS = ['/', '/index.html', '/style.css', '/app.js', '/manifest.webmanifest'].map(withToken);
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(OFFLINE_ASSETS))
.catch(() => undefined),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
)),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith((async () => {
const cached = await caches.match(event.request, { ignoreSearch: true });
if (cached) {
return cached;
}
try {
return await fetch(event.request);
} catch {
const fallback = await caches.match('/index.html', { ignoreSearch: true });
return fallback || new Response('Offline', { status: 503 });
}
})());
});
self.addEventListener('push', (event) => {
let payload = {
title: 'Flynn',
body: 'You have a new update.',
};
try {
const parsed = event.data?.json();
if (parsed && typeof parsed === 'object') {
payload = {
...payload,
...parsed,
};
}
} catch {
const text = event.data?.text();
if (text) {
payload.body = text;
}
}
event.waitUntil(self.registration.showNotification(payload.title, {
body: payload.body,
tag: payload.tag || 'flynn-webchat',
renotify: false,
data: payload.data || {},
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil((async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.length > 0) {
await clients[0].focus();
return;
}
await self.clients.openWindow('/#/chat');
})());
});