Fix mobile web gestures, compact chat layout and notification diagnostics
This commit is contained in:
+43
-24
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user