feat: web push notifications (VAPID)
- push_utils.py: subscription storage + send helper (auto-prunes 410 endpoints) - routers/push.py: GET /api/push/vapid-key (public), POST/DELETE /api/push/subscribe - sw.js: push event listener shows notification; notificationclick focuses/opens tab - app.js: subscribe/unsubscribe flow + "Enable notifications" toggle in settings dropdown - tools/notify.py: web_push orchestrator tool (user-level, no admin required) - VAPID keys in .env; pywebpush added to requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1806,7 +1806,93 @@
|
||||
if (stored) resumeSession(stored, true).catch(clear_stored_session);
|
||||
}
|
||||
|
||||
// ── Service worker registration ───────────────────────────────
|
||||
// ── Service worker + Web Push ────────────────────────────────
|
||||
const pushBtn = document.getElementById('push-btn');
|
||||
const pushBtnLabel = document.getElementById('push-btn-label');
|
||||
|
||||
function _urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function _getPushSubscription() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return reg.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
async function _syncPushBtn() {
|
||||
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return;
|
||||
pushBtn.style.display = '';
|
||||
const sub = await _getPushSubscription();
|
||||
if (sub) {
|
||||
pushBtnLabel.textContent = 'Notifications on';
|
||||
pushBtn.classList.add('push-active');
|
||||
} else {
|
||||
pushBtnLabel.textContent = 'Enable notifications';
|
||||
pushBtn.classList.remove('push-active');
|
||||
}
|
||||
}
|
||||
|
||||
async function _subscribePush() {
|
||||
try {
|
||||
const keyRes = await fetch('/api/push/vapid-key');
|
||||
if (!keyRes.ok) { showToast('Push not configured on server'); return; }
|
||||
const { public_key } = await keyRes.json();
|
||||
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') { showToast('Notification permission denied'); return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: _urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: sub.toJSON() }),
|
||||
});
|
||||
|
||||
showToast('Push notifications enabled');
|
||||
await _syncPushBtn();
|
||||
} catch (e) {
|
||||
showToast('Could not enable push: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function _unsubscribePush() {
|
||||
try {
|
||||
const sub = await _getPushSubscription();
|
||||
if (!sub) { await _syncPushBtn(); return; }
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
|
||||
await sub.unsubscribe();
|
||||
showToast('Notifications disabled');
|
||||
await _syncPushBtn();
|
||||
} catch (e) {
|
||||
showToast('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (pushBtn) {
|
||||
pushBtn.addEventListener('click', async () => {
|
||||
settings_dd_el.classList.remove('open');
|
||||
const sub = await _getPushSubscription();
|
||||
if (sub) await _unsubscribePush();
|
||||
else await _subscribePush();
|
||||
});
|
||||
_syncPushBtn();
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
<a href="/settings" class="hdr-dd-item">
|
||||
<svg data-lucide="user" class="btn-icon"></svg> Account
|
||||
</a>
|
||||
<button id="push-btn" class="hdr-dd-item" style="display:none">
|
||||
<svg data-lucide="bell" class="btn-icon"></svg>
|
||||
<span id="push-btn-label">Enable notifications</span>
|
||||
</button>
|
||||
<div class="hdr-dd-divider"></div>
|
||||
<form method="POST" action="/logout" style="margin:0">
|
||||
<button type="submit" class="hdr-dd-item">
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hdr-dd-item:hover { background: var(--border); }
|
||||
.hdr-dd-item.push-active { color: var(--accent); }
|
||||
|
||||
.hdr-dd-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'cortex-v1';
|
||||
const CACHE = 'cortex-v2';
|
||||
|
||||
const PRECACHE = [
|
||||
'/static/style.css',
|
||||
@@ -28,6 +28,37 @@ self.addEventListener('activate', evt => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('push', evt => {
|
||||
let data = { title: 'Cortex', body: '', url: '/' };
|
||||
if (evt.data) {
|
||||
try { data = { ...data, ...evt.data.json() }; } catch (_) {}
|
||||
}
|
||||
evt.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/static/icon-192.png',
|
||||
badge: '/static/icon-192.png',
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', evt => {
|
||||
evt.notification.close();
|
||||
const url = evt.notification.data?.url || '/';
|
||||
evt.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
||||
for (const c of list) {
|
||||
if (c.url.includes(self.location.origin) && 'focus' in c) {
|
||||
c.navigate(url);
|
||||
return c.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', evt => {
|
||||
const url = new URL(evt.request.url);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user