Fix mobile web gestures, compact chat layout and notification diagnostics

This commit is contained in:
2026-11-02 18:34:44 -08:00
parent d4f8710ee8
commit 332d10b401
17 changed files with 683 additions and 66 deletions
+43 -24
View File
@@ -1,3 +1,5 @@
import { AgentPushError, agentPushFailure, agentPushHttpFailure, type AgentPushStage } from "./agent-push-errors.ts";
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
@@ -18,8 +20,9 @@ export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|| (config.webPushAvailable && config.vapidPublicKey === undefined)
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
throw new Error("Notification configuration is unavailable.");
throw new AgentPushError("The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration.");
}
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
}
@@ -36,28 +39,28 @@ export class AgentPushClient {
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
const token = this.options.token();
if (!token) throw new Error("Sign in to manage notifications.");
if (!token) throw new AgentPushError("Sign in to manage notifications.");
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
try {
// Capture the bearer before hashing, then use that exact bearer for the
// request. A concurrent sign-in must not turn old-authority cleanup into
// a new-authority DELETE whose204 falsely clears a durable tombstone.
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again.");
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
});
// Never echo server/provider bodies: they may contain endpoint or key material.
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
if (!response.ok) throw agentPushHttpFailure(response.status);
return response.status === 204 ? undefined : await response.json();
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
} catch (cause) { throw agentPushFailure(cause, "service"); }
finally { clearTimeout(timeout); }
}
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new AgentPushError("The Sybil server did not acknowledge the notification registration. Refresh and try again.");
}
async revoke(id: string, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
@@ -94,7 +97,7 @@ export function parseAgentPushConsent(raw: string | null): AgentPushConsent | nu
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
type PushPlatform = {
support: () => "supported" | "unsupported" | "install";
support: () => "supported" | "unsupported" | "install" | "insecure";
permission: () => NotificationPermission;
requestPermission: () => Promise<NotificationPermission>;
subscription: () => Promise<PushSubscriptionHandle | null>;
@@ -109,15 +112,16 @@ export function createAgentPushController(options: {
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
}) {
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
let refreshError: string | null = null;
let scope: string | null = null;
let registeredSubscription: string | null = null;
const currentConsent = () => localRevocation || options.read();
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !!config.vapidPublicKey && !config.quarantined && agentPushSourceId(config.sourceId);
const refreshContext = async () => {
const nextScope = await options.scope(), nextConfig = await options.api.config();
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
if (nextScope !== await options.scope()) { scope = null; config = null; throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); }
scope = nextScope; config = nextConfig;
};
const recheckContext = async (record: AgentPushConsent) => {
@@ -130,14 +134,17 @@ export function createAgentPushController(options: {
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
: support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again."
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
: permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
: config?.quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications."
: config?.enabled && config.webPushAvailable && !agentPushSourceId(config.sourceId) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection."
: !available ? "Notifications are not configured on this server yet."
: enabled ? "Notifications are on for this browser."
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error: error || refreshError };
};
const publish = () => options.changed(state());
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
@@ -195,7 +202,7 @@ export function createAgentPushController(options: {
await revoke(pending);
};
const disable = async () => {
error = null;
error = null; refreshError = null;
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
// Synchronous durable intent is necessary before logout clears page credentials.
registeredSubscription = null;
@@ -216,8 +223,9 @@ export function createAgentPushController(options: {
}
finally { busy = false; publish(); }
};
const refresh = async () => {
const refresh = async (clearError = false) => {
if (busy) return;
if (clearError) error = null;
try {
await refreshContext();
await options.lock(async () => {
@@ -246,18 +254,18 @@ export function createAgentPushController(options: {
const stillMatching = await recheckContext(record);
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
if (!stillMatching) { await invalidate(record); return; }
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
} catch (cause) { await invalidate(record); throw cause; }
registeredSubscription = serialized;
}
}
});
error = null;
} catch { error = "Notification service is unavailable. Try again when connected."; }
refreshError = null;
} catch (cause) { refreshError = agentPushFailure(cause, "service").message; }
publish();
};
const enable = async () => {
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
error = null;
error = null; refreshError = null;
// Explicit revocation permanently retires the old UUID on the server. Each
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
@@ -267,35 +275,46 @@ export function createAgentPushController(options: {
// Must execute in the original click stack, before locks, worker startup or fetches.
let permission: Promise<NotificationPermission>;
try { permission = options.platform.requestPermission(); }
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
catch (cause) { error = agentPushFailure(cause, "permission").message; publish(); return; }
busy = true; publish();
let stage: AgentPushStage = "permission";
try {
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
const decision = await permission;
if (decision !== "granted") { error = decision === "denied"
? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
: "The notification prompt was dismissed. Tap Enable notifications again and choose Allow."; return; }
stage = "lock";
await options.lock(async () => {
stage = "service";
const unchanged = () => options.read()?.revision === record.revision;
if (!unchanged()) return;
const matching = await recheckContext(record);
if (!unchanged()) return;
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
stage = "subscription";
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
if (!unchanged()) { await subscription.unsubscribe(); return; }
// A tombstone also covers a lost registration response or failed local storage write.
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
const pending = { ...record, enabled: false, revocationPending: true };
try {
stage = "storage"; write(pending);
stage = "service";
const matchingBeforeRegister = await recheckContext(record);
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
const matchingAfterRegister = await recheckContext(record);
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
write({ ...record, enabled: true, revocationPending: false });
stage = "storage"; write({ ...record, enabled: true, revocationPending: false });
registeredSubscription = JSON.stringify(subscription.toJSON());
} catch {
} catch (cause) {
// Retain cleanup intent even when storage fails after subscribing.
if (options.read()?.revision === record.revision) localRevocation = pending;
await revoke(pending);
throw new Error("Notifications could not be enabled. Try again when connected.");
throw agentPushFailure(cause, stage);
}
});
} catch { error = "Notifications could not be enabled. Try again when connected."; }
} catch (cause) { error = agentPushFailure(cause, stage).message; }
finally { busy = false; publish(); }
};
return { state, refresh, enable, disable };