feat(agent): add opt-in push notifications
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
} from "./agent-push.ts";
|
||||
|
||||
let sessionId: string | undefined;
|
||||
function browserUuid() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
|
||||
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
|
||||
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
async function consentScope() {
|
||||
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
|
||||
const digest = await agentPushAuthorityScope(base, token);
|
||||
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
|
||||
return digest;
|
||||
}
|
||||
const listeners = new Set<(state: AgentPushView) => void>();
|
||||
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
|
||||
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
|
||||
});
|
||||
}
|
||||
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||
});
|
||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||
const support = () => {
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
if (ios && !standalone) return "install" as const;
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
|
||||
? "supported" as const : "unsupported" as const;
|
||||
};
|
||||
async function postWorker(message: unknown) {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// No registration from background sync. A page worker can safely receive only sanitized IDs.
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
const worker = registration?.active;
|
||||
if (!worker) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
|
||||
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
|
||||
worker.postMessage(message, [channel.port2]);
|
||||
});
|
||||
}
|
||||
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
|
||||
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
|
||||
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
|
||||
}
|
||||
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
const controller = createAgentPushController({
|
||||
api, read: readConsent, scope: consentScope,
|
||||
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
|
||||
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
|
||||
uuid: browserUuid,
|
||||
lock: async operation => {
|
||||
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
|
||||
finally { clearTimeout(timeout); }
|
||||
},
|
||||
changed: state => { for (const listener of listeners) listener(state); },
|
||||
platform: {
|
||||
support, permission: () => "Notification" in window ? Notification.permission : "denied",
|
||||
requestPermission: () => Notification.requestPermission(),
|
||||
subscription: async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||
return subscription ? subscriptionHandle(subscription) : null;
|
||||
},
|
||||
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||
},
|
||||
});
|
||||
export const getAgentPushState = () => controller.state();
|
||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||
export const enableAgentBrowserPush = () => controller.enable();
|
||||
export const disableAgentBrowserPush = () => controller.disable();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||
|
||||
const presence = createAgentPresenceReporter({
|
||||
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
|
||||
});
|
||||
let stopSession: (() => void) | undefined;
|
||||
export async function revokeAgentBrowserPush() {
|
||||
stopSession?.();
|
||||
// Revoke/mark the durable tombstone before the caller clears the bearer token.
|
||||
const id = getAgentPresenceSessionId();
|
||||
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
|
||||
}
|
||||
/** Mounted from shared authentication so /search, every chat and every tab participate. */
|
||||
export function startAgentBrowserSession() {
|
||||
stopSession?.();
|
||||
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
|
||||
const refresh = async () => {
|
||||
if (!active || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await controller.refresh();
|
||||
if (active && document.visibilityState === "visible") {
|
||||
const read = await api.readState();
|
||||
if (active && read) await syncAgentNotificationRead(read);
|
||||
}
|
||||
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
|
||||
finally { refreshing = false; }
|
||||
};
|
||||
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
|
||||
const interaction = (event: Event) => {
|
||||
if (!event.isTrusted) return;
|
||||
presence.interact();
|
||||
if (!interactionTimer) {
|
||||
report();
|
||||
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
|
||||
}
|
||||
};
|
||||
const hide = () => report(true, true);
|
||||
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
|
||||
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
|
||||
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
|
||||
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
|
||||
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
|
||||
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
|
||||
document.addEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
|
||||
navigator.serviceWorker?.addEventListener("message", workerMessage);
|
||||
report(); void refresh();
|
||||
const stop = () => {
|
||||
if (!active) return;
|
||||
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
|
||||
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
|
||||
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
|
||||
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
|
||||
document.removeEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
|
||||
navigator.serviceWorker?.removeEventListener("message", workerMessage);
|
||||
if (stopSession === stop) stopSession = undefined;
|
||||
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
|
||||
};
|
||||
stopSession = stop;
|
||||
return stop;
|
||||
}
|
||||
Reference in New Issue
Block a user