diff options
-rw-r--r-- | doc/classes/JavaScript.xml | 22 | ||||
-rw-r--r-- | misc/dist/html/editor.html | 36 | ||||
-rw-r--r-- | misc/dist/html/service-worker.js | 112 | ||||
-rw-r--r-- | platform/javascript/api/api.cpp | 9 | ||||
-rw-r--r-- | platform/javascript/api/javascript_singleton.h | 2 | ||||
-rw-r--r-- | platform/javascript/export/export_plugin.cpp | 52 | ||||
-rw-r--r-- | platform/javascript/godot_js.h | 2 | ||||
-rw-r--r-- | platform/javascript/javascript_singleton.cpp | 8 | ||||
-rw-r--r-- | platform/javascript/js/engine/config.js | 8 | ||||
-rw-r--r-- | platform/javascript/js/engine/engine.js | 3 | ||||
-rw-r--r-- | platform/javascript/js/libs/library_godot_os.js | 55 | ||||
-rw-r--r-- | platform/javascript/os_javascript.cpp | 16 | ||||
-rw-r--r-- | platform/javascript/os_javascript.h | 5 |
13 files changed, 264 insertions, 66 deletions
diff --git a/doc/classes/JavaScript.xml b/doc/classes/JavaScript.xml index aeaf8ac1f5..4ea60ad867 100644 --- a/doc/classes/JavaScript.xml +++ b/doc/classes/JavaScript.xml @@ -53,5 +53,27 @@ Returns an interface to a JavaScript object that can be used by scripts. The [code]interface[/code] must be a valid property of the JavaScript [code]window[/code]. The callback must accept a single [Array] argument, which will contain the JavaScript [code]arguments[/code]. See [JavaScriptObject] for usage. </description> </method> + <method name="pwa_needs_update" qualifiers="const"> + <return type="bool" /> + <description> + Returns [code]true[/code] if a new version of the progressive web app is waiting to be activated. + [b]Note:[/b] Only relevant when exported as a Progressive Web App. + </description> + </method> + <method name="pwa_update"> + <return type="int" enum="Error" /> + <description> + Performs the live update of the progressive web app. Forcing the new version to be installed and the page to be reloaded. + [b]Note:[/b] Your application will be [b]reloaded in all browser tabs[/b]. + [b]Note:[/b] Only relevant when exported as a Progressive Web App and [method pwa_needs_update] returns [code]true[/code]. + </description> + </method> </methods> + <signals> + <signal name="pwa_update_available"> + <description> + Emitted when an update for this progressive web app has been detected but is waiting to be activated because a previous version is active. See [method pwa_update] to force the update to take place immediately. + </description> + </signal> + </signals> </class> diff --git a/misc/dist/html/editor.html b/misc/dist/html/editor.html index a681a2a1c3..9e03f50c37 100644 --- a/misc/dist/html/editor.html +++ b/misc/dist/html/editor.html @@ -265,6 +265,7 @@ <button id="btn-close-editor" class="btn close-btn" disabled="disabled" onclick="closeEditor()">×</button> <button id="btn-tab-game" class="btn tab-btn" disabled="disabled" onclick="showTab('game')">Game</button> <button id="btn-close-game" class="btn close-btn" disabled="disabled" onclick="closeGame()">×</button> + <button id="btn-tab-update" class="btn tab-btn" style="display: none;">Update</button> </div> <div id="tabs"> <div id="tab-loader"> @@ -324,10 +325,39 @@ <div id="status-notice" class="godot" style="display: none;"></div> </div> </div> - <script> + <script>//<![CDATA[ window.addEventListener("load", () => { + function notifyUpdate(sw) { + const btn = document.getElementById("btn-tab-update"); + btn.onclick = function () { + if (!window.confirm("Are you sure you want to update?\nClicking \"OK\" will reload all active instances!")) { + return; + } + sw.postMessage("update"); + btn.innerHTML = "Updating..."; + btn.disabled = true; + }; + btn.style.display = ""; + } if ("serviceWorker" in navigator) { - navigator.serviceWorker.register("service.worker.js"); + navigator.serviceWorker.register("service.worker.js").then(function (reg) { + if (reg.waiting) { + notifyUpdate(reg.waiting); + } + reg.addEventListener("updatefound", function () { + const update = reg.installing; + update.addEventListener("statechange", function () { + if (update.state === "installed") { + // It's a new install, claim and perform aggressive caching. + if (!reg.active) { + update.postMessage("claim"); + } else { + notifyUpdate(update); + } + } + }); + }); + }); } if (localStorage.getItem("welcomeModalDismissed") !== 'true') { @@ -342,7 +372,7 @@ localStorage.setItem("welcomeModalDismissed", 'true'); } } - </script> + //]]></script> <script src="godot.tools.js"></script> <script>//<![CDATA[ diff --git a/misc/dist/html/service-worker.js b/misc/dist/html/service-worker.js index 063e40a6cb..310574f21d 100644 --- a/misc/dist/html/service-worker.js +++ b/misc/dist/html/service-worker.js @@ -4,7 +4,8 @@ // Incrementing CACHE_VERSION will kick off the install event and force // previously cached resources to be updated from the network. const CACHE_VERSION = "@GODOT_VERSION@"; -const CACHE_NAME = "@GODOT_NAME@-cache"; +const CACHE_PREFIX = "@GODOT_NAME@-sw-cache-"; +const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION; const OFFLINE_URL = "@GODOT_OFFLINE_PAGE@"; // Files that will be cached on load. const CACHED_FILES = @GODOT_CACHE@; @@ -13,26 +14,35 @@ const CACHABLE_FILES = @GODOT_OPT_CACHE@; const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES); self.addEventListener("install", (event) => { - event.waitUntil(async function () { - const cache = await caches.open(CACHE_NAME); - // Clear old cache (including optionals). - await Promise.all(FULL_CACHE.map(path => cache.delete(path))); - // Insert new one. - const done = await cache.addAll(CACHED_FILES); - return done; - }()); + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CACHED_FILES))); }); self.addEventListener("activate", (event) => { - event.waitUntil(async function () { - if ("navigationPreload" in self.registration) { - await self.registration.navigationPreload.enable(); - } - }()); - // Tell the active service worker to take control of the page immediately. - self.clients.claim(); + event.waitUntil(caches.keys().then( + function (keys) { + // Remove old caches. + return Promise.all(keys.filter(key => key.startsWith(CACHE_PREFIX) && key != CACHE_NAME).map(key => caches.delete(key))); + }).then(function() { + // Enable navigation preload if available. + return ("navigationPreload" in self.registration) ? self.registration.navigationPreload.enable() : Promise.resolve(); + }) + ); }); +async function fetchAndCache(event, cache, isCachable) { + // Use the preloaded response, if it's there + let response = await event.preloadResponse; + if (!response) { + // Or, go over network. + response = await self.fetch(event.request); + } + if (isCachable) { + // And update the cache + cache.put(event.request, response.clone()); + } + return response; +} + self.addEventListener("fetch", (event) => { const isNavigate = event.request.mode === "navigate"; const url = event.request.url || ""; @@ -42,32 +52,54 @@ self.addEventListener("fetch", (event) => { const isCachable = FULL_CACHE.some(v => v === local) || (base === referrer && base.endsWith(CACHED_FILES[0])); if (isNavigate || isCachable) { event.respondWith(async function () { - try { - // Use the preloaded response, if it's there - let request = event.request.clone(); - let response = await event.preloadResponse; - if (!response) { - // Or, go over network. - response = await fetch(event.request); - } - if (isCachable) { - // Update the cache - const cache = await caches.open(CACHE_NAME); - cache.put(request, response.clone()); - } - return response; - } catch (error) { - const cache = await caches.open(CACHE_NAME); - if (event.request.mode === "navigate") { - // Check if we have full cache. - const cached = await Promise.all(FULL_CACHE.map(name => cache.match(name))); - const missing = cached.some(v => v === undefined); - const cachedResponse = missing ? await caches.match(OFFLINE_URL) : await caches.match(CACHED_FILES[0]); - return cachedResponse; + // Try to use cache first + const cache = await caches.open(CACHE_NAME); + if (event.request.mode === "navigate") { + // Check if we have full cache during HTML page request. + const fullCache = await Promise.all(FULL_CACHE.map(name => cache.match(name))); + const missing = fullCache.some(v => v === undefined); + if (missing) { + try { + // Try network if some cached file is missing (so we can display offline page in case). + return await fetchAndCache(event, cache, isCachable); + } catch (e) { + // And return the hopefully always cached offline page in case of network failure. + console.error("Network error: ", e); + return await caches.match(OFFLINE_URL); + } } - const cachedResponse = await caches.match(event.request); - return cachedResponse; + } + const cached = await cache.match(event.request); + if (cached) { + return cached; + } else { + // Try network if don't have it in cache. + return await fetchAndCache(event, cache, isCachable); } }()); } }); + +self.addEventListener("message", (event) => { + // No cross origin + if (event.origin != self.origin) { + return; + } + const id = event.source.id || ""; + const msg = event.data || ""; + // Ensure it's one of our clients. + self.clients.get(id).then(function (client) { + if (!client) { + return; // Not a valid client. + } + if (msg === "claim") { + self.skipWaiting().then(() => self.clients.claim()); + } else if (msg === "clear") { + caches.delete(CACHE_NAME); + } else if (msg === "update") { + self.skipWaiting().then(() => self.clients.claim()).then(() => self.clients.matchAll()).then(all => all.forEach(c => c.navigate(c.url))); + } else { + onClientMessage(event); + } + }); +}); diff --git a/platform/javascript/api/api.cpp b/platform/javascript/api/api.cpp index 0c4accccc3..4190b24b8e 100644 --- a/platform/javascript/api/api.cpp +++ b/platform/javascript/api/api.cpp @@ -71,6 +71,9 @@ void JavaScript::_bind_methods() { ClassDB::bind_vararg_method(METHOD_FLAGS_DEFAULT, "create_object", &JavaScript::_create_object_bind, mi); } ClassDB::bind_method(D_METHOD("download_buffer", "buffer", "name", "mime"), &JavaScript::download_buffer, DEFVAL("application/octet-stream")); + ClassDB::bind_method(D_METHOD("pwa_needs_update"), &JavaScript::pwa_needs_update); + ClassDB::bind_method(D_METHOD("pwa_update"), &JavaScript::pwa_update); + ADD_SIGNAL(MethodInfo("pwa_update_available")); } #if !defined(JAVASCRIPT_ENABLED) || !defined(JAVASCRIPT_EVAL_ENABLED) @@ -102,6 +105,12 @@ Variant JavaScript::_create_object_bind(const Variant **p_args, int p_argcount, } #endif #if !defined(JAVASCRIPT_ENABLED) +bool JavaScript::pwa_needs_update() const { + return false; +} +Error JavaScript::pwa_update() { + return ERR_UNAVAILABLE; +} void JavaScript::download_buffer(Vector<uint8_t> p_arr, const String &p_name, const String &p_mime) { } #endif diff --git a/platform/javascript/api/javascript_singleton.h b/platform/javascript/api/javascript_singleton.h index 63f1aec624..e93b0a18a1 100644 --- a/platform/javascript/api/javascript_singleton.h +++ b/platform/javascript/api/javascript_singleton.h @@ -59,6 +59,8 @@ public: Ref<JavaScriptObject> create_callback(const Callable &p_callable); Variant _create_object_bind(const Variant **p_args, int p_argcount, Callable::CallError &r_error); void download_buffer(Vector<uint8_t> p_arr, const String &p_name, const String &p_mime = "application/octet-stream"); + bool pwa_needs_update() const; + Error pwa_update(); static JavaScript *get_singleton(); JavaScript(); diff --git a/platform/javascript/export/export_plugin.cpp b/platform/javascript/export/export_plugin.cpp index 92826630b4..d4c198d631 100644 --- a/platform/javascript/export/export_plugin.cpp +++ b/platform/javascript/export/export_plugin.cpp @@ -139,8 +139,7 @@ void EditorExportPlatformJavaScript::_fix_html(Vector<uint8_t> &p_html, const Re } if (p_preset->get("progressive_web_app/enabled")) { head_include += "<link rel='manifest' href='" + p_name + ".manifest.json'>\n"; - head_include += "<script type='application/javascript'>window.addEventListener('load', () => {if ('serviceWorker' in navigator) {navigator.serviceWorker.register('" + - p_name + ".service.worker.js');}});</script>\n"; + config["serviceWorker"] = p_name + ".service.worker.js"; } // Replaces HTML string @@ -188,35 +187,46 @@ Error EditorExportPlatformJavaScript::_add_manifest_icon(const String &p_path, c } Error EditorExportPlatformJavaScript::_build_pwa(const Ref<EditorExportPreset> &p_preset, const String p_path, const Vector<SharedObject> &p_shared_objects) { + String proj_name = ProjectSettings::get_singleton()->get_setting("application/config/name"); + if (proj_name.is_empty()) { + proj_name = "Godot Game"; + } + // Service worker const String dir = p_path.get_base_dir(); const String name = p_path.get_file().get_basename(); const ExportMode mode = (ExportMode)(int)p_preset->get("variant/export_type"); Map<String, String> replaces; - replaces["@GODOT_VERSION@"] = "1"; - replaces["@GODOT_NAME@"] = name; + replaces["@GODOT_VERSION@"] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec()); + replaces["@GODOT_NAME@"] = proj_name.substr(0, 16); replaces["@GODOT_OFFLINE_PAGE@"] = name + ".offline.html"; - Array files; - replaces["@GODOT_OPT_CACHE@"] = Variant(files).to_json_string(); - files.push_back(name + ".html"); - files.push_back(name + ".js"); - files.push_back(name + ".wasm"); - files.push_back(name + ".pck"); - files.push_back(name + ".offline.html"); + + // Files cached during worker install. + Array cache_files; + cache_files.push_back(name + ".html"); + cache_files.push_back(name + ".js"); + cache_files.push_back(name + ".offline.html"); if (p_preset->get("html/export_icon")) { - files.push_back(name + ".icon.png"); - files.push_back(name + ".apple-touch-icon.png"); + cache_files.push_back(name + ".icon.png"); + cache_files.push_back(name + ".apple-touch-icon.png"); } if (mode == EXPORT_MODE_THREADS) { - files.push_back(name + ".worker.js"); - files.push_back(name + ".audio.worklet.js"); - } else if (mode == EXPORT_MODE_GDNATIVE) { - files.push_back(name + ".side.wasm"); + cache_files.push_back(name + ".worker.js"); + cache_files.push_back(name + ".audio.worklet.js"); + } + replaces["@GODOT_CACHE@"] = Variant(cache_files).to_json_string(); + + // Heavy files that are cached on demand. + Array opt_cache_files; + opt_cache_files.push_back(name + ".wasm"); + opt_cache_files.push_back(name + ".pck"); + if (mode == EXPORT_MODE_GDNATIVE) { + opt_cache_files.push_back(name + ".side.wasm"); for (int i = 0; i < p_shared_objects.size(); i++) { - files.push_back(p_shared_objects[i].path.get_file()); + opt_cache_files.push_back(p_shared_objects[i].path.get_file()); } } - replaces["@GODOT_CACHE@"] = Variant(files).to_json_string(); + replaces["@GODOT_OPT_CACHE@"] = Variant(opt_cache_files).to_json_string(); const String sw_path = dir.plus_file(name + ".service.worker.js"); Vector<uint8_t> sw; @@ -256,10 +266,6 @@ Error EditorExportPlatformJavaScript::_build_pwa(const Ref<EditorExportPreset> & const int orientation = CLAMP(int(p_preset->get("progressive_web_app/orientation")), 0, 3); Dictionary manifest; - String proj_name = ProjectSettings::get_singleton()->get_setting("application/config/name"); - if (proj_name.is_empty()) { - proj_name = "Godot Game"; - } manifest["name"] = proj_name; manifest["start_url"] = "./" + name + ".html"; manifest["display"] = String::utf8(modes[display]); diff --git a/platform/javascript/godot_js.h b/platform/javascript/godot_js.h index 15de8b5dc7..2cb5c3025c 100644 --- a/platform/javascript/godot_js.h +++ b/platform/javascript/godot_js.h @@ -49,6 +49,8 @@ extern void godot_js_os_fs_sync(void (*p_callback)()); extern int godot_js_os_execute(const char *p_json); extern void godot_js_os_shell_open(const char *p_uri); extern int godot_js_os_hw_concurrency_get(); +extern int godot_js_pwa_cb(void (*p_callback)()); +extern int godot_js_pwa_update(); // Input extern void godot_js_input_mouse_button_cb(int (*p_callback)(int p_pressed, int p_button, double p_x, double p_y, int p_modifiers)); diff --git a/platform/javascript/javascript_singleton.cpp b/platform/javascript/javascript_singleton.cpp index b10e8007c0..77858bff01 100644 --- a/platform/javascript/javascript_singleton.cpp +++ b/platform/javascript/javascript_singleton.cpp @@ -30,6 +30,7 @@ #include "api/javascript_singleton.h" #include "emscripten.h" +#include "os_javascript.h" extern "C" { extern void godot_js_os_download_buffer(const uint8_t *p_buf, int p_buf_size, const char *p_name, const char *p_mime); @@ -355,3 +356,10 @@ Variant JavaScript::eval(const String &p_code, bool p_use_global_exec_context) { void JavaScript::download_buffer(Vector<uint8_t> p_arr, const String &p_name, const String &p_mime) { godot_js_os_download_buffer(p_arr.ptr(), p_arr.size(), p_name.utf8().get_data(), p_mime.utf8().get_data()); } + +bool JavaScript::pwa_needs_update() const { + return OS_JavaScript::get_singleton()->pwa_needs_update(); +} +Error JavaScript::pwa_update() { + return OS_JavaScript::get_singleton()->pwa_update(); +} diff --git a/platform/javascript/js/engine/config.js b/platform/javascript/js/engine/config.js index a6f9c4614c..2e5e1ed0d1 100644 --- a/platform/javascript/js/engine/config.js +++ b/platform/javascript/js/engine/config.js @@ -107,6 +107,13 @@ const InternalConfig = function (initConfig) { // eslint-disable-line no-unused- */ experimentalVK: false, /** + * The progressive web app service worker to install. + * @memberof EngineConfig + * @default + * @type {string} + */ + serviceWorker: '', + /** * @ignore * @type {Array.<string>} */ @@ -249,6 +256,7 @@ const InternalConfig = function (initConfig) { // eslint-disable-line no-unused- this.persistentDrops = parse('persistentDrops', this.persistentDrops); this.experimentalVK = parse('experimentalVK', this.experimentalVK); this.focusCanvas = parse('focusCanvas', this.focusCanvas); + this.serviceWorker = parse('serviceWorker', this.serviceWorker); this.gdnativeLibs = parse('gdnativeLibs', this.gdnativeLibs); this.fileSizes = parse('fileSizes', this.fileSizes); this.args = parse('args', this.args); diff --git a/platform/javascript/js/engine/engine.js b/platform/javascript/js/engine/engine.js index 17a8df9e29..d2ba595083 100644 --- a/platform/javascript/js/engine/engine.js +++ b/platform/javascript/js/engine/engine.js @@ -189,6 +189,9 @@ const Engine = (function () { preloader.preloadedFiles.length = 0; // Clear memory me.rtenv['callMain'](me.config.args); initPromise = null; + if (me.config.serviceWorker && 'serviceWorker' in navigator) { + navigator.serviceWorker.register(me.config.serviceWorker); + } resolve(); }); }); diff --git a/platform/javascript/js/libs/library_godot_os.js b/platform/javascript/js/libs/library_godot_os.js index 662d215443..12d06a8d51 100644 --- a/platform/javascript/js/libs/library_godot_os.js +++ b/platform/javascript/js/libs/library_godot_os.js @@ -368,3 +368,58 @@ const GodotEventListeners = { }, }; mergeInto(LibraryManager.library, GodotEventListeners); + +const GodotPWA = { + + $GodotPWA__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotPWA: { + hasUpdate: false, + + updateState: function (cb, reg) { + if (!reg) { + return; + } + if (!reg.active) { + return; + } + if (reg.waiting) { + GodotPWA.hasUpdate = true; + cb(); + } + GodotEventListeners.add(reg, 'updatefound', function () { + const installing = reg.installing; + GodotEventListeners.add(installing, 'statechange', function () { + if (installing.state === 'installed') { + GodotPWA.hasUpdate = true; + cb(); + } + }); + }); + }, + }, + + godot_js_pwa_cb__sig: 'vi', + godot_js_pwa_cb: function (p_update_cb) { + if ('serviceWorker' in navigator) { + const cb = GodotRuntime.get_func(p_update_cb); + navigator.serviceWorker.getRegistration().then(GodotPWA.updateState.bind(null, cb)); + } + }, + + godot_js_pwa_update__sig: 'i', + godot_js_pwa_update: function () { + if ('serviceWorker' in navigator && GodotPWA.hasUpdate) { + navigator.serviceWorker.getRegistration().then(function (reg) { + if (!reg || !reg.waiting) { + return; + } + reg.waiting.postMessage('update'); + }); + return 0; + } + return 1; + }, +}; + +autoAddDeps(GodotPWA, '$GodotPWA'); +mergeInto(LibraryManager.library, GodotPWA); diff --git a/platform/javascript/os_javascript.cpp b/platform/javascript/os_javascript.cpp index 39eea13ca1..de5ca44f9d 100644 --- a/platform/javascript/os_javascript.cpp +++ b/platform/javascript/os_javascript.cpp @@ -45,6 +45,7 @@ #include <emscripten.h> #include <stdlib.h> +#include "api/javascript_singleton.h" #include "godot_js.h" void OS_JavaScript::alert(const String &p_alert, const String &p_title) { @@ -203,6 +204,19 @@ void OS_JavaScript::file_access_close_callback(const String &p_file, int p_flags } } +void OS_JavaScript::update_pwa_state_callback() { + if (OS_JavaScript::get_singleton()) { + OS_JavaScript::get_singleton()->pwa_is_waiting = true; + } + if (JavaScript::get_singleton()) { + JavaScript::get_singleton()->emit_signal("pwa_update_available"); + } +} + +Error OS_JavaScript::pwa_update() { + return godot_js_pwa_update() ? FAILED : OK; +} + bool OS_JavaScript::is_userfs_persistent() const { return idb_available; } @@ -226,6 +240,8 @@ OS_JavaScript::OS_JavaScript() { godot_js_config_locale_get(locale_ptr, 16); setenv("LANG", locale_ptr, true); + godot_js_pwa_cb(&OS_JavaScript::update_pwa_state_callback); + if (AudioDriverJavaScript::is_available()) { #ifdef NO_THREADS audio_drivers.push_back(memnew(AudioDriverScriptProcessor)); diff --git a/platform/javascript/os_javascript.h b/platform/javascript/os_javascript.h index 3404fe9dcf..9e272f9aa1 100644 --- a/platform/javascript/os_javascript.h +++ b/platform/javascript/os_javascript.h @@ -45,11 +45,13 @@ class OS_JavaScript : public OS_Unix { bool idb_is_syncing = false; bool idb_available = false; bool idb_needs_sync = false; + bool pwa_is_waiting = false; static void main_loop_callback(); static void file_access_close_callback(const String &p_file, int p_flags); static void fs_sync_callback(); + static void update_pwa_state_callback(); protected: void initialize() override; @@ -65,6 +67,9 @@ public: // Override return type to make writing static callbacks less tedious. static OS_JavaScript *get_singleton(); + bool pwa_needs_update() const { return pwa_is_waiting; } + Error pwa_update(); + void initialize_joypads() override; MainLoop *get_main_loop() const override; |