Files
fluxer/fluxer_app/src/sw/worker.ts

203 lines
5.3 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope &
typeof globalThis & {
skipWaiting(): void;
__WB_MANIFEST: unknown;
};
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
} else if (event.data?.type === 'APP_UPDATE_BADGE') {
const rawCount = event.data?.count;
let badgeCount: number | null = null;
if (typeof rawCount === 'number' && Number.isFinite(rawCount)) {
badgeCount = rawCount;
} else if (typeof rawCount === 'string' && rawCount.length > 0) {
const parsed = Number(rawCount);
badgeCount = Number.isFinite(parsed) ? parsed : null;
}
event.waitUntil(updateAppBadge(badgeCount));
}
});
interface PushPayload {
title?: string;
body?: string;
icon?: string;
badge?: string;
data?: Record<string, unknown>;
}
declare global {
interface Navigator {
setAppBadge?: (value?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
}
}
const getBadgeCount = (payload: PushPayload): number | null => {
const badgeValue = payload.data?.badge_count;
if (typeof badgeValue === 'number' && Number.isFinite(badgeValue)) {
return badgeValue;
}
if (typeof badgeValue === 'string' && badgeValue.length > 0) {
const parsed = Number(badgeValue);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const updateAppBadge = async (count: number | null): Promise<void> => {
if (typeof navigator.setAppBadge !== 'function' && typeof navigator.clearAppBadge !== 'function') {
return;
}
try {
if (count !== null && count > 0) {
if (typeof navigator.setAppBadge === 'function') {
await navigator.setAppBadge(count);
}
} else if (typeof navigator.clearAppBadge === 'function') {
await navigator.clearAppBadge();
}
} catch (error) {
console.error('[SW] Failed to update app badge', error);
}
};
const resolveTargetUrl = (url?: string): string | null => {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
return new URL(url, self.location.origin).toString();
} catch {
return null;
}
};
const postMessageToClients = async (message: Record<string, unknown>): Promise<ReadonlyArray<WindowClient>> => {
try {
const clientList = (await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})) as ReadonlyArray<WindowClient>;
for (const client of clientList) {
client.postMessage(message);
}
return clientList;
} catch (error) {
console.error('[SW] Unable to broadcast to clients', error);
return [];
}
};
const focusOrOpenClient = async (targetUrl: string, targetUserId?: string): Promise<void> => {
const message: Record<string, unknown> = {
type: 'NOTIFICATION_CLICK_NAVIGATE',
url: targetUrl,
};
if (targetUserId) {
message.targetUserId = targetUserId;
}
const clientList = await postMessageToClients(message);
const exact = clientList.find((c) => c.url === targetUrl);
if (exact) {
await exact.focus();
return;
}
const sameOrigin = clientList.find((c) => {
try {
return new URL(c.url).origin === self.location.origin;
} catch {
return false;
}
});
if (sameOrigin) {
await sameOrigin.focus();
return;
}
if (self.clients.openWindow) {
await self.clients.openWindow(targetUrl);
}
};
self.addEventListener('push', (event: PushEvent) => {
const payload: PushPayload = event.data?.json?.() ?? {
title: 'Fluxer',
};
const title = payload.title ?? 'Fluxer';
const options: NotificationOptions = {
body: payload.body ?? undefined,
icon: payload.icon ?? undefined,
badge: payload.badge ?? undefined,
data: payload.data ?? undefined,
};
const badgeCount = getBadgeCount(payload);
event.waitUntil(
(async () => {
await Promise.all([self.registration.showNotification(title, options), updateAppBadge(badgeCount)]);
})(),
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
const targetUrl = resolveTargetUrl(event.notification.data?.url as string | undefined);
if (!targetUrl) return;
const targetUserId = event.notification.data?.target_user_id as string | undefined;
event.waitUntil(
(async () => {
await focusOrOpenClient(targetUrl, targetUserId);
})(),
);
});
self.addEventListener('pushsubscriptionchange', (event: ExtendableEvent) => {
event.waitUntil(
postMessageToClients({type: 'PUSH_SUBSCRIPTION_CHANGE'})
.then(() => undefined)
.catch(() => undefined),
);
});