class_name Updater extends Node ## Updates the game in place to the newest release: downloads the new game next to the ## running one, checks it, swaps the files and starts the new version. ## ## The game is one file on Windows and Linux (the game data is embedded). A running .exe ## can't be overwritten on Windows, but it can be renamed, so it is moved aside to ## [code].old[/code], which [method remove_old_files] deletes at the next start. On ## Linux the new file simply replaces the running one. ## ## [member executable_path], [member os_name], [member in_editor], [member start_process] ## and [member quit] can be replaced, so tests choose the platform and nothing restarts. ## Where the newest release can be downloaded by hand. const RELEASES_PAGE: String = "https://gitea.sanplex.xyz/Sansan/MusicPlayer/releases/latest" ## Added to the game's path for the downloaded new version. const UPDATE_SUFFIX: String = ".update" ## Added to the game's path for the old version, on Windows. const OLD_SUFFIX: String = ".old" ## rwxr-xr-x, for the new game on Linux. const EXECUTABLE_PERMISSIONS: int = 0b111_101_101 ## Seconds the whole download may take. const DOWNLOAD_TIMEOUT: float = 900.0 ## The running game's file. var executable_path: String = OS.get_executable_path() ## The platform, as [method OS.get_name] names it. var os_name: String = OS.get_name() ## Whether the game runs from the Godot editor, where there is no game file to replace. var in_editor: bool = OS.has_feature("editor") ## Starts a program: [code](path: String, arguments: PackedStringArray) -> int[/code]. var start_process: Callable = OS.create_process ## Quits the game. var quit: Callable var _request: HTTPRequest var _on_progress: Callable func _init() -> void: quit = func() -> void: get_tree().quit() func _process(_delta: float) -> void: if _request != null && _on_progress.is_valid(): _on_progress.call(_request.get_downloaded_bytes(), _request.get_body_size()) ## The release file to download for [param os_name]: the game itself to update in place, ## or, when not [param in_place], what a person downloads, which on Linux is the package ## with an installer. Empty when there is no release for the platform. static func asset_for(for_os: String, in_place: bool) -> String: match for_os: "Windows": return "windows" "Linux": return "linux" if in_place else "linux-package" return "" ## Why the game can't update itself here, or empty when it can. func can_update_in_place() -> String: if in_editor: return "The game runs from the Godot editor." if asset_for(os_name, true).is_empty(): return "The game updates itself on Windows and Linux." var probe: String = executable_path + ".write-test" var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE) if file == null: return "The game's folder %s can't be written to." % executable_path.get_base_dir() file.close() DirAccess.remove_absolute(probe) return "" ## Where the new version is downloaded to, next to the game so it can be renamed into ## place. func update_path() -> String: return executable_path + UPDATE_SUFFIX ## Downloads the new game from [param url]. Calls [param on_progress] with the bytes ## downloaded and the total (-1 while unknown) every frame, then [param on_done] with the ## file's path once it is checked, or [param on_failed] with the reason. func download(url: String, on_progress: Callable, on_done: Callable, on_failed: Callable) -> void: cancel() _on_progress = on_progress _request = HTTPRequest.new() _request.download_file = update_path() _request.timeout = DOWNLOAD_TIMEOUT add_child(_request) _request.request_completed.connect(_on_downloaded.bind(on_done, on_failed)) var error: Error = _request.request(url) if error != OK: cancel() on_failed.call("The download could not start: " + error_string(error)) ## Stops a download and deletes what was downloaded. func cancel() -> void: if _request != null: _request.cancel_request() _request.queue_free() _request = null DirAccess.remove_absolute(update_path()) ## Whether [param path] looks like the game for [param for_os]: a Windows program starts ## with "MZ", a Linux one with the ELF header. An error page saved by mistake does not. static func check_file(path: String, for_os: String) -> bool: var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null || file.get_length() < 4: return false var start: PackedByteArray = file.get_buffer(4) match for_os: "Windows": return start.slice(0, 2) == "MZ".to_ascii_buffer() "Linux": return start == PackedByteArray([0x7F, 0x45, 0x4C, 0x46]) return false ## Puts the new game at [param update] in place of the game at [param exe]. On Windows ## the running game is moved aside first, and put back if the new one can't be moved in. static func install(update: String, exe: String, for_os: String) -> Error: if for_os == "Windows": var old: String = exe + OLD_SUFFIX if FileAccess.file_exists(old): DirAccess.remove_absolute(old) var error: Error = DirAccess.rename_absolute(exe, old) if error != OK: return error error = DirAccess.rename_absolute(update, exe) if error != OK: DirAccess.rename_absolute(old, exe) return error FileAccess.set_unix_permissions(update, EXECUTABLE_PERMISSIONS) return DirAccess.rename_absolute(update, exe) ## Deletes what an update left next to the game at [param exe]: the old version, and a ## download that was not finished. static func remove_old_files(exe: String) -> void: for suffix: String in [OLD_SUFFIX, UPDATE_SUFFIX]: if FileAccess.file_exists(exe + suffix): DirAccess.remove_absolute(exe + suffix) ## Starts the game again, now the new version, and quits this one. func restart() -> void: start_process.call(executable_path, OS.get_cmdline_args()) quit.call() func _on_downloaded(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray, on_done: Callable, on_failed: Callable) -> void: var expected: int = _request.get_body_size() _request.queue_free() _request = null var reason: String = "" if result != HTTPRequest.RESULT_SUCCESS: reason = "The download failed. Check the connection and try again." elif response_code != 200: reason = "The download failed: the server answered %d." % response_code elif expected >= 0 && FileAccess.get_size(update_path()) != expected: reason = "The download stopped before the end. Try again." elif !check_file(update_path(), os_name): reason = "The downloaded file is not the game for %s." % os_name if !reason.is_empty(): DirAccess.remove_absolute(update_path()) on_failed.call(reason) return on_done.call(update_path())