diff --git a/docs/develop.md b/docs/develop.md index dde20b56..01ffbb50 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -255,6 +255,7 @@ Reference: \ diff --git a/web/public/sw.js b/web/public/sw.js index fa2ac9e0..0e408206 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -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. */ diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d9380438..89d9a526 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -153,6 +153,7 @@ class AccountApi { method: "PATCH", headers: withBearerAuth({}, session.token()), }); + await session.setLastExtendedAtAsync(); } async deleteToken(token) { diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 7464150c..976e9f3c 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -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(); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 1b4a78b7..b7d0b6f1 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -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");