From ad160c7f461132775a8067f64cc1fa7464565d2e Mon Sep 17 00:00:00 2001 From: Fabio Alessandrelli Date: Fri, 26 Mar 2021 11:48:56 +0100 Subject: [PATCH 1/4] [HTML5] Debug HttpServer now runs in `web` cache subdir. Serving all files in that folder and using a known list of mime types. Makes it easy to add more exported files, while still playing safe. --- platform/javascript/export/export.cpp | 141 ++++++++++---------------- 1 file changed, 56 insertions(+), 85 deletions(-) diff --git a/platform/javascript/export/export.cpp b/platform/javascript/export/export.cpp index 540b286e3175..015bfb63af08 100644 --- a/platform/javascript/export/export.cpp +++ b/platform/javascript/export/export.cpp @@ -38,10 +38,10 @@ #include "platform/javascript/run_icon.gen.h" class EditorHTTPServer : public Reference { - private: Ref server; Ref connection; + Map mimes; uint64_t time = 0; uint8_t req_buf[4096]; int req_pos = 0; @@ -55,6 +55,13 @@ class EditorHTTPServer : public Reference { public: EditorHTTPServer() { + mimes["html"] = "text/html"; + mimes["js"] = "application/javascript"; + mimes["json"] = "application/json"; + mimes["pck"] = "application/octet-stream"; + mimes["png"] = "image/png"; + mimes["svg"] = "image/svg"; + mimes["wasm"] = "application/wasm"; server.instance(); stop(); } @@ -83,44 +90,12 @@ class EditorHTTPServer : public Reference { // Wrong protocol ERR_FAIL_COND_MSG(req[0] != "GET" || req[2] != "HTTP/1.1", "Invalid method or HTTP version."); - const String cache_path = EditorSettings::get_singleton()->get_cache_dir(); - const String basereq = "/tmp_js_export"; - String filepath; - String ctype; - if (req[1] == basereq + ".html") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "text/html"; - } else if (req[1] == basereq + ".js") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/javascript"; - } else if (req[1] == basereq + ".audio.worklet.js") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/javascript"; - } else if (req[1] == basereq + ".worker.js") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/javascript"; - } else if (req[1] == basereq + ".pck") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/octet-stream"; - } else if (req[1] == basereq + ".png" || req[1] == "/favicon.png") { - // Also allow serving the generated favicon for a smoother loading experience. - if (req[1] == "/favicon.png") { - filepath = EditorSettings::get_singleton()->get_cache_dir().plus_file("favicon.png"); - } else { - filepath = basereq + ".png"; - } - ctype = "image/png"; - } else if (req[1] == basereq + ".side.wasm") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/wasm"; - } else if (req[1] == basereq + ".wasm") { - filepath = cache_path.plus_file(req[1].get_file()); - ctype = "application/wasm"; - } else if (req[1].ends_with(".wasm")) { - filepath = cache_path.plus_file(req[1].get_file()); // TODO dangerous? - ctype = "application/wasm"; - } - if (filepath.empty() || !FileAccess::exists(filepath)) { + const String req_file = req[1].get_file(); + const String req_ext = req[1].get_extension(); + const String cache_path = EditorSettings::get_singleton()->get_cache_dir().plus_file("web"); + const String filepath = cache_path.plus_file(req_file); + + if (!mimes.has(req_ext) || !FileAccess::exists(filepath)) { String s = "HTTP/1.1 404 Not Found\r\n"; s += "Connection: Close\r\n"; s += "\r\n"; @@ -128,6 +103,8 @@ class EditorHTTPServer : public Reference { connection->put_data((const uint8_t *)cs.get_data(), cs.size() - 1); return; } + const String ctype = mimes[req_ext]; + FileAccess *f = FileAccess::open(filepath, FileAccess::READ); ERR_FAIL_COND(!f); String s = "HTTP/1.1 200 OK\r\n"; @@ -161,11 +138,13 @@ class EditorHTTPServer : public Reference { } void poll() { - if (!server->is_listening()) + if (!server->is_listening()) { return; + } if (connection.is_null()) { - if (!server->is_connection_available()) + if (!server->is_connection_available()) { return; + } connection = server->take_connection(); time = OS::get_singleton()->get_ticks_usec(); } @@ -173,11 +152,11 @@ class EditorHTTPServer : public Reference { _clear_client(); return; } - if (connection->get_status() != StreamPeerTCP::STATUS_CONNECTED) + if (connection->get_status() != StreamPeerTCP::STATUS_CONNECTED) { return; + } while (true) { - char *r = (char *)req_buf; int l = req_pos - 1; if (l > 3 && r[l] == '\n' && r[l - 1] == '\r' && r[l - 2] == '\n' && r[l - 3] == '\r') { @@ -203,7 +182,6 @@ class EditorHTTPServer : public Reference { }; class EditorExportPlatformJavaScript : public EditorExportPlatform { - GDCLASS(EditorExportPlatformJavaScript, EditorExportPlatform); Ref logo; @@ -268,7 +246,6 @@ class EditorExportPlatformJavaScript : public EditorExportPlatform { virtual Ref get_run_icon() const; virtual void get_platform_features(List *r_features) { - r_features->push_back("web"); r_features->push_back(get_os_name()); } @@ -281,7 +258,6 @@ class EditorExportPlatformJavaScript : public EditorExportPlatform { }; void EditorExportPlatformJavaScript::_fix_html(Vector &p_html, const Ref &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector p_shared_objects, const Dictionary &p_file_sizes) { - String str_template = String::utf8(reinterpret_cast(p_html.ptr()), p_html.size()); String str_export; Vector lines = str_template.split("\n"); @@ -305,7 +281,6 @@ void EditorExportPlatformJavaScript::_fix_html(Vector &p_html, const Re const String str_config = JSON::print(config); 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")); @@ -322,7 +297,6 @@ void EditorExportPlatformJavaScript::_fix_html(Vector &p_html, const Re } void EditorExportPlatformJavaScript::get_preset_features(const Ref &p_preset, List *r_features) { - if (p_preset->get("vram_texture_compression/for_desktop")) { r_features->push_back("s3tc"); } @@ -353,6 +327,7 @@ void EditorExportPlatformJavaScript::get_export_options(List *r_op r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "variant/export_type", PROPERTY_HINT_ENUM, "Regular,Threads,GDNative"), 0)); // Export type. r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_desktop"), true)); // S3TC r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_mobile"), false)); // ETC or ETC2, depending on renderer + r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "html/custom_html_shell", PROPERTY_HINT_FILE, "*.html"), "")); 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)); @@ -360,22 +335,18 @@ void EditorExportPlatformJavaScript::get_export_options(List *r_op } String EditorExportPlatformJavaScript::get_name() const { - return "HTML5"; } String EditorExportPlatformJavaScript::get_os_name() const { - return "HTML5"; } Ref EditorExportPlatformJavaScript::get_logo() const { - return logo; } bool EditorExportPlatformJavaScript::can_export(const Ref &p_preset, String &r_error, bool &r_missing_templates) const { - String err; bool valid = false; ExportMode mode = (ExportMode)(int)p_preset->get("variant/export_type"); @@ -410,14 +381,14 @@ bool EditorExportPlatformJavaScript::can_export(const Ref &p } } - if (!err.empty()) + if (!err.empty()) { r_error = err; + } return valid; } List EditorExportPlatformJavaScript::get_binary_extensions(const Ref &p_preset) const { - List list; list.push_back("html"); return list; @@ -435,7 +406,6 @@ Error EditorExportPlatformJavaScript::export_project(const Refget("variant/export_type"); template_path = find_export_template(_get_template_name(mode, p_debug)); } @@ -473,7 +443,6 @@ Error EditorExportPlatformJavaScript::export_project(const Refshow_warning(TTR("Could not open template for export:") + "\n" + template_path); return ERR_FILE_NOT_FOUND; } @@ -515,22 +484,18 @@ Error EditorExportPlatformJavaScript::export_project(const Refshow_warning(TTR("Could not read custom HTML shell:") + "\n" + custom_html); @@ -620,11 +584,9 @@ Error EditorExportPlatformJavaScript::export_project(const Ref preset; for (int i = 0; i < EditorExport::get_singleton()->get_export_preset_count(); i++) { - Ref ep = EditorExport::get_singleton()->get_export_preset(i); if (ep->is_runnable() && ep->get_platform() == this) { preset = ep; @@ -636,9 +598,8 @@ bool EditorExportPlatformJavaScript::poll_export() { menu_options = preset.is_valid(); if (server->is_listening()) { if (menu_options == 0) { - server_lock.lock(); + MutexLock lock(server_lock); server->stop(); - server_lock.unlock(); } else { menu_options += 1; } @@ -651,20 +612,26 @@ Ref EditorExportPlatformJavaScript::get_option_icon(int p_index) c } int EditorExportPlatformJavaScript::get_options_count() const { - return menu_options; } Error EditorExportPlatformJavaScript::run(const Ref &p_preset, int p_option, int p_debug_flags) { - if (p_option == 1) { - server_lock.lock(); + MutexLock lock(server_lock); server->stop(); - server_lock.unlock(); return OK; } - const String basepath = EditorSettings::get_singleton()->get_cache_dir().plus_file("tmp_js_export"); + const String dest = EditorSettings::get_singleton()->get_cache_dir().plus_file("web"); + DirAccessRef da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM); + if (!da->dir_exists(dest)) { + Error err = da->make_dir_recursive(dest); + if (err != OK) { + EditorNode::get_singleton()->show_warning(TTR("Could not create HTTP server directory:") + "\n" + dest); + return err; + } + } + const String basepath = dest.plus_file("tmp_js_export"); Error err = export_project(p_preset, true, basepath + ".html", p_debug_flags); if (err != OK) { // Export generates several files, clean them up on failure. @@ -676,7 +643,7 @@ Error EditorExportPlatformJavaScript::run(const Ref &p_prese DirAccess::remove_file_or_error(basepath + ".png"); DirAccess::remove_file_or_error(basepath + ".side.wasm"); DirAccess::remove_file_or_error(basepath + ".wasm"); - DirAccess::remove_file_or_error(EditorSettings::get_singleton()->get_cache_dir().plus_file("favicon.png")); + DirAccess::remove_file_or_error(dest.plus_file("favicon.png")); return err; } @@ -692,11 +659,16 @@ Error EditorExportPlatformJavaScript::run(const Ref &p_prese ERR_FAIL_COND_V_MSG(!bind_ip.is_valid(), ERR_INVALID_PARAMETER, "Invalid editor setting 'export/web/http_host': '" + bind_host + "'. Try using '127.0.0.1'."); // Restart server. - server_lock.lock(); - server->stop(); - err = server->listen(bind_port, bind_ip); - server_lock.unlock(); - ERR_FAIL_COND_V_MSG(err != OK, err, "Unable to start HTTP server."); + { + MutexLock lock(server_lock); + + server->stop(); + err = server->listen(bind_port, bind_ip); + } + if (err != OK) { + EditorNode::get_singleton()->show_warning(TTR("Error starting HTTP server:") + "\n" + itos(err)); + return err; + } OS::get_singleton()->shell_open(String("http://" + bind_host + ":" + itos(bind_port) + "/tmp_js_export.html")); // FIXME: Find out how to clean up export files after running the successfully @@ -705,7 +677,6 @@ Error EditorExportPlatformJavaScript::run(const Ref &p_prese } Ref EditorExportPlatformJavaScript::get_run_icon() const { - return run_icon; } @@ -713,14 +684,14 @@ void EditorExportPlatformJavaScript::_server_thread_poll(void *data) { EditorExportPlatformJavaScript *ej = (EditorExportPlatformJavaScript *)data; while (!ej->server_quit) { OS::get_singleton()->delay_usec(1000); - ej->server_lock.lock(); - ej->server->poll(); - ej->server_lock.unlock(); + { + MutexLock lock(ej->server_lock); + ej->server->poll(); + } } } EditorExportPlatformJavaScript::EditorExportPlatformJavaScript() { - server.instance(); server_thread.start(_server_thread_poll, this); @@ -733,10 +704,11 @@ EditorExportPlatformJavaScript::EditorExportPlatformJavaScript() { run_icon->create_from_image(img); Ref theme = EditorNode::get_singleton()->get_editor_theme(); - if (theme.is_valid()) + if (theme.is_valid()) { stop_icon = theme->get_icon("Stop", "EditorIcons"); - else + } else { stop_icon.instance(); + } } EditorExportPlatformJavaScript::~EditorExportPlatformJavaScript() { @@ -746,7 +718,6 @@ EditorExportPlatformJavaScript::~EditorExportPlatformJavaScript() { } void register_javascript_exporter() { - EDITOR_DEF("export/web/http_host", "localhost"); EDITOR_DEF("export/web/http_port", 8060); EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::INT, "export/web/http_port", PROPERTY_HINT_RANGE, "1,65535,1")); From a7f2b723d652e200334bc12b4589f92239e727f6 Mon Sep 17 00:00:00 2001 From: Fabio Alessandrelli Date: Fri, 26 Mar 2021 12:44:17 +0100 Subject: [PATCH 2/4] [HTML5] Optional icon generation, use export name for it. We used to only generate the favicon if it was specified in the user project settings, now it's optional, will export it to `NAME.icon.png`, (falling back to the default project icon if none is set in project settings), and the `` tag is added using the `$HEAD_INCLUDE` instead of being hardcoded in the template. --- misc/dist/html/full-size.html | 1 - platform/javascript/export/export.cpp | 37 +++++++++++++++++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/misc/dist/html/full-size.html b/misc/dist/html/full-size.html index abc0479739de..7afb6fdb6bea 100644 --- a/misc/dist/html/full-size.html +++ b/misc/dist/html/full-size.html @@ -3,7 +3,6 @@ - $GODOT_PROJECT_NAME + + +

You are offline

+

This application requires an Internet connection to run for the first time.

+

Press the button below to try reloading:

+ + + + + diff --git a/misc/dist/html/service-worker.js b/misc/dist/html/service-worker.js index d4eaed2b177a..f8dee8cd5bb9 100644 --- a/misc/dist/html/service-worker.js +++ b/misc/dist/html/service-worker.js @@ -5,22 +5,11 @@ // 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"; +const OFFLINE_URL = "@GODOT_OFFLINE_PAGE@"; // 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. -const CACHABLE_FILES = [ - "godot.tools.wasm", -]; +const CACHABLE_FILES = @GODOT_OPT_CACHE@; const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES); self.addEventListener("install", (event) => { diff --git a/platform/javascript/emscripten_helpers.py b/platform/javascript/emscripten_helpers.py index b3b15a157491..ab98838e20f0 100644 --- a/platform/javascript/emscripten_helpers.py +++ b/platform/javascript/emscripten_helpers.py @@ -1,4 +1,4 @@ -import os +import os, json from SCons.Util import WhereIs @@ -59,7 +59,23 @@ def create_template_zip(env, js, wasm, extra): if env["tools"]: # 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) in_files.append(html) out_files.append(zip_dir.File(binary_name + ".html")) @@ -82,6 +98,10 @@ def create_template_zip(env, js, wasm, extra): # HTML in_files.append("#misc/dist/html/full-size.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) env.Zip( diff --git a/platform/javascript/export/export.cpp b/platform/javascript/export/export.cpp index 8a5ed227a2c9..5c198dced487 100644 --- a/platform/javascript/export/export.cpp +++ b/platform/javascript/export/export.cpp @@ -288,7 +288,32 @@ class EditorExportPlatformJavaScript : public EditorExportPlatform { return name; } + Ref _get_project_icon() const { + Ref 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 _get_project_splash() const { + Ref 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(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 p_replaces, Vector &r_template); void _fix_html(Vector &p_html, const Ref &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector 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 &p_preset, const String p_path, const Vector &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); @@ -325,10 +350,90 @@ class EditorExportPlatformJavaScript : public EditorExportPlatform { ~EditorExportPlatformJavaScript(); }; -void EditorExportPlatformJavaScript::_fix_html(Vector &p_html, const Ref &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector p_shared_objects, const Dictionary &p_file_sizes) { - String str_template = String::utf8(reinterpret_cast(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 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 p_replaces, Vector &r_template) { + String str_template = String::utf8(reinterpret_cast(r_template.ptr()), r_template.size()); + String out; Vector lines = str_template.split("\n"); + for (int i = 0; i < lines.size(); i++) { + String current_line = lines[i]; + for (Map::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 &p_html, const Ref &p_preset, const String &p_name, bool p_debug, int p_flags, const Vector p_shared_objects, const Dictionary &p_file_sizes) { + // Engine.js config + Dictionary config; Array libs; for (int i = 0; i < p_shared_objects.size(); i++) { libs.push_back(p_shared_objects[i].path.get_file()); @@ -339,34 +444,172 @@ void EditorExportPlatformJavaScript::_fix_html(Vector &p_html, const Re for (int i = 0; i < flags.size(); i++) { args.push_back(flags[i]); } - Dictionary config; config["canvasResizePolicy"] = p_preset->get("html/canvas_resize_policy"); config["experimentalVK"] = p_preset->get("html/experimental_virtual_keyboard"); config["gdnativeLibs"] = libs; config["executable"] = p_name; config["args"] = args; config["fileSizes"] = p_file_sizes; - const String str_config = JSON::print(config); String head_include; if (p_preset->get("html/export_icon")) { head_include += "\n"; + head_include += "\n"; } - head_include += static_cast(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 += "\n"; + head_include += "\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 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 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 &p_preset, const String p_path, const Vector &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 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 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 &p_preset, List *r_features) { @@ -406,6 +649,14 @@ void EditorExportPlatformJavaScript::get_export_options(List *r_op 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::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 { @@ -471,21 +722,25 @@ List EditorExportPlatformJavaScript::get_binary_extensions(const Ref &p_preset, bool p_debug, const String &p_path, int 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(); - if (template_path == String()) { ExportMode mode = (ExportMode)(int)p_preset->get("variant/export_type"); 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; } @@ -494,8 +749,9 @@ Error EditorExportPlatformJavaScript::export_project(const Ref 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); if (error != OK) { EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + pck_path); @@ -503,7 +759,7 @@ Error EditorExportPlatformJavaScript::export_project(const Refcopy(shared_objects[i].path, dst); if (error != OK) { 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 Refshow_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 html; + // Parse generated file sizes (pck and wasm, to help show a meaningful loading bar). 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 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); + 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); - html.resize(0); + f = NULL; } - Ref 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(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 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 splash = _get_project_splash(); + const String splash_png_path = base_path + ".png"; if (splash->save_png(splash_png_path) != OK) { EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + splash_png_path); return ERR_FILE_CANT_WRITE; @@ -638,24 +824,26 @@ Error EditorExportPlatformJavaScript::export_project(const Ref 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 favicon = _get_project_icon(); + const String favicon_png_path = base_path + ".icon.png"; if (favicon->save_png(favicon_png_path) != OK) { EditorNode::get_singleton()->show_warning(TTR("Could not write file:") + "\n" + favicon_png_path); 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; @@ -714,14 +902,17 @@ Error EditorExportPlatformJavaScript::run(const Ref &p_prese if (err != OK) { // Export generates several files, clean them up on failure. 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 + ".worker.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 + ".png"); DirAccess::remove_file_or_error(basepath + ".side.wasm"); DirAccess::remove_file_or_error(basepath + ".wasm"); DirAccess::remove_file_or_error(basepath + ".icon.png"); + DirAccess::remove_file_or_error(basepath + ".apple-touch-icon.png"); return err; }