public/sw.js
Rama main · réplica de lectura del repositorio original.
const CACHE = "my-ax-static-v14";
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("message", (event) => {
if (event.data?.type === "my-ax:skip-waiting") self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key === CACHE || key.startsWith("my-ax-")).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
async function setAttentionBadge(count) {
try {
if (count > 0) await self.registration.setAppBadge?.(count);
else await self.registration.clearAppBadge?.();
} catch {}
try {
if (count > 0) await self.navigator?.setAppBadge?.(count);
else await self.navigator?.clearAppBadge?.();
} catch {}
}
function notificationActions(payload) {
if (Array.isArray(payload.actions) && payload.actions.length) return payload.actions.slice(0, 2);
return [{ action: "open", title: "Open notification" }, { action: "attention", title: "All notifications" }];
}
function normalizedDismissalTags(tags) {
if (!Array.isArray(tags)) return [];
return [...new Set(tags.filter((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))];
}
async function closeDismissedNotifications(tags) {
const tagSet = new Set(normalizedDismissalTags(tags));
if (!tagSet.size) return false;
const notifications = await self.registration.getNotifications();
for (const notification of notifications) {
if (tagSet.has(notification.tag)) notification.close();
}
return true;
}
async function updateBadgeFromNotifications(fallback) {
try {
const notifications = await self.registration.getNotifications();
await setAttentionBadge(notifications.length);
} catch {
await setAttentionBadge(fallback);
}
}
self.addEventListener("push", (event) => {
let payload = {};
try { payload = event.data?.json() ?? {}; } catch { payload = {}; }
event.waitUntil((async () => {
const dismissed = await closeDismissedNotifications(payload.dismissTags);
await self.registration.showNotification(payload.title || "my · ax", {
body: payload.body || "You have a new my · ax notification.",
data: {
href: payload.href || "/?action=attention",
attentionHref: "/?action=attention",
destinationHref: payload.destinationHref || "/",
decision: payload.decision,
unread: Number(payload.unread || 0),
},
tag: payload.progressTag || payload.attentionId || undefined,
renotify: payload.progressTag ? payload.progressTerminal === true : !!payload.attentionId,
requireInteraction: payload.kind === "deploy.gate" || payload.kind === "job.needs_input",
icon: "/static/brand/icon-192.png",
badge: "/static/brand/icon-maskable-192.png",
actions: notificationActions(payload),
});
if (dismissed) await updateBadgeFromNotifications(Number(payload.unread || 0));
else await setAttentionBadge(Number(payload.unread || 1));
for (const client of await clients.matchAll({ type: "window", includeUncontrolled: true })) client.postMessage({ type: "my-ax:attention" });
})());
});
function isDecisionAction(action) {
return typeof action === "string" && /^decision:\d+$/.test(action);
}
function notificationDecision(data, action) {
const index = Number(action.slice("decision:".length));
const id = data?.decision?.id;
const options = data?.decision?.options;
if (!Number.isInteger(index) || typeof id !== "string" || id.length === 0 || !Array.isArray(options) || typeof options[index] !== "string") return null;
return { id, choice: options[index] };
}
function decisionPageHref(data) {
const id = data?.decision?.id;
if (typeof id === "string" && id.length > 0) return `/api/decisions/${encodeURIComponent(id)}`;
return data?.href || "/?action=attention";
}
async function showDecisionFailure(data, status) {
const alreadyAnswered = status === 409;
const href = decisionPageHref(data);
await self.registration.showNotification(alreadyAnswered ? "Decision already answered" : "Decision not recorded", {
body: alreadyAnswered
? "This decision was already answered. Open it to review."
: "Your choice was not recorded. Open the decision to sign in and try again.",
data: { href, attentionHref: href, destinationHref: href },
icon: "/static/brand/icon-192.png",
badge: "/static/brand/icon-maskable-192.png",
actions: [{ action: "open", title: "Open decision" }],
});
}
async function submitDecision(notification, action) {
const data = notification.data;
const decision = notificationDecision(data, action);
if (!decision) {
await showDecisionFailure(data, 0);
return;
}
try {
const response = await fetch(`/api/decisions/${encodeURIComponent(decision.id)}/respond`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ choice: decision.choice }),
});
if (!response.ok) {
await showDecisionFailure(data, response.status);
return;
}
notification.close();
await setAttentionBadge(Math.max(0, Number(data?.unread ?? 1) - 1));
} catch {
await showDecisionFailure(data, 0);
}
}
async function navigateNotification(href) {
const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
const sameOrigin = windows.filter((client) => new URL(client.url).origin === self.location.origin);
const existing = sameOrigin.find((client) => client.focused) || sameOrigin.find((client) => client.visibilityState === "visible") || sameOrigin[0];
const target = new URL(href, self.location.origin);
const absolute = target.href;
const sameOriginTarget = target.origin === self.location.origin;
if (existing) {
if (target.pathname === "/" && !target.search && !target.hash && sameOriginTarget) return existing.focus();
if (!sameOriginTarget) return clients.openWindow(absolute);
const acked = await new Promise((resolve) => {
const onAck = (ev) => {
if (ev.data?.href !== absolute) return;
if (ev.data?.type === "my-ax:navigate-ack") {
self.removeEventListener("message", onAck);
resolve(true);
}
if (ev.data?.type === "my-ax:navigate-nack") {
self.removeEventListener("message", onAck);
resolve(false);
}
};
self.addEventListener("message", onAck);
existing.postMessage({ type: "my-ax:navigate", href: absolute });
setTimeout(() => { self.removeEventListener("message", onAck); resolve(false); }, 400);
});
if (!acked) await existing.navigate(absolute);
return existing.focus();
}
return clients.openWindow(absolute);
}
self.addEventListener("notificationclick", (event) => {
if (isDecisionAction(event.action)) {
event.waitUntil(submitDecision(event.notification, event.action));
return;
}
event.notification.close();
const href = event.action === "attention"
? (event.notification.data?.attentionHref || "/?action=attention")
: event.action === "destination"
? (event.notification.data?.destinationHref || "/")
: (event.notification.data?.href || "/?action=attention");
event.waitUntil(navigateNotification(href));
});