2026-05-16 15:26:18 +00:00
|
|
|
/* Tx OS service worker — Web Push only.
|
|
|
|
|
*
|
2026-05-16 15:29:28 +00:00
|
|
|
* Scope: registered with `scope: BASE_URL`, so the SW only controls
|
|
|
|
|
* paths under the SPA's base path (e.g. `/`, or `/tx-os/` in a future
|
|
|
|
|
* subpath deployment). Every URL the SW touches — icons, navigation
|
|
|
|
|
* targets — is resolved against `self.registration.scope` so the same
|
|
|
|
|
* code works at root and at a subpath without code changes.
|
2026-05-16 15:26:18 +00:00
|
|
|
*
|
|
|
|
|
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
|
|
|
|
|
* on a single machine, so the network is fast and reliable. Adding
|
|
|
|
|
* cache layers would risk shipping stale React bundles after an
|
|
|
|
|
* upgrade.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-05-16 15:29:28 +00:00
|
|
|
function scopedUrl(relative) {
|
|
|
|
|
// self.registration.scope is the full origin + base path with a
|
|
|
|
|
// trailing slash (e.g. "https://host/" or "https://host/tx-os/").
|
|
|
|
|
// Strip a leading slash from `relative` so URL() treats it as a
|
|
|
|
|
// path under scope rather than origin-relative.
|
|
|
|
|
const cleaned = String(relative || "").replace(/^\/+/, "");
|
|
|
|
|
return new URL(cleaned, self.registration.scope).pathname;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:26:18 +00:00
|
|
|
self.addEventListener("install", () => {
|
|
|
|
|
self.skipWaiting();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
|
|
|
event.waitUntil(self.clients.claim());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
self.addEventListener("push", (event) => {
|
|
|
|
|
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
|
|
|
|
|
let payload = {};
|
|
|
|
|
try {
|
|
|
|
|
payload = event.data ? event.data.json() : {};
|
|
|
|
|
} catch {
|
|
|
|
|
payload = { title: event.data ? event.data.text() : "Tx OS" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const title = payload.title || "Tx OS";
|
2026-05-16 15:29:28 +00:00
|
|
|
const iconUrl = scopedUrl("icons/icon-192.png");
|
2026-05-16 15:26:18 +00:00
|
|
|
const options = {
|
|
|
|
|
body: payload.body || "",
|
|
|
|
|
tag: payload.tag || payload.type || "tx-os",
|
|
|
|
|
renotify: true,
|
2026-05-16 15:29:28 +00:00
|
|
|
icon: iconUrl,
|
|
|
|
|
badge: iconUrl,
|
2026-05-16 15:26:18 +00:00
|
|
|
data: {
|
|
|
|
|
url: payload.url || "/",
|
|
|
|
|
type: payload.type || null,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
event.waitUntil(self.registration.showNotification(title, options));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
|
|
|
event.notification.close();
|
2026-05-16 15:29:28 +00:00
|
|
|
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
|
|
|
|
|
const targetPath = scopedUrl(rawTarget);
|
2026-05-16 15:26:18 +00:00
|
|
|
|
|
|
|
|
event.waitUntil(
|
|
|
|
|
(async () => {
|
|
|
|
|
const allClients = await self.clients.matchAll({
|
|
|
|
|
type: "window",
|
|
|
|
|
includeUncontrolled: true,
|
|
|
|
|
});
|
2026-05-16 15:29:28 +00:00
|
|
|
// Prefer an already-open Tx OS tab under our scope; navigate it
|
|
|
|
|
// to the target and focus.
|
2026-05-16 15:26:18 +00:00
|
|
|
for (const client of allClients) {
|
|
|
|
|
try {
|
|
|
|
|
await client.focus();
|
|
|
|
|
if ("navigate" in client) {
|
|
|
|
|
await client.navigate(targetPath);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
} catch {
|
|
|
|
|
/* try next */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (self.clients.openWindow) {
|
|
|
|
|
await self.clients.openWindow(targetPath);
|
|
|
|
|
}
|
|
|
|
|
})(),
|
|
|
|
|
);
|
|
|
|
|
});
|