[HTML5] Add PWA support to the editor page.
This allows to install it as an app, and provide offline support (after the first run). Practically, this boils down to adding a JSON file as a manifest, an offline page to be displayed when the cached files are not avaialble, and a JS file to cache resources and return them. The reason for the "first run requirements" is that some browsers, will emit an "install" by just visiting the page (to see if the JS code is compatibile), and we do not want to force casual visitors to just download the 10 MiB+ compressed editor WebAssembly file without pressing the start button. Special thanks to Hugo Locurcio (Calinou) for the initial work.
This commit is contained in:
parent
9028b7a095
commit
d942d553ef
7 changed files with 229 additions and 44 deletions
20
misc/dist/html/editor.html
vendored
20
misc/dist/html/editor.html
vendored
|
@ -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
misc/dist/html/manifest.json
vendored
Normal file
18
misc/dist/html/manifest.json
vendored
Normal file
|
@ -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
misc/dist/html/offline.html
vendored
Normal file
42
misc/dist/html/offline.html
vendored
Normal file
|
@ -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
misc/dist/html/service-worker.js
vendored
Normal file
84
misc/dist/html/service-worker.js
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}());
|
||||
}
|
||||
});
|
|
@ -85,40 +85,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)
|
||||
|
|
|
@ -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
|
||||
|
@ -145,12 +145,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"
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
Loading…
Reference in a new issue