diff --git a/service-worker.js b/service-worker.js index 6271db7b8..ccdb049ff 100644 --- a/service-worker.js +++ b/service-worker.js @@ -3,7 +3,7 @@ * in order to capture the HTTP requests made by an article, and respond with the * corresponding content, coming from the archive * - * Copyright 2015 Mossroy and contributors + * Copyright 2022 Mossroy, Jaifroid and contributors * License GPL v3: * * This file is part of Kiwix. @@ -53,7 +53,15 @@ const APP_CACHE = 'kiwixjs-appCache-' + appVersion; * Caching is on by default but can be turned off by the user in Configuration * @type {Boolean} */ -var useCache = true; +var useAssetsCache = true; + +/** + * A global Boolean that governs whether the APP_CACHE will be used + * This is an expert setting in Configuration + * @type {Boolean} + */ + var useAppCache = true; + /** * A regular expression that matches the Content-Types of assets that may be stored in ASSETS_CACHE @@ -176,7 +184,10 @@ self.addEventListener('activate', function (event) { let outgoingMessagePort = null; let fetchCaptureEnabled = false; -self.addEventListener('fetch', function (event) { +/** + * Intercept selected Fetch requests from the browser window + */ + self.addEventListener('fetch', function (event) { // Only cache GET requests if (event.request.method !== "GET") return; // Remove any querystring before requesting from the cache @@ -207,20 +218,23 @@ self.addEventListener('fetch', function (event) { } 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 successful, add or update it in the cache, but be careful not to cache the ZIM archive itself! - if (!regexpExcludedURLSchema.test(rqUrl) && !/\.zim\w{0,2}$/i.test(rqUrl)) { - event.waitUntil(updateCache(APP_CACHE, event.request, response.clone())); - } - return response; + // If request was successful, add or update it in the cache, but be careful not to cache the ZIM archive itself! + 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); + console.debug("[SW] Network request failed and no cache.", error); }); } }) ); }); -self.addEventListener('message', function (event) { +/** + * Handle custom commands sent from app.js + */ + self.addEventListener('message', function (event) { if (event.data.action) { if (event.data.action === 'init') { // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener @@ -231,10 +245,18 @@ self.addEventListener('message', function (event) { outgoingMessagePort = null; fetchCaptureEnabled = false; } - if (event.data.action.useCache) { - // Turns caching on or off (a string value of 'on' turns it on, any other string turns it off) - useCache = event.data.action.useCache === 'on'; - console.debug('[SW] Caching was turned ' + event.data.action.useCache); + var oldValue; + if (event.data.action.assetsCache) { + // Turns caching on or off (a string value of 'enable' turns it on, any other string turns it off) + oldValue = useAssetsCache; + useAssetsCache = event.data.action.assetsCache === 'enable'; + if (useAssetsCache !== oldValue) console.debug('[SW] Use of assetsCache was switched to: ' + useAssetsCache); + } + if (event.data.action.appCache) { + // Enables or disables use of appCache + oldValue = useAppCache; + useAppCache = event.data.action.appCache === 'enable'; + if (useAppCache !== oldValue) console.debug('[SW] Use of appCache was switched to: ' + useAppCache); } if (event.data.action === 'getCacheNames') { event.ports[0].postMessage({ 'app': APP_CACHE, 'assets': ASSETS_CACHE }); @@ -328,7 +350,7 @@ function removeUrlParameters(url) { */ function fromCache(cache, requestUrl) { // Prevents use of Cache API if user has disabled it - if (!useCache && cache === ASSETS_CACHE) return Promise.reject('disabled'); + if (!useAppCache && cache === APP_CACHE || !useAssetsCache && 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) { @@ -349,7 +371,7 @@ function fromCache(cache, requestUrl) { */ function updateCache(cache, request, response) { // Prevents use of Cache API if user has disabled it - if (!useCache && cache === ASSETS_CACHE) return Promise.resolve(); + if (!useAppCache && cache === APP_CACHE || !useAssetsCache && 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); @@ -364,7 +386,7 @@ function updateCache(cache, request, response) { */ function testCacheAndCountAssets(url) { if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'custom', 'Custom', '-']); - if (!useCache) return Promise.resolve(['none', 'none', 'None', 0]); + if (!useAssetsCache) 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]; diff --git a/www/css/app.css b/www/css/app.css index 7683f9a91..ba9a19b75 100644 --- a/www/css/app.css +++ b/www/css/app.css @@ -105,6 +105,10 @@ background: #171717; } +.dark .card-warning .card-header { + background-color: #FFFF00; +} + /* End of app theme: dark */ /* Content themes: _invert, _mwInvert */ @@ -222,6 +226,11 @@ button { margin: 2px; } +.btn-danger { + background-color: lightyellow; + color: darkred; +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } diff --git a/www/index.html b/www/index.html index daced0ce7..07f900f30 100644 --- a/www/index.html +++ b/www/index.html @@ -106,7 +106,7 @@

  • Title Search Usage
  • Image Download
  • Privacy Policy
  • -
  • Expert Settings and Technical Information +
  • Technical Information

  • -

    Expert settings

    -
    +

    Compatibility settings

    +
    Content injection mode
    -

    See About (Expert Settings) for an explanation of the difference between these modes:

    +

    See About (Technical Information) for an explanation of the difference between these modes:

    +
    +
    +
    +
    +
    +
    +

    Expert settings

    +
    +
    Troubleshooting and development
    +
    +
    + +
    +
    + +
    +

    Reset the app to default settings and erase all caches:

    +
    +
    @@ -592,7 +614,7 @@

    Expert settings

    Unable to display active content: This ZIM is not fully supported in jQuery mode.
    Content may be available by searching above (type a space or a letter of the alphabet), or else switch to Service Worker mode - if your platform supports it.  [Permanently hide] + if your platform supports it.  [Permanently hide]
    diff --git a/www/js/app.js b/www/js/app.js index d9d132b60..2416ea7d8 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -82,8 +82,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys params['showUIAnimations'] = settingsStore.getItem('showUIAnimations') ? settingsStore.getItem('showUIAnimations') === 'true' : true; // Maximum number of article titles to return (range is 5 - 50, default 25) params['maxSearchResultsSize'] = settingsStore.getItem('maxSearchResultsSize') || 25; - // A global parameter that turns caching on or off and deletes the cache (it defaults to true unless explicitly turned off in UI) - params['useCache'] = settingsStore.getItem('useCache') !== 'false'; + // Turns caching of assets on or off and deletes the cache (it defaults to true unless explicitly turned off in UI) + params['assetsCache'] = settingsStore.getItem('assetsCache') !== 'false'; + // Turns caching of the PWA's code on or off and deletes the cache (it defaults to true unless the bypass option is set in Expert Settings) + params['appCache'] = settingsStore.getItem('appCache') !== 'false'; // A parameter to set the app theme and, if necessary, the CSS theme for article content (defaults to 'light') params['appTheme'] = settingsStore.getItem('appTheme') || 'light'; // Currently implemented: light|dark|dark_invert|dark_mwInvert // A global parameter to turn on/off the use of Keyboard HOME Key to focus search bar @@ -152,11 +154,12 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys document.getElementById('hideActiveContentWarningCheck').checked = params.hideActiveContentWarning; document.getElementById('showUIAnimationsCheck').checked = params.showUIAnimations; document.getElementById('titleSearchRange').value = params.maxSearchResultsSize; - document.getElementById('titleSearchRangeVal').innerHTML = encodeURIComponent(params.maxSearchResultsSize); + document.getElementById('titleSearchRangeVal').textContent = params.maxSearchResultsSize; document.getElementById('appThemeSelect').value = params.appTheme; uiUtil.applyAppTheme(params.appTheme); document.getElementById('useHomeKeyToFocusSearchBarCheck').checked = params.useHomeKeyToFocusSearchBar; switchHomeKeyToFocusSearchBar(); + document.getElementById('bypassAppCacheCheck').checked = !params.appCache; document.getElementById('appVersion').innerHTML = 'Kiwix ' + params.appVersion; setContentInjectionMode(params.contentInjectionMode); @@ -411,6 +414,21 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // Do the necessary to enable or disable the Service Worker setContentInjectionMode(this.value); }); + document.getElementById('btnReset').addEventListener('click', function () { + settingsStore.reset(); + }); + document.getElementById('bypassAppCacheCheck').addEventListener('change', function () { + if (params.contentInjectionMode !== 'serviceworker') { + alert('This setting can only be used in Service Worker mode!'); + this.checked = false; + } else { + params.appCache = !this.checked; + settingsStore.setItem('appCache', params.appCache, Infinity); + settingsStore.reset('cacheAPI'); + } + // This will also send any new values to Service Worker + refreshCacheStatus(); + }); $('input:checkbox[name=hideActiveContentWarning]').on('change', function () { params.hideActiveContentWarning = this.checked ? true : false; settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity); @@ -431,27 +449,29 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys }); document.getElementById('cachedAssetsModeRadioTrue').addEventListener('change', function (e) { if (e.target.checked) { - settingsStore.setItem('useCache', true, Infinity); - params.useCache = true; + settingsStore.setItem('assetsCache', true, Infinity); + params.assetsCache = true; refreshCacheStatus(); } }); document.getElementById('cachedAssetsModeRadioFalse').addEventListener('change', function (e) { if (e.target.checked) { - settingsStore.setItem('useCache', false, Infinity); - params.useCache = false; + settingsStore.setItem('assetsCache', false, Infinity); + params.assetsCache = false; // Delete all caches resetCssCache(); if ('caches' in window) caches.delete(ASSETS_CACHE); refreshCacheStatus(); } }); + var titleSearchRangeVal = document.getElementById('titleSearchRangeVal'); document.getElementById('titleSearchRange').addEventListener('change', function(e) { settingsStore.setItem('maxSearchResultsSize', e.target.value, Infinity); params.maxSearchResultsSize = e.target.value; + titleSearchRangeVal.textContent = e.target.value; }); document.getElementById('titleSearchRange').addEventListener('input', function(e) { - document.getElementById('titleSearchRangeVal').innerHTML = e.target.value; + titleSearchRangeVal.textContent = e.target.value; }); document.getElementById('modesLink').addEventListener('click', function () { document.getElementById('btnAbout').click(); @@ -550,9 +570,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys } apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized'; decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName; - // Add a warning colour to the API Status Panel if any of the above tests failed apiStatusPanel.classList.add(apiPanelClass); + + // Set visibility of UI elements according to mode + document.getElementById('bypassAppCacheDiv').style.display = params.contentInjectionMode === 'serviceworker' ? 'block' : 'none'; } /** @@ -574,16 +596,17 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // Ask Service Worker for its cache status and asset count navigator.serviceWorker.controller.postMessage({ 'action': { - 'useCache': params.useCache ? 'on' : 'off', + 'assetsCache': params.assetsCache ? 'enable' : 'disable', + 'appCache': params.appCache ? 'enable' : 'disable', 'checkCache': window.location.href } }, [channel.port2]); } else { // No Service Worker has been established, so we resolve the Promise with cssCache details only resolve({ - 'type': params.useCache ? 'memory' : 'none', + 'type': params.assetsCache ? 'memory' : 'none', 'name': 'cssCache', - 'description': params.useCache ? 'Memory' : 'None', + 'description': params.assetsCache ? 'Memory' : 'None', 'count': cssCache.size }); } @@ -591,11 +614,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys } /** - * Refreshes the UI (Configuration) with the cache attributes obtained from getCacheAttributes() + * Refreshes the UI (Configuration) with the cache attributes obtained from getAssetsCacheAttributes() */ function refreshCacheStatus() { // Update radio buttons and checkbox - document.getElementById('cachedAssetsModeRadio' + (params.useCache ? 'True' : 'False')).checked = true; + document.getElementById('cachedAssetsModeRadio' + (params.assetsCache ? 'True' : 'False')).checked = true; // Get cache attributes, then update the UI with the obtained data getAssetsCacheAttributes().then(function (cache) { if (cache.type === 'cacheAPI' && ASSETS_CACHE !== cache.name) { @@ -609,7 +632,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // IE11 cannot remove more than one class from a list at a time card.classList.remove('card-success'); card.classList.remove('card-warning'); - if (params.useCache) card.classList.add('card-success'); + if (params.assetsCache) card.classList.add('card-success'); else card.classList.add('card-warning'); }); }); @@ -660,6 +683,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys function setContentInjectionMode(value) { params.contentInjectionMode = value; if (value === 'jquery') { + if (!params.appCache) { + alert('You must deselect the "Bypass AppCache" option before switching to JQuery mode!'); + setContentInjectionMode('serviceworker'); + return; + } if (params.referrerExtensionURL) { // We are in an extension, and the user may wish to revert to local code var message = 'This will switch to using locally packaged code only. Some configuration settings may be lost.\n\n' + @@ -692,7 +720,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var channel = new MessageChannel(); if (isServiceWorkerAvailable() && navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ - 'action': { 'useCache': 'off' } + 'action': { 'assetsCache': 'disable' } }, [channel.port2]); } caches.delete(ASSETS_CACHE); @@ -1698,13 +1726,13 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys uiUtil.replaceCSSLinkWithInlineCSS(link, cssContent); cssFulfilled++; } else { - if (params.useCache) $('#cachingAssets').show(); + if (params.assetsCache) $('#cachingAssets').show(); selectedArchive.getDirEntryByPath(url) .then(function (dirEntry) { return selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, content) { var fullUrl = fileDirEntry.namespace + "/" + fileDirEntry.url; - if (params.useCache) cssCache.set(fullUrl, content); + if (params.assetsCache) cssCache.set(fullUrl, content); uiUtil.replaceCSSLinkWithInlineCSS(link, content); cssFulfilled++; renderIfCSSFulfilled(fileDirEntry.url); @@ -1797,7 +1825,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys * @param {String} title The title of the file to display in the caching message block */ function updateCacheStatus(title) { - if (params.useCache && /\.css$|\.js$/i.test(title)) { + if (params.assetsCache && /\.css$|\.js$/i.test(title)) { var cacheBlock = document.getElementById('cachingAssets'); cacheBlock.style.display = 'block'; title = title.replace(/[^/]+\//g, '').substring(0,18); diff --git a/www/js/lib/settingsStore.js b/www/js/lib/settingsStore.js index 33e1c038f..fefd759ac 100644 --- a/www/js/lib/settingsStore.js +++ b/www/js/lib/settingsStore.js @@ -42,7 +42,8 @@ define([], function () { * @type {Array} */ var deprecatedKeys = [ - 'lastContentInjectionMode' + 'lastContentInjectionMode', + 'useCache' ]; /** @@ -91,6 +92,125 @@ define([], function () { return type; } + /** + * Performs a full app reset, deleting all caches and settings + * Or, if a parameter is supplied, deletes or disables the object + * @param {String} object Optional name of the object to disable or delete ('cookie', 'localStorage', 'cacheAPI') + */ + function reset(object) { + // If no specific object was specified, we are doing a general reset, so ask user for confirmation + if (!object && !confirm('WARNING: This will reset the app to a freshly installed state, deleting all app caches and settings!')) return; + + // 1. Clear any cookie entries + if (!object || object === 'cookie') { + var regexpCookieKeys = /(?:^|;)\s*([^=]+)=([^;]*)/ig; + var currentCookie = document.cookie; + var foundCrumb = false; + var cookieCrumb = regexpCookieKeys.exec(currentCookie); + while (cookieCrumb !== null) { + // DEV: Note that we don't use the keyPrefix in legacy cookie support + foundCrumb = true; + // This expiry date will cause the browser to delete the cookie crumb on next page refresh + document.cookie = cookieCrumb[1] + '=;expires=Thu, 21 Sep 1979 00:00:01 UTC;'; + cookieCrumb = regexpCookieKeys.exec(currentCookie); + } + if (foundCrumb) console.debug('All cookie keys were expired...'); + } + + // 2. Clear any localStorage settings + if (!object || object === 'localStorage') { + if (params.storeType === 'local_storage') { + localStorage.clear(); + console.debug('All Local Storage settings were deleted...'); + } + } + + // 3. Clear any Cache API caches + if (!object || object === 'cacheAPI') { + getCacheNames(function (cacheNames) { + if (cacheNames && !cacheNames.error) { + var cnt = 0; + for (var cacheName in cacheNames) { + cnt++; + caches.delete(cacheNames[cacheName]).then(function () { + cnt--; + if (!cnt) { + // All caches deleted + console.debug('All Cache API caches were deleted...'); + // Reload if user performed full reset or if appCache is needed + if (!object || params.appCache) _reloadApp(); + } + }); + } + } else { + console.debug('No Cache API caches were in use (or we do not have access to the names).'); + // All operations complete, reload if user performed full reset or if appCache is needed + if (!object || params.appCache) _reloadApp(); + } + }); + } + } + + // Gets cache names from Service Worker, as we cannot rely on having them in params.cacheNames + function getCacheNames(callback) { + if (navigator.serviceWorker && navigator.serviceWorker.controller) { + var channel = new MessageChannel(); + channel.port1.onmessage = function (event) { + var names = event.data; + callback(names); + }; + navigator.serviceWorker.controller.postMessage({ + action: 'getCacheNames' + }, [channel.port2]); + } else { + callback(null); + } + } + + // Deregisters all Service Workers and reboots the app + function _reloadApp() { + var reboot = function () { + console.debug('Performing app reload...'); + setTimeout(function () { + window.location.href = location.origin + location.pathname + uriParams + }, 300); + }; + // Blank the querystring, so that parameters are not set on reload + var uriParams = ''; + if (~window.location.href.indexOf(params.PWAServer) && params.referrerExtensionURL) { + // However, if we're in a PWA that was called from local code, then by definition we must remain in SW mode and we need to + // ensure the user still has access to the referrerExtensionURL (so they can get back to local code from the UI) + uriParams = '?allowInternetAccess=truee&contentInjectionMode=serviceworker'; + uriParams += '&referrerExtensionURL=' + encodeURIComponent(params.referrerExtensionURL); + } + if (navigator && navigator.serviceWorker) { + console.debug('Deregistering Service Workers...'); + var cnt = 0; + navigator.serviceWorker.getRegistrations().then(function (registrations) { + if (!registrations.length) { + reboot(); + return; + } + cnt++; + registrations.forEach(function (registration) { + registration.unregister().then(function () { + cnt--; + if (!cnt) { + console.debug('All Service Workers unregistered...'); + reboot(); + } + }); + }); + }).catch(function (err) { + console.error(err); + reboot(); + }); + } else { + console.debug('Performing app reload...'); + reboot(); + } + } + var settingsStore = { getItem: function (sKey) { if (!sKey) { @@ -179,6 +299,8 @@ define([], function () { setItem: settingsStore.setItem, removeItem: settingsStore.removeItem, hasItem: settingsStore.hasItem, + getCacheNames: getCacheNames, + reset: reset, getBestAvailableStorageAPI: getBestAvailableStorageAPI }; }); diff --git a/www/js/lib/uiUtil.js b/www/js/lib/uiUtil.js index 7566936ee..b40563992 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -24,14 +24,14 @@ // DEV: Put your RequireJS definition in the rqDef array below, and any function exports in the function parenthesis of the define statement // We need to do it this way in order to load WebP polyfills conditionally. The WebP polyfills are only needed by a few old browsers, so loading them // only if needed saves approximately 1MB of memory. -var rqDef = []; +var rqDef = ['settingsStore']; // Add WebP polyfill only if webpHero was loaded in init.js if (webpMachine) { rqDef.push('webpHeroBundle'); } -define(rqDef, function() { +define(rqDef, function(settingsStore) { /** * Creates either a blob: or data: URI from the given content @@ -231,13 +231,10 @@ define(rqDef, function() { var updateAlert = document.getElementById('updateAlert'); function checkUpdateStatus(appstate) { if ('serviceWorker' in navigator && !appstate.pwaUpdateNeeded) { - // Create a Message Channel - var channel = new MessageChannel(); - // Handler for receiving message reply from service worker - channel.port1.onmessage = function (event) { - var cacheNames = event.data; - if (cacheNames.error) return; - else { + settingsStore.getCacheNames(function (cacheNames) { + if (cacheNames && !cacheNames.error) { + // Store the cacheNames globally for use elsewhere + params.cacheNames = cacheNames; caches.keys().then(function (keyList) { updateAlert.style.display = 'none'; var cachePrefix = cacheNames.app.replace(/^([^\d]+).+/, '$1'); @@ -253,10 +250,7 @@ define(rqDef, function() { }); }); } - }; - if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage({ - action: 'getCacheNames' - }, [channel.port2]); + }); } } if (updateAlert) updateAlert.querySelector('button[data-hide]').addEventListener('click', function () {