Browse Source

[HTML5] Export as Progressive Web App.

Adds possibility to export as a progressive web app.
Allows customizing base icons, display mode, orientation and offline
page.
Fabio Alessandrelli 4 years ago
parent
commit
9446be7dad

+ 42 - 0
misc/dist/html/offline-export.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: #000000;
+			color: #ffffff;
+		}
+
+		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>

+ 3 - 14
misc/dist/html/service-worker.js

@@ -5,22 +5,11 @@
 // previously cached resources to be updated from the network.
 // previously cached resources to be updated from the network.
 const CACHE_VERSION = "@GODOT_VERSION@";
 const CACHE_VERSION = "@GODOT_VERSION@";
 const CACHE_NAME = "@GODOT_NAME@-cache";
 const CACHE_NAME = "@GODOT_NAME@-cache";
-const OFFLINE_URL = "offline.html";
+const OFFLINE_URL = "@GODOT_OFFLINE_PAGE@";
 // Files that will be cached on load.
 // 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",
-];
-
+const CACHED_FILES = @GODOT_CACHE@;
 // Files that we might not want the user to preload, and will only be cached on first load.
 // 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 CACHABLE_FILES = @GODOT_OPT_CACHE@;
 const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES);
 const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES);
 
 
 self.addEventListener("install", (event) => {
 self.addEventListener("install", (event) => {

+ 22 - 2
platform/javascript/emscripten_helpers.py

@@ -1,4 +1,4 @@
-import os
+import os, json
 
 
 from SCons.Util import WhereIs
 from SCons.Util import WhereIs
 
 
@@ -59,7 +59,23 @@ def create_template_zip(env, js, wasm, extra):
     if env["tools"]:
     if env["tools"]:
         # HTML
         # HTML
         html = "#misc/dist/html/editor.html"
         html = "#misc/dist/html/editor.html"
-        subst_dict = {"@GODOT_VERSION@": get_build_version(), "@GODOT_NAME@": "GodotEngine"}
+        cache = [
+            "godot.tools.html",
+            "offline.html",
+            "godot.tools.js",
+            "godot.tools.worker.js",
+            "godot.tools.audio.worklet.js",
+            "logo.svg",
+            "favicon.png",
+        ]
+        opt_cache = ["godot.tools.wasm"]
+        subst_dict = {
+            "@GODOT_VERSION@": get_build_version(),
+            "@GODOT_NAME@": "GodotEngine",
+            "@GODOT_CACHE@": json.dumps(cache),
+            "@GODOT_OPT_CACHE@": json.dumps(opt_cache),
+            "@GODOT_OFFLINE_PAGE@": "offline.html",
+        }
         html = env.Substfile(target="#bin/godot${PROGSUFFIX}.html", source=html, SUBST_DICT=subst_dict)
         html = env.Substfile(target="#bin/godot${PROGSUFFIX}.html", source=html, SUBST_DICT=subst_dict)
         in_files.append(html)
         in_files.append(html)
         out_files.append(zip_dir.File(binary_name + ".html"))
         out_files.append(zip_dir.File(binary_name + ".html"))
@@ -82,6 +98,10 @@ def create_template_zip(env, js, wasm, extra):
         # HTML
         # HTML
         in_files.append("#misc/dist/html/full-size.html")
         in_files.append("#misc/dist/html/full-size.html")
         out_files.append(zip_dir.File(binary_name + ".html"))
         out_files.append(zip_dir.File(binary_name + ".html"))
+        in_files.append(service_worker)
+        out_files.append(zip_dir.File(binary_name + ".service.worker.js"))
+        in_files.append("#misc/dist/html/offline-export.html")
+        out_files.append(zip_dir.File("godot.offline.html"))
 
 
     zip_files = env.InstallAs(out_files, in_files)
     zip_files = env.InstallAs(out_files, in_files)
     env.Zip(
     env.Zip(

+ 339 - 148
platform/javascript/export/export.cpp

@@ -288,7 +288,32 @@ class EditorExportPlatformJavaScript : public EditorExportPlatform {
 		return name;
 		return name;
 	}
 	}
 
 
+	Ref<Image> _get_project_icon() const {
+		Ref<Image> icon;
+		icon.instance();
+		const String icon_path = String(GLOBAL_GET("application/config/icon")).strip_edges();
+		if (icon_path.empty() || ImageLoader::load_image(icon_path, icon) != OK) {
+			return EditorNode::get_singleton()->get_editor_theme()->get_icon("DefaultProjectIcon", "EditorIcons")->get_data();
+		}
+		return icon;
+	}
+
+	Ref<Image> _get_project_splash() const {
+		Ref<Image> splash;
+		splash.instance();
+		const String splash_path = String(GLOBAL_GET("application/boot_splash/image")).strip_edges();
+		if (splash_path.empty() || ImageLoader::load_image(splash_path, splash) != OK) {
+			return Ref<Image>(memnew(Image(boot_splash_png)));
+		}
+		return splash;
+	}
+
+	Error _extract_template(const String &p_template, const String &p_dir, const String &p_name, bool pwa);
+	void _replace_strings(Map<String, String> p_replaces, Vector<uint8_t> &r_template);
 	void _fix_html(Vector<uint8_t> &p_html, const Ref<EditorExportPreset> &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector<SharedObject> p_shared_objects, const Dictionary &p_file_sizes);
 	void _fix_html(Vector<uint8_t> &p_html, const Ref<EditorExportPreset> &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector<SharedObject> p_shared_objects, const Dictionary &p_file_sizes);
+	Error _add_manifest_icon(const String &p_path, const String &p_icon, int p_size, Array &r_arr);
+	Error _build_pwa(const Ref<EditorExportPreset> &p_preset, const String p_path, const Vector<SharedObject> &p_shared_objects);
+	Error _write_or_error(const uint8_t *p_content, int p_len, String p_path);
 
 
 	static void _server_thread_poll(void *data);
 	static void _server_thread_poll(void *data);
 
 
@@ -325,10 +350,90 @@ public:
 	~EditorExportPlatformJavaScript();
 	~EditorExportPlatformJavaScript();
 };
 };
 
 
-void EditorExportPlatformJavaScript::_fix_html(Vector<uint8_t> &p_html, const Ref<EditorExportPreset> &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector<SharedObject> p_shared_objects, const Dictionary &p_file_sizes) {
-	String str_template = String::utf8(reinterpret_cast<const char *>(p_html.ptr()), p_html.size());
-	String str_export;
+Error EditorExportPlatformJavaScript::_extract_template(const String &p_template, const String &p_dir, const String &p_name, bool pwa) {
+	FileAccess *src_f = NULL;
+	zlib_filefunc_def io = zipio_create_io_from_file(&src_f);
+	unzFile pkg = unzOpen2(p_template.utf8().get_data(), &io);
+
+	if (!pkg) {
+		EditorNode::get_singleton()->show_warning(TTR("Could not open template for export:") + "\n" + p_template);
+		return ERR_FILE_NOT_FOUND;
+	}
+
+	if (unzGoToFirstFile(pkg) != UNZ_OK) {
+		EditorNode::get_singleton()->show_warning(TTR("Invalid export template:") + "\n" + p_template);
+		unzClose(pkg);
+		return ERR_FILE_CORRUPT;
+	}
+
+	do {
+		//get filename
+		unz_file_info info;
+		char fname[16384];
+		unzGetCurrentFileInfo(pkg, &info, fname, 16384, NULL, 0, NULL, 0);
+
+		String file = fname;
+
+		// Skip service worker and offline page if not exporting pwa.
+		if (!pwa && (file == "godot.service.worker.js" || file == "godot.offline.html")) {
+			continue;
+		}
+		Vector<uint8_t> data;
+		data.resize(info.uncompressed_size);
+
+		//read
+		unzOpenCurrentFile(pkg);
+		unzReadCurrentFile(pkg, data.ptrw(), data.size());
+		unzCloseCurrentFile(pkg);
+
+		//write
+		String dst = p_dir.plus_file(file.replace("godot", p_name));
+		FileAccess *f = FileAccess::open(dst, FileAccess::WRITE);
+		if (!f) {
+			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + dst);
+			unzClose(pkg);
+			return ERR_FILE_CANT_WRITE;
+		}
+		f->store_buffer(data.ptr(), data.size());
+		memdelete(f);
+
+	} while (unzGoToNextFile(pkg) == UNZ_OK);
+	unzClose(pkg);
+	return OK;
+}
+
+Error EditorExportPlatformJavaScript::_write_or_error(const uint8_t *p_content, int p_size, String p_path) {
+	FileAccess *f = FileAccess::open(p_path, FileAccess::WRITE);
+	if (!f) {
+		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + p_path);
+		return ERR_FILE_CANT_WRITE;
+	}
+	f->store_buffer(p_content, p_size);
+	memdelete(f);
+	return OK;
+}
+
+void EditorExportPlatformJavaScript::_replace_strings(Map<String, String> p_replaces, Vector<uint8_t> &r_template) {
+	String str_template = String::utf8(reinterpret_cast<const char *>(r_template.ptr()), r_template.size());
+	String out;
 	Vector<String> lines = str_template.split("\n");
 	Vector<String> lines = str_template.split("\n");
+	for (int i = 0; i < lines.size(); i++) {
+		String current_line = lines[i];
+		for (Map<String, String>::Element *E = p_replaces.front(); E; E = E->next()) {
+			current_line = current_line.replace(E->key(), E->get());
+		}
+		out += current_line + "\n";
+	}
+	CharString cs = out.utf8();
+	r_template.resize(cs.length());
+	for (int i = 0; i < cs.length(); i++) {
+		r_template.write[i] = cs[i];
+	}
+}
+
+void EditorExportPlatformJavaScript::_fix_html(Vector<uint8_t> &p_html, const Ref<EditorExportPreset> &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector<SharedObject> p_shared_objects, const Dictionary &p_file_sizes) {
+	// Engine.js config
+	Dictionary config;
 	Array libs;
 	Array libs;
 	for (int i = 0; i < p_shared_objects.size(); i++) {
 	for (int i = 0; i < p_shared_objects.size(); i++) {
 		libs.push_back(p_shared_objects[i].path.get_file());
 		libs.push_back(p_shared_objects[i].path.get_file());
@@ -339,34 +444,172 @@ void EditorExportPlatformJavaScript::_fix_html(Vector<uint8_t> &p_html, const Re
 	for (int i = 0; i < flags.size(); i++) {
 	for (int i = 0; i < flags.size(); i++) {
 		args.push_back(flags[i]);
 		args.push_back(flags[i]);
 	}
 	}
-	Dictionary config;
 	config["canvasResizePolicy"] = p_preset->get("html/canvas_resize_policy");
 	config["canvasResizePolicy"] = p_preset->get("html/canvas_resize_policy");
 	config["experimentalVK"] = p_preset->get("html/experimental_virtual_keyboard");
 	config["experimentalVK"] = p_preset->get("html/experimental_virtual_keyboard");
 	config["gdnativeLibs"] = libs;
 	config["gdnativeLibs"] = libs;
 	config["executable"] = p_name;
 	config["executable"] = p_name;
 	config["args"] = args;
 	config["args"] = args;
 	config["fileSizes"] = p_file_sizes;
 	config["fileSizes"] = p_file_sizes;
-	const String str_config = JSON::print(config);
 
 
 	String head_include;
 	String head_include;
 	if (p_preset->get("html/export_icon")) {
 	if (p_preset->get("html/export_icon")) {
 		head_include += "<link id='-gd-engine-icon' rel='icon' type='image/png' href='" + p_name + ".icon.png' />\n";
 		head_include += "<link id='-gd-engine-icon' rel='icon' type='image/png' href='" + p_name + ".icon.png' />\n";
+		head_include += "<link rel='apple-touch-icon' href='" + p_name + ".apple-touch-icon.png'/>\n";
 	}
 	}
-	head_include += static_cast<String>(p_preset->get("html/head_include"));
-	for (int i = 0; i < lines.size(); i++) {
-		String current_line = lines[i];
-		current_line = current_line.replace("$GODOT_URL", p_name + ".js");
-		current_line = current_line.replace("$GODOT_PROJECT_NAME", ProjectSettings::get_singleton()->get_setting("application/config/name"));
-		current_line = current_line.replace("$GODOT_HEAD_INCLUDE", head_include);
-		current_line = current_line.replace("$GODOT_CONFIG", str_config);
-		str_export += current_line + "\n";
+	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";
 	}
 	}
 
 
-	CharString cs = str_export.utf8();
-	p_html.resize(cs.length());
-	for (int i = 0; i < cs.length(); i++) {
-		p_html.write[i] = cs[i];
+	// Replaces HTML string
+	const String str_config = JSON::print(config);
+	const String custom_head_include = p_preset->get("html/head_include");
+	Map<String, String> replaces;
+	replaces["$GODOT_URL"] = p_name + ".js";
+	replaces["$GODOT_PROJECT_NAME"] = ProjectSettings::get_singleton()->get_setting("application/config/name");
+	replaces["$GODOT_HEAD_INCLUDE"] = head_include + custom_head_include;
+	replaces["$GODOT_CONFIG"] = str_config;
+	_replace_strings(replaces, p_html);
+}
+
+Error EditorExportPlatformJavaScript::_add_manifest_icon(const String &p_path, const String &p_icon, int p_size, Array &r_arr) {
+	const String name = p_path.get_file().get_basename();
+	const String icon_name = vformat("%s.%dx%d.png", name, p_size, p_size);
+	const String icon_dest = p_path.get_base_dir().plus_file(icon_name);
+
+	Ref<Image> icon;
+	if (!p_icon.empty()) {
+		icon.instance();
+		const Error err = ImageLoader::load_image(p_icon, icon);
+		if (err != OK) {
+			EditorNode::get_singleton()->show_warning(TTR("Could not read file:") + "\n" + p_icon);
+			return err;
+		}
+		if (icon->get_width() != p_size || icon->get_height() != p_size) {
+			icon->resize(p_size, p_size);
+		}
+	} else {
+		icon = _get_project_icon();
+		icon->resize(p_size, p_size);
+	}
+	const Error err = icon->save_png(icon_dest);
+	if (err != OK) {
+		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + icon_dest);
+		return err;
+	}
+	Dictionary icon_dict;
+	icon_dict["sizes"] = vformat("%dx%d", p_size, p_size);
+	icon_dict["type"] = "image/png";
+	icon_dict["src"] = icon_name;
+	r_arr.push_back(icon_dict);
+	return err;
+}
+
+Error EditorExportPlatformJavaScript::_build_pwa(const Ref<EditorExportPreset> &p_preset, const String p_path, const Vector<SharedObject> &p_shared_objects) {
+	// 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_OFFLINE_PAGE@"] = name + ".offline.html";
+	Array files;
+	replaces["@GODOT_OPT_CACHE@"] = JSON::print(files);
+	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");
+	if (p_preset->get("html/export_icon")) {
+		files.push_back(name + ".icon.png");
+		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");
+		for (int i = 0; i < p_shared_objects.size(); i++) {
+			files.push_back(p_shared_objects[i].path.get_file());
+		}
+	}
+	replaces["@GODOT_CACHE@"] = JSON::print(files);
+
+	const String sw_path = dir.plus_file(name + ".service.worker.js");
+	Vector<uint8_t> sw;
+	{
+		FileAccess *f = FileAccess::open(sw_path, FileAccess::READ);
+		if (!f) {
+			EditorNode::get_singleton()->show_warning(TTR("Could not read file:") + "\n" + sw_path);
+			return ERR_FILE_CANT_READ;
+		}
+		sw.resize(f->get_len());
+		f->get_buffer(sw.ptrw(), sw.size());
+		memdelete(f);
+		f = NULL;
+	}
+	_replace_strings(replaces, sw);
+	Error err = _write_or_error(sw.ptr(), sw.size(), dir.plus_file(name + ".service.worker.js"));
+	if (err != OK) {
+		return err;
+	}
+
+	// Custom offline page
+	const String offline_page = p_preset->get("progressive_web_app/offline_page");
+	if (!offline_page.empty()) {
+		DirAccess *da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+		const String offline_dest = dir.plus_file(name + ".offline.html");
+		err = da->copy(ProjectSettings::get_singleton()->globalize_path(offline_page), offline_dest);
+		if (err != OK) {
+			EditorNode::get_singleton()->show_warning(TTR("Could not read file:") + "\n" + offline_dest);
+			return err;
+		}
+	}
+
+	// Manifest
+	const char *modes[4] = { "fullscreen", "standalone", "minimal-ui", "browser" };
+	const char *orientations[3] = { "any", "landscape", "portrait" };
+	const int display = CLAMP(int(p_preset->get("progressive_web_app/display")), 0, 4);
+	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.empty()) {
+		proj_name = "Godot Game";
+	}
+	manifest["name"] = proj_name;
+	manifest["start_url"] = "./" + name + ".html";
+	manifest["display"] = String::utf8(modes[display]);
+	manifest["orientation"] = String::utf8(orientations[orientation]);
+	manifest["background_color"] = "#" + p_preset->get("progressive_web_app/background_color").operator Color().to_html(false);
+
+	Array icons_arr;
+	const String icon144_path = p_preset->get("progressive_web_app/icon_144x144");
+	err = _add_manifest_icon(p_path, icon144_path, 144, icons_arr);
+	if (err != OK) {
+		return err;
+	}
+	const String icon180_path = p_preset->get("progressive_web_app/icon_180x180");
+	err = _add_manifest_icon(p_path, icon180_path, 180, icons_arr);
+	if (err != OK) {
+		return err;
+	}
+	const String icon512_path = p_preset->get("progressive_web_app/icon_512x512");
+	err = _add_manifest_icon(p_path, icon512_path, 512, icons_arr);
+	if (err != OK) {
+		return err;
 	}
 	}
+	manifest["icons"] = icons_arr;
+
+	CharString cs = JSON::print(manifest).utf8();
+	err = _write_or_error((const uint8_t *)cs.get_data(), cs.length(), dir.plus_file(name + ".manifest.json"));
+	if (err != OK) {
+		return err;
+	}
+
+	return OK;
 }
 }
 
 
 void EditorExportPlatformJavaScript::get_preset_features(const Ref<EditorExportPreset> &p_preset, List<String> *r_features) {
 void EditorExportPlatformJavaScript::get_preset_features(const Ref<EditorExportPreset> &p_preset, List<String> *r_features) {
@@ -406,6 +649,14 @@ void EditorExportPlatformJavaScript::get_export_options(List<ExportOption> *r_op
 	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "html/head_include", PROPERTY_HINT_MULTILINE_TEXT), ""));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "html/head_include", PROPERTY_HINT_MULTILINE_TEXT), ""));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "html/canvas_resize_policy", PROPERTY_HINT_ENUM, "None,Project,Adaptive"), 2));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "html/canvas_resize_policy", PROPERTY_HINT_ENUM, "None,Project,Adaptive"), 2));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/experimental_virtual_keyboard"), false));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/experimental_virtual_keyboard"), false));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "progressive_web_app/enabled"), false));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/offline_page", PROPERTY_HINT_FILE, "*.html"), ""));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/display", PROPERTY_HINT_ENUM, "Fullscreen,Standalone,Minimal Ui,Browser"), 1));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/orientation", PROPERTY_HINT_ENUM, "Any,Landscape,Portrait"), 0));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_144x144", PROPERTY_HINT_FILE, "*.png,*.webp,*.svg,*.svgz"), ""));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_180x180", PROPERTY_HINT_FILE, "*.png,*.webp,*.svg,*.svgz"), ""));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_512x512", PROPERTY_HINT_FILE, "*.png,*.webp,*.svg,*.svgz"), ""));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::COLOR, "progressive_web_app/background_color", PROPERTY_HINT_COLOR_NO_ALPHA), Color()));
 }
 }
 
 
 String EditorExportPlatformJavaScript::get_name() const {
 String EditorExportPlatformJavaScript::get_name() const {
@@ -471,21 +722,25 @@ List<String> EditorExportPlatformJavaScript::get_binary_extensions(const Ref<Edi
 Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags) {
 Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags) {
 	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
 	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
 
 
-	String custom_debug = p_preset->get("custom_template/debug");
-	String custom_release = p_preset->get("custom_template/release");
-	String custom_html = p_preset->get("html/custom_html_shell");
-	bool export_icon = p_preset->get("html/export_icon");
+	const String custom_debug = p_preset->get("custom_template/debug");
+	const String custom_release = p_preset->get("custom_template/release");
+	const String custom_html = p_preset->get("html/custom_html_shell");
+	const bool export_icon = p_preset->get("html/export_icon");
+	const bool pwa = p_preset->get("progressive_web_app/enabled");
 
 
-	String template_path = p_debug ? custom_debug : custom_release;
+	const String base_dir = p_path.get_base_dir();
+	const String base_path = p_path.get_basename();
+	const String base_name = p_path.get_file().get_basename();
 
 
+	// Find the correct template
+	String template_path = p_debug ? custom_debug : custom_release;
 	template_path = template_path.strip_edges();
 	template_path = template_path.strip_edges();
-
 	if (template_path == String()) {
 	if (template_path == String()) {
 		ExportMode mode = (ExportMode)(int)p_preset->get("variant/export_type");
 		ExportMode mode = (ExportMode)(int)p_preset->get("variant/export_type");
 		template_path = find_export_template(_get_template_name(mode, p_debug));
 		template_path = find_export_template(_get_template_name(mode, p_debug));
 	}
 	}
 
 
-	if (!DirAccess::exists(p_path.get_base_dir())) {
+	if (!DirAccess::exists(base_dir)) {
 		return ERR_FILE_BAD_PATH;
 		return ERR_FILE_BAD_PATH;
 	}
 	}
 
 
@@ -494,8 +749,9 @@ Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPrese
 		return ERR_FILE_NOT_FOUND;
 		return ERR_FILE_NOT_FOUND;
 	}
 	}
 
 
+	// Export pck and shared objects
 	Vector<SharedObject> shared_objects;
 	Vector<SharedObject> shared_objects;
-	String pck_path = p_path.get_basename() + ".pck";
+	String pck_path = base_path + ".pck";
 	Error error = save_pack(p_preset, pck_path, &shared_objects);
 	Error error = save_pack(p_preset, pck_path, &shared_objects);
 	if (error != OK) {
 	if (error != OK) {
 		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + pck_path);
 		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + pck_path);
@@ -503,7 +759,7 @@ Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPrese
 	}
 	}
 	DirAccess *da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
 	DirAccess *da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
 	for (int i = 0; i < shared_objects.size(); i++) {
 	for (int i = 0; i < shared_objects.size(); i++) {
-		String dst = p_path.get_base_dir().plus_file(shared_objects[i].path.get_file());
+		String dst = base_dir.plus_file(shared_objects[i].path.get_file());
 		error = da->copy(shared_objects[i].path, dst);
 		error = da->copy(shared_objects[i].path, dst);
 		if (error != OK) {
 		if (error != OK) {
 			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + shared_objects[i].path.get_file());
 			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + shared_objects[i].path.get_file());
@@ -512,124 +768,54 @@ Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPrese
 		}
 		}
 	}
 	}
 	memdelete(da);
 	memdelete(da);
+	da = NULL;
 
 
-	FileAccess *src_f = NULL;
-	zlib_filefunc_def io = zipio_create_io_from_file(&src_f);
-	unzFile pkg = unzOpen2(template_path.utf8().get_data(), &io);
-
-	if (!pkg) {
-		EditorNode::get_singleton()->show_warning(TTR("Could not open template for export:") + "\n" + template_path);
-		return ERR_FILE_NOT_FOUND;
-	}
-
-	if (unzGoToFirstFile(pkg) != UNZ_OK) {
-		EditorNode::get_singleton()->show_warning(TTR("Invalid export template:") + "\n" + template_path);
-		unzClose(pkg);
-		return ERR_FILE_CORRUPT;
+	// Extract templates.
+	error = _extract_template(template_path, base_dir, base_name, pwa);
+	if (error) {
+		return error;
 	}
 	}
 
 
-	Vector<uint8_t> html;
+	// Parse generated file sizes (pck and wasm, to help show a meaningful loading bar).
 	Dictionary file_sizes;
 	Dictionary file_sizes;
-	do {
-		//get filename
-		unz_file_info info;
-		char fname[16384];
-		unzGetCurrentFileInfo(pkg, &info, fname, 16384, NULL, 0, NULL, 0);
-
-		String file = fname;
-
-		// HTML is handled later
-		if (file == "godot.html") {
-			if (custom_html.empty()) {
-				html.resize(info.uncompressed_size);
-				unzOpenCurrentFile(pkg);
-				unzReadCurrentFile(pkg, html.ptrw(), html.size());
-				unzCloseCurrentFile(pkg);
-			}
-			continue;
-		}
-		Vector<uint8_t> data;
-		data.resize(info.uncompressed_size);
-
-		//read
-		unzOpenCurrentFile(pkg);
-		unzReadCurrentFile(pkg, data.ptrw(), data.size());
-		unzCloseCurrentFile(pkg);
-
-		//write
-
-		if (file == "godot.js") {
-			file = p_path.get_file().get_basename() + ".js";
-
-		} else if (file == "godot.worker.js") {
-			file = p_path.get_file().get_basename() + ".worker.js";
-
-		} else if (file == "godot.side.wasm") {
-			file = p_path.get_file().get_basename() + ".side.wasm";
-
-		} else if (file == "godot.audio.worklet.js") {
-			file = p_path.get_file().get_basename() + ".audio.worklet.js";
-
-		} else if (file == "godot.wasm") {
-			file = p_path.get_file().get_basename() + ".wasm";
-			file_sizes[file.get_file()] = (uint64_t)info.uncompressed_size;
-		}
-
-		String dst = p_path.get_base_dir().plus_file(file);
-		FileAccess *f = FileAccess::open(dst, FileAccess::WRITE);
-		if (!f) {
-			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + dst);
-			unzClose(pkg);
-			return ERR_FILE_CANT_WRITE;
-		}
-		f->store_buffer(data.ptr(), data.size());
-		memdelete(f);
-
-	} while (unzGoToNextFile(pkg) == UNZ_OK);
-	unzClose(pkg);
-
-	if (!custom_html.empty()) {
-		FileAccess *f = FileAccess::open(custom_html, FileAccess::READ);
-		if (!f) {
-			EditorNode::get_singleton()->show_warning(TTR("Could not read custom HTML shell:") + "\n" + custom_html);
-			return ERR_FILE_CANT_READ;
-		}
-		html.resize(f->get_len());
-		f->get_buffer(html.ptrw(), html.size());
+	FileAccess *f = NULL;
+	f = FileAccess::open(pck_path, FileAccess::READ);
+	if (f) {
+		file_sizes[pck_path.get_file()] = (uint64_t)f->get_len();
 		memdelete(f);
 		memdelete(f);
+		f = NULL;
 	}
 	}
-	{
-		FileAccess *f = FileAccess::open(pck_path, FileAccess::READ);
-		if (f) {
-			file_sizes[pck_path.get_file()] = (uint64_t)f->get_len();
-			memdelete(f);
-			f = NULL;
-		}
-		_fix_html(html, p_preset, p_path.get_file().get_basename(), p_debug, p_flags, shared_objects, file_sizes);
-		f = FileAccess::open(p_path, FileAccess::WRITE);
-		if (!f) {
-			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + p_path);
-			return ERR_FILE_CANT_WRITE;
-		}
-		f->store_buffer(html.ptr(), html.size());
+	f = FileAccess::open(base_path + ".wasm", FileAccess::READ);
+	if (f) {
+		file_sizes[base_name + ".wasm"] = (uint64_t)f->get_len();
 		memdelete(f);
 		memdelete(f);
-		html.resize(0);
+		f = NULL;
 	}
 	}
 
 
-	Ref<Image> splash;
-	const String splash_path = String(GLOBAL_GET("application/boot_splash/image")).strip_edges();
-	if (!splash_path.empty()) {
-		splash.instance();
-		const Error err = ImageLoader::load_image(splash_path, splash);
-		if (err) {
-			EditorNode::get_singleton()->show_warning(TTR("Could not read boot splash image file:") + "\n" + splash_path + "\n" + TTR("Using default boot splash image."));
-			splash.unref();
-		}
-	}
-	if (splash.is_null()) {
-		splash = Ref<Image>(memnew(Image(boot_splash_png)));
+	// Read the HTML shell file (custom or from template).
+	const String html_path = custom_html.empty() ? base_path + ".html" : custom_html;
+	Vector<uint8_t> html;
+	f = FileAccess::open(html_path, FileAccess::READ);
+	if (!f) {
+		EditorNode::get_singleton()->show_warning(TTR("Could not read HTML shell:") + "\n" + html_path);
+		return ERR_FILE_CANT_READ;
+	}
+	html.resize(f->get_len());
+	f->get_buffer(html.ptrw(), html.size());
+	memdelete(f);
+	f = NULL;
+
+	// Generate HTML file with replaced strings.
+	_fix_html(html, p_preset, base_name, p_debug, p_flags, shared_objects, file_sizes);
+	Error err = _write_or_error(html.ptr(), html.size(), p_path);
+	if (err != OK) {
+		return err;
 	}
 	}
-	const String splash_png_path = p_path.get_base_dir().plus_file(p_path.get_file().get_basename() + ".png");
+	html.resize(0);
+
+	// Export splash (why?)
+	Ref<Image> splash = _get_project_splash();
+	const String splash_png_path = base_path + ".png";
 	if (splash->save_png(splash_png_path) != OK) {
 	if (splash->save_png(splash_png_path) != OK) {
 		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + splash_png_path);
 		EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + splash_png_path);
 		return ERR_FILE_CANT_WRITE;
 		return ERR_FILE_CANT_WRITE;
@@ -638,24 +824,26 @@ Error EditorExportPlatformJavaScript::export_project(const Ref<EditorExportPrese
 	// Save a favicon that can be accessed without waiting for the project to finish loading.
 	// Save a favicon that can be accessed without waiting for the project to finish loading.
 	// This way, the favicon can be displayed immediately when loading the page.
 	// This way, the favicon can be displayed immediately when loading the page.
 	if (export_icon) {
 	if (export_icon) {
-		Ref<Image> favicon;
-		const String favicon_path = String(GLOBAL_GET("application/config/icon")).strip_edges();
-		if (!favicon_path.empty()) {
-			favicon.instance();
-			const Error err = ImageLoader::load_image(favicon_path, favicon);
-			if (err) {
-				favicon.unref();
-			}
-		}
-
-		if (favicon.is_null()) {
-			favicon = EditorNode::get_singleton()->get_editor_theme()->get_icon("DefaultProjectIcon", "EditorIcons")->get_data();
-		}
-		const String favicon_png_path = p_path.get_base_dir().plus_file(p_path.get_file().get_basename() + ".icon.png");
+		Ref<Image> favicon = _get_project_icon();
+		const String favicon_png_path = base_path + ".icon.png";
 		if (favicon->save_png(favicon_png_path) != OK) {
 		if (favicon->save_png(favicon_png_path) != OK) {
 			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + favicon_png_path);
 			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + favicon_png_path);
 			return ERR_FILE_CANT_WRITE;
 			return ERR_FILE_CANT_WRITE;
 		}
 		}
+		favicon->resize(180, 180);
+		const String apple_icon_png_path = base_path + ".apple-touch-icon.png";
+		if (favicon->save_png(apple_icon_png_path) != OK) {
+			EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + apple_icon_png_path);
+			return ERR_FILE_CANT_WRITE;
+		}
+	}
+
+	// Generate the PWA worker and manifest
+	if (pwa) {
+		err = _build_pwa(p_preset, p_path, shared_objects);
+		if (err != OK) {
+			return err;
+		}
 	}
 	}
 
 
 	return OK;
 	return OK;
@@ -714,14 +902,17 @@ Error EditorExportPlatformJavaScript::run(const Ref<EditorExportPreset> &p_prese
 	if (err != OK) {
 	if (err != OK) {
 		// Export generates several files, clean them up on failure.
 		// Export generates several files, clean them up on failure.
 		DirAccess::remove_file_or_error(basepath + ".html");
 		DirAccess::remove_file_or_error(basepath + ".html");
+		DirAccess::remove_file_or_error(basepath + ".offline.html");
 		DirAccess::remove_file_or_error(basepath + ".js");
 		DirAccess::remove_file_or_error(basepath + ".js");
 		DirAccess::remove_file_or_error(basepath + ".worker.js");
 		DirAccess::remove_file_or_error(basepath + ".worker.js");
 		DirAccess::remove_file_or_error(basepath + ".audio.worklet.js");
 		DirAccess::remove_file_or_error(basepath + ".audio.worklet.js");
+		DirAccess::remove_file_or_error(basepath + ".service.worker.js");
 		DirAccess::remove_file_or_error(basepath + ".pck");
 		DirAccess::remove_file_or_error(basepath + ".pck");
 		DirAccess::remove_file_or_error(basepath + ".png");
 		DirAccess::remove_file_or_error(basepath + ".png");
 		DirAccess::remove_file_or_error(basepath + ".side.wasm");
 		DirAccess::remove_file_or_error(basepath + ".side.wasm");
 		DirAccess::remove_file_or_error(basepath + ".wasm");
 		DirAccess::remove_file_or_error(basepath + ".wasm");
 		DirAccess::remove_file_or_error(basepath + ".icon.png");
 		DirAccess::remove_file_or_error(basepath + ".icon.png");
+		DirAccess::remove_file_or_error(basepath + ".apple-touch-icon.png");
 		return err;
 		return err;
 	}
 	}