class_name Updater extends Node ## The network half of the in-client auto-updater: it reaches the player's chosen release ## channel on GitHub, decides whether a newer `game.pck` is published, downloads it, ## and atomically swaps it into the payload sandbox. The boot scene drives it; all the ## judgements (is-it-newer, is-this-client-new-enough, where-do-files-go) live in the ## network-free `UpdateManifest`, kept apart so they stay unit-testable. ## ## This is the Godot-native form of the-way-out's `updater.py` "author pushes, player ## gets it" loop. There the thin launcher pulled a source zip and swapped an `app/` ## dir; here the client *is* the launcher and the churning part is a `game.pck` the ## boot scene loads over the bundled seed. The semantics are deliberately the same: ## throttled cold-start probe, atomic swap via staged download, a kept-back `.prev` ## for rollback, and fail-soft — any network or integrity failure leaves the working ## install untouched and the client runs whatever it already has. ## ## Hard safety rule (from that updater): every write lands under ## `UpdateManifest.PAYLOAD_DIR`. Player data lives at the `user://` root, a sibling, ## and is never touched — a swap cannot wipe settings or a future save. ## A finished update check. `available` is true only when a newer pck is published ## *and* this client is new enough to load it; `info` carries the build details ## (`sha`, `version`, `pck_url`, `min_client`, `needs_client_upgrade`, `offline`). signal check_done(available: bool, info: Dictionary) ## Download progress as a 0..1 fraction while a pck is being fetched, for the boot bar. signal download_progress(ratio: float) ## An apply attempt finished — true when the new pck is live, false when the install ## was left as it was (download, integrity, or swap failure). signal applied(ok: bool) ## The public GitHub repo, owner and name kept apart (joined only in the API URL) so the ## release path is built from parts rather than a bare "owner/name" slug. const REPO_OWNER := "ajhahnde" const REPO_NAME := "Theria" const MANIFEST_ASSET := "manifest.json" ## GitHub's API wants a User-Agent or it 403s; Accept pins the stable API media type. const HEADERS: PackedStringArray = [ "User-Agent: theria-updater", "Accept: application/vnd.github+json", ] ## How long a Godot .pck's header magic reads as, used to reject a truncated or ## error-page download before it is ever promoted to the live pck. const PCK_MAGIC := "GDPC" ## Cold-start probe throttle: after a successful reach, skip the launch-time check ## until this many seconds pass, so a slow link does not stall every launch. The ## in-menu "Check now" path bypasses it. const CHECK_INTERVAL_S := 86400.0 ## Per-request ceiling. A JSON call is tiny; the pck download gets the longer window. const REQUEST_TIMEOUT_S := 10.0 const DOWNLOAD_TIMEOUT_S := 120.0 ## Which release channel the updater pulls, set by the boot scene from the player's saved ## choice before the check runs. Beta (the default) pulls the rolling `playtest` pre-release ## — a fresh pck per push to main; Stable pulls the latest tagged release. Mapped to the ## GitHub releases-API path by `UpdateManifest.release_path`. var channel := UpdateManifest.CHANNEL_DEFAULT var _http: HTTPRequest ## True while a pck download is in flight, so `_process` emits progress only then. var _downloading := false func _ready() -> void: _http = HTTPRequest.new() _http.timeout = REQUEST_TIMEOUT_S add_child(_http) ## Emits a download fraction each frame while a pck is being fetched. Body size is ## unknown until the server's headers arrive, so it stays quiet until then. func _process(_delta: float) -> void: if not _downloading: return var total := _http.get_body_size() if total > 0: download_progress.emit(clampf(float(_http.get_downloaded_bytes()) / float(total), 0.0, 1.0)) ## True when a launch-time probe is worth doing: always on a fresh install (no payload ## yet), otherwise only once `CHECK_INTERVAL_S` has elapsed since the last successful ## reach. Mirrors the-way-out's `should_check` so a captive or slow network does not ## pause every launch by the request timeout. func should_check() -> bool: if not UpdateManifest.has_payload(): return true if not FileAccess.file_exists(UpdateManifest.LAST_CHECK_PATH): return true var last := FileAccess.get_modified_time(UpdateManifest.LAST_CHECK_PATH) return (Time.get_unix_time_from_system() - float(last)) >= CHECK_INTERVAL_S ## Reaches the channel and reports whether an installable update exists, via ## `check_done`. The flow: read the release (for its asset list), read the ## `manifest.json` asset, resolve the pck's download URL from the assets, then judge ## newer-than-installed and client-new-enough. Any unreachable step reports ## `available = false` with `offline = true` so the caller just runs the install it has. func check() -> void: var release := await _get_json(_release_url()) if not release["ok"]: check_done.emit(false, {"offline": true}) return var assets := _asset_urls(release["data"]) if not assets.has(MANIFEST_ASSET): # Reached the channel but it carries no manifest yet (e.g. before the first # publish): a clean "nothing to install", not an error. _mark_checked() check_done.emit(false, {}) return var manifest_resp := await _get_json(assets[MANIFEST_ASSET]) if not manifest_resp["ok"]: check_done.emit(false, {"offline": true}) return _mark_checked() var verdict := _judge(manifest_resp["data"], assets) check_done.emit(verdict[0], verdict[1]) ## Turns a parsed manifest and the release's asset URLs into the `check_done` ## arguments `[available, info]`. An update is offered only when the build is newer ## than the installed sha, its pck asset is actually present, and this client clears ## the pck's `min_client` floor; a newer build this client is too old to load reports ## `available = false` with `needs_client_upgrade = true` so the boot screen can ask ## the player to re-download the client rather than silently doing nothing. func _judge(manifest_data: Variant, assets: Dictionary) -> Array: var m := UpdateManifest.parse(JSON.stringify(manifest_data)) var info := { "sha": m["sha"], "version": m["version"], "pck_url": assets.get(m["pck"], ""), "min_client": m["min_client"], "needs_client_upgrade": false, } var newer := UpdateManifest.is_newer(m["sha"], UpdateManifest.local_sha()) var has_pck := not (info["pck_url"] as String).is_empty() if newer and not UpdateManifest.client_supported(m["min_client"], UpdateManifest.client_version()): info["needs_client_upgrade"] = true return [false, info] return [newer and has_pck, info] ## Downloads the pck named in `info` into the staging slot, verifies it, and swaps it ## live, reporting the outcome via `applied`. On any failure the existing install is ## left exactly as it was — the download lands in `.new` and is only promoted once it ## verifies. Emits `applied(false)` and returns early without touching the live pck. func apply(info: Dictionary) -> void: var url: String = info.get("pck_url", "") if url.is_empty(): applied.emit(false) return if not _ensure_payload_dir(): applied.emit(false) return if not await _download(url, UpdateManifest.PCK_NEW_PATH): applied.emit(false) return if not _is_valid_pck(UpdateManifest.PCK_NEW_PATH): DirAccess.remove_absolute(UpdateManifest.PCK_NEW_PATH) applied.emit(false) return applied.emit(_swap_in(info.get("sha", ""), info.get("version", ""))) ## Promotes the staged `.new` pck to the live slot: roll the current live pck to ## `.prev` (for rollback), move `.new` into place, and record the installed sha (the ## comparison key) plus its human version (for the footer). The live pck does not exist ## only for the instant between the two renames; both are within the payload dir (same ## filesystem) so each is atomic. Returns false on any filesystem error, leaving the ## staged file behind for the next attempt. The sha write gates the result — it is the ## newer-than-installed key; the version marker is cosmetic, written best-effort after. func _swap_in(sha: String, version: String) -> bool: if FileAccess.file_exists(UpdateManifest.PCK_PATH): DirAccess.remove_absolute(UpdateManifest.PCK_PREV_PATH) if DirAccess.rename_absolute(UpdateManifest.PCK_PATH, UpdateManifest.PCK_PREV_PATH) != OK: return false if DirAccess.rename_absolute(UpdateManifest.PCK_NEW_PATH, UpdateManifest.PCK_PATH) != OK: return false var f := FileAccess.open(UpdateManifest.VERSION_PATH, FileAccess.WRITE) if f == null: return false f.store_string(sha) var vf := FileAccess.open(UpdateManifest.PAYLOAD_VERSION_PATH, FileAccess.WRITE) if vf != null: vf.store_string(version) return true ## A GET that returns its body parsed as JSON: `{ok, data}`. `ok` is false on a ## transport error, a non-200 status, or a body that is not valid JSON, so every ## unreachable or malformed response collapses to one "not ok" the callers handle. func _get_json(url: String) -> Dictionary: _http.download_file = "" # in-memory body, not a file if _http.request(url, HEADERS) != OK: return {"ok": false} var result: Array = await _http.request_completed if result[0] != HTTPRequest.RESULT_SUCCESS or result[1] != 200: return {"ok": false} var json := JSON.new() if json.parse((result[3] as PackedByteArray).get_string_from_utf8()) != OK: return {"ok": false} return {"ok": true, "data": json.data} ## Downloads `url` straight to `dest`, returning true on a 200. The longer timeout ## covers the pck (a JSON call uses the default); progress is emitted from `_process` ## while `_downloading` holds. Restores the request timeout on the way out. func _download(url: String, dest: String) -> bool: _http.download_file = dest _http.timeout = DOWNLOAD_TIMEOUT_S _downloading = true var ok := false if _http.request(url, HEADERS) == OK: var result: Array = await _http.request_completed ok = result[0] == HTTPRequest.RESULT_SUCCESS and result[1] == 200 _downloading = false _http.timeout = REQUEST_TIMEOUT_S _http.download_file = "" return ok ## True when `path` starts with the Godot pack magic, so a truncated download or an ## HTML error page served in place of the pck is rejected before it is swapped live. func _is_valid_pck(path: String) -> bool: var f := FileAccess.open(path, FileAccess.READ) if f == null: return false return f.get_buffer(4).get_string_from_ascii() == PCK_MAGIC ## Maps each release asset's file name to its download URL, so a manifest's `pck` ## filename and the manifest asset itself resolve to fetchable URLs. Empty when the ## release JSON carries no asset list. func _asset_urls(release_data: Variant) -> Dictionary: var urls := {} if typeof(release_data) != TYPE_DICTIONARY: return urls var assets: Variant = (release_data as Dictionary).get("assets", []) if typeof(assets) != TYPE_ARRAY: return urls for asset in assets: if typeof(asset) == TYPE_DICTIONARY and asset.has("name"): urls[asset["name"]] = asset.get("browser_download_url", "") return urls func _release_url() -> String: var path := UpdateManifest.release_path(channel) return "https://api.github.com/repos/%s/%s/%s" % [REPO_OWNER, REPO_NAME, path] ## Creates the payload sandbox if absent. Returns false on a filesystem error, which ## aborts the apply rather than writing outside the dir. func _ensure_payload_dir() -> bool: if DirAccess.dir_exists_absolute(UpdateManifest.PAYLOAD_DIR): return true return DirAccess.make_dir_recursive_absolute(UpdateManifest.PAYLOAD_DIR) == OK ## Records a successful reach to the channel by touching the throttle marker; best ## effort, since a write failure only means the next launch probes again. func _mark_checked() -> void: if not _ensure_payload_dir(): return var f := FileAccess.open(UpdateManifest.LAST_CHECK_PATH, FileAccess.WRITE) if f != null: f.store_string("")