Files
Sybil-2/web/src/lib/agent-push-browser.ts
T

159 lines
9.9 KiB
TypeScript
Raw Normal View History

2026-10-14 22:03:21 -07:00
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;
}