extends Node const API_SLUG := "https://api.github.com/repos/" const RELEASES_SLUG := "godotengine/godot/releases" const ASSET_SLUG := "assets/%s" const RELEASES_BASE_FOLDER := "user://releases" const RELEASES_FILE := RELEASES_BASE_FOLDER + "/releases.json" const TEMP_FOLDER := "user://_tmp/" const FETCH_COOLDOWN := 60.0 # seconds var releases: Releases var github_token: String = OS.get_environment("GITHUB_TOKEN") var http: HTTPRequest = HTTPRequest.new() var is_downloading: bool = false var threads: Array[Thread] signal releases_fetched(releases: Releases) signal version_downloaded(version: String) signal export_templates_downloaded(version: String) signal status_update(status: String) enum Status{ IDLE, RELEASE_DOWNLOAD_IN_PROGRESS, TEMPLATE_DOWNLOAD_IN_PROGRESS, DOWNLOAD_DONE, FETCHING_RELEASES, FETCHING_DONE, VERSION_NOT_IN_INDEX, RELEASE_ALREADY_EXISTS, TEMPLATE_ALREADY_EXISTS, UNPACKING_TEMPLATES, UNPACKING_RELEASE, RELEASE_INSTALLED, TEMPLATE_INSTALLED, } const STATUS_MESSAGES := { Status.IDLE: "Idle", Status.RELEASE_DOWNLOAD_IN_PROGRESS: "Downloading Godot v%s", Status.TEMPLATE_DOWNLOAD_IN_PROGRESS: "Downloading Export Templates for v%s", Status.DOWNLOAD_DONE: "Done downloading", Status.FETCHING_RELEASES: "Fetching releases", Status.FETCHING_DONE: "Done fetching releases", Status.VERSION_NOT_IN_INDEX: "Version not in index. Fetch first", Status.RELEASE_ALREADY_EXISTS: "This version already exists", Status.TEMPLATE_ALREADY_EXISTS: "Templates for this version already exist", Status.UNPACKING_TEMPLATES: "Unpacking Export Templates for v%s", Status.UNPACKING_RELEASE: "Unpacking Godot v%s", Status.RELEASE_INSTALLED: "Godot v%s installed", Status.TEMPLATE_INSTALLED: "Export Templates for Godot v%s installed", } func _ready() -> void: DirAccess.make_dir_absolute(TEMP_FOLDER) DirAccess.make_dir_absolute(RELEASES_BASE_FOLDER) add_child(http) releases = load_releases() clean_tmp() func fetch_releases(force_local: bool = false) -> void: if force_local && FileAccess.file_exists(RELEASES_FILE): releases_fetched.emit(releases) return if is_downloading: print("Another download is in progress, try again later") return if releases.last_checked_at + FETCH_COOLDOWN > Time.get_unix_time_from_system(): print("already fetched recently, returning local") releases_fetched.emit(releases) return var response_func = func(result: int, _response_code: int, _headers: PackedStringArray, body: PackedByteArray): if result != OK: return releases.clear() releases.last_checked_at = Time.get_unix_time_from_system() var d: Array = JSON.parse_string(body.get_string_from_ascii()) for i in d: # tag name is version name var rm := releases.get_or_create_version_metadata(i.tag_name) # we already have this one if rm.binary_github_asset_id != -1: continue var file_name_placeholder := "Godot_v%s.zip" # only linux for now var file_name_template: String = "%s_linux.x86_64" % i.tag_name var file_name_template_x11: String = "%s_x11.64" % i.tag_name var file_name := file_name_placeholder % file_name_template var file_name_x11 := file_name_placeholder % file_name_template_x11 var export_template: String = "Godot_v%s_export_templates.tpz" % i.tag_name # go through assets of this release and find our os for asset in i.assets: if asset.name == file_name || asset.name == file_name_x11: rm.binary_github_asset_id = asset.id rm.binary_github_filename = asset.name if asset.name == export_template: rm.export_github_asset_id = asset.id rm.export_github_filename = asset.name # if we still have an empty item, it's too old to use # (meaning it's not mirrored on github) # so we remove it if rm.is_empty(): releases.releases.erase(i.tag_name) releases_fetched.emit(releases) status_update.emit(STATUS_MESSAGES[Status.FETCHING_DONE]) is_downloading = false save_releases() var headers := [ "Accept: application/vnd.github+json", "Authorization: Bearer %s" % github_token, "X-GitHub-Api-Version: 2022-11-28", ] is_downloading = true status_update.emit(STATUS_MESSAGES[Status.FETCHING_RELEASES]) http.request(API_SLUG + RELEASES_SLUG, PackedStringArray(headers)) http.request_completed.connect(response_func, CONNECT_ONE_SHOT) func download_release(version: String) -> void: if is_downloading: print("downloading something, try again later") return if !releases.releases.has(version): # print("this version is not in the index yet or does not exist. fetch first") status_update.emit(STATUS_MESSAGES[Status.VERSION_NOT_IN_INDEX]) return if (releases.releases[version] as ReleaseMetadata).binary_path != "": # print("already have this version") status_update.emit(STATUS_MESSAGES[Status.RELEASE_ALREADY_EXISTS]) return var rm: ReleaseMetadata = releases.releases[version] var response_func = func(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray, filename: String): if response_code == 200: # download happens in place. # we know the file is zip, so save it to temp first before unpacking status_update.emit(STATUS_MESSAGES[Status.DOWNLOAD_DONE]) print("got it in place") print("downloading url: ", rm.binary_github_filename) var zip_filename := TEMP_FOLDER.path_join("%s.zip" % version) print("making zip: ", zip_filename) var f := FileAccess.open(zip_filename, FileAccess.WRITE) if f.get_error() != OK: print("zip file creation error:", f.get_error()) return f.store_buffer(body) f.close() var zip := ZIPReader.new() var zerr := zip.open(zip_filename) if zerr != OK: print("error opening zip file: ", zerr) return var res := zip.read_file(filename.trim_suffix(".zip")) zip.close() clean_tmp() status_update.emit(STATUS_MESSAGES[Status.UNPACKING_RELEASE] % version) var d := DirAccess.open(RELEASES_BASE_FOLDER) d.make_dir_recursive(version) var new_file := RELEASES_BASE_FOLDER.path_join(version) new_file = new_file.path_join("godot") f = FileAccess.open(new_file, FileAccess.WRITE) f.store_buffer(res) f.close() rm.binary_path = new_file version_downloaded.emit(version) status_update.emit(STATUS_MESSAGES[Status.RELEASE_INSTALLED] % version) is_downloading = false save_releases() elif response_code == 302: # not in place print("tried downloading, but got a 302 which is not currently supported") var headers = [ "Accept: application/octet-stream", "Authorization: Bearer %s" % github_token, "X-GitHub-Api-Version: 2022-11-28", ] var asset_url = API_SLUG.path_join(RELEASES_SLUG).path_join(ASSET_SLUG) % rm.binary_github_asset_id status_update.emit(STATUS_MESSAGES[Status.RELEASE_DOWNLOAD_IN_PROGRESS] % version) is_downloading = true http.request(asset_url, PackedStringArray(headers)) http.request_completed.connect(response_func.bind(rm.binary_github_filename), CONNECT_ONE_SHOT) func download_export_templates(version: String) -> void: if is_downloading: print("downloading something, try again later") return if !releases.releases.has(version): # print("this version is not in the index yet or does not exist. fetch first") status_update.emit(STATUS_MESSAGES[Status.VERSION_NOT_IN_INDEX]) return if (releases.releases[version] as ReleaseMetadata).export_templates_path != "": # print("already have templates for this version") status_update.emit(STATUS_MESSAGES[Status.TEMPLATE_ALREADY_EXISTS]) return var rm: ReleaseMetadata = releases.releases[version] var response_func = func(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray): if response_code == 200: status_update.emit(STATUS_MESSAGES[Status.DOWNLOAD_DONE]) print("got it in place") print("downloading url: ", rm.export_github_filename) var zip_filename := TEMP_FOLDER.path_join("%s_export.tpz" % version) print("making zip: ", zip_filename) var f := FileAccess.open(zip_filename, FileAccess.WRITE) if f.get_error() != OK: print("zip file creation error: ", f.get_error()) return f.store_buffer(body) f.close() var zip := ZIPReader.new() var zerr := zip.open(zip_filename) if zerr != OK: print("error opening zip file: ", zerr) return var files := zip.get_files() var d := DirAccess.open(RELEASES_BASE_FOLDER) d.make_dir_recursive(version.path_join("templates")) var templates_folder := RELEASES_BASE_FOLDER.path_join(version) status_update.emit(STATUS_MESSAGES[Status.UNPACKING_TEMPLATES] % version) for file in files: var t := Thread.new() threads.append(t) t.start(unpack_zip_file.bind(zip_filename, file, templates_folder.path_join(file), t, version)) zip.close() # don't clean tmp just yet, since there might be other threads # that are still unpacking from the same zip. # side note: why do we have to have the zip in filesystem instead # of memory? this would be much easier... rm.export_templates_path = ProjectSettings.globalize_path(templates_folder.path_join("templates")) export_templates_downloaded.emit(version) is_downloading = false save_releases() elif response_code == 302: # not in place print("tried downloading, but got a 302 which is not currently supported") var headers = [ "Accept: application/octet-stream", "Authorization: Bearer %s" % github_token, "X-GitHub-Api-Version: 2022-11-28", ] var asset_url = API_SLUG.path_join(RELEASES_SLUG).path_join(ASSET_SLUG) % rm.export_github_asset_id status_update.emit(STATUS_MESSAGES[Status.TEMPLATE_DOWNLOAD_IN_PROGRESS] % version) is_downloading = true http.request(asset_url, PackedStringArray(headers)) http.request_completed.connect(response_func, CONNECT_ONE_SHOT) func unpack_zip_file(zip_file: String, from_file: String, dest_file: String, thread: Thread, version: String) -> void: print("extracting file ", from_file, " to: ", dest_file) var z := ZIPReader.new() var f := z.open(zip_file) if f != OK: print("error opening zip file: ", zip_file, " from thread ", thread, ", exiting early.") _clean_threads.call_deferred(thread) return var file := FileAccess.open(dest_file, FileAccess.WRITE) file.store_buffer(z.read_file(from_file)) file.close() z.close() _clean_threads.call_deferred(thread, version) func _clean_threads(thread: Thread, version: String) -> void: threads.erase(thread) thread.wait_to_finish() if threads.is_empty(): status_update.emit(STATUS_MESSAGES[Status.TEMPLATE_INSTALLED] % version) clean_tmp() func save_releases() -> void: var f := FileAccess.open(RELEASES_FILE, FileAccess.WRITE) f.store_string(releases.to_json()) func load_releases() -> Releases: if !FileAccess.file_exists(RELEASES_FILE): return Releases.new() var f := FileAccess.open(RELEASES_FILE, FileAccess.READ) return Releases.from_json(f.get_as_text()) func _exit_tree() -> void: for t in threads: t.wait_to_finish() clean_tmp() func get_version_metadata(version: String) -> ReleaseMetadata: return releases.releases.get(version) func get_installed_versions() -> Dictionary: var res: Dictionary for version in releases.releases: if is_version_installed(version): res[version] = releases.releases[version] return res func clean_tmp() -> void: var d := DirAccess.open(TEMP_FOLDER) if !d: return var to_delete: Array[String] = [] d.list_dir_begin() var file_name := d.get_next() while file_name != "": to_delete.append(file_name) file_name = d.get_next() d.list_dir_end() for f in to_delete: d.remove(f) # helper methods func is_version_installed(version: String) -> bool: return get_version_metadata(version).binary_path != "" func is_version_templates_installed(version) -> bool: return get_version_metadata(version).export_templates_path != "" class ReleaseMetadata: var binary_path: String var is_mono: bool var export_templates_path: String var binary_github_asset_id: int = -1 var binary_github_filename: String var export_github_asset_id: int = -1 var export_github_filename: String func is_empty() -> bool: return \ binary_github_asset_id == -1 && export_github_asset_id == -1 func to_d() -> Dictionary: return { "binary_path": binary_path, "is_mono": is_mono, "export_templates_path": export_templates_path, "binary_github_asset_id": binary_github_asset_id, "export_github_asset_id": export_github_asset_id, "binary_github_filename": binary_github_filename, "export_github_filename": export_github_filename, } static func from_d(d: Dictionary) -> ReleaseMetadata: var res := ReleaseMetadata.new() res.binary_path = d.binary_path res.is_mono = d.is_mono res.export_templates_path = d.export_templates_path res.binary_github_asset_id = d.binary_github_asset_id res.export_github_asset_id = d.export_github_asset_id res.binary_github_filename = d.binary_github_filename res.export_github_filename = d.export_github_filename return res class Releases: # version[String]: ReleaseMetadata var releases: Dictionary var last_checked_at: float var os: String func clear() -> void: for r in releases.keys(): if (releases[r] as ReleaseMetadata).binary_path == "": releases.erase(r) func get_or_create_version_metadata(version: String) -> ReleaseMetadata: if releases.has(version): return releases[version] var r := ReleaseMetadata.new() releases[version] = r return r func is_empty() -> bool: return releases.is_empty() func to_json() -> String: var releases_dict := {} for i in releases: releases_dict[i] = (releases[i] as ReleaseMetadata).to_d() var d := { "os": os, "last_checked_at": last_checked_at, "releases": releases_dict, } return JSON.stringify(d, "\t", false) static func from_json(json: String) -> Releases: var d: Dictionary = JSON.parse_string(json) var releases_dict := {} for i in d.releases: releases_dict[i] = ReleaseMetadata.from_d(d.releases[i]) var r := Releases.new() r.releases = releases_dict r.os = d.os r.last_checked_at = d.last_checked_at return r