Enable Service Worker mode in Firefox extension via PWA workaround #764 (#771)

This commit is contained in:
Jaifroid 2022-01-09 09:19:51 +00:00 committed by GitHub
parent 0373e53c40
commit e42995047c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 827 additions and 216 deletions

View File

@ -24,6 +24,16 @@ jobs:
# Clone the repo and checkout the commit for which the workflow was triggered
- uses: actions/checkout@v2
- name: Test integrity of app parameters
shell: bash
run: |
# Check that values of assetsCache and appVersion are correctly duplicated
chmod +x ./scripts/test_duplicate_values.sh
./scripts/test_duplicate_values.sh
# Check that PWAServer is correctly set in app.js
chmod +x ./scripts/test_pwa_server.sh
./scripts/test_pwa_server.sh
# Install Node.js LTS
- uses: actions/setup-node@v2
with:

View File

@ -0,0 +1,12 @@
FROM nginx:latest
EXPOSE 80
RUN mkdir /usr/share/nginx/html/current/
COPY ./docker/index.nginx.html /usr/share/nginx/html/index.html
COPY ./manifest.json /usr/share/nginx/html/current/
COPY ./service-worker.js /usr/share/nginx/html/current/
COPY ./index.html /usr/share/nginx/html/current/
COPY ./CHANGELOG.md /usr/share/nginx/html/current/
COPY ./LICENSE-GPLv3.txt /usr/share/nginx/html/current/
COPY ./www /usr/share/nginx/html/current/www/

19
docker/index.nginx.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html>
<!-- This file will be used by dockerfile-moz-extension to provide a redirect from the server's html directory to
the implementation. This is a convenience to any user who may visit the root of the server. However, note that
this is out of the scope of the Service Worker in the implementation's subdirectory, so this redirect will only
work if the client is online. The client will only be able to access the implementation OFFLINE if the browser
is pointed to <domain>/current/ or <domain>/current/www/index.html (or equivalent in a versioned directory).
The browser extension should always be pointed to the full path of the index.html to be loaded in SW mode.
-->
<head>
<meta http-equiv="refresh" content="0; url=current/www/index.html">
<title>Redirection to index.html</title>
</head>
<body>
</body>
</html>

View File

@ -1,8 +1,12 @@
<!doctype html>
<html>
<head>
<meta http-equiv="refresh" content="0; url=www/index.html">
<title>Redirection to index.html</title>
<title>Redirection to index.html</title>
</head>
<body>
</body>
</html>

View File

@ -32,6 +32,8 @@
"id": "kiwix-html5-unlisted@kiwix.org"
}
},
"web_accessible_resources": ["www/index.html"],
"background": {
"scripts": ["webextension/backgroundscript.js"]

View File

@ -0,0 +1,133 @@
# This is a utility script which helps developers choose sensible values for publishing the implementation of this app
# to GitHub Pages, or to eh docker container. It is useful for testing and developing code in a specific branch. It checks
# app.js and service-worker.js for consistency, and checks that the underlying branch of a PR has been checked out
# (rather than the PR itself). It then calls the GitHub REST API for dispatching the workflow using the provided values.
#
# IMPORTANT: Ensure that your personal github token is in your local copy of the '/scripts' directory, saved as 'github_token'.
#
# You may run this script with commandline switches -machine_name (this could be 'dev'), -target (either 'ghpages' or 'docker'),
# the -branch_name, and -dryrun (this will show the changes that would be made if run without the -dryrun switch).
# Alternatively, if you do not provide these values, you will be prompted with sensible defaults.
# Prevents execution with unrecognized switches
[CmdletBinding()]
param (
[string]$machine_name = "",
[string]$target = "",
[string]$branch_name = "",
[switch]$dryrun = $false
)
# Provide parameters
$release_uri = 'https://api.github.com/repos/kiwix/kiwix-js/actions/workflows/publish-extension.yaml/dispatches'
$app_params = Select-String 'appVersion' "$PSScriptRoot\..\www\js\app.js" -List
$serviceworker = Select-String 'appVersion' "$PSScriptRoot\..\service-worker.js" -List
$suggested_build = ''
$app_tag = ''
if ($app_params -match 'params\[[''"]appVersion[''"]]\s*=\s*[''"]([^''"]+)') {
$app_tag = $matches[1]
$suggested_build = 'dev-' + $app_tag
} else {
"*** WARNING: App version is incorrectly set in app.js.`nPlease correct before continuing.`n"
exit
}
$sw_tag = ''
if ($serviceworker -match 'appVersion\s*=\s*[''"]([^''"]+)') {
$sw_tag = $matches[1]
if ($sw_tag -ne $app_tag) {
"*** WARNING: The version in app.js [$app_tag] does not match the version in service-worker.js [$sw_tag]! ***"
"Please correct before continuing.`n"
exit
} else {
"`nVersion in app.js: $app_tag"
"Version in service-worker.js: $sw_tag`n"
}
} else {
"*** WARNING: App version is incorrectly set in service-worker.js.`nPlease correct before continuing.`n"
exit
}
if (Test-Path $PSScriptRoot/github_token -PathType Leaf) {
$github_token = Get-Content -Raw "$PSScriptRoot/github_token"
} else {
Write-Warning "Missing file github_token! Please add it to $PSScriptRoot to run this script.`n"
$github_token = $false
}
if ($machine_name -eq "") {
if (-Not $dryrun) {
$dryrun_check = Read-Host "Is this a dry run? [Y/N]"
$dryrun = -Not ( $dryrun_check -imatch 'n' )
If ($dryrun) {
"[DRYRUN]: Initiating dry run..."
}
}
""
if ($target -eq "") {
$target = Read-Host "Which implementation (ghpages or docker) do you wish to update? Enter to accept suggested [ghpages]"
}
$machine_name = Read-Host "Give the name to use for the implementation, or Enter to accept suggested name [$suggested_build]"
""
if (-Not $machine_name) {
$machine_name = $suggested_build
$warning_message = "Please note that ""$app_tag"" will appear in the app as the appVersion. If you want to change that, press Ctrl-C`nand re-run this script entering a build number matching 9.9.9."
} elseif ($machine_name -match '^[\d.]+') {
$warning_message = "*** Please be aware that you have entered a release tag [$machine_name], and so it will be used as the appVersion of the container`n" +
"and will be visible to users. If this is NOT want you want, press Ctrl-C to abort this script, and re-run with the suggested build number."
}
if ($warning_message) { Write-Warning $warning_message }
}
if (-Not $target) {
$target = "ghpages"
}
if ($branch_name -eq "") {
$suggested_branch = &{ git branch --show-current }
$branch_name = Read-Host "`nGive the branch name to use of the implementation, or Enter to accept [$suggested_branch]"
if (-Not $branch_name) { $branch_name = $suggested_branch }
if ($branch_name -imatch '^pr/\d+') {
""
Write-Warning "You appear to have indicated a PR. Please check out the underlying branch to use this script,`nor else run it again and give the branch name at the prompt.`n"
return
}
}
"`nMachine name set to: $machine_name"
"Target set to: $target"
"Branch name set to: $branch_name"
if (-Not $dryrun -and -Not $github_token) {
"`nSupply token to continue.`n"
exit
}
# Set up dispatch_params object - for API see https://docs.github.com/en/rest/reference/actions#create-a-workflow-dispatch-event
$dispatch_params = @{
Uri = $release_uri
Method = 'POST'
Headers = @{
'Authorization' = "token $github_token"
'Accept' = 'application/vnd.github.v3+json'
}
Body = @{
'ref' = $branch_name
'inputs' = @{
'version' = $machine_name
'target' = $target
}
} | ConvertTo-Json
ContentType = "application/json"
}
$dispatch_f = ($dispatch_params | Format-List | Out-String);
"`nDispatch parameters:`n$dispatch_f"
# Post to the release server
if (-Not $dryrun) {
Invoke-RestMethod @dispatch_params
"`nCheck for any error message above. An empty dispatch is normal, and indicates that the command was accepted.`n"
} else {
"[DRYRUN]: Complete.`n"
}

View File

@ -58,7 +58,8 @@ else
sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.json
fi
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.webapp
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/www/index.html
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/service-worker.js
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/www/js/app.js
mkdir -p build
rm -rf build/*

View File

@ -0,0 +1,30 @@
#!/bin/bash
# This bash script tests whether app.js and service-worker.js have the same value for appVersion and for ASSETS_CACHE.
# Find the repo dir (it's the parent of the dir that contains this script)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
# Check values in files
cd $REPO_DIR
SW_VERSION="$(grep 'appVersion\s=' service-worker.js | sed -E "s/[^[:digit:]]+([^\"']+).*/\1/")"
APP_VERSION="$(grep 'params\[.appVersion' www/js/app.js | sed -E "s/[^[:digit:]]+([^\"']+).*/\1/")"
echo "service-worker.js : $SW_VERSION"
echo "app.js : $APP_VERSION"
if [ $SW_VERSION == $APP_VERSION ] ; then
echo "Both values of 'appVersion' are identical"
else
echo "ERROR! Please ensure values for 'appVersion' in app.js and service-worker.js are identical!"
exit 1
fi
SW_ASSETS_CACHE="$( grep 'ASSETS_CACHE\s=' service-worker.js | sed -E "s/[^']+'([^']+).*/\1/")"
APP_ASSETS_CACHE="$(grep 'ASSETS_CACHE\s=' www/js/app.js | sed -E "s/[^']+'([^']+).*/\1/")"
echo "service-worker.js : $SW_ASSETS_CACHE"
echo "app.js : $APP_ASSETS_CACHE"
if [ $SW_ASSETS_CACHE == $APP_ASSETS_CACHE ] ; then
echo "Both values of 'ASSETS_CACHE' are identical"
else
echo "ERROR! Please ensure values for 'ASSETS_CACHE' in app.js and service-worker.js are identical!"
exit 1
fi

View File

@ -0,0 +1,14 @@
#!/bin/bash
# This bash script tests whether PWAServer has been set correctly in app.js
SERVER=$(grep -E '^[^/]+params.+?PWAServer.+?http' ./www/js/app.js)
echo "The PWAServer is set to $SERVER"
SERVER_COUNT=$(grep -o 'PWAServer' <<< "$SERVER" | wc -l)
echo "$SERVER_COUNT server(s) are set in app.js"
if [[ $SERVER_COUNT > 1 || ! $SERVER =~ 'kiwix.org' ]]; then
echo "WARNING: The value of params['PWAServer'] is incorrectly set in app.js!"
exit 1
else
echo "PWAServer is correctly set in app.js"
fi

View File

@ -24,21 +24,39 @@
'use strict';
/**
* The name of the Cache API cache in which assets defined in regexpCachedContentTypes will be stored
* The value is defined in app.js and will be passed to Service Worker on initialization (to avoid duplication)
* @type {String}
* App version number - ENSURE IT MATCHES VALUE IN app.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
*/
var CACHE_NAME;
const appVersion = '3.3-WIP';
/**
* A global Boolean that governs whether CACHE_NAME will be used
* 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 regular expression that matches the Content-Types of assets that may be stored in CACHE_NAME
* 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}
*/
@ -50,63 +68,156 @@ var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascri
* 'example-extension' is included to show how to add another schema if necessary
* @type {RegExp}
*/
var regexpExcludedURLSchema = /^(?:chrome-extension|example-extension):/i;
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}
*/
var regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/;
const regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/;
self.addEventListener('install', function (event) {
event.waitUntil(self.skipWaiting());
/**
* 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/css/app.css",
"www/css/bootstrap.css",
"www/css/kiwixJS_invert.css",
"www/css/kiwixJS_mwInvert.css",
"www/css/transition.css",
"www/img/icons/kiwix-256.png",
"www/img/icons/kiwix-32.png",
"www/img/icons/kiwix-60.png",
"www/img/spinner.gif",
"www/img/Icon_External_Link.png",
"www/index.html",
"www/article.html",
"www/main.html",
"www/js/app.js",
"www/js/init.js",
"www/js/lib/abstractFilesystemAccess.js",
"www/js/lib/arrayFromPolyfill.js",
"www/js/lib/bootstrap.bundle.js",
"www/js/lib/filecache.js",
"www/js/lib/jquery-3.2.1.slim.js",
"www/js/lib/promisePolyfill.js",
"www/js/lib/require.js",
"www/js/lib/settingsStore.js",
"www/js/lib/uiUtil.js",
"www/js/lib/utf8.js",
"www/js/lib/util.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/lib/fontawesome/fontawesome.js",
"www/js/lib/fontawesome/solid.js",
"www/js/lib/xzdec-asm.js",
"www/js/lib/zstddec-asm.js",
"www/js/lib/xzdec-wasm.js",
"www/js/lib/xzdec-wasm.wasm",
"www/js/lib/zstddec-wasm.js",
"www/js/lib/zstddec-wasm.wasm"
];
// 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);
});
})
);
})
);
});
// Allow sw to control current page
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());
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 outgoingMessagePort = null;
var fetchCaptureEnabled = false;
let outgoingMessagePort = null;
let fetchCaptureEnabled = false;
self.addEventListener('fetch', function (event) {
if (fetchCaptureEnabled &&
regexpZIMUrlWithNamespace.test(event.request.url) &&
event.request.method === "GET") {
// The ServiceWorker will handle this request either from CACHE_NAME or from app.js
event.respondWith(
// First see if the content is in the cache
fromCache(event.request).then(
function (response) {
// The response was found in the cache so we respond with it
// Only cache GET requests
if (event.request.method !== "GET") return;
// Remove any querystring before requesting from the cache
var rqUrl = event.request.url.replace(/\?[^?]+$/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 in the ZIM
// and add it to the cache if it is an asset type (css or js)
if (cache === ASSETS_CACHE && regexpZIMUrlWithNamespace.test(rqUrl)) {
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;
},
function () {
// The response was not found in the cache so we look for it in the ZIM
// and add it to the cache if it is an asset type (css or js)
return fetchRequestFromZIM(event).then(function (response) {
// Add css or js assets to CACHE_NAME (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(event.request, response.clone()));
}
return response;
}).catch(function (msgPortData, title) {
console.error('Invalid message received from app.js for ' + title, msgPortData);
return msgPortData;
});
}
)
);
}
// 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.
}).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 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);
});
}
})
);
});
self.addEventListener('message', function (event) {
@ -123,13 +234,15 @@ self.addEventListener('message', function (event) {
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';
if (useCache) CACHE_NAME = event.data.cacheName;
console.log('[SW] Caching was turned ' + event.data.action.useCache);
console.debug('[SW] Caching was turned ' + event.data.action.useCache);
}
if (event.data.action === 'getCacheNames') {
event.ports[0].postMessage({ 'app': APP_CACHE, 'assets': ASSETS_CACHE });
}
if (event.data.action.checkCache) {
// Checks and returns the caching strategy: checkCache key should contain a sample URL string to test
testCacheAndCountAssets(event.data.action.checkCache).then(function (cacheArr) {
event.ports[0].postMessage({ 'type': cacheArr[0], 'description': cacheArr[1], 'count': cacheArr[2] });
event.ports[0].postMessage({ type: cacheArr[0], name: cacheArr[1], description: cacheArr[2], count: cacheArr[3] });
});
}
}
@ -208,51 +321,53 @@ function removeUrlParameters(url) {
}
/**
* Looks up a Request in CACHE_NAME and returns a Promise for the matched Response
* @param {Request} request The Request to fulfill from CACHE_NAME
* 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<Response>} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match'
*/
function fromCache(request) {
function fromCache(cache, requestUrl) {
// Prevents use of Cache API if user has disabled it
if (!useCache) return Promise.reject('disabled');
return caches.open(CACHE_NAME).then(function (cache) {
return cache.match(request).then(function (matching) {
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');
}
console.log('[SW] Supplying ' + request.url + ' from ' + CACHE_NAME + '...');
console.debug('[SW] Supplying ' + requestUrl + ' from ' + cache + '...');
return matching;
});
});
}
/**
* Stores or updates in CACHE_NAME the given Request/Response pair
* 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(request, response) {
function updateCache(cache, request, response) {
// Prevents use of Cache API if user has disabled it
if (!useCache) return Promise.resolve();
return caches.open(CACHE_NAME).then(function (cache) {
console.log('[SW] Adding ' + request.url + ' to ' + CACHE_NAME + '...');
return cache.put(request, response);
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 CACHE_NAME
* number of assets in ASSETS_CACHE
* @param {String} url A URL to test against excludedURLSchema
* @returns {Promise<Array>} 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', 0]);
return caches.open(CACHE_NAME).then(function (cache) {
if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', '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', 'Cache API', keys.length];
return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length];
}).catch(function(err) {
return err;
});

View File

@ -55,7 +55,7 @@
<section id="search-article" role="region">
<header id="top">
<nav class="navbar navbar-expand-md bg-light" role="navigation">
<a class="navbar-brand">Kiwix 3.3-WIP</a>
<a id="appVersion" class="navbar-brand">Kiwix</a>
<!-- Toggler/collapsible Button -->
<button class="navbar-toggler" type="button" data-toggle="collapse"
@ -302,10 +302,10 @@
supported in this mode. It can feel initially a little slower while commonly used assets are being cached,
but it soon equals JQuery mode in speed, at least in modern browsers. However, older browsers such as IE11 do
not support this mode, and the app must be running in a secure context (<code>https:</code>, <code>localhost</code>,
or certain browser extensions). Unfortunately, this mode is not currently supported in Mozilla (Firefox) browser
extensions. It <i>is</i> supported in Chromium browser extensions (e.g. Chrome or new Edge). Note that this mode
cannot run with the <code>file:</code> protocol (but only IE11 and old Edge allow the app to run by launching
<code>index.html</code> from the file system).
or certain browser extensions). While this mode is not natively supported in Mozilla (Firefox) browser
extensions, we provide a functional workaround by re-launching the extension as a Progressive Web App (PWA).
Note that this mode cannot run with the <code>file:</code> protocol (but only IE11 and old Edge allow the app to run
by launching <code>index.html</code> from the file system).
</li>
</ul>
</p>
@ -403,6 +403,11 @@
<div id="configuration" style="display: none;">
<div class="container">
<h2>Configuration</h2>
<!-- Bootstrap alert box -->
<div id="updateAlert" class="alert alert-warning alert-dismissible" style="display: none;">
<button type="button" class="close" data-hide="alert">&times;</button>
<span id="persistentMessage"></span>
</div>
<p id="downloadInstruction">This application needs a ZIM archive to work. For download
instructions, please see the About section</p>
<div id="selectorsDisplay" style="display: none;">
@ -582,7 +587,7 @@
</div>
<!-- Bootstrap alert box -->
<div id="alertBoxHeader">
<div id="activeContent" style="display:none;" class="alert alert-warning alert-dismissible fade show">
<div id="activeContent" style="display:none;" class="kiwix-alert alert alert-warning alert-dismissible fade show">
<button type="button" class="close" data-hide="alert">&times;</button>
<strong>Unable to display active content:</strong> This ZIM is not fully supported in jQuery mode.<br />
Content may be available by searching above (type a space or a letter of the alphabet), or else
@ -595,7 +600,7 @@
<footer>
<!-- Bootstrap alert box -->
<div id="alertBoxFooter">
<div id="downloadAlert" style="display:none;" class="alert alert-info alert-dismissible fade show">
<div id="downloadAlert" style="display:none;" class="kiwix-alert alert alert-info alert-dismissible fade show">
<button type="button" class="close" data-hide="alert">&times;</button>
<span id="alertMessage"></span>
</div>

View File

@ -38,11 +38,12 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
/**
* The name of the Cache API cache to use for caching Service Worker requests and responses for certain asset types
* This name will be passed to service-worker.js in messaging to avoid duplication: see comment in service-worker.js
* We need access to this constant in app.js in order to complete utility actions when Service Worker is not initialized
* We need access to the cache name in app.js in order to complete utility actions when Service Worker is not initialized,
* so we have to duplicate it here
* @type {String}
*/
const CACHE_NAME = 'kiwixjs-assetCache';
// DEV: Ensure this matches the name defined in service-worker.js (a check is provided in refreshCacheStatus() below)
const ASSETS_CACHE = 'kiwixjs-assetsCache';
/**
* Memory cache for CSS styles contained in ZIM: it significantly speeds up subsequent page display
@ -64,34 +65,101 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
*/
var selectedArchive = null;
// Set parameters and associated UI elements from the Settings Store
// DEV: The params global object is declared in init.js so that it is available to modules
params['storeType'] = settingsStore.getBestAvailableStorageAPI(); // A parameter to determine the Settings Store API in use
/**
* Set parameters from the Settings Store, together with any defaults
* Note that the params global object is declared in init.js so that it is available to modules
* WARNING: Only change these paramaeters if you know what you are doing
*/
// The current version number of this app
params['appVersion'] = '3.3-WIP'; // **IMPORTANT** Ensure this is the same as the version number in service-worker.js
// The PWA server (currently only for use with the Mozilla extension)
params['PWAServer'] = 'https://moz-extension.kiwix.org/current/'; // Include final slash!
// params['PWAServer'] = 'https://kiwix.github.io/kiwix-js/'; // DEV: Uncomment this line for testing code on GitHub Pages
// params['PWAServer'] = 'http://localhost:8080/'; // DEV: Uncomment this line (and adjust) for local testing
// A parameter to determine the Settings Store API in use
params['storeType'] = settingsStore.getBestAvailableStorageAPI();
params['hideActiveContentWarning'] = settingsStore.getItem('hideActiveContentWarning') === 'true';
params['showUIAnimations'] = settingsStore.getItem('showUIAnimations') ? settingsStore.getItem('showUIAnimations') === 'true' : true;
document.getElementById('hideActiveContentWarningCheck').checked = params.hideActiveContentWarning;
document.getElementById('showUIAnimationsCheck').checked = params.showUIAnimations;
// Maximum number of article titles to return (range is 5 - 50, default 25)
params['maxSearchResultsSize'] = settingsStore.getItem('maxSearchResultsSize') || 25;
document.getElementById('titleSearchRange').value = params.maxSearchResultsSize;
document.getElementById('titleSearchRangeVal').innerHTML = params.maxSearchResultsSize;
// 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';
// 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
document.getElementById('appThemeSelect').value = params.appTheme;
uiUtil.applyAppTheme(params.appTheme);
// A global parameter to turn on/off the use of Keyboard HOME Key to focus search bar
params['useHomeKeyToFocusSearchBar'] = settingsStore.getItem('useHomeKeyToFocusSearchBar') === 'true';
document.getElementById('useHomeKeyToFocusSearchBarCheck').checked = params.useHomeKeyToFocusSearchBar;
switchHomeKeyToFocusSearchBar();
// A parameter to access the URL of any extension that this app was launched from
params['referrerExtensionURL'] = settingsStore.getItem('referrerExtensionURL');
// A parameter to set the content injection mode ('jquery' or 'serviceworker') used by this app
params['contentInjectionMode'] = settingsStore.getItem('contentInjectionMode') || 'jquery'; // Defaults to jquery for now
// An object to hold the current search and its state (allows cancellation of search across modules)
appstate['search'] = {
'prefix': '', // A field to hold the original search string
'status': '', // The status of the search: ''|'init'|'interim'|'cancelled'|'complete'
'type': '' // The type of the search: 'basic'|'full' (set automatically in search algorithm)
};
// A Boolean to store the update status of the PWA version (currently only used with Firefox Extension)
appstate['pwaUpdateNeeded'] = false; // This will be set to true if the Service Worker has an update waiting
/**
* Apply any override parameters that might be in the querystring.
* This is used for communication between the PWA and any local code (e.g. Firefox Extension), both ways.
* It is also possible for DEV (or user) to launch the app with certain settings, or to unset potentially
* problematic settings, by crafting the querystring appropriately.
*/
(function overrideParams() {
var regexpUrlParams = /[?&]([^=]+)=([^&]+)/g;
var matches = regexpUrlParams.exec(window.location.search);
while (matches) {
if (matches[1] && matches[2]) {
var paramKey = decodeURIComponent(matches[1]);
var paramVal = decodeURIComponent(matches[2]);
if (paramKey !== 'title') {
console.debug('Setting key-pair: ' + paramKey + ':' + paramVal);
// Make values Boolean if 'true'/'false'
paramVal = paramVal === 'true' || (paramVal === 'false' ? false : paramVal);
settingsStore.setItem(paramKey, paramVal, Infinity);
params[paramKey] = paramVal;
}
}
matches = regexpUrlParams.exec(window.location.search);
}
// If we are in the PWA version launched from an extension, send a 'success' message to the extension
if (params.referrerExtensionURL && ~window.location.href.indexOf(params.PWAServer)) {
var message = '?PWA_launch=success';
// DEV: To test failure of the PWA, you could pause on next line and set message to '?PWA_launch=fail'
// Note that, as a failsafe, the PWA_launch key is set to 'fail' (in the extension) before each PWA launch
// so we need to send a 'success' message each time the PWA is launched
var frame = document.createElement('iframe');
frame.id = 'kiwixComm';
frame.style.display = 'none';
document.body.appendChild(frame);
frame.src = params.referrerExtensionURL + '/www/index.html'+ message;
// Now remove redundant frame. We cannot use onload, because it doesn't give time for the script to run.
setTimeout(function () {
var kiwixComm = document.getElementById('kiwixComm');
// The only browser which does not support .remove() is IE11, but it will never run this code
if (kiwixComm) kiwixComm.remove();
}, 3000);
}
})();
/**
* Set the State and UI settings associated with parameters defined above
*/
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('appThemeSelect').value = params.appTheme;
uiUtil.applyAppTheme(params.appTheme);
document.getElementById('useHomeKeyToFocusSearchBarCheck').checked = params.useHomeKeyToFocusSearchBar;
switchHomeKeyToFocusSearchBar();
document.getElementById('appVersion').innerHTML = 'Kiwix ' + params.appVersion;
setContentInjectionMode(params.contentInjectionMode);
// Define globalDropZone (universal drop area) and configDropZone (highlighting area on Config page)
var globalDropZone = document.getElementById('search-article');
var configDropZone = document.getElementById('configuration');
@ -130,7 +198,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// Do not initiate the same search if it is already in progress
if (appstate.search.prefix === prefix && !/^(cancelled|complete)$/.test(appstate.search.status)) return;
$("#welcomeText").hide();
$('.alert').hide();
$('.kiwix-alert').hide();
$("#searchingArticles").show();
pushBrowserHistoryState(null, prefix);
// Initiate the search
@ -306,9 +374,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
$('#formArticleSearch').hide();
$("#welcomeText").hide();
$("#searchingArticles").hide();
$('.alert').hide();
$('.kiwix-alert').hide();
refreshAPIStatus();
refreshCacheStatus();
uiUtil.checkUpdateStatus(appstate);
// Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms
setTimeout(resizeIFrame, 400);
return false;
@ -333,7 +402,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
$("#welcomeText").hide();
$('#articleListWithHeader').hide();
$("#searchingArticles").hide();
$('.alert').hide();
$('.kiwix-alert').hide();
// Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms
setTimeout(resizeIFrame, 400);
return false;
@ -373,7 +442,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
params.useCache = false;
// Delete all caches
resetCssCache();
if ('caches' in window) caches.delete(CACHE_NAME);
if ('caches' in window) caches.delete(ASSETS_CACHE);
refreshCacheStatus();
}
});
@ -491,9 +560,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
* If Service Worker is not available, the attributes of the memory cache are returned instead
* @returns {Promise<Object>} A Promise for an object with cache attributes 'type', 'description', and 'count'
*/
function getCacheAttributes() {
function getAssetsCacheAttributes() {
return new Promise(function (resolve, reject) {
if (contentInjectionMode === 'serviceworker') {
if (params.contentInjectionMode === 'serviceworker' && navigator.serviceWorker && navigator.serviceWorker.controller) {
// Create a Message Channel
var channel = new MessageChannel();
// Handler for recieving message reply from service worker
@ -507,13 +576,13 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
'action': {
'useCache': params.useCache ? 'on' : 'off',
'checkCache': window.location.href
},
'cacheName': CACHE_NAME
}
}, [channel.port2]);
} else {
// No Service Worker has been established, so we resolve the Promise with cssCache details only
resolve({
'type': params.useCache ? 'memory' : 'none',
'name': 'cssCache',
'description': params.useCache ? 'Memory' : 'None',
'count': cssCache.size
});
@ -528,7 +597,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// Update radio buttons and checkbox
document.getElementById('cachedAssetsModeRadio' + (params.useCache ? 'True' : 'False')).checked = true;
// Get cache attributes, then update the UI with the obtained data
getCacheAttributes().then(function (cache) {
getAssetsCacheAttributes().then(function (cache) {
if (cache.type === 'cacheAPI' && ASSETS_CACHE !== cache.name) {
console.error('DEV: The ASSETS_CACHE defined in app.js does not match the ASSETS_CACHE defined in service-worker.js!');
}
document.getElementById('cacheUsed').innerHTML = cache.description;
document.getElementById('assetsCount').innerHTML = cache.count;
var cacheSettings = document.getElementById('performanceSettingsDiv');
@ -543,8 +615,8 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
});
}
var contentInjectionMode;
var keepAliveServiceWorkerHandle;
var serviceWorkerRegistration;
/**
* Send an 'init' message to the ServiceWorker with a new MessageChannel
@ -553,20 +625,31 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
* and the application
*/
function initOrKeepAliveServiceWorker() {
if (contentInjectionMode === 'serviceworker') {
var delay = DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER;
if (params.contentInjectionMode === 'serviceworker') {
// Create a new messageChannel
var tmpMessageChannel = new MessageChannel();
tmpMessageChannel.port1.onmessage = handleMessageChannelMessage;
// Send the init message to the ServiceWorker, with this MessageChannel as a parameter
navigator.serviceWorker.controller.postMessage({'action': 'init'}, [tmpMessageChannel.port2]);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
'action': 'init'
}, [tmpMessageChannel.port2]);
} else if (keepAliveServiceWorkerHandle) {
console.error('The Service Worker is active but is not controlling the current page! We have to reload.');
window.location.reload();
} else {
// If this is the first time we are initiating the SW, allow Promises to complete by delaying potential reload till next tick
delay = 0;
}
messageChannel = tmpMessageChannel;
// Schedule to do it again regularly to keep the 2-way communication alive.
// See https://github.com/kiwix/kiwix-js/issues/145 to understand why
clearTimeout(keepAliveServiceWorkerHandle);
keepAliveServiceWorkerHandle = setTimeout(initOrKeepAliveServiceWorker, DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER, false);
keepAliveServiceWorkerHandle = setTimeout(initOrKeepAliveServiceWorker, delay, false);
}
}
/**
* Sets the given injection mode.
* This involves registering (or re-enabling) the Service Worker if necessary
@ -575,19 +658,45 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
* @param {String} value The chosen content injection mode : 'jquery' or 'serviceworker'
*/
function setContentInjectionMode(value) {
params.contentInjectionMode = value;
if (value === 'jquery') {
if (isServiceWorkerReady()) {
// We need to disable the ServiceWorker
// Unregistering it does not seem to work as expected : the ServiceWorker
// is indeed unregistered but still active...
// So we have to disable it manually (even if it's still registered and active)
navigator.serviceWorker.controller.postMessage({'action': 'disable'});
messageChannel = null;
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' +
'WARNING: After this, you may not be able to switch back to SW mode without an online connection!';
var launchLocal = function () {
settingsStore.setItem('allowInternetAccess', false, Infinity);
var uriParams = '?allowInternetAccess=false&contentInjectionMode=jquery&hideActiveContentWarning=false';
uriParams += '&appTheme=' + params.appTheme;
uriParams += '&showUIAnimations=' + params.showUIAnimations;
window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
'Beam me down, Scotty!';
};
var response = confirm(message);
if (response) {
launchLocal();
} else {
setContentInjectionMode('serviceworker');
}
return;
}
// Because the "outer" Service Worker still runs in a PWA app, we don't actually disable the SW in this context, but it will no longer
// be intercepting requests
if ('serviceWorker' in navigator) {
serviceWorkerRegistration = null;
}
refreshAPIStatus();
// User has switched to jQuery mode, so no longer needs CACHE_NAME
// We should empty it to prevent unnecessary space usage
if ('caches' in window) caches.delete(CACHE_NAME);
// User has switched to jQuery mode, so no longer needs ASSETS_CACHE
// We should empty it and turn it off to prevent unnecessary space usage
if ('caches' in window && isMessageChannelAvailable()) {
var channel = new MessageChannel();
if (isServiceWorkerAvailable() && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
'action': { 'useCache': 'off' }
}, [channel.port2]);
}
caches.delete(ASSETS_CACHE);
}
} else if (value === 'serviceworker') {
if (!isServiceWorkerAvailable()) {
alert("The ServiceWorker API is not available on your device. Falling back to JQuery mode");
@ -599,55 +708,62 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
setContentInjectionMode('jquery');
return;
}
var protocol = window.location.protocol;
if (!isServiceWorkerReady()) {
$('#serviceWorkerStatus').html("ServiceWorker API available : trying to register it...");
navigator.serviceWorker.register('../service-worker.js').then(function (reg) {
// The ServiceWorker is registered
serviceWorkerRegistration = reg;
if (navigator.serviceWorker.controller) {
console.log("Active service worker found, no need to register");
serviceWorkerRegistration = true;
// Remove any jQuery hooks from a previous jQuery session
$('#articleContent').contents().remove();
// Create the MessageChannel and send 'init'
initOrKeepAliveServiceWorker();
refreshAPIStatus();
// We need to wait for the ServiceWorker to be activated
// before sending the first init message
var serviceWorker = reg.installing || reg.waiting || reg.active;
serviceWorker.addEventListener('statechange', function(statechangeevent) {
if (statechangeevent.target.state === 'activated') {
// Remove any jQuery hooks from a previous jQuery session
$('#articleContent').contents().remove();
// Create the MessageChannel
} else {
navigator.serviceWorker.register('../service-worker.js').then(function (reg) {
// The ServiceWorker is registered
serviceWorkerRegistration = reg;
// We need to wait for the ServiceWorker to be activated
// before sending the first init message
var serviceWorker = reg.installing || reg.waiting || reg.active;
serviceWorker.addEventListener('statechange', function(statechangeevent) {
if (statechangeevent.target.state === 'activated') {
// Remove any jQuery hooks from a previous jQuery session
$('#articleContent').contents().remove();
// Create the MessageChannel and send the 'init' message to the ServiceWorker
initOrKeepAliveServiceWorker();
// We need to refresh cache status here on first activation because SW was inaccessible till now
// We also initialize the ASSETS_CACHE constant in SW here
refreshCacheStatus();
refreshAPIStatus();
}
});
if (serviceWorker.state === 'activated') {
// Even if the ServiceWorker is already activated,
// We need to re-create the MessageChannel
// and send the 'init' message to the ServiceWorker
// in case it has been stopped and lost its context
initOrKeepAliveServiceWorker();
// We need to refresh cache status here on first activation because SW was inaccessible till now
// We also initialize the CACHE_NAME constant in SW here
refreshCacheStatus();
}
refreshCacheStatus();
refreshAPIStatus();
}).catch(function (err) {
if (protocol === 'moz-extension:') {
launchMozillaExtensionServiceWorker();
} else {
console.error('Error while registering serviceWorker', err);
refreshAPIStatus();
var message = "The ServiceWorker could not be properly registered. Switching back to jQuery mode. Error message : " + err;
if (protocol === 'file:') {
message += "\n\nYou seem to be opening kiwix-js with the file:// protocol. You should open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)";
}
alert(message);
setContentInjectionMode("jquery");
}
});
if (serviceWorker.state === 'activated') {
// Even if the ServiceWorker is already activated,
// We need to re-create the MessageChannel
// and send the 'init' message to the ServiceWorker
// in case it has been stopped and lost its context
initOrKeepAliveServiceWorker();
}
}, function (err) {
console.error('error while registering serviceWorker', err);
refreshAPIStatus();
var message = "The ServiceWorker could not be properly registered. Switching back to jQuery mode. Error message : " + err;
var protocol = window.location.protocol;
if (protocol === 'moz-extension:') {
message += "\n\nYou seem to be using kiwix-js through a Firefox extension : ServiceWorkers are disabled by Mozilla in extensions.";
message += "\nPlease vote for https://bugzilla.mozilla.org/show_bug.cgi?id=1344561 so that some future Firefox versions support it";
}
else if (protocol === 'file:') {
message += "\n\nYou seem to be opening kiwix-js with the file:// protocol. You should open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)";
}
alert(message);
setContentInjectionMode("jquery");
return;
});
}
} else {
// We need to set this variable earlier else the ServiceWorker does not get reactivated
contentInjectionMode = value;
// We need to reactivate Service Worker
initOrKeepAliveServiceWorker();
}
// User has switched to Service Worker mode, so no longer needs the memory cache
@ -656,25 +772,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}
$('input:radio[name=contentInjectionMode]').prop('checked', false);
$('input:radio[name=contentInjectionMode]').filter('[value="' + value + '"]').prop('checked', true);
contentInjectionMode = value;
// Save the value in the Settings Store, so that to be able to keep it after a reload/restart
settingsStore.setItem('lastContentInjectionMode', value, Infinity);
settingsStore.setItem('contentInjectionMode', value, Infinity);
refreshCacheStatus();
refreshAPIStatus();
}
// At launch, we try to set the last content injection mode (stored in Settings Store)
var lastContentInjectionMode = settingsStore.getItem('lastContentInjectionMode');
if (lastContentInjectionMode) {
setContentInjectionMode(lastContentInjectionMode);
}
else {
setContentInjectionMode('jquery');
}
var serviceWorkerRegistration = null;
// We need to establish the caching capabilities before first page launch
refreshCacheStatus();
/**
* Tells if the ServiceWorker API is available
@ -710,6 +812,69 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// Return true if the serviceWorkerRegistration is not null and not undefined
return (serviceWorkerRegistration);
}
function launchMozillaExtensionServiceWorker () {
// DEV: See explanation below for why we access localStorage directly here
var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success';
var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true';
var message = 'To enable the Service Worker, we need one-time access to our secure server ' +
'so that the app can re-launch as a Progressive Web App (PWA).\n\n' +
'The PWA will be able to run offline, but will auto-update periodically when online ' +
'as per the Service Worker spec.\n\n' +
'You can switch back any time by returning to JQuery mode.\n\n' +
'WARNING: This will attempt to access the following server: \n' + params.PWAServer + '\n';
var launchPWA = function () {
uiUtil.spinnerDisplay(false);
var uriParams = '?contentInjectionMode=serviceworker&allowInternetAccess=true';
uriParams += '&referrerExtensionURL=' + encodeURIComponent(window.location.href.replace(/\/www\/index.html.*$/i, ''));
if (!PWASuccessfullyLaunched || !allowInternetAccess) {
// Add any further params that should only be passed when the user is intentionally switching to SW mode
uriParams += '&appTheme=' + params.appTheme;
uriParams += '&showUIAnimations=' + params.showUIAnimations;
}
settingsStore.setItem('contentInjectionMode', 'serviceworker', Infinity);
// This is needed so that we get passthrough on subsequent launches
settingsStore.setItem('allowInternetAccess', true, Infinity);
// Signal failure of PWA until it has successfully launched (in init.js it will be changed to 'success')
// DEV: We write directly to localStorage instead of using settingsStore here because we need 100% certainty
// regarding the location of the key to be able to retrieve it in init.js before settingsStore is initialized
localStorage.setItem(params.keyPrefix + 'PWA_launch', 'fail');
window.location.href = params.PWAServer + 'www/index.html' + uriParams;
'Beam me up, Scotty!';
};
var checkPWAIsOnline = function () {
uiUtil.spinnerDisplay(true, 'Checking server access...');
uiUtil.checkServerIsAccessible(params.PWAServer + 'www/img/icons/kiwix-32.png', launchPWA, function () {
uiUtil.spinnerDisplay(false);
alert('The server is not currently accessible! ' +
'\n\n(Kiwix needs one-time access to the server to cache the PWA).' +
'\nPlease try again when you have a stable Internet connection.', 'Error!');
settingsStore.setItem('allowInternetAccess', false, Infinity);
setContentInjectionMode('jquery');
});
};
var response;
if (settingsStore.getItem('allowInternetAccess') === 'true') {
if (PWASuccessfullyLaunched) {
checkPWAIsOnline();
} else {
response = confirm('The last attempt to launch the PWA appears to have failed.\n\nDo you wish to try again?');
if (response) {
checkPWAIsOnline();
} else {
settingsStore.setItem('allowInternetAccess', false, Infinity);
setContentInjectionMode('jquery');
}
}
} else {
response = confirm(message);
if (response) checkPWAIsOnline();
else {
setContentInjectionMode('jquery');
settingsStore.setItem('allowInternetAccess', false, Infinity);
}
}
}
/**
*
@ -1169,7 +1334,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// but we should not do this when opening the landing page (or else one of the Unit Tests fails, at least on Chrome 58)
if (!params.isLandingPage) document.getElementById('articleContent').contentWindow.focus();
if (contentInjectionMode === 'serviceworker') {
if (params.contentInjectionMode === 'serviceworker') {
// In ServiceWorker mode, we simply set the iframe src.
// (reading the backend is handled by the ServiceWorker itself)
@ -1220,7 +1385,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}
// We put the ZIM filename as a prefix in the URL, so that browser caches are separate for each ZIM file
iframeArticleContent.src = "../" + selectedArchive._file._files[0].name + "/" + dirEntry.namespace + "/" + encodedUrl;
iframeArticleContent.src = "../" + selectedArchive._file.name + "/" + dirEntry.namespace + "/" + encodedUrl;
} else {
// In jQuery mode, we read the article content in the backend and manually insert it in the iframe
if (dirEntry.isRedirect()) {

View File

@ -30,50 +30,64 @@
*/
var params = {};
require.config({
baseUrl: 'js/lib',
paths: {
'jquery': 'jquery-3.2.1.slim',
'bootstrap': 'bootstrap.bundle',
'webpHeroBundle': 'webpHeroBundle_0.0.0-dev.27',
'fontawesome': 'fontawesome/fontawesome',
'fontawesome-solid': 'fontawesome/solid'
},
shim: {
'jquery': {
exports: '$'
// The key prefix used by the settingsStore.js (see comment there for explanation), but we also need it below
params['keyPrefix'] = 'kiwixjs-'
// The following lines check the querystring for a communication from the PWA indicating it has successfully launched.
// If this querystring is received, then the app will set a success key in the extension's localStorage and then halt further processing.
// This is used to prevent a "boot loop" where the app will keep jumping to a failed install of the PWA.
if (/PWA_launch=/.test(window.location.search)) {
var match = /PWA_launch=([^&]+)/.exec(window.location.search);
localStorage.setItem(params.keyPrefix + 'PWA_launch', match[1]);
console.warn('Launch of PWA has been registered as "' + match[1] + '" by the extension. Exiting local code.');
} else {
require.config({
baseUrl: 'js/lib',
paths: {
'jquery': 'jquery-3.2.1.slim',
'bootstrap': 'bootstrap.bundle',
'webpHeroBundle': 'webpHeroBundle_0.0.0-dev.27',
'fontawesome': 'fontawesome/fontawesome',
'fontawesome-solid': 'fontawesome/solid'
},
'bootstrap': {
deps: ['jquery', 'fontawesome', 'fontawesome-solid']
},
'webpHeroBundle': ''
}
});
shim: {
'jquery': {
exports: '$'
},
'bootstrap': {
deps: ['jquery', 'fontawesome', 'fontawesome-solid']
},
'webpHeroBundle': ''
}
});
var req = ['bootstrap']; // Baseline Require array
var req = ['bootstrap']; // Baseline Require array
// Add polyfills to the Require array only if needed
if (!('Promise' in self)) req.push('promisePolyfill');
if (!('from' in Array)) req.push('arrayFromPolyfill');
// Add polyfills to the Require array only if needed
if (!('Promise' in self)) req.push('promisePolyfill');
if (!('from' in Array)) req.push('arrayFromPolyfill');
requirejs(req, function () {
requirejs(['../app']);
});
requirejs(req, function () {
requirejs(['../app']);
});
// Test if WebP is natively supported, and if not, set webpMachine to true. The value of webpMachine
// will determine whether the WebP Polyfills will be loaded (currently only used in uiUtil.js)
var webpMachine = false;
// Test if WebP is natively supported, and if not, set webpMachine to true. The value of webpMachine
// will determine whether the WebP Polyfills will be loaded (currently only used in uiUtil.js)
var webpMachine = false;
// We use a self-invoking function here to avoid defining unnecessary global functions and variables
(function (callback) {
// Tests for native WebP support
var webP = new Image();
webP.onload = webP.onerror = function () {
callback(webP.height === 2);
};
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
})(function (support) {
if (!support) {
webpMachine = true;
}
});
// We use a self-invoking function here to avoid defining unnecessary global functions and variables
(function (callback) {
// Tests for native WebP support
var webP = new Image();
webP.onload = webP.onerror = function () {
callback(webP.height === 2);
};
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
})(function (support) {
if (!support) {
webpMachine = true;
}
});
}

View File

@ -34,15 +34,24 @@ define([], function () {
*/
var regexpCookieKeysToMigrate = new RegExp([
'hideActiveContentWarning', 'showUIAnimations', 'appTheme', 'useCache',
'lastContentInjectionMode', 'listOfArchives', 'lastSelectedArchive'
'contentInjectionMode', 'listOfArchives', 'lastSelectedArchive'
].join('|'));
/**
* A constant to set the prefix that will be added to keys when stored in localStorage: this is used to prevent
* A list of deprecated keys that should be removed. Add any further keys to the list of strings separated by a comma.
* @type {Array}
*/
var deprecatedKeys = [
'lastContentInjectionMode'
];
/**
* The prefix that will be added to keys when stored in localStorage: this is used to prevent
* potential collision of key names with localStorage keys used by code inside ZIM archives
* It is set in init.js because it is needed early in app loading
* @type {String}
*/
const keyPrefix = 'kiwixjs-';
var keyPrefix = params.keyPrefix;
// Tests for available Storage APIs (document.cookie or localStorage) and returns the best available of these
function getBestAvailableStorageAPI() {
@ -72,6 +81,11 @@ define([], function () {
// If both cookies and localStorage are supported, and document.cookie contains keys to migrate,
// migrate settings to use localStorage
if (kiwixCookieTest && localStorageTest && regexpCookieKeysToMigrate.test(document.cookie)) _migrateStorageSettings();
// Remove any deprecated keys
deprecatedKeys.forEach(function (key) {
if (localStorageTest) localStorage.removeItem(keyPrefix + key);
settingsStore.removeItem(key); // Because this runs before we have returned a store type, this will remove from cookie too
});
// Note that if this function returns 'none', the cookie implementations below will run anyway. This is because storing a cookie
// does not cause an exception even if cookies are blocked in some contexts, whereas accessing localStorage may cause an exception
return type;

View File

@ -225,6 +225,76 @@ define(rqDef, function() {
$("#searchingArticles").hide();
}
/**
* Check for update of Service Worker (PWA) and display information to user
*/
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 {
caches.keys().then(function (keyList) {
updateAlert.style.display = 'none';
var cachePrefix = cacheNames.app.replace(/^([^\d]+).+/, '$1');
keyList.forEach(function (key) {
if (key === cacheNames.app || key === cacheNames.assets) return;
// Ignore any keys that do not begin with the appCache prefix (they could be from other apps using the same domain)
if (key.indexOf(cachePrefix)) return;
// If we get here, then there is a cache key that does not match our version, i.e. a PWA-in-waiting
appstate.pwaUpdateNeeded = true;
updateAlert.style.display = 'block';
document.getElementById('persistentMessage').innerHTML = 'Version ' + key.replace(cachePrefix, '') +
' is ready to install. (Re-launch app to install.)';
});
});
}
};
if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage({
action: 'getCacheNames'
}, [channel.port2]);
}
}
if (updateAlert) updateAlert.querySelector('button[data-hide]').addEventListener('click', function () {
updateAlert.style.display = 'none';
});
/**
* Checks if a server is accessible by attempting to load a test image from the server
* @param {String} imageSrc The full URI of the image
* @param {any} onSuccess A function to call if the image can be loaded
* @param {any} onError A function to call if the image cannot be loaded
*/
function checkServerIsAccessible(imageSrc, onSuccess, onError) {
var image = new Image();
image.onload = onSuccess;
image.onerror = onError;
image.src = imageSrc;
}
/**
* Show or hide the spinner together with a message
* @param {Boolean} show True to show the spinner, false to hide it
* @param {String} message A message to display, or hide the message if null
*/
function spinnerDisplay(show, message) {
var searchingArticles = document.getElementById('searchingArticles');
var spinnerMessage = document.getElementById('cachingAssets');
if (show) searchingArticles.style.display = 'block';
else searchingArticles.style.display = 'none';
if (message) {
spinnerMessage.innerHTML = message;
spinnerMessage.style.display = 'block';
} else {
spinnerMessage.innerHTML = 'Caching assets...';
spinnerMessage.style.display = 'none';
}
}
/**
* Checks whether an element is partially or fully inside the current viewport
*
@ -402,7 +472,7 @@ define(rqDef, function() {
}
// If we are in Config and a real document has been loaded already, expose return link so user can see the result of the change
// DEV: The Placeholder string below matches the dummy article.html that is loaded before any articles are loaded
if (document.getElementById('liConfigureNav').classList.contains('active') &&
if (document.getElementById('liConfigureNav').classList.contains('active') && doc &&
doc.title !== "Placeholder for injecting an article into the iframe") {
showReturnLink();
}
@ -453,6 +523,9 @@ define(rqDef, function() {
removeUrlParameters: removeUrlParameters,
displayActiveContentWarning: displayActiveContentWarning,
displayFileDownloadAlert: displayFileDownloadAlert,
checkUpdateStatus: checkUpdateStatus,
checkServerIsAccessible: checkServerIsAccessible,
spinnerDisplay: spinnerDisplay,
isElementInView: isElementInView,
htmlEscapeChars: htmlEscapeChars,
removeAnimationClasses: removeAnimationClasses,