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 | |