evOo-local/apps/web/src/lib/web-push.ts

137 lines
5.5 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);
}
/**
* True on iOS/iPadOS when opened in a browser tab rather than as an installed Home-Screen PWA
* (where Apple WebKit only exposes Web Push in standalone display mode).
*/
export function isIosBrowserNeedsHomeScreen(): boolean {
if (typeof window === "undefined" || typeof navigator === "undefined" || desktopBridge()) {
return false;
}
const ua = navigator.userAgent || "";
const isIos =
/iPad|iPhone|iPod/i.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isIos) return false;
const standalone =
(typeof window.matchMedia === "function" &&
window.matchMedia("(display-mode: standalone)").matches) ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
return !standalone && !webPushSupported();
}
/** Syncs the installed PWA / OS app icon badge with the actionable "Needs You" count. */
export function syncAppBadge(count: number): void {
if (typeof navigator === "undefined") return;
const nav = navigator as Navigator & {
setAppBadge?: (contents?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
};
if (count > 0 && typeof nav.setAppBadge === "function") {
void nav.setAppBadge(count).catch(() => undefined);
} else if (count <= 0 && typeof nav.clearAppBadge === "function") {
void nav.clearAppBadge().catch(() => undefined);
}
}