diff --git a/addons/website_event_track/controllers/webmanifest.py b/addons/website_event_track/controllers/webmanifest.py index 9f4967ed70d..627c2581577 100644 --- a/addons/website_event_track/controllers/webmanifest.py +++ b/addons/website_event_track/controllers/webmanifest.py @@ -48,8 +48,13 @@ class TrackManifest(http.Controller): """ Returns a ServiceWorker javascript file scoped for website_event """ sw_file = get_module_resource('website_event_track', 'static/src/js/service_worker.js') - with open(sw_file, 'rb') as fp: + with open(sw_file, 'r') as fp: body = fp.read() + js_cdn_url = 'undefined' + if request.website.cdn_activated: + cdn_url = request.website.cdn_url.replace('"','%22').replace('\x5c','%5C') + js_cdn_url = '"%s"' % cdn_url + body = body.replace('__ODOO_CDN_URL__', js_cdn_url) response = request.make_response(body, [ ('Content-Type', 'text/javascript'), ('Service-Worker-Allowed', url_for('/event')), diff --git a/addons/website_event_track/static/src/js/service_worker.js b/addons/website_event_track/static/src/js/service_worker.js index aec317435ef..41ce7e54297 100644 --- a/addons/website_event_track/static/src/js/service_worker.js +++ b/addons/website_event_track/static/src/js/service_worker.js @@ -3,6 +3,9 @@ importScripts("/website_event_track/static/lib/idb-keyval/idb-keyval.js"); const PREFIX = "odoo-event"; const SYNCABLE_ROUTES = ["/event/track/toggle_reminder"]; const CACHABLE_ROUTES = ["/web/webclient/version_info"]; +const MAX_CACHE_SIZE = 512 * 1024 * 1024; // 500 MB +const MAX_CACHE_QUOTA = 0.5; +const CDN_URL = __ODOO_CDN_URL__; // {string|undefined} the cdn_url configured for the website if activated const { Store, set, get, del } = idbKeyval; const pendingRequestsQueueName = `${PREFIX}-pending-requests`; @@ -10,6 +13,8 @@ const cacheName = `${PREFIX}-cache`; const syncStore = new Store(`${PREFIX}-sync-db`, `${PREFIX}-sync-store`); const cacheStore = new Store(`${PREFIX}-cache-db`, `${PREFIX}-cache-store`); const offlineRoute = `${self.registration.scope}/offline`; +const scopeURL = new URL(self.registration.scope); +const cdnURL = CDN_URL ? (CDN_URL.startsWith("http") ? new URL(CDN_URL) : new URL(`http:${CDN_URL}`)) : undefined; /** * @@ -44,6 +49,18 @@ const isSyncableURL = canHandleRoutes(SYNCABLE_ROUTES); */ const isCachableURL = canHandleRoutes(CACHABLE_ROUTES); +/** + * + * @returns {boolean} true if navigator has a quota we can read and we reached it + */ +const isCacheFull = async () => { + if (!("storage" in navigator && "estimate" in navigator.storage)) { + return false; + } + const { usage, quota } = await navigator.storage.estimate(); + return usage / quota > MAX_CACHE_QUOTA || usage > MAX_CACHE_SIZE; +}; + /** * * @return {Promise} @@ -133,6 +150,33 @@ const buildEmptyResponse = () => new Response(JSON.stringify({ jsonrpc: "2.0", i * @returns {Promise} */ const cacheRequest = async (request, response) => { + // only attempts to cache local or cdn delivered urls + const url = new URL(request.url); + if (url.hostname !== scopeURL.hostname && (!cdnURL || url.hostname !== cdnURL.hostname)) { + console.error(`ignoring cache for ${request.url} => ${url.hostname}, local: ${scopeURL.hostname}, cdn: ${cdnURL ? cdnURL.hostname : cdnURL}`); + return; + } + + // don't even attempt to cache: + // - error pages (why cache that?) + // - non-"basic" response types, which include tracker 1-time opaque requests + // that are consuming cache space for no reason (namely due to padding MBs accounted for + // each opaque request) + if (!response || !response.ok || response.type !== "basic") { + console.error(`ignoring cache for ${request.url} => ${response.type}, mode: ${request.mode}, cache: ${request.cache}`); + return; + } + + // never blow up cache quota, as it will break things, and the space + // is shared with cookies and localStorage + if (await isCacheFull()) { + // TODO: clear some part of the cache to free older/less-relevant content + console.log("Cache full, not caching!"); + return; + } + + console.log(`grant cache for ${request.url} => ${response.type}, mode: ${request.mode}, cache: ${request.cache}, + isGet: ${isGET(request)}, isCachable: ${isCachableURL(request.url)}`); if (isGET(request)) { const cache = await caches.open(cacheName); await cache.put(request, response.clone()); @@ -191,7 +235,7 @@ const matchCache = async (request) => { * @param {FetchEvent} param0 * @returns {Promise} */ -const processFetchEvent = async ({ request }) => { +const processFetchRequest = async ({ request }) => { const requestCopy = request.clone(); let response; try { @@ -254,11 +298,17 @@ const processPendingRequests = async () => { */ const prefetchUrls = async (urls = []) => { const cache = await caches.open(cacheName); - let urlsToCache = new Set(urls); - for (let url of urlsToCache) { - (await cache.match(url)) ? urlsToCache.delete(url) : undefined; + const uniqUrls = new Set(urls); + for (let url of uniqUrls) { + if (await cache.match(url)) { + continue; + } + try { + await processFetchRequest({ request: new Request(url) }); + } catch (error) { + console.error(`fail to prefetch ${url} : ${error}`); + } } - return cache.addAll([...urlsToCache]); }; /** @@ -293,7 +343,7 @@ const processMessage = (data) => { }; self.addEventListener("fetch", (event) => { - event.respondWith(processFetchEvent(event)); + event.respondWith(processFetchRequest(event)); }); self.addEventListener("sync", (event) => { diff --git a/addons/website_event_track/static/src/js/website_event_pwa_widget.js b/addons/website_event_track/static/src/js/website_event_pwa_widget.js index 1eb93a9cc22..09a3f635417 100644 --- a/addons/website_event_track/static/src/js/website_event_pwa_widget.js +++ b/addons/website_event_track/static/src/js/website_event_pwa_widget.js @@ -1,6 +1,21 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { "use strict"; + /* + * The "deferredPrompt" Promise will resolve only if the "beforeinstallprompt" event + * has been triggered. It allows to register this listener as soon as possible + * to avoid missed-events (as the browser can trigger it very early in the page lifecycle). + */ + var deferredPrompt = new Promise(function (resolve, reject) { + if (!("serviceWorker" in navigator)) { + return reject(); + } + window.addEventListener("beforeinstallprompt", function (ev) { + ev.preventDefault(); + resolve(ev); + }); + }); + var config = require("web.config"); var publicWidget = require("web.public.widget"); var utils = require("web.utils"); @@ -35,23 +50,21 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { prompt_close_bar: "_onPromptCloseBar", }, - /** - * - * @override - */ - init: function () { - this._super.apply(this, arguments); - this.beforeInstallPromptHandler = this._onBeforeInstallPrompt.bind(this); - }, - /** * * @override */ start: function () { - var superProm = this._super.apply(this, arguments); - window.addEventListener("beforeinstallprompt", this.beforeInstallPromptHandler); - return superProm.then(this._registerServiceWorker.bind(this)).then(this._prefetch.bind(this)); + var self = this; + return this._super.apply(this, arguments) + .then(this._registerServiceWorker.bind(this)) + .then(function () { + // Don't wait for the prompt's Promise as it may never resolve. + deferredPrompt.then(self._showInstallBanner.bind(self)).catch(function () { + console.log("ServiceWorker not supported"); + }); + }) + .then(this._prefetch.bind(this)); }, /** @@ -59,7 +72,6 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { * @override */ destroy: function () { - window.removeEventListener("beforeinstallprompt", this.beforeInstallPromptHandler); this._super.apply(this, arguments); }, @@ -67,32 +79,21 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { // Private //-------------------------------------------------------------------------- - /** - * Returns the website's language from the URL - * - * Note: this method performs a matching against both the simple (eg. "en") and complete (eg. "en_US") - * language formatting in the url. This is required as the cookie always returns the complete - * form but the URL can use both, depending on the language (eg. "en_US" => "en" but "en_UK" => "en_UK"). - * @private - * @return {String} - */ - _getLangPrefix: function () { - var lang = utils.get_cookie("frontend_lang"); - var path = window.location.pathname; - var matches = path.match(new RegExp("^\/?(" + lang + "|" + lang.slice(0, 2) + ")\/")); - if (lang && matches && matches[1]) { - return "/" + matches[1]; - } - return ""; - }, - /** * Returns the PWA's scope + * + * Note: this method performs a matching to handle URLs with the language prefix. + * Typically this prefix is in the form of "en" or "en_US" but it can also be + * any string using the customization options in the Website's settings. * @private * @returns {String} */ _getScope: function () { - return this._getLangPrefix() + "/event"; + var matches = window.location.pathname.match(/^(\/(?:event|[^/]+\/event))\/?/); + if (matches && matches[1]) { + return matches[1]; + } + return "/event"; }, /** @@ -156,6 +157,9 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { * @private */ _showInstallBanner: function () { + if (!config.device.isMobile) { + return; + } var self = this; this.installBanner = new PWAInstallBanner(this); this.installBanner.appendTo(this.$el).then(function () { @@ -169,18 +173,6 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { // Handlers //-------------------------------------------------------------------------- - /** - * @private - * @param ev {Event} - */ - _onBeforeInstallPrompt: function (ev) { - if (!config.device.isMobile) { - return; - } - ev.preventDefault(); - this.deferredPrompt = ev; - this._showInstallBanner(); - }, /** * @private * @param ev {Event} @@ -195,15 +187,20 @@ odoo.define("website_event_track.website_event_pwa_widget", function (require) { */ _onPromptInstall: function (ev) { ev.stopPropagation(); - this.deferredPrompt.prompt(); this._hideInstallBanner(); - this.deferredPrompt.userChoice.then(function (choiceResult) { - if (choiceResult.outcome === "accepted") { - console.log("User accepted the install prompt"); - } else { - console.log("User dismissed the install prompt"); - } - }); + deferredPrompt.then(function (prompt) { + prompt.prompt(); + prompt.userChoice.then(function (choiceResult) { + if (choiceResult.outcome === "accepted") { + console.log("User accepted the install prompt"); + } else { + console.log("User dismissed the install prompt"); + } + }); + }) + .catch(function () { + console.log("ServiceWorker not supported"); + }); }, });