@tool ## Utility node for downloading and unzipping a file from a URL to an extraction destination. extends Node ## Sent when the run has completed. signal run_completed ## Sent when a response is received from the server. signal response_received(response_body) ## Sent when the run has failed or exited early for any reason. signal run_failed(error : String) ## Sent when the zip file has finished saving. signal zip_saved const TEMPORARY_ZIP_PATH = "res://temp.zip" const RESULT_CANT_CONNECT = "Failed to connect" const RESULT_CANT_RESOLVE = "Failed to resolve" const RESULT_CONNECTION_ERROR = "Connection error" const RESULT_TIMEOUT = "Connection timeout" const RESULT_SERVER_ERROR = "Server error" const REQUEST_FAILED = "Error in the request" const REQUEST_TIMEOUT = "Request timed out on the client side" const DOWNLOAD_IN_PROGRESS = "Download already in progress" const EXTRACT_IN_PROGRESS = "Extract already in progress" const DELETE_IN_PROGRESS = "Delete already in progress" const FAILED_TO_SAVE_ZIP_FILE = "Failed to save the zip file" const FAILED_TO_MAKE_EXTRACT_DIR = "Failed to make extract directory" const FAILED_TO_READ_ZIP_FILE = "Failed to read the zip file" const DOWNLOADED_ZIP_FILE_DOESNT_EXIST = "The downloaded ZIP file doesn't exist" const URL_NOT_SET = "URL parameter is not set" enum DownloadAndExtractStage{ NONE, DOWNLOAD, SAVE, EXTRACT, DELETE, } ## Location of the zip file to be downloaded. @export var zip_url : String ## Path where the zipped files are to be extracted. @export_dir var extract_path : String @export_group("Advanced") ## If not empty, zipped file paths that do not contain a match to the string will be ignored. @export var path_match_string : String = "" ## Assuming zip file contains a single base directory, the flag copies all of the contents, ## as if they were at the base of the zip file. It never makes the base directory locally. @export var skip_base_zip_dir : bool = false ## Forces a download and extraction even if the files already exist. @export var force : bool = false ## Path where the zip file will be stored. @export var zip_file_path : String = TEMPORARY_ZIP_PATH ## If true, delete a downloaded zip file after the contents are extracted. @export var delete_zip_file : bool = true ## Ratio of processing time that should be spent on extracting files. @export_range(0.0, 1.0) var process_time_ratio : float = 0.75 ## Seconds of delay added between saving the zip file and extracting it. @export_range(0.0, 3.0) var extraction_delay : float = 0.25 ## Duration to wait before the request times out. @export var request_timeout : float = 0.0 @export var _start_run_action : bool = false : set(value): if value and Engine.is_editor_hint(): run() # For Godot 4.4 # @export_tool_button("Download & Extract") var _start_run_action = run @onready var _http_request : HTTPRequest = $HTTPRequest @onready var _timeout_timer : Timer= $TimeoutTimer ## State flag for whether the connection has timed out on the client-side. var timed_out : bool = false ## Current stage of the download and extract process. var stage : DownloadAndExtractStage = DownloadAndExtractStage.NONE var zip_reader : ZIPReader = ZIPReader.new() var zipped_file_paths : PackedStringArray = [] var extracted_file_paths : Array[String] = [] var skipped_file_paths : Array[String] = [] var downloaded_zip_file : bool = false var base_zip_path : String = "" var _save_progress : float = 0.0 func get_http_request() -> HTTPRequest: return _http_request func get_zip_url() -> String: return zip_url func _zip_exists() -> bool: return FileAccess.file_exists(zip_file_path) func get_request_method() -> int: return HTTPClient.METHOD_GET ## Sends the request to download the target zip file, and then extracts the contents. func run(request_headers : Array = []) -> void: if stage == DownloadAndExtractStage.DOWNLOAD: run_failed.emit(DOWNLOAD_IN_PROGRESS) push_warning(DOWNLOAD_IN_PROGRESS) return if _zip_exists() and not force: _extract_files.call_deferred() return var local_http_request : HTTPRequest = get_http_request() var url : String = get_zip_url() var method : int = get_request_method() if url.is_empty(): run_failed.emit(URL_NOT_SET) push_error(URL_NOT_SET) return if request_timeout > 0.0: local_http_request.timeout = request_timeout var error = local_http_request.request(url, request_headers, method) if error != OK: run_failed.emit(REQUEST_FAILED) push_error("HTTP Request error: %d" % error) return if request_timeout > 0.0: _timeout_timer.start(request_timeout + 1.0) stage = DownloadAndExtractStage.DOWNLOAD func _delete_zip_file() -> void: if not delete_zip_file or not downloaded_zip_file: return if stage == DownloadAndExtractStage.DELETE: run_failed.emit(DELETE_IN_PROGRESS) push_warning(DELETE_IN_PROGRESS) return stage = DownloadAndExtractStage.DELETE DirAccess.remove_absolute(zip_file_path) downloaded_zip_file = false func _save_zip_file(body : PackedByteArray) -> void: stage = DownloadAndExtractStage.SAVE var file = FileAccess.open(zip_file_path, FileAccess.WRITE) if not file: run_failed.emit(FAILED_TO_SAVE_ZIP_FILE) push_error(FAILED_TO_SAVE_ZIP_FILE) return file.store_buffer(body) file.close() downloaded_zip_file = true zip_saved.emit() func extract_path_exists() -> bool: return DirAccess.dir_exists_absolute(extract_path) func _make_extract_path() -> void: var err := DirAccess.make_dir_recursive_absolute(extract_path) if err != OK: run_failed.emit(FAILED_TO_MAKE_EXTRACT_DIR) push_error(FAILED_TO_MAKE_EXTRACT_DIR) func _extract_files() -> void: if stage == DownloadAndExtractStage.EXTRACT: run_failed.emit(EXTRACT_IN_PROGRESS) push_warning(EXTRACT_IN_PROGRESS) return stage = DownloadAndExtractStage.EXTRACT if not _zip_exists(): run_failed.emit(DOWNLOADED_ZIP_FILE_DOESNT_EXIST) push_error(DOWNLOADED_ZIP_FILE_DOESNT_EXIST) return if not extract_path_exists(): _make_extract_path() var error = zip_reader.open(zip_file_path) if error != OK: run_failed.emit(FAILED_TO_READ_ZIP_FILE) push_error("ZIP Reader error: %d" % error) return zipped_file_paths = zip_reader.get_files() if skip_base_zip_dir: base_zip_path = zipped_file_paths[0] if not base_zip_path.ends_with("/"): push_warning("Skipping extracting base path, but it is not a directory.") zipped_file_paths.remove_at(0) func _on_request_completed(result, response_code, headers, body) -> void: # If already timed out on client-side, then return. if timed_out: return _timeout_timer.stop() if _zip_exists(): _delete_zip_file() if result == HTTPRequest.RESULT_SUCCESS: if body is PackedByteArray: response_received.emit(body) _save_zip_file(body) _save_progress = 0.0 var tween = create_tween() tween.tween_property(self, "_save_progress", 1.0, extraction_delay) await tween.finished _extract_files.call_deferred() else: var error_message : String match(result): HTTPRequest.RESULT_CANT_CONNECT: error_message = RESULT_CANT_CONNECT HTTPRequest.RESULT_CANT_RESOLVE: error_message = RESULT_CANT_RESOLVE HTTPRequest.RESULT_CONNECTION_ERROR: error_message = RESULT_CONNECTION_ERROR HTTPRequest.RESULT_TIMEOUT: error_message = RESULT_TIMEOUT _: error_message = RESULT_SERVER_ERROR run_failed.emit(error_message) push_error("HTTP Result error: %d" % result) func _on_http_request_request_completed(result, response_code, headers, body) -> void: _on_request_completed(result, response_code, headers, body) func _on_timeout_timer_timeout() -> void: timed_out = true run_failed.emit(REQUEST_TIMEOUT) push_warning(REQUEST_TIMEOUT) func get_progress() -> float: if stage == DownloadAndExtractStage.DOWNLOAD: return get_download_progress() elif stage == DownloadAndExtractStage.SAVE: return get_save_progress() elif stage == DownloadAndExtractStage.EXTRACT: return get_extraction_progress() return 0.0 func get_save_progress() -> float: return _save_progress func get_extraction_progress() -> float: if zipped_file_paths.size() == 0: return 0.0 return float(extracted_file_paths.size()) / float(zipped_file_paths.size()) func get_download_progress() -> float: var body_size := _http_request.get_body_size() if body_size < 1: return 0.0 return float(_http_request.get_downloaded_bytes()) / float(body_size) func _zipped_files_remaining() -> int: return zipped_file_paths.size() - (extracted_file_paths.size() + skipped_file_paths.size()) func _extract_next_zipped_file() -> void: var path_index = extracted_file_paths.size() + skipped_file_paths.size() var zipped_file_path := zipped_file_paths[path_index] if path_match_string and not zipped_file_path.contains(path_match_string): skipped_file_paths.append(zipped_file_path) return var extract_path_dir := extract_path if not extract_path_dir.ends_with("/"): extract_path_dir += "/" var full_path := extract_path_dir if skip_base_zip_dir: full_path += zipped_file_path.replace(base_zip_path, "") else: full_path += zipped_file_path if full_path.ends_with("/"): if not DirAccess.dir_exists_absolute(full_path): DirAccess.make_dir_recursive_absolute(full_path) else: if not FileAccess.file_exists(full_path) or force: var file_access := FileAccess.open(full_path, FileAccess.WRITE) if file_access == null: skipped_file_paths.append(zipped_file_path) push_error("Failed to open file: %s" % full_path) return var file_contents = zip_reader.read_file(zipped_file_path) file_access.store_buffer(file_contents) file_access.close() extracted_file_paths.append(full_path) func _finish_extraction() -> void: zip_reader.close() _delete_zip_file() stage = DownloadAndExtractStage.NONE run_completed.emit() func _process(delta : float) -> void: if stage == DownloadAndExtractStage.EXTRACT: var frame_start_time : float = Time.get_unix_time_from_system() var frame_time : float = 0.0 while (frame_time < delta * process_time_ratio): if _zipped_files_remaining() == 0: _finish_extraction() break _extract_next_zipped_file() frame_time = Time.get_unix_time_from_system() - frame_start_time