Browse Source

Merge pull request #46796 from Faless/js/4.x_pwa_simple

[HTML5] Add PWA support to the editor page.
Rémi Verschelde 4 years ago
parent
commit
8507b69c13

+ 18 - 2
misc/dist/html/editor.html

@@ -2,8 +2,18 @@
 <html xmlns='http://www.w3.org/1999/xhtml' lang='' xml:lang=''>
 <head>
 	<meta charset='utf-8' />
-	<meta name='viewport' content='width=device-width, user-scalable=no' />
+	<meta name='viewport' content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no' />
+	<meta name="mobile-web-app-capable" content="yes" />
+	<meta name="apple-mobile-web-app-capable" content="yes" />
+	<meta name="application-name" content="Godot" />
+	<meta name="apple-mobile-web-app-title" content="Godot" />
+	<meta name="theme-color" content="#478cbf" />
+	<meta name="msapplication-navbutton-color" content="#478cbf" />
+	<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+	<meta name="msapplication-starturl" content="/latest" />
 	<link id='-gd-engine-icon' rel='icon' type='image/png' href='favicon.png' />
+	<link rel="apple-touch-icon" type="image/png" href="favicon.png" />
+	<link rel="manifest" href="manifest.json" />
 	<title>Godot Engine Web Editor (@GODOT_VERSION@)</title>
 	<style>
 		*:focus {
@@ -250,7 +260,13 @@
 			<div id='status-notice' class='godot' style='display: none;'></div>
 		</div>
 	</div>
-
+	<script>
+		window.addEventListener("load", () => {
+			if ("serviceWorker" in navigator) {
+				navigator.serviceWorker.register("service.worker.js");
+			}
+		});
+	</script>
 	<script src='godot.tools.js'></script>
 	<script>//<![CDATA[
 

+ 18 - 0
misc/dist/html/manifest.json

@@ -0,0 +1,18 @@
+{
+  "name": "Godot Engine",
+  "short_name": "Godot",
+  "description": "Multi-platform 2D and 3D game engine with a feature-rich editor",
+  "lang": "en",
+  "start_url": "/godot.tools.html",
+  "display": "standalone",
+  "orientation": "landscape",
+  "theme_color": "#478cbf",
+  "icons": [
+    {
+      "src": "favicon.png",
+      "sizes": "256x256",
+      "type": "image/png"
+    }
+  ],
+  "background_color": "#333b4f"
+}

+ 42 - 0
misc/dist/html/offline.html

@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="utf-8" />
+	<meta http-equiv="X-UA-Compatible" content="IE=edge" />
+	<meta name="viewport" content="width=device-width, initial-scale=1" />
+	<title>You are offline</title>
+	<style>
+		html {
+			background-color: #333b4f;
+			color: #e0e0e0;
+		}
+
+		body {
+			font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+			margin: 2rem;
+		}
+
+		p {
+			margin-block: 1rem;
+		}
+
+		button {
+			display: block;
+			padding: 1rem 2rem;
+			margin: 3rem auto 0;
+		}
+	</style>
+</head>
+<body>
+	<h1>You are offline</h1>
+	<p>This application requires an Internet connection to run for the first time.</p>
+	<p>Press the button below to try reloading:</p>
+	<button type="button">Reload</button>
+
+	<script>
+		document.querySelector("button").addEventListener("click", () => {
+			window.location.reload();
+		});
+	</script>
+</body>
+</html>

+ 84 - 0
misc/dist/html/service-worker.js

@@ -0,0 +1,84 @@
+// This service worker is required to expose an exported Godot project as a
+// Progressive Web App. It provides an offline fallback page telling the user
+// that they need an Internet conneciton to run the project if desired.
+// 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 OFFLINE_URL = "offline.html";
+// Files that will be cached on load.
+const CACHED_FILES = [
+	"godot.tools.html",
+	"offline.html",
+	"godot.tools.js",
+	"godot.tools.worker.js",
+	"godot.tools.audio.worklet.js",
+	"logo.svg",
+	"favicon.png",
+];
+
+// Files that we might not want the user to preload, and will only be cached on first load.
+const CACHABLE_FILES = [
+	"godot.tools.wasm",
+];
+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;
+	}());
+});
+
+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();
+});
+
+self.addEventListener("fetch", (event) => {
+	const isNavigate = event.request.mode === "navigate";
+	const url = event.request.url || "";
+	const referrer = event.request.referrer || "";
+	const base = referrer.slice(0, referrer.lastIndexOf("/") + 1);
+	const local = url.startsWith(base) ? url.replace(base, "") : "";
+	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;
+				}
+				const cachedResponse = await caches.match(event.request);
+				return cachedResponse;
+			}
+		}());
+	}
+});

+ 3 - 37
platform/javascript/SCsub

@@ -86,40 +86,6 @@ wrap_list = [
 ]
 js_wrapped = env.Textfile("#bin/godot", [env.File(f) for f in wrap_list], TEXTFILESUFFIX="${PROGSUFFIX}.wrapped.js")
 
-zip_dir = env.Dir("#bin/.javascript_zip")
-binary_name = "godot.tools" if env["tools"] else "godot"
-out_files = [
-    zip_dir.File(binary_name + ".js"),
-    zip_dir.File(binary_name + ".wasm"),
-    zip_dir.File(binary_name + ".html"),
-    zip_dir.File(binary_name + ".audio.worklet.js"),
-]
-html_file = "#misc/dist/html/full-size.html"
-if env["tools"]:
-    subst_dict = {"@GODOT_VERSION@": env.GetBuildVersion()}
-    html_file = env.Substfile(
-        target="#bin/godot${PROGSUFFIX}.html", source="#misc/dist/html/editor.html", SUBST_DICT=subst_dict
-    )
-
-in_files = [js_wrapped, build[1], html_file, "#platform/javascript/js/libs/audio.worklet.js"]
-if env["gdnative_enabled"]:
-    in_files.append(build[2])  # Runtime
-    out_files.append(zip_dir.File(binary_name + ".side.wasm"))
-elif env["threads_enabled"]:
-    in_files.append(build[2])  # Worker
-    out_files.append(zip_dir.File(binary_name + ".worker.js"))
-
-if env["tools"]:
-    in_files.append("#misc/dist/html/logo.svg")
-    out_files.append(zip_dir.File("logo.svg"))
-    in_files.append("#icon.png")
-    out_files.append(zip_dir.File("favicon.png"))
-
-zip_files = env.InstallAs(out_files, in_files)
-env.Zip(
-    "#bin/godot",
-    zip_files,
-    ZIPROOT=zip_dir,
-    ZIPSUFFIX="${PROGSUFFIX}${ZIPSUFFIX}",
-    ZIPCOMSTR="Archiving $SOURCES as $TARGET",
-)
+# Extra will be the thread worker, or the GDNative side, or None
+extra = build[2] if len(build) > 2 else None
+env.CreateTemplateZip(js_wrapped, build[1], extra)

+ 4 - 4
platform/javascript/detect.py

@@ -7,7 +7,7 @@ from emscripten_helpers import (
     add_js_libraries,
     add_js_pre,
     add_js_externs,
-    get_build_version,
+    create_template_zip,
 )
 from methods import get_compiler_version
 from SCons.Util import WhereIs
@@ -147,12 +147,12 @@ def configure(env):
     env.AddMethod(add_js_pre, "AddJSPre")
     env.AddMethod(add_js_externs, "AddJSExterns")
 
-    # Add method for getting build version string.
-    env.AddMethod(get_build_version, "GetBuildVersion")
-
     # Add method that joins/compiles our Engine files.
     env.AddMethod(create_engine_file, "CreateEngineFile")
 
+    # Add method for creating the final zip file
+    env.AddMethod(create_template_zip, "CreateTemplateZip")
+
     # Closure compiler extern and support for ecmascript specs (const, let, etc).
     env["ENV"]["EMCC_CLOSURE_ARGS"] = "--language_in ECMASCRIPT6"
 

+ 60 - 1
platform/javascript/emscripten_helpers.py

@@ -15,7 +15,7 @@ def run_closure_compiler(target, source, env, for_signature):
     return " ".join(cmd)
 
 
-def get_build_version(env):
+def get_build_version():
     import version
 
     name = "custom_build"
@@ -30,6 +30,65 @@ def create_engine_file(env, target, source, externs):
     return env.Textfile(target, [env.File(s) for s in source])
 
 
+def create_template_zip(env, js, wasm, extra):
+    binary_name = "godot.tools" if env["tools"] else "godot"
+    zip_dir = env.Dir("#bin/.javascript_zip")
+    in_files = [
+        js,
+        wasm,
+        "#platform/javascript/js/libs/audio.worklet.js",
+    ]
+    out_files = [
+        zip_dir.File(binary_name + ".js"),
+        zip_dir.File(binary_name + ".wasm"),
+        zip_dir.File(binary_name + ".audio.worklet.js"),
+    ]
+    # GDNative/Threads specific
+    if env["gdnative_enabled"]:
+        in_files.append(extra)  # Runtime
+        out_files.append(zip_dir.File(binary_name + ".side.wasm"))
+    elif env["threads_enabled"]:
+        in_files.append(extra)  # Worker
+        out_files.append(zip_dir.File(binary_name + ".worker.js"))
+
+    service_worker = "#misc/dist/html/service-worker.js"
+    if env["tools"]:
+        # HTML
+        html = "#misc/dist/html/editor.html"
+        subst_dict = {"@GODOT_VERSION@": get_build_version(), "@GODOT_NAME@": "GodotEngine"}
+        html = env.Substfile(target="#bin/godot${PROGSUFFIX}.html", source=html, SUBST_DICT=subst_dict)
+        in_files.append(html)
+        out_files.append(zip_dir.File(binary_name + ".html"))
+        # And logo/favicon
+        in_files.append("#misc/dist/html/logo.svg")
+        out_files.append(zip_dir.File("logo.svg"))
+        in_files.append("#icon.png")
+        out_files.append(zip_dir.File("favicon.png"))
+        # PWA
+        service_worker = env.Substfile(
+            target="#bin/godot${PROGSUFFIX}.service.worker.js", source=service_worker, SUBST_DICT=subst_dict
+        )
+        in_files.append(service_worker)
+        out_files.append(zip_dir.File("service.worker.js"))
+        in_files.append("#misc/dist/html/manifest.json")
+        out_files.append(zip_dir.File("manifest.json"))
+        in_files.append("#misc/dist/html/offline.html")
+        out_files.append(zip_dir.File("offline.html"))
+    else:
+        # HTML
+        in_files.append("#misc/dist/html/full-size.html")
+        out_files.append(zip_dir.File(binary_name + ".html"))
+
+    zip_files = env.InstallAs(out_files, in_files)
+    env.Zip(
+        "#bin/godot",
+        zip_files,
+        ZIPROOT=zip_dir,
+        ZIPSUFFIX="${PROGSUFFIX}${ZIPSUFFIX}",
+        ZIPCOMSTR="Archiving $SOURCES as $TARGET",
+    )
+
+
 def add_js_libraries(env, libraries):
     env.Append(JS_LIBS=env.File(libraries))
 

+ 4 - 1
platform/javascript/js/libs/library_godot_audio.js

@@ -238,6 +238,9 @@ const GodotAudioWorklet = {
 
 		close: function () {
 			return new Promise(function (resolve, reject) {
+				if (GodotAudioWorklet.promise === null) {
+					return;
+				}
 				GodotAudioWorklet.promise.then(function () {
 					GodotAudioWorklet.worklet.port.postMessage({
 						'cmd': 'stop',
@@ -247,7 +250,7 @@ const GodotAudioWorklet = {
 					GodotAudioWorklet.worklet = null;
 					GodotAudioWorklet.promise = null;
 					resolve();
-				});
+				}).catch(function (err) { /* aborted? */ });
 			});
 		},
 	},