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
49Error EditorExportPlatformWeb::_extract_template(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
105Error 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
115void 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
133void 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
175Error 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
208Error 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
317void 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
328void 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
352String EditorExportPlatformWeb::get_name() const {
353 return "Web";
354}
355
356String EditorExportPlatformWeb::get_os_name() const {
357 return "Web";
358}
359
360Ref<Texture2D> EditorExportPlatformWeb::get_logo() const {
361 return logo;
362}
363
364bool 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
404bool 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
423List<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
429Error 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
553bool 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
577Ref<ImageTexture> EditorExportPlatformWeb::get_option_icon(int p_index) const {
578 return p_index == 1 ? stop_icon : EditorExportPlatform::get_option_icon(p_index);
579}
580
581int EditorExportPlatformWeb::get_options_count() const {
582 return menu_options;
583}
584
585Error 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
654Ref<Texture2D> EditorExportPlatformWeb::get_run_icon() const {
655 return run_icon;
656}
657
658void 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
669EditorExportPlatformWeb::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
694EditorExportPlatformWeb::~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