Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
import { desktopBridge } from "./desktop";
|
|
import { rpc } from "./rpc";
|
|
|
|
const SERVICE_WORKER_URL = "/sw.js";
|
|
const SERVICE_WORKER_SCOPE = "/";
|
|
|
|
/**
|
|
* Web push needs the Push API, a service worker and a secure context (https or localhost). The
|
|
* desktop app keeps its in-app notifications instead.
|
|
*/
|
|
export function webPushSupported(): boolean {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
window.isSecureContext &&
|
|
"serviceWorker" in navigator &&
|
|
"PushManager" in window &&
|
|
"Notification" in window &&
|
|
!desktopBridge()
|
|
);
|
|
}
|
|
|
|
/** Registers the push service worker where web push works; safe to call on every start. */
|
|
export async function registerPushServiceWorker(): Promise<void> {
|
|
if (!webPushSupported()) return;
|
|
await navigator.serviceWorker
|
|
.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
async function currentSubscription(): Promise<PushSubscription | null> {
|
|
const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE);
|
|
return (await registration?.pushManager.getSubscription()) ?? null;
|
|
}
|
|
|
|
/** True when this browser has granted notifications and holds a push subscription. */
|
|
export async function webPushEnabled(): Promise<boolean> {
|
|
if (!webPushSupported() || Notification.permission !== "granted") return false;
|
|
return Boolean(await currentSubscription().catch(() => null));
|
|
}
|
|
|
|
function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
|
|
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
|
|
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
return bytes;
|
|
}
|
|
|
|
function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean {
|
|
if (!current || current.byteLength !== expected.byteLength) return false;
|
|
const bytes = new Uint8Array(current);
|
|
return bytes.every((byte, index) => byte === expected[index]);
|
|
}
|
|
|
|
/**
|
|
* Turns on push for this browser and registers it with the server. Call it straight from a click:
|
|
* browsers only show the permission prompt during a user gesture. Throws when the server cannot
|
|
* be reached.
|
|
*/
|
|
export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> {
|
|
if (!webPushSupported()) return "unsupported";
|
|
const permission =
|
|
Notification.permission === "default"
|
|
? await Notification.requestPermission()
|
|
: Notification.permission;
|
|
if (permission !== "granted") return "denied";
|
|
const config = await rpc.notifications.webPush.config();
|
|
if (!config.available || !config.publicKey) return "unsupported";
|
|
const applicationServerKey = base64UrlToBytes(config.publicKey);
|
|
let subscription: PushSubscription;
|
|
try {
|
|
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE });
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const existing = await registration.pushManager.getSubscription();
|
|
// A subscription made for other server keys cannot receive this server's pushes.
|
|
if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) {
|
|
await existing.unsubscribe();
|
|
}
|
|
subscription =
|
|
existing && sameKey(existing.options.applicationServerKey, applicationServerKey)
|
|
? existing
|
|
: await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
|
|
} catch {
|
|
// Browsers without a push service (or with it switched off) reject the subscription.
|
|
return "unsupported";
|
|
}
|
|
const keys = subscription.toJSON().keys ?? {};
|
|
await rpc.notifications.webPush.subscribe({
|
|
endpoint: subscription.endpoint,
|
|
keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" },
|
|
});
|
|
return "enabled";
|
|
}
|
|
|
|
/** Stops push for this browser, on the server first so no push arrives after it returns. */
|
|
export async function disableWebPush(): Promise<void> {
|
|
if (!webPushSupported()) return;
|
|
const subscription = await currentSubscription();
|
|
if (!subscription) return;
|
|
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
|
|
await subscription.unsubscribe().catch(() => undefined);
|
|
}
|