137 lines
5.5 KiB
TypeScript
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);
|
|
}
|
|
}
|