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:
Scott Idem
2026-05-05 19:38:58 -04:00
parent 0b96772fa6
commit ddf44a2aee
14 changed files with 350 additions and 13 deletions

View File

@@ -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(() => {});
}