From d9ec8c20c3f86cd1f97f468e27dc26a7313637d1 Mon Sep 17 00:00:00 2001 From: Jaifroid Date: Sat, 25 Dec 2021 09:47:04 +0000 Subject: [PATCH] Use flexible, adaptive caching and split appcache and assetscache Former-commit-id: 90733545b8784ebbd6ae74a43e13349762b1d587 [formerly 1fb5fd48e1c084c0813ad47d0771204a52c9770a] [formerly 0361a9fa05d4e8063a150172a3c6adcfef946b56] [formerly d8d77598de0b001f5fc60bc58eccb8eb673a609b [formerly 49a5574bcac4fe4a2f945b3f1f9e8c8fb946ea8e [formerly 5d24bdba777c0cc6e8f2a1857e82d3fb9282378b]]] Former-commit-id: 14ac63d892cfc8c7df292cdc2fa26c0a74d191f0 [formerly 0bace1751b875550d897ca1368b396b4f6ccae61 [formerly 0a77fb56d2811e33ebf81fd2ec9cb1ade54f2c8b]] Former-commit-id: 2078b106016fe16cec442278a464bf555d0ef902 [formerly f3bb117918e9f4577a4e73e7973e7dc70ee5d9dd] Former-commit-id: 5c26226eb043c8a0b475904fe99fdfcaee3d0e16 --- Dockerfile.pwa | 2 +- KiwixWebApp.jsproj | 2 +- package.json | 2 +- package.json.nwjs | 6 +- pwabuilder-sw.js | 360 +------------------ scripts/Build-DockerContainer.ps1 | 8 +- scripts/Build-NWJS.ps1 | 2 +- scripts/Create-DraftRelease.ps1 | 8 +- service-worker.js | 554 +++++++++++++++++++++++------- www/index.html | 2 +- www/js/app.js | 45 ++- www/js/init.js | 4 +- www/js/lib/cache.js | 155 +++++---- 13 files changed, 557 insertions(+), 593 deletions(-) diff --git a/Dockerfile.pwa b/Dockerfile.pwa index f813826b..3e8270bb 100644 --- a/Dockerfile.pwa +++ b/Dockerfile.pwa @@ -3,7 +3,7 @@ FROM nginx:latest EXPOSE 80 COPY ./manifest.json /usr/share/nginx/html -COPY ./pwabuilder-sw.js /usr/share/nginx/html +COPY ./service-worker.js /usr/share/nginx/html COPY index.html /usr/share/nginx/html COPY CHANGELOG.md /usr/share/nginx/html COPY LICENSE /usr/share/nginx/html diff --git a/KiwixWebApp.jsproj b/KiwixWebApp.jsproj index 5d7f340a..7942cd3a 100644 --- a/KiwixWebApp.jsproj +++ b/KiwixWebApp.jsproj @@ -174,7 +174,7 @@ - + diff --git a/package.json b/package.json index 5157296a..95133b3a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "asar": false, "files": [ "archives/**", - "pwabuilder-sw.js", + "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", diff --git a/package.json.nwjs b/package.json.nwjs index b3b1f46f..1e8d8ea9 100644 --- a/package.json.nwjs +++ b/package.json.nwjs @@ -22,7 +22,7 @@ "nwVersion": "0.14.7", "output": "bld/nwjs/win-x86-xp", "files": [ - "pwabuilder-sw.js", + "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", @@ -40,7 +40,7 @@ "output": "bld/nwjs/win-x64", "files": [ "archives/**", - "pwabuilder-sw.js", + "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", @@ -54,7 +54,7 @@ "nwVersion": "0.58.0", "output": "bld/nwjs/win-x86", "files": [ - "pwabuilder-sw.js", + "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", diff --git a/pwabuilder-sw.js b/pwabuilder-sw.js index 264c90e8..7b9f4990 100644 --- a/pwabuilder-sw.js +++ b/pwabuilder-sw.js @@ -1,348 +1,14 @@ -// Service Worker with Cache-first network, with some code from pwabuilder.com -'use strict'; - -// App version number - ENSURE IT MATCHES VALUE IN init.js -// DEV: Changing this will cause the browser to recognize that the Service Worker has changed, and it will download and -// install a new copy -const appVersion = '1.8.5'; - -// Kiwix ZIM Archive Download Server in regex form -// DEV: The server URL is defined in init.js, but is not available to us in SW -const regexpKiwixDownloadLinks = /download\.kiwix\.org/i; - -// Pattern for ZIM file namespace - see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces -// In our case, there is also the ZIM file name, used as a prefix in the URL -const regexpZIMUrlWithNamespace = /(?:^|\/)([^\/]+\/)([-ABCIJMUVWX])\/(.+)/; - -const CACHE = "kiwix-precache-" + appVersion; -let precacheFiles = [ - ".", - "manifest.json", - "pwabuilder-sw.js", - "www/-/mw/ext.cite.styles.css", - "www/-/mw/ext.cite.ux-enhancements.css", - "www/-/mw/ext.math.scripts.css", - "www/-/mw/ext.math.styles.css", - "www/-/mw/ext.kartographer.frame.css", - "www/-/mw/ext.kartographer.link.css", - "www/-/mw/ext.kartographer.style.css", - "www/-/mw/ext.scribunto.logs.css", - "www/-/mw/ext.tmh.thumbnail.styles.css", - "www/-/mw/inserted_style.css", - "www/-/mw/inserted_style_mobile.css", - "www/-/mw/mediawiki.page.gallery.styles.css", - "www/-/mw/mobile.css", - "www/-/mw/mw.MediaWikiPlayer.loader.css", - "www/-/mw/mw.PopUpMediaTransform.css", - "www/-/mw/mw.TMHGalleryHook.js.css", - "www/-/mw/style.css", - "www/-/s/css_modules/content.parsoid.css", - "www/-/s/css_modules/ext.cite.a11y.css", - "www/-/s/css_modules/ext.cite.styles.css", - "www/-/s/css_modules/ext.cite.ux-enhancements.css", - "www/-/s/css_modules/ext.inputBox.styles.css", - "www/-/s/css_modules/ext.kartographer.frame.css", - "www/-/s/css_modules/ext.kartographer.link.css", - "www/-/s/css_modules/ext.kartographer.style.css", - "www/-/s/css_modules/inserted_style.css", - "www/-/s/css_modules/inserted_style_mobile.css", - "www/-/s/css_modules/mobile.css", - "www/-/s/css_modules/style.css", - "www/-/style.css", - "www/-/s/style.css", - "www/-/s/style-dark.css", - "www/-/s/style-dark-invert.css", - "www/-/s/style-mobile.css", - "www/-/s/vector.css", - "www/I/COVID-19_lifecycle.jpg", - "www/I/s/Icon_External_Link.png", - "www/I/s/Icons-mini-file_acrobat.gif", - "www/css/app.css", - "www/css/bootstrap.min.css", - "www/fonts/glyphicons-halflings-regular.woff2", - "www/img/icons/kiwix-256.png", - "www/img/icons/kiwix-192.png", - "www/img/icons/kiwix-32.png", - "www/img/icons/kiwix-60.png", - "www/img/icons/kiwix-blue-32.png", - "www/img/icons/kiwix-midnightblue-90.png", - "www/img/icons/wikimed-blue-32.png", - "www/img/icons/wikimed-lightblue-32.png", - "www/img/icons/wikivoyage-90-white.png", - "www/img/icons/wikivoyage-black-32.png", - "www/img/icons/wikivoyage-white-32.png", - "www/img/icons/map_marker-30px.png", - "www/img/icons/map_marker-18px.png", - "www/img/spinner.gif", - "www/index.html", - "www/article.html", - "www/js/app.js", - "www/js/init.js", - "www/js/lib/arrayFromPolyfill.js", - "www/js/lib/bootstrap.js", - "www/js/lib/bootstrap.min.js", - "www/js/lib/cache.js", - "www/js/lib/filecache.js", - "www/js/lib/images.js", - "www/js/lib/jquery-3.2.1.slim.js", - "www/js/lib/kiwixServe.js", - "www/js/lib/promisePolyfill.js", - "www/js/lib/require.js", - "www/js/lib/settingsStore.js", - "www/js/lib/transformStyles.js", - "www/js/lib/uiUtil.js", - "www/js/lib/utf8.js", - "www/js/lib/util.js", - //"www/js/lib/webpHeroBundle_0.0.0-dev.27.js", - "www/js/lib/xzdec_wrapper.js", - "www/js/lib/zstddec_wrapper.js", - "www/js/lib/zimArchive.js", - "www/js/lib/zimArchiveLoader.js", - "www/js/lib/zimDirEntry.js", - "www/js/lib/zimfile.js", - "www/js/katex/katex.min.js", - "www/js/katex/katex.min.css", - "www/js/katex/contrib/mathtex-script-type.min.js", - "www/js/katex/fonts/KaTeX_AMS-Regular.woff2", - "www/js/katex/fonts/KaTeX_Main-Bold.woff2", - "www/js/katex/fonts/KaTeX_Main-Regular.woff2", - "www/js/katex/fonts/KaTeX_Math-Italic.woff2", - "www/js/katex/fonts/KaTeX_Size1-Regular.woff2", - "www/js/katex/fonts/KaTeX_Size2-Regular.woff2", - "www/js/katex/fonts/KaTeX_Size3-Regular.woff2", - "www/js/katex/fonts/KaTeX_Size4-Regular.woff2" -]; - -if ('WebAssembly' in self) { - precacheFiles.push( - "www/js/lib/xzdec-wasm.js", - "www/js/lib/xzdec-wasm.wasm", - "www/js/lib/zstddec-wasm.js", - "www/js/lib/zstddec-wasm.wasm" - ); -} else { - precacheFiles.push( - "www/js/lib/xzdec-asm.js", - "www/js/lib/zstddec-asm.js" - ); -} - -// DEV: add any URL schemata that should be excluded from caching with the Cache API to the regex below -// As of 08-2019 the chrome-extension: schema is incompatible with the Cache API -// 'example-extension' is included to show how to add another schema if necessary -var excludedURLSchema = /^(?:file|chrome-extension|example-extension):/i; - -self.addEventListener("install", function (event) { - console.log("[SW] Install Event processing"); - // DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate... - // self.skipWaiting(); - var requests = precacheFiles.map(function(url) { - return new Request(url + '?v' + appVersion, { cache: 'no-cache' }); +// Self-destroying service-worker - see https://github.com/NekR/self-destroying-sw +self.addEventListener('install', function(e) { + self.skipWaiting(); }); - if (!excludedURLSchema.test(requests[0].url)) event.waitUntil( - caches.open(CACHE).then(function (cache) { - return Promise.all( - requests.map(function (request) { - return fetch(request).then(function (response) { - // Fail on 404, 500 etc - if (!response.ok) throw Error('Could not fetch ' + request.url); - return cache.put(request.url.replace(/\?v[^?/]+$/, ''), response); - }).catch(function (err) { - console.error("There was an error pre-caching files", err); - }); - }) - ); - }) - ); -}); - -// Allow sw to control current page -self.addEventListener('activate', function (event) { - console.log("[SW] Claiming clients for current page"); - event.waitUntil( - caches.keys().then(function (keyList) { - return Promise.all(keyList.map(function (key) { - console.log('[SW] Current cache key is ' + key); - if (key !== CACHE) { - console.log("[SW] App updated to version " + appVersion + ": deleting old cache") - return caches.delete(key); - } - })); - }) - ); -}); - -/** - * A Boolean that governs whether images are displayed - * app.js can alter this variable via messaging - */ -let imageDisplay; - -let outgoingMessagePort = null; -let fetchCaptureEnabled = false; - -/** - * Handle custom commands 'init' and 'disable' from app.js - */ -self.addEventListener('message', function (event) { - if (event.data.action === 'init') { - // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener - outgoingMessagePort = event.ports[0]; - fetchCaptureEnabled = true; - } - if (event.data.action === 'disable') { - // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener - outgoingMessagePort = null; - fetchCaptureEnabled = false; - self.removeEventListener('fetch', intercept); - } -}); - -self.addEventListener('fetch', intercept); - -// Look up fetch in cache, and if it does not exist, try to get it from the network -function intercept(event) { - // Test if we're in an Electron app - // DEV: Electron uses the file:// protocol and hacks it to work with SW, but it has CORS issues when using the Fetch API to fetch local files, - // so we must bypass it here if we're fetching a local file - if (/^file:/i.test(event.request.url) && ! (regexpZIMUrlWithNamespace.test(event.request.url) && /\.zim\w{0,2}\//i.test(event.request.url))) return; - // console.debug('[SW] Service Worker ' + (event.request.method === "GET" ? 'intercepted ' : 'noted ') + event.request.url, event.request.method); - if (event.request.method !== "GET") return; - // Don't cache download links - if (regexpKiwixDownloadLinks.test(event.request.url)) return; - // Remove any querystring except 'kiwix-display' - var rqUrl = event.request.url.replace(/\?(?!kiwix-display)[^?]+$/i, ''); - event.respondWith( - fromCache(rqUrl).then(function (response) { - console.debug('[SW] Supplying ' + rqUrl + ' from CACHE...'); - return response; - }, - function () { - // The response was not found in the cache so we look for it on the server - if (/\.zim\w{0,2}\//i.test(rqUrl) && regexpZIMUrlWithNamespace.test(rqUrl)) { - if (imageDisplay !== 'all' && /(^|\/)[IJ]\/.*\.(jpe?g|png|svg|gif|webp)($|[?#])(?!kiwix-display)/i.test(rqUrl)) { - // If the user has disabled the display of images, and the browser wants an image, respond with empty SVG - // A URL with "?kiwix-display" query string acts as a passthrough so that the regex will not match and - // the image will be fetched by app.js - // DEV: If you need to hide more image types, add them to regex below and also edit equivalent regex in app.js - var svgResponse; - if (imageDisplay === 'manual') - svgResponse = ""; - else - svgResponse = ""; - return new Response(svgResponse, { - headers: { - 'Content-Type': 'image/svg+xml' - } - }); - } - - // Let's ask app.js for that content - return new Promise(function (resolve, reject) { - var nameSpace; - var title; - var titleWithNameSpace; - var regexpResult = regexpZIMUrlWithNamespace.exec(rqUrl); - var prefix = regexpResult[1]; - nameSpace = regexpResult[2]; - title = regexpResult[3]; - - // We need to remove the potential parameters in the URL - title = removeUrlParameters(decodeURIComponent(title)); - - titleWithNameSpace = nameSpace + '/' + title; - - // Let's instantiate a new messageChannel, to allow app.js to give us the content - var messageChannel = new MessageChannel(); - messageChannel.port1.onmessage = function (msgEvent) { - if (msgEvent.data.action === 'giveContent') { - // Content received from app.js - var contentLength = msgEvent.data.content ? msgEvent.data.content.byteLength : null; - var contentType = msgEvent.data.mimetype; - // Set the imageDisplay variable if it has been sent in the event data - imageDisplay = typeof msgEvent.data.imageDisplay !== 'undefined' ? - msgEvent.data.imageDisplay : imageDisplay; - var headers = new Headers(); - if (contentLength) headers.set('Content-Length', contentLength); - // Prevent CORS issues in PWAs - if (contentLength) headers.set('Access-Control-Allow-Origin', '*'); - if (contentType) headers.set('Content-Type', contentType); - // Test if the content is a video or audio file - // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") - // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp - if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { - // In case of a video (at least), Chrome and Edge need these HTTP headers else seeking doesn't work - // (even if we always send all the video content, not the requested range, until the backend supports it) - headers.set('Accept-Ranges', 'bytes'); - headers.set('Content-Range', 'bytes 0-' + (contentLength - 1) + '/' + contentLength); - } - var responseInit = { - status: 200, - statusText: 'OK', - headers: headers - }; - - var httpResponse = new Response(msgEvent.data.content, responseInit); - - // Add or update css or javascript assets to the cache - if (!excludedURLSchema.test(rqUrl) && /(text|application)\/(css|javascript)/i.test(contentType)) { - updateCache(event.request, httpResponse.clone()); - } - - // Let's send the content back from the ServiceWorker - resolve(httpResponse); - } else if (msgEvent.data.action === 'sendRedirect') { - resolve(Response.redirect(prefix + msgEvent.data.redirectUrl)); - } else { - console.error('Invalid message received from app.js for ' + titleWithNameSpace, msgEvent.data); - reject(msgEvent.data); - } - }; - outgoingMessagePort.postMessage({ - 'action': 'askForContent', - 'title': titleWithNameSpace - }, [messageChannel.port2]); - }); - } else { - // It's not a ZIM URL - return fetch(event.request).then(function (response) { - // If request was success, add or update it in the cache - if (!excludedURLSchema.test(rqUrl) && !/\.zim\w{0,2}$/i.test(rqUrl)) { - event.waitUntil(updateCache(event.request, response.clone())); - } - return response; - }).catch(function (error) { - console.debug("[SW] Network request failed and no cache.", error); - }); - } - } - ) - ); -} - -function fromCache(request) { - // Check to see if you have it in the cache - // Return response - // If not in the cache, then return - return caches.open(CACHE).then(function (cache) { - return cache.match(request).then(function (matching) { - if (!matching || matching.status === 404) { - return Promise.reject("no-match"); - } - return matching; - }); - }); -} - -function updateCache(request, response) { - if (!excludedURLSchema.test(request.url||request)) { - return caches.open(CACHE).then(function (cache) { - return cache.put(request, response); - }); - } -} - -// Removes parameters and anchors from a URL -function removeUrlParameters(url) { - return url.replace(/([^?#]+)[?#].*$/, "$1"); -} + + self.addEventListener('activate', function(e) { + self.registration.unregister() + .then(function() { + return self.clients.matchAll(); + }) + .then(function(clients) { + clients.forEach(client => client.navigate(client.url)) + }); + }); \ No newline at end of file diff --git a/scripts/Build-DockerContainer.ps1 b/scripts/Build-DockerContainer.ps1 index 892acf62..afaec760 100644 --- a/scripts/Build-DockerContainer.ps1 +++ b/scripts/Build-DockerContainer.ps1 @@ -21,7 +21,7 @@ param ( $release_uri = 'https://api.github.com/repos/kiwix/kiwix-js-windows/actions/workflows/publish-docker.yaml/dispatches' $app_params = Select-String 'appVersion' "$PSScriptRoot\..\www\js\init.js" -List -$serviceworker = Select-String 'appVersion' "$PSScriptRoot\..\pwabuilder-sw.js" -List +$serviceworker = Select-String 'appVersion' "$PSScriptRoot\..\service-worker.js" -List $suggested_build = '' $app_tag = '' if ($app_params -match 'params\[[''"]appVersion[''"]]\s*=\s*[''"]([^''"]+)') { @@ -35,15 +35,15 @@ $sw_tag = '' if ($serviceworker -match 'appVersion\s*=\s*[''"]([^''"]+)') { $sw_tag = $matches[1] if ($sw_tag -ne $app_tag) { - "*** WARNING: The version in init.js [$app_tag] does not match the version in pwabuilder-sw.js [$sw_tag]! ***" + "*** WARNING: The version in init.js [$app_tag] does not match the version in service-worker.js [$sw_tag]! ***" "Please correct before continuing.`n" exit } else { "`nVersion in init.js: $app_tag" - "Version in pwabuilder-sw.js: $sw_tag`n" + "Version in service-worker.js: $sw_tag`n" } } else { - "*** WARNING: App version is incorrectly set in pwabuilder-sw.js.`nPlease correct before continuing.`n" + "*** WARNING: App version is incorrectly set in service-worker.js.`nPlease correct before continuing.`n" exit } diff --git a/scripts/Build-NWJS.ps1 b/scripts/Build-NWJS.ps1 index 82f4eda2..85fa5484 100644 --- a/scripts/Build-NWJS.ps1 +++ b/scripts/Build-NWJS.ps1 @@ -68,7 +68,7 @@ foreach ($build in $builds) { # Copy latest binary x64 cp $buildLocation\* $fullTarget -Recurse $root = $PSScriptRoot -replace 'scripts.*$', '' - cp $root\package.json, $root\pwabuilder-sw.js, $root\index.html, $root\CHANGELOG.md, $root\LICENSE, $root\www $fullTarget -Recurse + cp $root\package.json, $root\service-worker.js, $root\index.html, $root\CHANGELOG.md, $root\LICENSE, $root\www $fullTarget -Recurse "Copying archive..." md $archiveFolder cp "$root\archives\$PackagedArchive", "$root\archives\*.txt", "$root\archives\README.md" $archiveFolder diff --git a/scripts/Create-DraftRelease.ps1 b/scripts/Create-DraftRelease.ps1 index 8c875f6b..09dd215a 100644 --- a/scripts/Create-DraftRelease.ps1 +++ b/scripts/Create-DraftRelease.ps1 @@ -22,7 +22,7 @@ $release_uri = 'https://api.github.com/repos/kiwix/kiwix-js-windows/releases' $github_token = Get-Content -Raw "$PSScriptRoot/github_token" $init_params = Get-Content -Raw "$PSScriptRoot\..\www\js\init.js" -$serviceworker = Select-String 'appVersion' "$PSScriptRoot\..\pwabuilder-sw.js" -List +$serviceworker = Select-String 'appVersion' "$PSScriptRoot\..\service-worker.js" -List $file_tag = '' if ($init_params -match 'params\[[''"]appVersion[''"]]\s*=\s*[''"]([^''"]+)') { @@ -35,15 +35,15 @@ $sw_tag = '' if ($serviceworker -match 'appVersion\s*=\s*[''"]([^''"]+)') { $sw_tag = 'v' + $matches[1] if ($sw_tag -ne $file_tag) { - "`n*** WARNING: The version in init.js [$file_tag] does not match the version in pwabuilder-sw.js [$sw_tag]! ***" + "`n*** WARNING: The version in init.js [$file_tag] does not match the version in service-worker.js [$sw_tag]! ***" "Please correct before continuing.`n" exit } else { "`nVersion in init.js: $file_tag" - "Version in pwabuilder-sw.js: $sw_tag" + "Version in service-worker.js: $sw_tag" } } else { - "`n*** WARNING: App version is incorrectly set in pwabuilder-sw.js.`nPlease correct before continuing.`n" + "`n*** WARNING: App version is incorrectly set in service-worker.js.`nPlease correct before continuing.`n" exit } diff --git a/service-worker.js b/service-worker.js index d5049fbf..cc92ea35 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,5 +1,5 @@ /** - * service-worker.js : Service Worker implementation, + * pwabuilder.js : Service Worker implementation, * in order to capture the HTTP requests made by an article, and respond with the * corresponding content, coming from the archive * @@ -24,146 +24,446 @@ 'use strict'; /** - * A global Boolean that governs whether images are displayed + * App version number - ENSURE IT MATCHES VALUE IN init.js + * DEV: Changing this will cause the browser to recognize that the Service Worker has changed, and it will + * download and install a new copy; we have to hard code this here because it is needed before any other file + * is cached in APP_CACHE + */ +const appVersion = '1.8.5'; + +/** + * The name of the Cache API cache in which assets defined in regexpCachedContentTypes will be stored + * The value is sometimes needed here before it can be passed from app.js, so we have to duplicate it + * @type {String} + */ +// DEV: Ensure this matches the name defined in app.js +const ASSETS_CACHE = 'kiwixjs-assetsCache'; + +/** + * The name of the application cache to use for caching online code so that it can be used offline + * The cache name is made up of the prefix below and the appVersion: this is necessary so that when + * the app is updated, a new cache is created. The new cache will start being used after the user + * restarts the app, when we will also delete the old cache. + * @type {String} + */ +const APP_CACHE = 'kiwixjs-appCache-' + appVersion; + +/** + * A global Boolean that governs whether ASSETS_CACHE will be used + * Caching is on by default but can be turned off by the user in Configuration + * @type {Boolean} + */ +var useCache = true; + +/** + * A Boolean that governs whether images are displayed * app.js can alter this variable via messaging */ -var imageDisplay; +let imageDisplay; -self.addEventListener('install', function(event) { - event.waitUntil(self.skipWaiting()); +// Kiwix ZIM Archive Download Server in regex form +// DEV: The server URL is defined in init.js, but is not available to us in SW +const regexpKiwixDownloadLinks = /download\.kiwix\.org/i; + + +/** + * A regular expression that matches the Content-Types of assets that may be stored in ASSETS_CACHE + * Add any further Content-Types you wish to cache to the regexp, separated by '|' + * @type {RegExp} + */ +var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascript/i; + +/** + * A regular expression that excludes listed schemata from caching attempts + * As of 08-2019 the chrome-extension: schema is incompatible with the Cache API + * 'example-extension' is included to show how to add another schema if necessary + * @type {RegExp} + */ +var regexpExcludedURLSchema = /^(?:file|chrome-extension|example-extension):/i; + +/** + * Pattern for ZIM file namespace: see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces + * In our case, there is also the ZIM file name used as a prefix in the URL + * @type {RegExp} + */ +const regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/; + +/** + * The list of files that the app needs in order to run entirely from offline code + */ +let precacheFiles = [ + ".", // This caches the redirect to www/index.html, in case a user launches the app from its root directory + "manifest.json", + "service-worker.js", + "www/-/mw/ext.cite.styles.css", + "www/-/mw/ext.cite.ux-enhancements.css", + "www/-/mw/ext.math.scripts.css", + "www/-/mw/ext.math.styles.css", + "www/-/mw/ext.kartographer.frame.css", + "www/-/mw/ext.kartographer.link.css", + "www/-/mw/ext.kartographer.style.css", + "www/-/mw/ext.scribunto.logs.css", + "www/-/mw/ext.tmh.thumbnail.styles.css", + "www/-/mw/inserted_style.css", + "www/-/mw/inserted_style_mobile.css", + "www/-/mw/mediawiki.page.gallery.styles.css", + "www/-/mw/mobile.css", + "www/-/mw/mw.MediaWikiPlayer.loader.css", + "www/-/mw/mw.PopUpMediaTransform.css", + "www/-/mw/mw.TMHGalleryHook.js.css", + "www/-/mw/style.css", + "www/-/s/css_modules/content.parsoid.css", + "www/-/s/css_modules/ext.cite.a11y.css", + "www/-/s/css_modules/ext.cite.styles.css", + "www/-/s/css_modules/ext.cite.ux-enhancements.css", + "www/-/s/css_modules/ext.inputBox.styles.css", + "www/-/s/css_modules/ext.kartographer.frame.css", + "www/-/s/css_modules/ext.kartographer.link.css", + "www/-/s/css_modules/ext.kartographer.style.css", + "www/-/s/css_modules/inserted_style.css", + "www/-/s/css_modules/inserted_style_mobile.css", + "www/-/s/css_modules/mobile.css", + "www/-/s/css_modules/style.css", + "www/-/style.css", + "www/-/s/style.css", + "www/-/s/style-dark.css", + "www/-/s/style-dark-invert.css", + "www/-/s/style-mobile.css", + "www/-/s/vector.css", + "www/I/COVID-19_lifecycle.jpg", + "www/I/s/Icon_External_Link.png", + "www/I/s/Icons-mini-file_acrobat.gif", + "www/css/app.css", + "www/css/bootstrap.min.css", + "www/fonts/glyphicons-halflings-regular.woff2", + "www/img/icons/kiwix-256.png", + "www/img/icons/kiwix-192.png", + "www/img/icons/kiwix-32.png", + "www/img/icons/kiwix-60.png", + "www/img/icons/kiwix-blue-32.png", + "www/img/icons/kiwix-midnightblue-90.png", + "www/img/icons/wikimed-blue-32.png", + "www/img/icons/wikimed-lightblue-32.png", + "www/img/icons/wikivoyage-90-white.png", + "www/img/icons/wikivoyage-black-32.png", + "www/img/icons/wikivoyage-white-32.png", + "www/img/icons/map_marker-30px.png", + "www/img/icons/map_marker-18px.png", + "www/img/spinner.gif", + "www/index.html", + "www/article.html", + "www/js/app.js", + "www/js/init.js", + "www/js/lib/arrayFromPolyfill.js", + "www/js/lib/bootstrap.js", + "www/js/lib/bootstrap.min.js", + "www/js/lib/cache.js", + "www/js/lib/filecache.js", + "www/js/lib/images.js", + "www/js/lib/jquery-3.2.1.slim.js", + "www/js/lib/kiwixServe.js", + "www/js/lib/promisePolyfill.js", + "www/js/lib/require.js", + "www/js/lib/settingsStore.js", + "www/js/lib/transformStyles.js", + "www/js/lib/uiUtil.js", + "www/js/lib/utf8.js", + "www/js/lib/util.js", + //"www/js/lib/webpHeroBundle_0.0.0-dev.27.js", + "www/js/lib/xzdec_wrapper.js", + "www/js/lib/zstddec_wrapper.js", + "www/js/lib/zimArchive.js", + "www/js/lib/zimArchiveLoader.js", + "www/js/lib/zimDirEntry.js", + "www/js/lib/zimfile.js", + "www/js/katex/katex.min.js", + "www/js/katex/katex.min.css", + "www/js/katex/contrib/mathtex-script-type.min.js", + "www/js/katex/fonts/KaTeX_AMS-Regular.woff2", + "www/js/katex/fonts/KaTeX_Main-Bold.woff2", + "www/js/katex/fonts/KaTeX_Main-Regular.woff2", + "www/js/katex/fonts/KaTeX_Math-Italic.woff2", + "www/js/katex/fonts/KaTeX_Size1-Regular.woff2", + "www/js/katex/fonts/KaTeX_Size2-Regular.woff2", + "www/js/katex/fonts/KaTeX_Size3-Regular.woff2", + "www/js/katex/fonts/KaTeX_Size4-Regular.woff2" +]; + +if ('WebAssembly' in self) { + precacheFiles.push( + "www/js/lib/xzdec-wasm.js", + "www/js/lib/xzdec-wasm.wasm", + "www/js/lib/zstddec-wasm.js", + "www/js/lib/zstddec-wasm.wasm" + ); +} else { + precacheFiles.push( + "www/js/lib/xzdec-asm.js", + "www/js/lib/zstddec-asm.js" + ); +} + + +// Process install event +self.addEventListener("install", function (event) { + console.debug("[SW] Install Event processing"); + // DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate... + // self.skipWaiting(); + // We try to circumvent the browser's cache by adding a header to the Request, and it ensures all files are explicitly versioned + var requests = precacheFiles.map(function (urlPath) { + return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' }); + }); + if (!regexpExcludedURLSchema.test(requests[0].url)) event.waitUntil( + caches.open(APP_CACHE).then(function (cache) { + return Promise.all( + requests.map(function (request) { + return fetch(request).then(function (response) { + // Fail on 404, 500 etc + if (!response.ok) throw Error('Could not fetch ' + request.url); + return cache.put(request.url.replace(/\?v[^?/]+$/, ''), response); + }).catch(function (err) { + console.error('There was an error pre-caching files', err); + }); + }) + ); + }) + ); }); -self.addEventListener('activate', function(event) { - // "Claiming" the ServiceWorker is necessary to make it work right away, - // without the need to reload the page. - // See https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim - event.waitUntil(self.clients.claim()); +// Allow sw to control current page +self.addEventListener('activate', function (event) { + console.debug('[SW] Claiming clients for current page'); + // Check all the cache keys, and delete any old caches + event.waitUntil( + caches.keys().then(function (keyList) { + return Promise.all(keyList.map(function (key) { + console.debug('[SW] Current cache key is ' + key); + if (key !== APP_CACHE && key !== ASSETS_CACHE) { + console.debug('[SW] App updated to version ' + appVersion + ': deleting old cache'); + return caches.delete(key); + } + })); + }) + ); }); -var regexpRemoveUrlParameters = new RegExp(/([^?#]+)[?#].*$/); +let outgoingMessagePort = null; +let fetchCaptureEnabled = false; -// This function is duplicated from uiUtil.js -// because using requirejs would force to add the 'fetch' event listener -// after the initial evaluation of this script, which is not supported any more -// in recent versions of the browsers. -// Cf https://bugzilla.mozilla.org/show_bug.cgi?id=1181127 -// TODO : find a way to avoid this duplication +self.addEventListener('fetch', intercept); + +// Look up fetch in cache, and if it does not exist, try to get it from the network +function intercept(event) { + // Test if we're in an Electron app + // DEV: Electron uses the file:// protocol and hacks it to work with SW, but it has CORS issues when using the Fetch API to fetch local files, + // so we must bypass it here if we're fetching a local file + if (/^file:/i.test(event.request.url) && ! (regexpZIMUrlWithNamespace.test(event.request.url) && /\.zim\w{0,2}\//i.test(event.request.url))) return; + // console.debug('[SW] Service Worker ' + (event.request.method === "GET" ? 'intercepted ' : 'noted ') + event.request.url, event.request.method); + if (event.request.method !== "GET") return; + // Don't cache download links + if (regexpKiwixDownloadLinks.test(event.request.url)) return; + // Remove any querystring except 'kiwix-display' + var rqUrl = event.request.url.replace(/\?(?!kiwix-display)[^?]+$/i, ''); + // Select cache depending on request format + var cache = /\.zim\//i.test(rqUrl) ? ASSETS_CACHE : APP_CACHE; + if (cache === ASSETS_CACHE && !fetchCaptureEnabled) return; + event.respondWith( + // First see if the content is in the cache + fromCache(cache, rqUrl).then(function (response) { + // The response was found in the cache so we respond with it + return response; + }, function () { + // The response was not found in the cache so we look for it on the server + if (cache === ASSETS_CACHE && regexpZIMUrlWithNamespace.test(rqUrl)) { + if (imageDisplay !== 'all' && /(^|\/)[IJ]\/.*\.(jpe?g|png|svg|gif|webp)($|[?#])(?!kiwix-display)/i.test(rqUrl)) { + // If the user has disabled the display of images, and the browser wants an image, respond with empty SVG + // A URL with "?kiwix-display" query string acts as a passthrough so that the regex will not match and + // the image will be fetched by app.js + // DEV: If you need to hide more image types, add them to regex below and also edit equivalent regex in app.js + var svgResponse; + if (imageDisplay === 'manual') + svgResponse = ""; + else + svgResponse = ""; + return new Response(svgResponse, { + headers: { + 'Content-Type': 'image/svg+xml' + } + }); + } + return fetchRequestFromZIM(event).then(function (response) { + // Add css or js assets to ASSETS_CACHE (or update their cache entries) unless the URL schema is not supported + if (regexpCachedContentTypes.test(response.headers.get('Content-Type')) && + !regexpExcludedURLSchema.test(event.request.url)) { + event.waitUntil(updateCache(ASSETS_CACHE, event.request, response.clone())); + } + return response; + }).catch(function (msgPortData, title) { + console.error('Invalid message received from app.js for ' + title, msgPortData); + return msgPortData; + }); + } else { + // It's not an asset, or it doesn't match a ZIM URL pattern, so we should fetch it with Fetch API + return fetch(event.request).then(function (response) { + // If request was success, add or update it in the cache + if (!regexpExcludedURLSchema.test(rqUrl) && !/\.zim\w{0,2}$/i.test(rqUrl)) { + event.waitUntil(updateCache(APP_CACHE, event.request, response.clone())); + } + return response; + }).catch(function (error) { + console.debug("[SW] Network request failed and no cache.", error); + }); + } + }) + ); +} + +/** + * Handle custom commands 'init' and 'disable' from app.js + */ + self.addEventListener('message', function (event) { + if (event.data.action === 'init') { + // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener + outgoingMessagePort = event.ports[0]; + fetchCaptureEnabled = true; + } + if (event.data.action === 'disable') { + // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener + outgoingMessagePort = null; + fetchCaptureEnabled = false; + self.removeEventListener('fetch', intercept); + } +}); + +/** + * Handles fetch events that need to be extracted from the ZIM + * + * @param {Event} fetchEvent The fetch event to be processed + * @returns {Promise} A Promise for the Response, or rejects with the invalid message port data + */ +function fetchRequestFromZIM(fetchEvent) { + return new Promise(function (resolve, reject) { + var nameSpace; + var title; + var titleWithNameSpace; + var regexpResult = regexpZIMUrlWithNamespace.exec(fetchEvent.request.url); + var prefix = regexpResult[1]; + nameSpace = regexpResult[2]; + title = regexpResult[3]; + + // We need to remove the potential parameters in the URL + title = removeUrlParameters(decodeURIComponent(title)); + + titleWithNameSpace = nameSpace + '/' + title; + + // Let's instantiate a new messageChannel, to allow app.js to give us the content + var messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = function (msgPortEvent) { + if (msgPortEvent.data.action === 'giveContent') { + // Content received from app.js + var contentLength = msgPortEvent.data.content ? msgPortEvent.data.content.byteLength : null; + var contentType = msgPortEvent.data.mimetype; + // Set the imageDisplay variable if it has been sent in the event data + imageDisplay = typeof msgPortEvent.data.imageDisplay !== 'undefined' ? + msgPortEvent.data.imageDisplay : imageDisplay; + var headers = new Headers(); + if (contentLength) headers.set('Content-Length', contentLength); + // Prevent CORS issues in PWAs + if (contentLength) headers.set('Access-Control-Allow-Origin', '*'); + if (contentType) headers.set('Content-Type', contentType); + // Test if the content is a video or audio file + // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") + // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp + if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { + // In case of a video (at least), Chrome and Edge need these HTTP headers or else seeking doesn't work + // (even if we always send all the video content, not the requested range, until the backend supports it) + headers.set('Accept-Ranges', 'bytes'); + headers.set('Content-Range', 'bytes 0-' + (contentLength - 1) + '/' + contentLength); + } + var responseInit = { + status: 200, + statusText: 'OK', + headers: headers + }; + + var httpResponse = new Response(msgPortEvent.data.content, responseInit); + + // Let's send the content back from the ServiceWorker + resolve(httpResponse); + } else if (msgPortEvent.data.action === 'sendRedirect') { + resolve(Response.redirect(prefix + msgPortEvent.data.redirectUrl)); + } else { + reject(msgPortEvent.data, titleWithNameSpace); + } + }; + outgoingMessagePort.postMessage({ + 'action': 'askForContent', + 'title': titleWithNameSpace + }, [messageChannel.port2]); + }); +} /** * Removes parameters and anchors from a URL - * @param {type} url - * @returns {String} same URL without its parameters and anchors + * @param {type} url The URL to be processed + * @returns {String} The same URL without its parameters and anchors */ function removeUrlParameters(url) { - return url.replace(regexpRemoveUrlParameters, "$1"); + return url.replace(/([^?#]+)[?#].*$/, '$1'); } -var outgoingMessagePort = null; -var fetchCaptureEnabled = false; -self.addEventListener('fetch', fetchEventListener); - -self.addEventListener('message', function (event) { - if (event.data.action === 'init') { - // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener - outgoingMessagePort = event.ports[0]; - fetchCaptureEnabled = true; - } - if (event.data.action === 'disable') { - // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener - outgoingMessagePort = null; - fetchCaptureEnabled = false; - } -}); - -// Pattern for ZIM file namespace - see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces -// In our case, there is also the ZIM file name, used as a prefix in the URL -var regexpZIMUrlWithNamespace = /(?:^|\/)([^\/]+\/)([-ABIJMUVWX])\/(.+)/; - -function fetchEventListener(event) { - if (fetchCaptureEnabled) { - - if (!/\.zim\//i.test(event.request.url)) { - console.log('SW is getting file from cache: ' + event.request.url); - return; - } - - if (regexpZIMUrlWithNamespace.test(event.request.url)) { - // The ServiceWorker will handle this request - - // If the user has disabled the display of images, and the browser wants an image, respond with empty SVG - // A URL with "?kiwix-display" query string acts as a passthrough so that the regex will not match and - // the image will be fetched by app.js - // DEV: If you need to hide more image types, add them to regex below and also edit equivalent regex in app.js - if (imageDisplay !== 'all' && /(^|\/)[IJ]\/.*\.(jpe?g|png|svg|gif)($|[?#])(?!kiwix-display)/i.test(event.request.url)) { - var svgResponse; - if (imageDisplay === 'manual') - svgResponse = ""; - else - svgResponse = ""; - event.respondWith(new Response(svgResponse, { headers: { 'Content-Type': 'image/svg+xml' } })); - return; +/** + * Looks up a Request in a cache and returns a Promise for the matched Response + * @param {String} cache The name of the cache to look in + * @param {String} requestUrl The Request URL to fulfill from cache + * @returns {Promise} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match' + */ +function fromCache(cache, requestUrl) { + // Prevents use of Cache API if user has disabled it + if (!useCache && cache === ASSETS_CACHE) return Promise.reject('disabled'); + return caches.open(cache).then(function (cacheObj) { + return cacheObj.match(requestUrl).then(function (matching) { + if (!matching || matching.status === 404) { + return Promise.reject('no-match'); } - - // Let's ask app.js for that content - event.respondWith(new Promise(function(resolve, reject) { - var nameSpace; - var title; - var titleWithNameSpace; - var regexpResult = regexpZIMUrlWithNamespace.exec(event.request.url); - var prefix = regexpResult[1]; - nameSpace = regexpResult[2]; - title = regexpResult[3]; - - // We need to remove the potential parameters in the URL - title = removeUrlParameters(decodeURIComponent(title)); - - titleWithNameSpace = nameSpace + '/' + title; - - // Let's instantiate a new messageChannel, to allow app.js to give us the content - var messageChannel = new MessageChannel(); - messageChannel.port1.onmessage = function(event) { - if (event.data.action === 'giveContent') { - // Content received from app.js - var contentLength = event.data.content ? event.data.content.byteLength : null; - var contentType = event.data.mimetype; - // Set the imageDisplay variable if it has been sent in the event data - imageDisplay = typeof event.data.imageDisplay !== 'undefined' ? - event.data.imageDisplay : imageDisplay; - var headers = new Headers (); - if (contentLength) headers.set('Content-Length', contentLength); - if (contentType) headers.set('Content-Type', contentType); - // Test if the content is a video or audio file - // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") - // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp - if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { - // In case of a video (at least), Chrome and Edge need these HTTP headers else seeking doesn't work - // (even if we always send all the video content, not the requested range, until the backend supports it) - headers.set('Accept-Ranges', 'bytes'); - headers.set('Content-Range', 'bytes 0-' + (contentLength-1) + '/' + contentLength); - } - var responseInit = { - status: 200, - statusText: 'OK', - headers: headers - }; - - var httpResponse = new Response(event.data.content, responseInit); - - // Let's send the content back from the ServiceWorker - resolve(httpResponse); - } - else if (event.data.action === 'sendRedirect') { - resolve(Response.redirect(prefix + event.data.redirectUrl)); - } - else { - console.error('Invalid message received from app.js for ' + titleWithNameSpace, event.data); - reject(event.data); - } - }; - outgoingMessagePort.postMessage({'action': 'askForContent', 'title': titleWithNameSpace}, [messageChannel.port2]); - })); - } - // If event.respondWith() isn't called because this wasn't a request that we want to handle, - // then the default request/response behavior will automatically be used. - } + console.debug('[SW] Supplying ' + requestUrl + ' from ' + cache + '...'); + return matching; + }); + }); +} + +/** + * Stores or updates in a cache the given Request/Response pair + * @param {String} cache The name of the cache to open + * @param {Request} request The original Request object + * @param {Response} response The Response received from the server/ZIM + * @returns {Promise} A Promise for the update action + */ +function updateCache(cache, request, response) { + // Prevents use of Cache API if user has disabled it + if (!useCache && cache === ASSETS_CACHE) return Promise.resolve(); + return caches.open(cache).then(function (cacheObj) { + console.debug('[SW] Adding ' + request.url + ' to ' + cache + '...'); + return cacheObj.put(request, response); + }); +} + +/** + * Tests the caching strategy available to this app and if it is Cache API, count the + * number of assets in ASSETS_CACHE + * @param {String} url A URL to test against excludedURLSchema + * @returns {Promise} A Promise for an array of format [cacheType, cacheDescription, assetCount] + */ +function testCacheAndCountAssets(url) { + if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'Custom', '-']); + if (!useCache) return Promise.resolve(['none', 'none', 'None', 0]); + return caches.open(ASSETS_CACHE).then(function (cache) { + return cache.keys().then(function (keys) { + return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length]; + }).catch(function(err) { + return err; + }); + }).catch(function(err) { + return err; + }); } diff --git a/www/index.html b/www/index.html index 2a94ebb7..ca822406 100644 --- a/www/index.html +++ b/www/index.html @@ -67,7 +67,7 @@ console.log('Service Worker is already active'); } else { // Register the service worker - navigator.serviceWorker.register("../pwabuilder-sw.js", { + navigator.serviceWorker.register("../service-worker.js", { scope: "../" }).then(function (reg) { console.log("Service Worker has been registered for scope: " + reg.scope); diff --git a/www/js/app.js b/www/js/app.js index 48dd158b..6f56262c 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -837,9 +837,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'util', 'cache', 'images', 'sett // Check for upgrade of PWA if (!params.upgradeNeeded && /PWA/.test(params.appType) && activeBtn === 'btnConfigure') { caches.keys().then(function (keyList) { - if (keyList.length < 2) document.getElementById('alertBoxPersistent').innerHTML = ''; + if (keyList.length < 3) document.getElementById('alertBoxPersistent').innerHTML = ''; keyList.forEach(function(key) { - if (key === 'kiwix-precache-' + params.appVersion) return; + if (key === cache.APPCACHE) return; + if (key === cache.CACHEAPI) return; // If we get here, then there is a cache key that does not match our version, i.e. a PWA-in-waiting params.upgradeNeeded = true; document.getElementById('alertBoxPersistent').innerHTML = @@ -848,7 +849,8 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'util', 'cache', 'images', 'sett ' \n' + '\n'; var loadOrInstall = params.PWAInstalled ? 'install' : 'load'; - document.getElementById('persistentMessage').innerHTML = 'Version ' + key.replace(/kiwix-precache-/, '') + ' is ready to ' + var cachePrefix = cache.APPCACHE.replace(/[\d.]+$/, ''); + document.getElementById('persistentMessage').innerHTML = 'Version ' + key.replace(cachePrefix, '') + ' is ready to ' + loadOrInstall + '! (Re-launch app to ' + loadOrInstall + '.)'; }); }); @@ -1892,7 +1894,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'util', 'cache', 'images', 'sett // Create the MessageChannel and send 'init' initOrKeepAliveServiceWorker(); } else { - navigator.serviceWorker.register('../pwabuilder-sw.js').then(function (reg) { + navigator.serviceWorker.register('../service-worker.js').then(function (reg) { // The ServiceWorker is registered console.log('Service worker is registered with a scope of ' + reg.scope); serviceWorkerRegistration = reg; @@ -4027,28 +4029,21 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'util', 'cache', 'images', 'sett blobArray.push([title, cssBlobCache.get(title)]); injectCSS(); } else { - appstate.selectedArchive.getDirEntryByPath(title) - .then(function (dirEntry) { - uiUtil.poll("Resolving CSS [" + title.replace(/[^/]+\//g, '').substring(0, 18) + "]..."); - return appstate.selectedArchive.readBinaryFile(dirEntry, - function (fileDirEntry, content) { - //DEV: Uncomment line below and break on next to capture cssContent for local filesystem cache - //var cssContent = util.uintToString(content); - var cssBlob = new Blob([content], { - type: 'text/css' - }); - var newURL = [fileDirEntry.namespace + "/" + fileDirEntry.url, URL.createObjectURL(cssBlob)]; - blobArray.push(newURL); - if (cssBlobCache) - cssBlobCache.set(newURL[0], newURL[1]); - injectCSS(); //DO NOT move this: it must run within .then function to pass correct values - }); - }).catch(function (e) { - console.error("could not find DirEntry for CSS : %s", title, e); - //@TODO Change this to push an array of [title, title] afters simplified code in injectCSS() - blobArray.push(title); - injectCSS(); + var cacheKey = appstate.selectedArchive._file.name + '/' + title; + cache.getItemFromCacheOrZIM(appstate.selectedArchive, cacheKey).then(function (content) { + //DEV: Uncomment line below and break on next to capture cssContent for local filesystem cache + //var cssContent = util.uintToString(content); + var cssBlob = new Blob([content], { + type: 'text/css' }); + var newURL = [title, URL.createObjectURL(cssBlob)]; + blobArray.push(newURL); + if (cssBlobCache) + cssBlobCache.set(newURL[0], newURL[1]); + injectCSS(); //DO NOT move this: it must run within .then function to pass correct values + }).catch(function (err) { + console.error(err); + }); } } diff --git a/www/js/init.js b/www/js/init.js index 7bffb5d1..618bfc61 100644 --- a/www/js/init.js +++ b/www/js/init.js @@ -49,7 +49,7 @@ var params = {}; * @type Object */ var appstate = {}; -/******** UPDATE VERSION IN pwabuilder-sw.js TO MATCH VERSION AND CHECK PWASERVER BELOW!!!!!!! *******/ +/******** UPDATE VERSION IN service-worker.js TO MATCH VERSION AND CHECK PWASERVER BELOW!!!!!!! *******/ params['appVersion'] = "1.8.5"; //DEV: Manually update this version when there is a new release: it is compared to the Settings Store "appVersion" in order to show first-time info, and the cookie is updated in app.js /******* UPDATE THIS ^^^^^^ IN service worker AND PWA-SERVER BELOW !! ********************/ params['packagedFile'] = getSetting('packagedFile') || "wikipedia_en_100_nopic_2021-11.zim"; //For packaged Kiwix JS (e.g. with Wikivoyage file), set this to the filename (for split files, give the first chunk *.zimaa) and place file(s) in default storage @@ -394,7 +394,7 @@ function getSetting(name) { function setSetting(name, val) { if (params.storeType === 'cookie') { - document.cookie = encodeUriComponent(name) + '=' + encodeUriComponent(val) + ';expires=Fri, 31 Dec 9999 23:59:59 GMT'; + document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(val) + ';expires=Fri, 31 Dec 9999 23:59:59 GMT'; } // Make Boolean value val = val === 'false' ? false : val === 'true' ? true : val; diff --git a/www/js/lib/cache.js b/www/js/lib/cache.js index fe2faa76..692b8d16 100644 --- a/www/js/lib/cache.js +++ b/www/js/lib/cache.js @@ -23,10 +23,11 @@ 'use strict'; define(['settingsStore', 'uiUtil'], function(settingsStore, uiUtil) { - const CACHEAPI = 'kiwix-precache-' + params.appVersion; // Set the database or cache name here - const CACHEIDB = 'kiwix-assetsCache'; // For idxDB we don't want the name to change + const CACHEAPI = 'kiwixjs-assetsCache'; // Set the database or cache name here, and synchronize with Service Worker + const CACHEIDB = 'kiwix-assetsCache'; // Slightly different name to disambiguate var objStore = 'kiwix-assets'; // Name of the object store - + const APPCACHE = 'kiwixjs-appCache-' + params.appVersion; // Ensure this is the same as in Service Worker + // DEV: Regex below defines the permitted key types for the cache; add further types as needed // NB: The key type of '.zim', or '.zimaa' (etc.) is used to store a ZIM's last-accessed article var regexpKeyTypes = /(?:(?:^|\/)A\/.+|\.[Jj][Ss]|\.[Cc][Ss][Ss]|\.[Zz][Ii][Mm]\w{0,2})$/; @@ -407,87 +408,87 @@ define(['settingsStore', 'uiUtil'], function(settingsStore, uiUtil) { * * @param {Object} selectedArchive The ZIM archive picked by the user * @param {String} key The cache key of the item to retrieve - * @param {Function} callback A function to call with the result * @param {Object} dirEntry If the item's dirEntry has already been looked up, it can optionally be * supplied here (saves a redundant dirEntry lookup) */ - function getItemFromCacheOrZIM(selectedArchive, key, callback, dirEntry) { - // First check if the item is already in the cache - var title = key.replace(/^[^/]+\//, ''); - getItem(key, function(result) { - if (result !== null && result !== false && typeof result !== 'undefined') { - console.log("Cache supplied " + title); - if (/\.css$/.test(title)) { - assetsCache.cssLoading--; - if (assetsCache.cssLoading <= 0) { - document.getElementById('articleContent').style.display = 'block'; + function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { + return new Promise(function (resolve, reject) { + // First check if the item is already in the cache + var title = key.replace(/^[^/]+\//, ''); + getItem(key, function (result) { + if (result !== null && result !== false && typeof result !== 'undefined') { + console.log("Cache supplied " + title); + if (/\.css$/.test(title)) { + assetsCache.cssLoading--; + if (assetsCache.cssLoading <= 0) { + document.getElementById('articleContent').style.display = 'block'; + document.getElementById('cachingAssets').style.display = 'none'; + document.getElementById('searchingArticles').style.display = 'none'; + } + } + resolve(result); + return; + } + // Since there was no result, post UI messages and look up asset in ZIM + if (regexpKeyTypes.test(key)) { + if (!/\.css$|\.js$/.test(key)) { document.getElementById('cachingAssets').style.display = 'none'; - document.getElementById('searchingArticles').style.display = 'none'; - } + document.getElementById('searchingArticles').style.display = 'block'; + } else if (params.useCache !== false) { + var shortTitle = key.replace(/[^/]+\//g, '').substring(0, 18); + document.getElementById('cachingAssets').innerHTML = 'Getting ' + shortTitle + '...'; + document.getElementById('cachingAssets').style.display = 'block'; + } } - callback(result); - return; - } - // Since there was no result, post UI messages and look up asset in ZIM - if (regexpKeyTypes.test(key)) { - if (!/\.css$|\.js$/.test(key)) { - document.getElementById('cachingAssets').style.display = 'none'; - document.getElementById('searchingArticles').style.display = 'block'; - } else if (params.useCache !== false) { - var shortTitle = key.replace(/[^/]+\//g, '').substring(0, 18); - document.getElementById('cachingAssets').innerHTML = 'Getting ' + shortTitle + '...'; - document.getElementById('cachingAssets').style.display = 'block'; - } - } - // Set the read function to use according to filetype - var readFile = regexpKeyTypes.test(title) ? - selectedArchive.readUtf8File : selectedArchive.readBinaryFile; - // Bypass getting dirEntry if we already have it - var getDirEntry = dirEntry ? Promise.Promise.resolve() : - selectedArchive.getDirEntryByPath(title); - // Read data from ZIM - getDirEntry.then(function(resolvedDirEntry) { - if (dirEntry) resolvedDirEntry = dirEntry; - if (resolvedDirEntry === null) { - console.log("Error: asset file not found: " + title); - callback(); - } else { - readFile(resolvedDirEntry, function (fileDirEntry, content) { - if (regexpKeyTypes.test(title)) { - console.log('Cache retrieved ' + title + ' from ZIM'); - // Process any pre-cache transforms - content = transform(content, title.replace(/^.*\.([^.]+)$/, '$1')); - } - // Hide article while it is rendering - if (/^text\/html$/.test(fileDirEntry.getMimetype())) { - // Count CSS so we can attempt to show article before JS/images are fully loaded - var cssCount = content.match(/<(?:link)[^>]+?href=["']([^"']+)[^>]+>/ig); - assetsCache.cssLoading = cssCount ? cssCount.length : 0; - if (assetsCache.cssLoading) document.getElementById('articleContent').style.display = 'none'; - } - if (/\.css$/.test(title)) { - assetsCache.cssLoading--; - if (assetsCache.cssLoading <= 0) { - document.getElementById('articleContent').style.display = 'block'; - document.getElementById('cachingAssets').style.display = 'none'; - document.getElementById('searchingArticles').style.display = 'none'; + // Set the read function to use according to filetype + var readFile = regexpKeyTypes.test(title) ? + selectedArchive.readUtf8File : selectedArchive.readBinaryFile; + // Bypass getting dirEntry if we already have it + var getDirEntry = dirEntry ? Promise.Promise.resolve() : + selectedArchive.getDirEntryByPath(title); + // Read data from ZIM + getDirEntry.then(function (resolvedDirEntry) { + if (dirEntry) resolvedDirEntry = dirEntry; + if (resolvedDirEntry === null) { + console.log("Error: asset file not found: " + title); + callback(); + } else { + readFile(resolvedDirEntry, function (fileDirEntry, content) { + if (regexpKeyTypes.test(title)) { + console.log('Cache retrieved ' + title + ' from ZIM'); + // Process any pre-cache transforms + content = transform(content, title.replace(/^.*\.([^.]+)$/, '$1')); } - } - callback(content); - setItem(key, content, function(result) { - if (result === -1) { - // Cache rejected item due to user settings - } else if (result) { - console.log('Cache: stored asset ' + title); - } else { - console.error('Cache: failed to store asset ' + title); + // Hide article while it is rendering + if (/^text\/html$/.test(fileDirEntry.getMimetype())) { + // Count CSS so we can attempt to show article before JS/images are fully loaded + var cssCount = content.match(/<(?:link)[^>]+?href=["']([^"']+)[^>]+>/ig); + assetsCache.cssLoading = cssCount ? cssCount.length : 0; + if (assetsCache.cssLoading) document.getElementById('articleContent').style.display = 'none'; } + if (/\.css$/.test(title)) { + assetsCache.cssLoading--; + if (assetsCache.cssLoading <= 0) { + document.getElementById('articleContent').style.display = 'block'; + document.getElementById('cachingAssets').style.display = 'none'; + document.getElementById('searchingArticles').style.display = 'none'; + } + } + setItem(key, content, function (result) { + if (result === -1) { + // Cache rejected item due to user settings + } else if (result) { + console.log('Cache: stored asset ' + title); + } else { + console.error('Cache: failed to store asset ' + title); + } + }); + resolve(content); }); - }); - } - }).fail(function (e) { - console.error("could not find DirEntry for asset : " + title, e); - callback(); + } + }).catch(function (e) { + reject("could not find DirEntry for asset : " + title, e); + }); }); }); } @@ -709,6 +710,8 @@ define(['settingsStore', 'uiUtil'], function(settingsStore, uiUtil) { * Functions and classes exposed by this module */ return { + APPCACHE: APPCACHE, + CACHEAPI: CACHEAPI, test: test, count: count, idxDB: idxDB,