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(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user