Make the app installable and deliver existing alert notifications as OS push, alongside the in-app bell. Backend: - PushSubscription model (per-device endpoint + keys, owned by a user) and register/unregister/test endpoints under /api/v2/push, cookie-auth scoped to the user like the subscription routes. - push.py: VAPID key management (env -> data/vapid.json -> generated) and a pywebpush send helper that reports gone endpoints for pruning. No DB coupling. - notify.py: after creating an in-app Notification, dispatch Web Push to the user's devices (guarded — a push failure never affects the in-app write; endpoints reported gone are pruned). - Serve the .webmanifest with the correct media type. Frontend: - manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages. - sw.js: push + notificationclick handlers (push-only; no fetch caching, so it doesn't fight the existing IndexedDB cache). Registered globally in nav.js in secure contexts. - push-client.js + a "Notifications on this device" toggle and test-send on the /alerts page, subscribing through the existing cookie-aware apiFetch. Push and service workers require a secure context, so this is active over HTTPS (or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
93 lines
3.3 KiB
JavaScript
93 lines
3.3 KiB
JavaScript
// Web Push enablement for the current device. The service worker itself is
|
|
// registered globally by nav.js; this module owns the *subscription* handshake:
|
|
// ask permission, subscribe with the server's VAPID key, and register the
|
|
// resulting endpoint against the signed-in user (via account.js's cookie-aware
|
|
// apiFetch). Used by the /alerts page to render a "Notifications on this device"
|
|
// control.
|
|
//
|
|
// Push + service workers require a secure context (HTTPS, or http://localhost),
|
|
// so on a plain-HTTP LAN origin this cleanly reports "unsupported" instead of
|
|
// throwing.
|
|
import { apiFetch } from "./account.js";
|
|
|
|
export function supported() {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
window.isSecureContext &&
|
|
"serviceWorker" in navigator &&
|
|
"PushManager" in window &&
|
|
"Notification" in window
|
|
);
|
|
}
|
|
|
|
// The applicationServerKey must be a Uint8Array of the base64url-decoded VAPID
|
|
// public key.
|
|
function urlB64ToUint8Array(base64) {
|
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
|
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const raw = atob(b64);
|
|
const out = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
async function registration() {
|
|
// nav.js registered sw.js already; ready resolves once it's active.
|
|
return navigator.serviceWorker.ready;
|
|
}
|
|
|
|
// Is this device currently subscribed? (a PushSubscription exists locally)
|
|
export async function isEnabled() {
|
|
if (!supported()) return false;
|
|
try {
|
|
const reg = await registration();
|
|
return !!(await reg.pushManager.getSubscription());
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function permission() {
|
|
return supported() ? Notification.permission : "unsupported";
|
|
}
|
|
|
|
// Subscribe this device and register it server-side. Throws on failure so the
|
|
// caller can surface a message.
|
|
export async function enable() {
|
|
if (!supported()) throw new Error("This browser doesn't support push notifications.");
|
|
const perm = await Notification.requestPermission();
|
|
if (perm !== "granted") throw new Error("Notification permission was not granted.");
|
|
|
|
const reg = await registration();
|
|
let sub = await reg.pushManager.getSubscription();
|
|
if (!sub) {
|
|
const res = await apiFetch("api/v2/push/vapid-key");
|
|
if (!res.ok) throw new Error("Couldn't fetch the server key.");
|
|
const { key } = await res.json();
|
|
sub = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlB64ToUint8Array(key),
|
|
});
|
|
}
|
|
const res = await apiFetch("api/v2/push/subscribe", { method: "POST", json: sub.toJSON() });
|
|
if (!res.ok) throw new Error("Couldn't register this device.");
|
|
}
|
|
|
|
// Unsubscribe locally and remove the endpoint server-side.
|
|
export async function disable() {
|
|
if (!supported()) return;
|
|
const reg = await registration();
|
|
const sub = await reg.pushManager.getSubscription();
|
|
if (!sub) return;
|
|
try {
|
|
await apiFetch("api/v2/push/subscribe", { method: "DELETE", json: { endpoint: sub.endpoint } });
|
|
} catch (e) { /* best-effort server cleanup */ }
|
|
await sub.unsubscribe();
|
|
}
|
|
|
|
// Ask the server to push a test notification to every device on the account.
|
|
export async function sendTest() {
|
|
const res = await apiFetch("api/v2/push/test", { method: "POST" });
|
|
if (!res.ok) throw new Error("Couldn't send a test notification.");
|
|
return res.json();
|
|
}
|