GDScript 266 lines
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("")