This commit is contained in:
Nihal Gonsalves 2026-05-13 00:45:05 +08:00 committed by GitHub
commit 8dfb7d6a83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 139 additions and 9 deletions

View file

@ -255,6 +255,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
go run main.go \
--log-level debug \
serve \
--base-url http://localhost \
--web-push-public-key KEY \
--web-push-private-key KEY \
--web-push-email-address <email> \

View file

@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
import session from "../src/app/Session";
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
@ -35,6 +36,7 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast");
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Message received", data);
@ -43,9 +45,24 @@ const handlePushMessage = async (data) => {
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
handlePushUnknown(data);
return;
}
// NOTE: As soon as possible, to avoid this Safari error:
// > Push event handling completed without showing any notification via
// > ServiceWorkerRegistration.showNotification(). This may trigger removal of
// > the push subscription.
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
// Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
@ -71,17 +88,71 @@ const handlePushMessage = async (data) => {
// Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message);
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
await extendToken();
};
const refreshThreshold = 1000 * 60 * 60; // 1 hour
const extendToken = async () => {
if (import.meta.env.DEV) {
console.warn("[ServiceWorker] Skipping token extension in development since no config.base_url exists");
return;
}
const token = await session.tokenAsync();
if (!token) {
console.debug("[ServiceWorker] No session token, skipping token extension");
return;
}
const lastExtendedAt = await session.lastExtendedAtAsync();
const now = Date.now();
if (lastExtendedAt && now - lastExtendedAt < refreshThreshold) {
console.debug(`[ServiceWorker] Token extended ${Math.floor((now - lastExtendedAt) / 1000 / 60)} minutes ago, skipping`);
return;
}
console.log("[ServiceWorker] Extending user access token");
// duplicated from utils.js#accountTokenUrl since we can't import that here
// as long as there's mp3 and other incompatible imports there
const tokenUrl = `${config.base_url}/v1/account/token`;
try {
const response = await fetch(tokenUrl, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
await session.setLastExtendedAtAsync();
console.log(`[ServiceWorker] Token extended successfully`);
} else {
console.error(`[ServiceWorker] Failed to extend token: ${response.status} ${response.statusText}`);
}
} catch (e) {
console.error("[ServiceWorker] Failed to extend token", e);
}
};
/**
* Registers a periodic-sync listener for `extend-token` (see hooks.js).
* This extends the token regardless of whether the browser is open.
*
* CAVEATS:
* - Chromium-only
* - Only when the PWA is _installed_ (not just running in a browser tab)
* - Only when notifications are granted
*/
self.addEventListener("periodicsync", (event) => {
if (event.tag === "extend-token") {
console.log('[ServiceWorker] Received periodicsync event "extend-token"');
event.waitUntil(extendToken());
}
});
/**
* Handle a message_delete event: delete the notification from the database.
*/

View file

@ -155,6 +155,7 @@ class AccountApi {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
});
await session.setLastExtendedAtAsync();
}
async deleteToken(token) {

View file

@ -36,6 +36,7 @@ class Session {
await this.db.kv.bulkPut([
{ key: "user", value: username },
{ key: "token", value: token },
{ key: "lastExtendedAt", value: Date.now() },
]);
localStorage.setItem("user", username);
localStorage.setItem("token", token);
@ -52,6 +53,18 @@ class Session {
return (await this.db.kv.get({ key: "user" }))?.value;
}
async tokenAsync() {
return (await this.db.kv.get({ key: "token" }))?.value;
}
async lastExtendedAtAsync() {
return (await this.db.kv.get({ key: "lastExtendedAt" }))?.value;
}
async setLastExtendedAtAsync() {
await this.db.kv.put({ key: "lastExtendedAt", value: Date.now() });
}
exists() {
return this.username() && this.token();
}

View file

@ -283,6 +283,49 @@ export const useStandaloneWebPushAutoSubscribe = () => {
}, [isLaunchedPWA]);
};
/**
* Registers a periodicsync listener for `extend-token` (see sw.js).
* This extends the token regardless of whether the browser is open.
*
* CAVEATS:
* - Chromium-only
* - Only when the PWA is _installed_ (not just running in a browser tab)
* - Only when notifications are granted
*/
const usePeriodicTokenExtend = () => {
const isLaunchedPWA = useIsLaunchedPWA();
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
useEffect(() => {
(async () => {
if (!isLaunchedPWA) {
console.debug("[usePeriodicTokenExtend] Skipping: Not running as PWA");
return;
}
if (!pushPossible) {
console.debug("[usePeriodicTokenExtend] Skipping: Web push not possible or granted");
return;
}
try {
const registration = await navigator.serviceWorker.ready;
if (!registration.periodicSync) {
console.debug("[usePeriodicTokenExtend] Skipping: Periodic Sync not supported");
return;
}
console.log(`[usePeriodicTokenExtend] Turning on periodicsync "extend-token"`);
await registration.periodicSync.register("extend-token", {
minInterval: 24 * 60 * 60 * 1000, // 24 hours
});
} catch (error) {
console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error);
}
})();
}, [isLaunchedPWA, pushPossible]);
};
/**
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
@ -305,6 +348,7 @@ const stopWorkers = () => {
export const useBackgroundProcesses = () => {
useStandaloneWebPushAutoSubscribe();
usePeriodicTokenExtend();
useEffect(() => {
console.log("[useBackgroundProcesses] mounting");