| 1 | /**************************************************************************/ |
| 2 | /* export_plugin.cpp */ |
| 3 | /**************************************************************************/ |
| 4 | /* This file is part of: */ |
| 5 | /* GODOT ENGINE */ |
| 6 | /* https://godotengine.org */ |
| 7 | /**************************************************************************/ |
| 8 | /* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ |
| 9 | /* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ |
| 10 | /* */ |
| 11 | /* Permission is hereby granted, free of charge, to any person obtaining */ |
| 12 | /* a copy of this software and associated documentation files (the */ |
| 13 | /* "Software"), to deal in the Software without restriction, including */ |
| 14 | /* without limitation the rights to use, copy, modify, merge, publish, */ |
| 15 | /* distribute, sublicense, and/or sell copies of the Software, and to */ |
| 16 | /* permit persons to whom the Software is furnished to do so, subject to */ |
| 17 | /* the following conditions: */ |
| 18 | /* */ |
| 19 | /* The above copyright notice and this permission notice shall be */ |
| 20 | /* included in all copies or substantial portions of the Software. */ |
| 21 | /* */ |
| 22 | /* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ |
| 23 | /* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ |
| 24 | /* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ |
| 25 | /* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ |
| 26 | /* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ |
| 27 | /* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ |
| 28 | /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ |
| 29 | /**************************************************************************/ |
| 30 | |
| 31 | #include "export_plugin.h" |
| 32 | |
| 33 | #include "logo_svg.gen.h" |
| 34 | #include "run_icon_svg.gen.h" |
| 35 | |
| 36 | #include "core/config/project_settings.h" |
| 37 | #include "editor/editor_scale.h" |
| 38 | #include "editor/editor_settings.h" |
| 39 | #include "editor/editor_string_names.h" |
| 40 | #include "editor/export/editor_export.h" |
| 41 | #include "editor/import/resource_importer_texture_settings.h" |
| 42 | #include "scene/resources/image_texture.h" |
| 43 | |
| 44 | #include "modules/modules_enabled.gen.h" // For mono and svg. |
| 45 | #ifdef MODULE_SVG_ENABLED |
| 46 | #include "modules/svg/image_loader_svg.h" |
| 47 | #endif |
| 48 | |
| 49 | Error EditorExportPlatformWeb::(const String &p_template, const String &p_dir, const String &p_name, bool pwa) { |
| 50 | Ref<FileAccess> io_fa; |
| 51 | zlib_filefunc_def io = zipio_create_io(&io_fa); |
| 52 | unzFile pkg = unzOpen2(p_template.utf8().get_data(), &io); |
| 53 | |
| 54 | if (!pkg) { |
| 55 | add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates" ), vformat(TTR("Could not open template for export: \"%s\"." ), p_template)); |
| 56 | return ERR_FILE_NOT_FOUND; |
| 57 | } |
| 58 | |
| 59 | if (unzGoToFirstFile(pkg) != UNZ_OK) { |
| 60 | add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates" ), vformat(TTR("Invalid export template: \"%s\"." ), p_template)); |
| 61 | unzClose(pkg); |
| 62 | return ERR_FILE_CORRUPT; |
| 63 | } |
| 64 | |
| 65 | do { |
| 66 | //get filename |
| 67 | unz_file_info info; |
| 68 | char fname[16384]; |
| 69 | unzGetCurrentFileInfo(pkg, &info, fname, 16384, nullptr, 0, nullptr, 0); |
| 70 | |
| 71 | String file = String::utf8(fname); |
| 72 | |
| 73 | // Skip folders. |
| 74 | if (file.ends_with("/" )) { |
| 75 | continue; |
| 76 | } |
| 77 | |
| 78 | // Skip service worker and offline page if not exporting pwa. |
| 79 | if (!pwa && (file == "godot.service.worker.js" || file == "godot.offline.html" )) { |
| 80 | continue; |
| 81 | } |
| 82 | Vector<uint8_t> data; |
| 83 | data.resize(info.uncompressed_size); |
| 84 | |
| 85 | //read |
| 86 | unzOpenCurrentFile(pkg); |
| 87 | unzReadCurrentFile(pkg, data.ptrw(), data.size()); |
| 88 | unzCloseCurrentFile(pkg); |
| 89 | |
| 90 | //write |
| 91 | String dst = p_dir.path_join(file.replace("godot" , p_name)); |
| 92 | Ref<FileAccess> f = FileAccess::open(dst, FileAccess::WRITE); |
| 93 | if (f.is_null()) { |
| 94 | add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates" ), vformat(TTR("Could not write file: \"%s\"." ), dst)); |
| 95 | unzClose(pkg); |
| 96 | return ERR_FILE_CANT_WRITE; |
| 97 | } |
| 98 | f->store_buffer(data.ptr(), data.size()); |
| 99 | |
| 100 | } while (unzGoToNextFile(pkg) == UNZ_OK); |
| 101 | unzClose(pkg); |
| 102 | return OK; |
| 103 | } |
| 104 | |
| 105 | Error EditorExportPlatformWeb::_write_or_error(const uint8_t *p_content, int p_size, String p_path) { |
| 106 | Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::WRITE); |
| 107 | if (f.is_null()) { |
| 108 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), p_path)); |
| 109 | return ERR_FILE_CANT_WRITE; |
| 110 | } |
| 111 | f->store_buffer(p_content, p_size); |
| 112 | return OK; |
| 113 | } |
| 114 | |
| 115 | void EditorExportPlatformWeb::_replace_strings(HashMap<String, String> p_replaces, Vector<uint8_t> &r_template) { |
| 116 | String str_template = String::utf8(reinterpret_cast<const char *>(r_template.ptr()), r_template.size()); |
| 117 | String out; |
| 118 | Vector<String> lines = str_template.split("\n" ); |
| 119 | for (int i = 0; i < lines.size(); i++) { |
| 120 | String current_line = lines[i]; |
| 121 | for (const KeyValue<String, String> &E : p_replaces) { |
| 122 | current_line = current_line.replace(E.key, E.value); |
| 123 | } |
| 124 | out += current_line + "\n" ; |
| 125 | } |
| 126 | CharString cs = out.utf8(); |
| 127 | r_template.resize(cs.length()); |
| 128 | for (int i = 0; i < cs.length(); i++) { |
| 129 | r_template.write[i] = cs[i]; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | void EditorExportPlatformWeb::_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) { |
| 134 | // Engine.js config |
| 135 | Dictionary config; |
| 136 | Array libs; |
| 137 | for (int i = 0; i < p_shared_objects.size(); i++) { |
| 138 | libs.push_back(p_shared_objects[i].path.get_file()); |
| 139 | } |
| 140 | Vector<String> flags; |
| 141 | gen_export_flags(flags, p_flags & (~DEBUG_FLAG_DUMB_CLIENT)); |
| 142 | Array args; |
| 143 | for (int i = 0; i < flags.size(); i++) { |
| 144 | args.push_back(flags[i]); |
| 145 | } |
| 146 | config["canvasResizePolicy" ] = p_preset->get("html/canvas_resize_policy" ); |
| 147 | config["experimentalVK" ] = p_preset->get("html/experimental_virtual_keyboard" ); |
| 148 | config["focusCanvas" ] = p_preset->get("html/focus_canvas_on_start" ); |
| 149 | config["gdextensionLibs" ] = libs; |
| 150 | config["executable" ] = p_name; |
| 151 | config["args" ] = args; |
| 152 | config["fileSizes" ] = p_file_sizes; |
| 153 | |
| 154 | String head_include; |
| 155 | if (p_preset->get("html/export_icon" )) { |
| 156 | head_include += "<link id='-gd-engine-icon' rel='icon' type='image/png' href='" + p_name + ".icon.png' />\n" ; |
| 157 | head_include += "<link rel='apple-touch-icon' href='" + p_name + ".apple-touch-icon.png'/>\n" ; |
| 158 | } |
| 159 | if (p_preset->get("progressive_web_app/enabled" )) { |
| 160 | head_include += "<link rel='manifest' href='" + p_name + ".manifest.json'>\n" ; |
| 161 | config["serviceWorker" ] = p_name + ".service.worker.js" ; |
| 162 | } |
| 163 | |
| 164 | // Replaces HTML string |
| 165 | const String str_config = Variant(config).to_json_string(); |
| 166 | const String custom_head_include = p_preset->get("html/head_include" ); |
| 167 | HashMap<String, String> replaces; |
| 168 | replaces["$GODOT_URL" ] = p_name + ".js" ; |
| 169 | replaces["$GODOT_PROJECT_NAME" ] = GLOBAL_GET("application/config/name" ); |
| 170 | replaces["$GODOT_HEAD_INCLUDE" ] = head_include + custom_head_include; |
| 171 | replaces["$GODOT_CONFIG" ] = str_config; |
| 172 | _replace_strings(replaces, p_html); |
| 173 | } |
| 174 | |
| 175 | Error EditorExportPlatformWeb::_add_manifest_icon(const String &p_path, const String &p_icon, int p_size, Array &r_arr) { |
| 176 | const String name = p_path.get_file().get_basename(); |
| 177 | const String icon_name = vformat("%s.%dx%d.png" , name, p_size, p_size); |
| 178 | const String icon_dest = p_path.get_base_dir().path_join(icon_name); |
| 179 | |
| 180 | Ref<Image> icon; |
| 181 | if (!p_icon.is_empty()) { |
| 182 | icon.instantiate(); |
| 183 | const Error err = ImageLoader::load_image(p_icon, icon); |
| 184 | if (err != OK) { |
| 185 | add_message(EXPORT_MESSAGE_ERROR, TTR("Icon Creation" ), vformat(TTR("Could not read file: \"%s\"." ), p_icon)); |
| 186 | return err; |
| 187 | } |
| 188 | if (icon->get_width() != p_size || icon->get_height() != p_size) { |
| 189 | icon->resize(p_size, p_size); |
| 190 | } |
| 191 | } else { |
| 192 | icon = _get_project_icon(); |
| 193 | icon->resize(p_size, p_size); |
| 194 | } |
| 195 | const Error err = icon->save_png(icon_dest); |
| 196 | if (err != OK) { |
| 197 | add_message(EXPORT_MESSAGE_ERROR, TTR("Icon Creation" ), vformat(TTR("Could not write file: \"%s\"." ), icon_dest)); |
| 198 | return err; |
| 199 | } |
| 200 | Dictionary icon_dict; |
| 201 | icon_dict["sizes" ] = vformat("%dx%d" , p_size, p_size); |
| 202 | icon_dict["type" ] = "image/png" ; |
| 203 | icon_dict["src" ] = icon_name; |
| 204 | r_arr.push_back(icon_dict); |
| 205 | return err; |
| 206 | } |
| 207 | |
| 208 | Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_preset, const String p_path, const Vector<SharedObject> &p_shared_objects) { |
| 209 | String proj_name = GLOBAL_GET("application/config/name" ); |
| 210 | if (proj_name.is_empty()) { |
| 211 | proj_name = "Godot Game" ; |
| 212 | } |
| 213 | |
| 214 | // Service worker |
| 215 | const String dir = p_path.get_base_dir(); |
| 216 | const String name = p_path.get_file().get_basename(); |
| 217 | bool extensions = (bool)p_preset->get("variant/extensions_support" ); |
| 218 | HashMap<String, String> replaces; |
| 219 | replaces["@GODOT_VERSION@" ] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec()); |
| 220 | replaces["@GODOT_NAME@" ] = proj_name.substr(0, 16); |
| 221 | replaces["@GODOT_OFFLINE_PAGE@" ] = name + ".offline.html" ; |
| 222 | |
| 223 | // Files cached during worker install. |
| 224 | Array cache_files; |
| 225 | cache_files.push_back(name + ".html" ); |
| 226 | cache_files.push_back(name + ".js" ); |
| 227 | cache_files.push_back(name + ".offline.html" ); |
| 228 | if (p_preset->get("html/export_icon" )) { |
| 229 | cache_files.push_back(name + ".icon.png" ); |
| 230 | cache_files.push_back(name + ".apple-touch-icon.png" ); |
| 231 | } |
| 232 | cache_files.push_back(name + ".worker.js" ); |
| 233 | cache_files.push_back(name + ".audio.worklet.js" ); |
| 234 | replaces["@GODOT_CACHE@" ] = Variant(cache_files).to_json_string(); |
| 235 | |
| 236 | // Heavy files that are cached on demand. |
| 237 | Array opt_cache_files; |
| 238 | opt_cache_files.push_back(name + ".wasm" ); |
| 239 | opt_cache_files.push_back(name + ".pck" ); |
| 240 | if (extensions) { |
| 241 | opt_cache_files.push_back(name + ".side.wasm" ); |
| 242 | for (int i = 0; i < p_shared_objects.size(); i++) { |
| 243 | opt_cache_files.push_back(p_shared_objects[i].path.get_file()); |
| 244 | } |
| 245 | } |
| 246 | replaces["@GODOT_OPT_CACHE@" ] = Variant(opt_cache_files).to_json_string(); |
| 247 | |
| 248 | const String sw_path = dir.path_join(name + ".service.worker.js" ); |
| 249 | Vector<uint8_t> sw; |
| 250 | { |
| 251 | Ref<FileAccess> f = FileAccess::open(sw_path, FileAccess::READ); |
| 252 | if (f.is_null()) { |
| 253 | add_message(EXPORT_MESSAGE_ERROR, TTR("PWA" ), vformat(TTR("Could not read file: \"%s\"." ), sw_path)); |
| 254 | return ERR_FILE_CANT_READ; |
| 255 | } |
| 256 | sw.resize(f->get_length()); |
| 257 | f->get_buffer(sw.ptrw(), sw.size()); |
| 258 | } |
| 259 | _replace_strings(replaces, sw); |
| 260 | Error err = _write_or_error(sw.ptr(), sw.size(), dir.path_join(name + ".service.worker.js" )); |
| 261 | if (err != OK) { |
| 262 | return err; |
| 263 | } |
| 264 | |
| 265 | // Custom offline page |
| 266 | const String offline_page = p_preset->get("progressive_web_app/offline_page" ); |
| 267 | if (!offline_page.is_empty()) { |
| 268 | Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM); |
| 269 | const String offline_dest = dir.path_join(name + ".offline.html" ); |
| 270 | err = da->copy(ProjectSettings::get_singleton()->globalize_path(offline_page), offline_dest); |
| 271 | if (err != OK) { |
| 272 | add_message(EXPORT_MESSAGE_ERROR, TTR("PWA" ), vformat(TTR("Could not read file: \"%s\"." ), offline_dest)); |
| 273 | return err; |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // Manifest |
| 278 | const char *modes[4] = { "fullscreen" , "standalone" , "minimal-ui" , "browser" }; |
| 279 | const char *orientations[3] = { "any" , "landscape" , "portrait" }; |
| 280 | const int display = CLAMP(int(p_preset->get("progressive_web_app/display" )), 0, 4); |
| 281 | const int orientation = CLAMP(int(p_preset->get("progressive_web_app/orientation" )), 0, 3); |
| 282 | |
| 283 | Dictionary manifest; |
| 284 | manifest["name" ] = proj_name; |
| 285 | manifest["start_url" ] = "./" + name + ".html" ; |
| 286 | manifest["display" ] = String::utf8(modes[display]); |
| 287 | manifest["orientation" ] = String::utf8(orientations[orientation]); |
| 288 | manifest["background_color" ] = "#" + p_preset->get("progressive_web_app/background_color" ).operator Color().to_html(false); |
| 289 | |
| 290 | Array icons_arr; |
| 291 | const String icon144_path = p_preset->get("progressive_web_app/icon_144x144" ); |
| 292 | err = _add_manifest_icon(p_path, icon144_path, 144, icons_arr); |
| 293 | if (err != OK) { |
| 294 | return err; |
| 295 | } |
| 296 | const String icon180_path = p_preset->get("progressive_web_app/icon_180x180" ); |
| 297 | err = _add_manifest_icon(p_path, icon180_path, 180, icons_arr); |
| 298 | if (err != OK) { |
| 299 | return err; |
| 300 | } |
| 301 | const String icon512_path = p_preset->get("progressive_web_app/icon_512x512" ); |
| 302 | err = _add_manifest_icon(p_path, icon512_path, 512, icons_arr); |
| 303 | if (err != OK) { |
| 304 | return err; |
| 305 | } |
| 306 | manifest["icons" ] = icons_arr; |
| 307 | |
| 308 | CharString cs = Variant(manifest).to_json_string().utf8(); |
| 309 | err = _write_or_error((const uint8_t *)cs.get_data(), cs.length(), dir.path_join(name + ".manifest.json" )); |
| 310 | if (err != OK) { |
| 311 | return err; |
| 312 | } |
| 313 | |
| 314 | return OK; |
| 315 | } |
| 316 | |
| 317 | void EditorExportPlatformWeb::get_preset_features(const Ref<EditorExportPreset> &p_preset, List<String> *r_features) const { |
| 318 | if (p_preset->get("vram_texture_compression/for_desktop" )) { |
| 319 | r_features->push_back("s3tc" ); |
| 320 | } |
| 321 | |
| 322 | if (p_preset->get("vram_texture_compression/for_mobile" )) { |
| 323 | r_features->push_back("etc2" ); |
| 324 | } |
| 325 | r_features->push_back("wasm32" ); |
| 326 | } |
| 327 | |
| 328 | void EditorExportPlatformWeb::get_export_options(List<ExportOption> *r_options) const { |
| 329 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "custom_template/debug" , PROPERTY_HINT_GLOBAL_FILE, "*.zip" ), "" )); |
| 330 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "custom_template/release" , PROPERTY_HINT_GLOBAL_FILE, "*.zip" ), "" )); |
| 331 | |
| 332 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "variant/extensions_support" ), false)); // Export type. |
| 333 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_desktop" ), true)); // S3TC |
| 334 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_mobile" ), false)); // ETC or ETC2, depending on renderer |
| 335 | |
| 336 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/export_icon" ), true)); |
| 337 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "html/custom_html_shell" , PROPERTY_HINT_FILE, "*.html" ), "" )); |
| 338 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "html/head_include" , PROPERTY_HINT_MULTILINE_TEXT), "" )); |
| 339 | r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "html/canvas_resize_policy" , PROPERTY_HINT_ENUM, "None,Project,Adaptive" ), 2)); |
| 340 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/focus_canvas_on_start" ), true)); |
| 341 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/experimental_virtual_keyboard" ), false)); |
| 342 | r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "progressive_web_app/enabled" ), false)); |
| 343 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/offline_page" , PROPERTY_HINT_FILE, "*.html" ), "" )); |
| 344 | r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/display" , PROPERTY_HINT_ENUM, "Fullscreen,Standalone,Minimal UI,Browser" ), 1)); |
| 345 | r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/orientation" , PROPERTY_HINT_ENUM, "Any,Landscape,Portrait" ), 0)); |
| 346 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_144x144" , PROPERTY_HINT_FILE, "*.png,*.webp,*.svg" ), "" )); |
| 347 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_180x180" , PROPERTY_HINT_FILE, "*.png,*.webp,*.svg" ), "" )); |
| 348 | r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/icon_512x512" , PROPERTY_HINT_FILE, "*.png,*.webp,*.svg" ), "" )); |
| 349 | r_options->push_back(ExportOption(PropertyInfo(Variant::COLOR, "progressive_web_app/background_color" , PROPERTY_HINT_COLOR_NO_ALPHA), Color())); |
| 350 | } |
| 351 | |
| 352 | String EditorExportPlatformWeb::get_name() const { |
| 353 | return "Web" ; |
| 354 | } |
| 355 | |
| 356 | String EditorExportPlatformWeb::get_os_name() const { |
| 357 | return "Web" ; |
| 358 | } |
| 359 | |
| 360 | Ref<Texture2D> EditorExportPlatformWeb::get_logo() const { |
| 361 | return logo; |
| 362 | } |
| 363 | |
| 364 | bool EditorExportPlatformWeb::has_valid_export_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error, bool &r_missing_templates, bool p_debug) const { |
| 365 | #ifdef MODULE_MONO_ENABLED |
| 366 | // Don't check for additional errors, as this particular error cannot be resolved. |
| 367 | r_error += TTR("Exporting to Web is currently not supported in Godot 4 when using C#/.NET. Use Godot 3 to target Web with C#/Mono instead." ) + "\n" ; |
| 368 | r_error += TTR("If this project does not use C#, use a non-C# editor build to export the project." ) + "\n" ; |
| 369 | return false; |
| 370 | #else |
| 371 | |
| 372 | String err; |
| 373 | bool valid = false; |
| 374 | bool extensions = (bool)p_preset->get("variant/extensions_support" ); |
| 375 | |
| 376 | // Look for export templates (first official, and if defined custom templates). |
| 377 | bool dvalid = exists_export_template(_get_template_name(extensions, true), &err); |
| 378 | bool rvalid = exists_export_template(_get_template_name(extensions, false), &err); |
| 379 | |
| 380 | if (p_preset->get("custom_template/debug" ) != "" ) { |
| 381 | dvalid = FileAccess::exists(p_preset->get("custom_template/debug" )); |
| 382 | if (!dvalid) { |
| 383 | err += TTR("Custom debug template not found." ) + "\n" ; |
| 384 | } |
| 385 | } |
| 386 | if (p_preset->get("custom_template/release" ) != "" ) { |
| 387 | rvalid = FileAccess::exists(p_preset->get("custom_template/release" )); |
| 388 | if (!rvalid) { |
| 389 | err += TTR("Custom release template not found." ) + "\n" ; |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | valid = dvalid || rvalid; |
| 394 | r_missing_templates = !valid; |
| 395 | |
| 396 | if (!err.is_empty()) { |
| 397 | r_error = err; |
| 398 | } |
| 399 | |
| 400 | return valid; |
| 401 | #endif // !MODULE_MONO_ENABLED |
| 402 | } |
| 403 | |
| 404 | bool EditorExportPlatformWeb::has_valid_project_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error) const { |
| 405 | String err; |
| 406 | bool valid = true; |
| 407 | |
| 408 | // Validate the project configuration. |
| 409 | |
| 410 | if (p_preset->get("vram_texture_compression/for_mobile" )) { |
| 411 | if (!ResourceImporterTextureSettings::should_import_etc2_astc()) { |
| 412 | valid = false; |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | if (!err.is_empty()) { |
| 417 | r_error = err; |
| 418 | } |
| 419 | |
| 420 | return valid; |
| 421 | } |
| 422 | |
| 423 | List<String> EditorExportPlatformWeb::get_binary_extensions(const Ref<EditorExportPreset> &p_preset) const { |
| 424 | List<String> list; |
| 425 | list.push_back("html" ); |
| 426 | return list; |
| 427 | } |
| 428 | |
| 429 | Error EditorExportPlatformWeb::export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags) { |
| 430 | ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags); |
| 431 | |
| 432 | const String custom_debug = p_preset->get("custom_template/debug" ); |
| 433 | const String custom_release = p_preset->get("custom_template/release" ); |
| 434 | const String custom_html = p_preset->get("html/custom_html_shell" ); |
| 435 | const bool export_icon = p_preset->get("html/export_icon" ); |
| 436 | const bool pwa = p_preset->get("progressive_web_app/enabled" ); |
| 437 | |
| 438 | const String base_dir = p_path.get_base_dir(); |
| 439 | const String base_path = p_path.get_basename(); |
| 440 | const String base_name = p_path.get_file().get_basename(); |
| 441 | |
| 442 | // Find the correct template |
| 443 | String template_path = p_debug ? custom_debug : custom_release; |
| 444 | template_path = template_path.strip_edges(); |
| 445 | if (template_path.is_empty()) { |
| 446 | bool extensions = (bool)p_preset->get("variant/extensions_support" ); |
| 447 | template_path = find_export_template(_get_template_name(extensions, p_debug)); |
| 448 | } |
| 449 | |
| 450 | if (!DirAccess::exists(base_dir)) { |
| 451 | return ERR_FILE_BAD_PATH; |
| 452 | } |
| 453 | |
| 454 | if (!template_path.is_empty() && !FileAccess::exists(template_path)) { |
| 455 | add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates" ), vformat(TTR("Template file not found: \"%s\"." ), template_path)); |
| 456 | return ERR_FILE_NOT_FOUND; |
| 457 | } |
| 458 | |
| 459 | // Export pck and shared objects |
| 460 | Vector<SharedObject> shared_objects; |
| 461 | String pck_path = base_path + ".pck" ; |
| 462 | Error error = save_pack(p_preset, p_debug, pck_path, &shared_objects); |
| 463 | if (error != OK) { |
| 464 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), pck_path)); |
| 465 | return error; |
| 466 | } |
| 467 | |
| 468 | { |
| 469 | Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM); |
| 470 | for (int i = 0; i < shared_objects.size(); i++) { |
| 471 | String dst = base_dir.path_join(shared_objects[i].path.get_file()); |
| 472 | error = da->copy(shared_objects[i].path, dst); |
| 473 | if (error != OK) { |
| 474 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), shared_objects[i].path.get_file())); |
| 475 | return error; |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | // Extract templates. |
| 481 | error = _extract_template(template_path, base_dir, base_name, pwa); |
| 482 | if (error) { |
| 483 | return error; |
| 484 | } |
| 485 | |
| 486 | // Parse generated file sizes (pck and wasm, to help show a meaningful loading bar). |
| 487 | Dictionary file_sizes; |
| 488 | Ref<FileAccess> f = FileAccess::open(pck_path, FileAccess::READ); |
| 489 | if (f.is_valid()) { |
| 490 | file_sizes[pck_path.get_file()] = (uint64_t)f->get_length(); |
| 491 | } |
| 492 | f = FileAccess::open(base_path + ".wasm" , FileAccess::READ); |
| 493 | if (f.is_valid()) { |
| 494 | file_sizes[base_name + ".wasm" ] = (uint64_t)f->get_length(); |
| 495 | } |
| 496 | |
| 497 | // Read the HTML shell file (custom or from template). |
| 498 | const String html_path = custom_html.is_empty() ? base_path + ".html" : custom_html; |
| 499 | Vector<uint8_t> html; |
| 500 | f = FileAccess::open(html_path, FileAccess::READ); |
| 501 | if (f.is_null()) { |
| 502 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not read HTML shell: \"%s\"." ), html_path)); |
| 503 | return ERR_FILE_CANT_READ; |
| 504 | } |
| 505 | html.resize(f->get_length()); |
| 506 | f->get_buffer(html.ptrw(), html.size()); |
| 507 | f.unref(); // close file. |
| 508 | |
| 509 | // Generate HTML file with replaced strings. |
| 510 | _fix_html(html, p_preset, base_name, p_debug, p_flags, shared_objects, file_sizes); |
| 511 | Error err = _write_or_error(html.ptr(), html.size(), p_path); |
| 512 | if (err != OK) { |
| 513 | return err; |
| 514 | } |
| 515 | html.resize(0); |
| 516 | |
| 517 | // Export splash (why?) |
| 518 | Ref<Image> splash = _get_project_splash(); |
| 519 | const String splash_png_path = base_path + ".png" ; |
| 520 | if (splash->save_png(splash_png_path) != OK) { |
| 521 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), splash_png_path)); |
| 522 | return ERR_FILE_CANT_WRITE; |
| 523 | } |
| 524 | |
| 525 | // Save a favicon that can be accessed without waiting for the project to finish loading. |
| 526 | // This way, the favicon can be displayed immediately when loading the page. |
| 527 | if (export_icon) { |
| 528 | Ref<Image> favicon = _get_project_icon(); |
| 529 | const String favicon_png_path = base_path + ".icon.png" ; |
| 530 | if (favicon->save_png(favicon_png_path) != OK) { |
| 531 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), favicon_png_path)); |
| 532 | return ERR_FILE_CANT_WRITE; |
| 533 | } |
| 534 | favicon->resize(180, 180); |
| 535 | const String apple_icon_png_path = base_path + ".apple-touch-icon.png" ; |
| 536 | if (favicon->save_png(apple_icon_png_path) != OK) { |
| 537 | add_message(EXPORT_MESSAGE_ERROR, TTR("Export" ), vformat(TTR("Could not write file: \"%s\"." ), apple_icon_png_path)); |
| 538 | return ERR_FILE_CANT_WRITE; |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | // Generate the PWA worker and manifest |
| 543 | if (pwa) { |
| 544 | err = _build_pwa(p_preset, p_path, shared_objects); |
| 545 | if (err != OK) { |
| 546 | return err; |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | return OK; |
| 551 | } |
| 552 | |
| 553 | bool EditorExportPlatformWeb::poll_export() { |
| 554 | Ref<EditorExportPreset> preset; |
| 555 | |
| 556 | for (int i = 0; i < EditorExport::get_singleton()->get_export_preset_count(); i++) { |
| 557 | Ref<EditorExportPreset> ep = EditorExport::get_singleton()->get_export_preset(i); |
| 558 | if (ep->is_runnable() && ep->get_platform() == this) { |
| 559 | preset = ep; |
| 560 | break; |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | int prev = menu_options; |
| 565 | menu_options = preset.is_valid(); |
| 566 | if (server->is_listening()) { |
| 567 | if (menu_options == 0) { |
| 568 | MutexLock lock(server_lock); |
| 569 | server->stop(); |
| 570 | } else { |
| 571 | menu_options += 1; |
| 572 | } |
| 573 | } |
| 574 | return menu_options != prev; |
| 575 | } |
| 576 | |
| 577 | Ref<ImageTexture> EditorExportPlatformWeb::get_option_icon(int p_index) const { |
| 578 | return p_index == 1 ? stop_icon : EditorExportPlatform::get_option_icon(p_index); |
| 579 | } |
| 580 | |
| 581 | int EditorExportPlatformWeb::get_options_count() const { |
| 582 | return menu_options; |
| 583 | } |
| 584 | |
| 585 | Error EditorExportPlatformWeb::run(const Ref<EditorExportPreset> &p_preset, int p_option, int p_debug_flags) { |
| 586 | if (p_option == 1) { |
| 587 | MutexLock lock(server_lock); |
| 588 | server->stop(); |
| 589 | return OK; |
| 590 | } |
| 591 | |
| 592 | const String dest = EditorPaths::get_singleton()->get_cache_dir().path_join("web" ); |
| 593 | Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM); |
| 594 | if (!da->dir_exists(dest)) { |
| 595 | Error err = da->make_dir_recursive(dest); |
| 596 | if (err != OK) { |
| 597 | add_message(EXPORT_MESSAGE_ERROR, TTR("Run" ), vformat(TTR("Could not create HTTP server directory: %s." ), dest)); |
| 598 | return err; |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | const String basepath = dest.path_join("tmp_js_export" ); |
| 603 | Error err = export_project(p_preset, true, basepath + ".html" , p_debug_flags); |
| 604 | if (err != OK) { |
| 605 | // Export generates several files, clean them up on failure. |
| 606 | DirAccess::remove_file_or_error(basepath + ".html" ); |
| 607 | DirAccess::remove_file_or_error(basepath + ".offline.html" ); |
| 608 | DirAccess::remove_file_or_error(basepath + ".js" ); |
| 609 | DirAccess::remove_file_or_error(basepath + ".worker.js" ); |
| 610 | DirAccess::remove_file_or_error(basepath + ".audio.worklet.js" ); |
| 611 | DirAccess::remove_file_or_error(basepath + ".service.worker.js" ); |
| 612 | DirAccess::remove_file_or_error(basepath + ".pck" ); |
| 613 | DirAccess::remove_file_or_error(basepath + ".png" ); |
| 614 | DirAccess::remove_file_or_error(basepath + ".side.wasm" ); |
| 615 | DirAccess::remove_file_or_error(basepath + ".wasm" ); |
| 616 | DirAccess::remove_file_or_error(basepath + ".icon.png" ); |
| 617 | DirAccess::remove_file_or_error(basepath + ".apple-touch-icon.png" ); |
| 618 | return err; |
| 619 | } |
| 620 | |
| 621 | const uint16_t bind_port = EDITOR_GET("export/web/http_port" ); |
| 622 | // Resolve host if needed. |
| 623 | const String bind_host = EDITOR_GET("export/web/http_host" ); |
| 624 | IPAddress bind_ip; |
| 625 | if (bind_host.is_valid_ip_address()) { |
| 626 | bind_ip = bind_host; |
| 627 | } else { |
| 628 | bind_ip = IP::get_singleton()->resolve_hostname(bind_host); |
| 629 | } |
| 630 | 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'." ); |
| 631 | |
| 632 | const bool use_tls = EDITOR_GET("export/web/use_tls" ); |
| 633 | const String tls_key = EDITOR_GET("export/web/tls_key" ); |
| 634 | const String tls_cert = EDITOR_GET("export/web/tls_certificate" ); |
| 635 | |
| 636 | // Restart server. |
| 637 | { |
| 638 | MutexLock lock(server_lock); |
| 639 | |
| 640 | server->stop(); |
| 641 | err = server->listen(bind_port, bind_ip, use_tls, tls_key, tls_cert); |
| 642 | } |
| 643 | if (err != OK) { |
| 644 | add_message(EXPORT_MESSAGE_ERROR, TTR("Run" ), vformat(TTR("Error starting HTTP server: %d." ), err)); |
| 645 | return err; |
| 646 | } |
| 647 | |
| 648 | OS::get_singleton()->shell_open(String((use_tls ? "https://" : "http://" ) + bind_host + ":" + itos(bind_port) + "/tmp_js_export.html" )); |
| 649 | // FIXME: Find out how to clean up export files after running the successfully |
| 650 | // exported game. Might not be trivial. |
| 651 | return OK; |
| 652 | } |
| 653 | |
| 654 | Ref<Texture2D> EditorExportPlatformWeb::get_run_icon() const { |
| 655 | return run_icon; |
| 656 | } |
| 657 | |
| 658 | void EditorExportPlatformWeb::_server_thread_poll(void *data) { |
| 659 | EditorExportPlatformWeb *ej = static_cast<EditorExportPlatformWeb *>(data); |
| 660 | while (!ej->server_quit) { |
| 661 | OS::get_singleton()->delay_usec(6900); |
| 662 | { |
| 663 | MutexLock lock(ej->server_lock); |
| 664 | ej->server->poll(); |
| 665 | } |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | EditorExportPlatformWeb::EditorExportPlatformWeb() { |
| 670 | if (EditorNode::get_singleton()) { |
| 671 | server.instantiate(); |
| 672 | server_thread.start(_server_thread_poll, this); |
| 673 | |
| 674 | #ifdef MODULE_SVG_ENABLED |
| 675 | Ref<Image> img = memnew(Image); |
| 676 | const bool upsample = !Math::is_equal_approx(Math::round(EDSCALE), EDSCALE); |
| 677 | |
| 678 | ImageLoaderSVG::create_image_from_string(img, _web_logo_svg, EDSCALE, upsample, false); |
| 679 | logo = ImageTexture::create_from_image(img); |
| 680 | |
| 681 | ImageLoaderSVG::create_image_from_string(img, _web_run_icon_svg, EDSCALE, upsample, false); |
| 682 | run_icon = ImageTexture::create_from_image(img); |
| 683 | #endif |
| 684 | |
| 685 | Ref<Theme> theme = EditorNode::get_singleton()->get_editor_theme(); |
| 686 | if (theme.is_valid()) { |
| 687 | stop_icon = theme->get_icon(SNAME("Stop" ), EditorStringName(EditorIcons)); |
| 688 | } else { |
| 689 | stop_icon.instantiate(); |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | |
| 694 | EditorExportPlatformWeb::~EditorExportPlatformWeb() { |
| 695 | if (server.is_valid()) { |
| 696 | server->stop(); |
| 697 | } |
| 698 | server_quit = true; |
| 699 | if (server_thread.is_started()) { |
| 700 | server_thread.wait_to_finish(); |
| 701 | } |
| 702 | } |
| 703 | |