diff --git a/scripts/build-plugins/sw-dev.js b/scripts/build-plugins/sw-dev.js new file mode 100644 index 00000000..876ba43e --- /dev/null +++ b/scripts/build-plugins/sw-dev.js @@ -0,0 +1,67 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fs from "fs/promises"; +import path from "path"; + +/** + * This rollup plugin makes it possible to use the serviceworker with the dev server. + * The service worker is located in `/src/platform/web/sw.js` and it contains some + * fields that need to be replaced with sensible values. + * + * We have a plugin that does this during build (see `./service-worker.js`). + * This plugin does more or less the same but for dev. + */ + +export function transformServiceWorkerInDevServer() { + // See https://vitejs.dev/config/shared-options.html#define + // Comes from vite.config.js + let define; + + return { + name: "hydrogen:transformServiceWorkerInDevServer", + apply: "serve", + enforce: "pre", + + configResolved(resolvedConfig) { + // store the resolved config + define = resolvedConfig.define; + }, + + async load(id) { + if (!id.includes("sw.js")) return null; + let code = await readServiceWorkerCode(); + for (const [key, value] of Object.entries(define)) { + code = code.replaceAll(key, value); + } + return code; + }, + }; +} + +/** + * Read service worker code from `src/platform/web/sw.js` + * @returns code as string + */ +async function readServiceWorkerCode() { + const resolvedLocation = path.resolve( + __dirname, + "../../", + "./src/platform/web/sw.js" + ); + const data = await fs.readFile(resolvedLocation, { encoding: "utf-8" }); + return data; +} diff --git a/src/platform/web/index.html b/src/platform/web/index.html index 16418699..37bfec1c 100644 --- a/src/platform/web/index.html +++ b/src/platform/web/index.html @@ -19,9 +19,7 @@ import {Platform} from "./Platform"; import configURL from "./assets/config.json?url"; import assetPaths from "./sdk/paths/vite"; - if (import.meta.env.PROD) { - assetPaths.serviceWorker = "sw.js"; - } + assetPaths.serviceWorker = "sw.js"; const platform = new Platform({ container: document.body, assetPaths, diff --git a/src/platform/web/public/icon.png b/src/platform/web/public/icon.png new file mode 100644 index 00000000..b6c70c00 Binary files /dev/null and b/src/platform/web/public/icon.png differ diff --git a/src/platform/web/sw.js b/src/platform/web/sw.js index 088bc059..2c0aca5f 100644 --- a/src/platform/web/sw.js +++ b/src/platform/web/sw.js @@ -15,8 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import NOTIFICATION_BADGE_ICON from "./assets/icon.png?url"; -// replaced by the service worker build plugin +const NOTIFICATION_BADGE_ICON = "icon.png"; + +// These are replaced by rollup plugins const UNHASHED_PRECACHED_ASSETS = DEFINE_UNHASHED_PRECACHED_ASSETS; const HASHED_PRECACHED_ASSETS = DEFINE_HASHED_PRECACHED_ASSETS; const HASHED_CACHED_ON_REQUEST_ASSETS = DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS; @@ -25,20 +26,24 @@ const unhashedCacheName = `hydrogen-assets-${DEFINE_GLOBAL_HASH}`; const hashedCacheName = `hydrogen-assets`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; -self.addEventListener('install', function(e) { - e.waitUntil((async () => { - const unhashedCache = await caches.open(unhashedCacheName); - await unhashedCache.addAll(UNHASHED_PRECACHED_ASSETS); - const hashedCache = await caches.open(hashedCacheName); - await Promise.all(HASHED_PRECACHED_ASSETS.map(async asset => { - if (!await hashedCache.match(asset)) { - await hashedCache.add(asset); - } - })); - })()); +self.addEventListener("install", function (e) { + e.waitUntil( + (async () => { + const unhashedCache = await caches.open(unhashedCacheName); + await unhashedCache.addAll(UNHASHED_PRECACHED_ASSETS); + const hashedCache = await caches.open(hashedCacheName); + await Promise.all( + HASHED_PRECACHED_ASSETS.map(async (asset) => { + if (!(await hashedCache.match(asset))) { + await hashedCache.add(asset); + } + }) + ); + })() + ); }); -self.addEventListener('activate', (event) => { +self.addEventListener("activate", (event) => { // on a first page load/sw install, // start using the service worker on all pages straight away self.clients.claim(); @@ -49,26 +54,29 @@ async function purgeOldCaches() { // remove any caches we don't know about const keyList = await caches.keys(); for (const key of keyList) { - if (key !== unhashedCacheName && key !== hashedCacheName && key !== mediaThumbnailCacheName) { + if ( + key !== unhashedCacheName && + key !== hashedCacheName && + key !== mediaThumbnailCacheName + ) { await caches.delete(key); } } // remove the cache for any old hashed resource const hashedCache = await caches.open(hashedCacheName); const keys = await hashedCache.keys(); - const hashedAssetURLs = - HASHED_PRECACHED_ASSETS - .concat(HASHED_CACHED_ON_REQUEST_ASSETS) - .map(a => new URL(a, self.registration.scope).href); + const hashedAssetURLs = HASHED_PRECACHED_ASSETS.concat( + HASHED_CACHED_ON_REQUEST_ASSETS + ).map((a) => new URL(a, self.registration.scope).href); for (const request of keys) { - if (!hashedAssetURLs.some(url => url === request.url)) { + if (!hashedAssetURLs.some((url) => url === request.url)) { hashedCache.delete(request); } } } -self.addEventListener('fetch', (event) => { +self.addEventListener("fetch", (event) => { /* service worker shouldn't handle xhr uploads because otherwise the progress events won't fire. @@ -95,12 +103,18 @@ let pendingFetchAbortController = new AbortController(); async function handleRequest(request) { try { - if (request.url.includes("config.json") || /theme-.+\.json/.test(request.url)) { + if ( + request.url.includes("config.json") || + /theme-.+\.json/.test(request.url) + ) { return handleStaleWhileRevalidateRequest(request); } const url = new URL(request.url); // rewrite / to /index.html so it hits the cache - if (url.origin === baseURL.origin && url.pathname === baseURL.pathname) { + if ( + url.origin === baseURL.origin && + url.pathname === baseURL.pathname + ) { request = new Request(new URL("index.html", baseURL.href)); } let response = await readCache(request); @@ -108,9 +122,15 @@ async function handleRequest(request) { // use cors so the resource in the cache isn't opaque and uses up to 7mb // https://developers.google.com/web/tools/chrome-devtools/progressive-web-apps?utm_source=devtools#opaque-responses if (isCacheableThumbnail(url)) { - response = await fetch(request, {signal: pendingFetchAbortController.signal, mode: "cors", credentials: "omit"}); + response = await fetch(request, { + signal: pendingFetchAbortController.signal, + mode: "cors", + credentials: "omit", + }); } else { - response = await fetch(request, {signal: pendingFetchAbortController.signal}); + response = await fetch(request, { + signal: pendingFetchAbortController.signal, + }); } await updateCache(request, response); } @@ -184,7 +204,7 @@ async function readCache(request) { if (response) { return response; } - + const url = new URL(request.url); if (isCacheableThumbnail(url)) { const mediaThumbnailCache = await caches.open(mediaThumbnailCacheName); @@ -198,9 +218,10 @@ async function readCache(request) { return response; } -self.addEventListener('message', (event) => { - const reply = payload => event.source.postMessage({replyTo: event.data.id, payload}); - const {replyTo} = event.data; +self.addEventListener("message", (event) => { + const reply = (payload) => + event.source.postMessage({ replyTo: event.data.id, payload }); + const { replyTo } = event.data; if (replyTo) { const resolve = pendingReplies.get(replyTo); if (resolve) { @@ -210,7 +231,10 @@ self.addEventListener('message', (event) => { } else { switch (event.data?.type) { case "version": - reply({version: DEFINE_VERSION, buildHash: DEFINE_GLOBAL_HASH}); + reply({ + version: DEFINE_VERSION, + buildHash: DEFINE_GLOBAL_HASH, + }); break; case "skipWaiting": self.skipWaiting(); @@ -220,8 +244,10 @@ self.addEventListener('message', (event) => { break; case "closeSession": event.waitUntil( - closeSession(event.data.payload.sessionId, event.source.id) - .finally(() => reply()) + closeSession( + event.data.payload.sessionId, + event.source.id + ).finally(() => reply()) ); break; } @@ -235,29 +261,40 @@ async function openClientFromNotif(event) { console.log("clicked notif with tag", event.notification.tag); return; } - const {sessionId, roomId} = event.notification.data; + const { sessionId, roomId } = event.notification.data; const sessionHash = `#/session/${sessionId}`; const roomHash = `${sessionHash}/room/${roomId}`; - const clientWithSession = await findClient(async client => { - return await sendAndWaitForReply(client, "hasSessionOpen", {sessionId}); + const clientWithSession = await findClient(async (client) => { + return await sendAndWaitForReply(client, "hasSessionOpen", { + sessionId, + }); }); if (clientWithSession) { - console.log("notificationclick: client has session open, showing room there"); + console.log( + "notificationclick: client has session open, showing room there" + ); // use a message rather than clientWithSession.navigate here as this refreshes the page on chrome - clientWithSession.postMessage({type: "openRoom", payload: {roomId}}); - if ('focus' in clientWithSession) { + clientWithSession.postMessage({ + type: "openRoom", + payload: { roomId }, + }); + if ("focus" in clientWithSession) { try { await clientWithSession.focus(); - } catch (err) { console.error(err); } // I've had this throw on me on Android + } catch (err) { + console.error(err); + } // I've had this throw on me on Android } } else if (self.clients.openWindow) { - console.log("notificationclick: no client found with session open, opening new window"); + console.log( + "notificationclick: no client found with session open, opening new window" + ); const roomURL = new URL(`./${roomHash}`, baseURL).href; await self.clients.openWindow(roomURL); } } -self.addEventListener('notificationclick', event => { +self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil(openClientFromNotif(event)); }); @@ -268,19 +305,30 @@ async function handlePushNotification(n) { let sender = n.sender_display_name || n.sender; if (sender && n.event_id) { const roomId = n.room_id; - const hasFocusedClientOnRoom = !!await findClient(async client => { + const hasFocusedClientOnRoom = !!(await findClient(async (client) => { if (client.visibilityState === "visible" && client.focused) { - return await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); + return await sendAndWaitForReply(client, "hasRoomOpen", { + sessionId, + roomId, + }); } - }); + })); if (hasFocusedClientOnRoom) { console.log("client is focused, room is open, don't show notif"); return; } - const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); - const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); - const hasMultiNotification = notifsForRoom.some(n => n.data.multi); - const hasSingleNotifsForRoom = newMessageNotifs.some(n => !n.data.multi); + const newMessageNotifs = Array.from( + await self.registration.getNotifications({ + tag: NOTIF_TAG_NEW_MESSAGE, + }) + ); + const notifsForRoom = newMessageNotifs.filter( + (n) => n.data.roomId === roomId + ); + const hasMultiNotification = notifsForRoom.some((n) => n.data.multi); + const hasSingleNotifsForRoom = newMessageNotifs.some( + (n) => !n.data.multi + ); const roomName = n.room_name || n.room_alias; let multi = false; let label; @@ -304,9 +352,9 @@ async function handlePushNotification(n) { } await self.registration.showNotification(label, { body, - data: {sessionId, roomId, multi}, + data: { sessionId, roomId, multi }, tag: NOTIF_TAG_NEW_MESSAGE, - badge: NOTIFICATION_BADGE_ICON + badge: NOTIFICATION_BADGE_ICON, }); } // we could consider hiding previous notifications here based on the unread count @@ -315,25 +363,31 @@ async function handlePushNotification(n) { // when no client is visible, see https://goo.gl/yqv4Q4 } -self.addEventListener('push', event => { +self.addEventListener("push", (event) => { event.waitUntil(handlePushNotification(event.data.json())); }); async function closeSession(sessionId, requestingClientId) { const clients = await self.clients.matchAll(); - await Promise.all(clients.map(async client => { - if (client.id !== requestingClientId) { - await sendAndWaitForReply(client, "closeSession", {sessionId}); - } - })); + await Promise.all( + clients.map(async (client) => { + if (client.id !== requestingClientId) { + await sendAndWaitForReply(client, "closeSession", { + sessionId, + }); + } + }) + ); } async function haltRequests() { // first ask all clients to block sending any more requests - const clients = await self.clients.matchAll({type: "window"}); - await Promise.all(clients.map(client => { - return sendAndWaitForReply(client, "haltRequests"); - })); + const clients = await self.clients.matchAll({ type: "window" }); + await Promise.all( + clients.map((client) => { + return sendAndWaitForReply(client, "haltRequests"); + }) + ); // and only then abort the current requests pendingFetchAbortController.abort(); } @@ -343,15 +397,15 @@ let messageIdCounter = 0; function sendAndWaitForReply(client, type, payload) { messageIdCounter += 1; const id = messageIdCounter; - const promise = new Promise(resolve => { + const promise = new Promise((resolve) => { pendingReplies.set(id, resolve); }); - client.postMessage({type, id, payload}); + client.postMessage({ type, id, payload }); return promise; } async function findClient(predicate) { - const clientList = await self.clients.matchAll({type: "window"}); + const clientList = await self.clients.matchAll({ type: "window" }); for (const client of clientList) { if (await predicate(client)) { return client;