extends Node ## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a ## newer Cinderwild than this one? Asked once at start-up, of the feed in ## Project Settings (`cinderwild/updates/feed`). The feed is either ## - a release API answer (Gitea, Forgejo or GitHub `releases/latest`): its ## `version.json` asset is fetched next, and its other assets are the ## downloads, by file name; or ## - a `version.json` itself: ## {"version": "0.2.0", "protocol": 13, "notes": "...", "page": "https://...", ## "downloads": {"macos": {"file": "Cinderwild-0.2.0-macos.zip", "sha256": "...", ## "size": 61164958, "url": "(optional; else the asset)"}, ## "windows": {...}}} ## tools/export.sh writes that file next to the zips, for the release. ## ## This only tells you (title screen, join refusals, the server's log); ## downloading and swapping in the new version is the next step. Off in ## headless runs (tests, bots) unless forced; a dedicated server forces it. ## Your setting (on or off) is in user://updates.json. signal checked(state: String) const TIMEOUT := 10.0 var version: String = String(ProjectSettings.get_setting("application/config/version", "0.0.0")) var feed: String = String(ProjectSettings.get_setting("cinderwild/updates/feed", "")) var prefs_path := "user://updates.json" var enabled := true ## idle | off | checking | current | available | failed var state := "idle" var latest: Dictionary = {} ## the version.json of the newest release var assets: Dictionary = {} ## file name -> download URL, from the release var page := "" ## a web page for the release (or the feed's "page") var error := "" ## Tests: url -> response text, instead of the network. var fetch_override: Callable func _ready() -> void: load_prefs() func load_prefs() -> void: enabled = true if FileAccess.file_exists(prefs_path): var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path)) if d is Dictionary: enabled = bool(d.get("check", true)) func set_enabled(on: bool) -> void: enabled = on var f := FileAccess.open(prefs_path, FileAccess.WRITE) if f != null: f.store_string(JSON.stringify({"check": enabled}, "\t")) ## Asks the feed once. `force`: even headless, even with the setting off (a ## dedicated server, which has its own setting). func check(force: bool = false) -> void: if state == "checking": return if not force and (not enabled or (DisplayServer.get_name() == "headless" and not fetch_override.is_valid())): _finish("off") return if feed.is_empty(): error = "no update feed set" _finish("failed") return state = "checking" latest = {} assets = {} page = "" error = "" var first: Variant = await _get_json(feed) if not (first is Dictionary): _finish("failed") return var d: Dictionary = first if not d.has("version") and d.has("assets"): # A release: its assets by name, then its version.json. page = String(d.get("html_url", "")) for a in d.get("assets", []): if a is Dictionary and a.has("name") and a.has("browser_download_url"): assets[String(a.name)] = String(a.browser_download_url) if not assets.has("version.json"): error = "the latest release has no version.json" _finish("failed") return var v: Variant = await _get_json(assets["version.json"]) if not (v is Dictionary): _finish("failed") return d = v if not d.has("version"): error = "the feed has no version" _finish("failed") return latest = d if page.is_empty(): page = String(d.get("page", "")) _finish("available" if compare_versions(String(d.version), version) > 0 else "current") func _finish(s: String) -> void: state = s if s == "failed": print("[updates] couldn't check for updates: ", error) elif s == "available": print("[updates] Cinderwild %s is out (this is %s)" % [latest_version(), version]) checked.emit(s) func _get_json(url: String) -> Variant: var text := "" if fetch_override.is_valid(): text = String(fetch_override.call(url)) else: var http := HTTPRequest.new() http.timeout = TIMEOUT add_child(http) var err := http.request(url, ["Accept: application/json", "User-Agent: Cinderwild/" + version]) if err != OK: http.queue_free() error = "couldn't ask %s (%d)" % [url, err] return null var r: Array = await http.request_completed http.queue_free() if int(r[0]) != HTTPRequest.RESULT_SUCCESS or int(r[1]) != 200: error = "%s answered %s" % [url, "HTTP %d" % int(r[1]) if int(r[0]) == HTTPRequest.RESULT_SUCCESS else "nothing (result %d)" % int(r[0])] return null text = (r[3] as PackedByteArray).get_string_from_utf8() var j := JSON.new() # (quietly: a broken feed isn't an engine error) if j.parse(text) != OK or not (j.data is Dictionary): error = "%s didn't answer with JSON" % url return null return j.data # --- what we found -------------------------------------------------------------------- func latest_version() -> String: return String(latest.get("version", "")) func latest_protocol() -> int: return int(latest.get("protocol", 0)) ## "macos", "windows" or "linux": the key in version.json's downloads. static func platform_key() -> String: match OS.get_name(): "macOS": return "macos" "Windows": return "windows" return "linux" ## This platform's download in the newest release: {file, sha256, size, url}. func download() -> Dictionary: var dl: Variant = latest.get("downloads", {}).get(platform_key(), {}) if not (dl is Dictionary) or dl.is_empty(): return {} var out: Dictionary = (dl as Dictionary).duplicate() if String(out.get("url", "")).is_empty(): out["url"] = String(assets.get(String(out.get("file", "")), "")) return out ## Where the Download button goes: this platform's file, else the release page. func download_url() -> String: var u := String(download().get("url", "")) return u if not u.is_empty() else page ## A packaged game (an exported build), as opposed to running from the source. static func packaged() -> bool: return OS.has_feature("template") ## -1, 0 or 1. "v0.10.2" > "0.9", "1.0.0-beta" == "1.0.0" (suffixes ignored). static func compare_versions(a: String, b: String) -> int: var pa := a.strip_edges().trim_prefix("v").get_slice("-", 0).split(".") var pb := b.strip_edges().trim_prefix("v").get_slice("-", 0).split(".") for i in range(maxi(pa.size(), pb.size())): var x := int(pa[i]) if i < pa.size() else 0 var y := int(pb[i]) if i < pb.size() else 0 if x != y: return 1 if x > y else -1 return 0