Update check: version feed from the git server's releases, title-screen banner, clearer version refusals
This commit is contained in:
177
scripts/core/updates.gd
Normal file
177
scripts/core/updates.gd
Normal file
@@ -0,0 +1,177 @@
|
||||
extends Node
|
||||
## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a
|
||||
## newer Emberwild than this one? Asked once at start-up, of the feed in
|
||||
## Project Settings (`emberwild/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": "Emberwild-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("emberwild/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] Emberwild %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: Emberwild/" + 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
|
||||
@@ -167,6 +167,18 @@ func _start_dedicated() -> void:
|
||||
if not err.is_empty():
|
||||
printerr("[server] ", err)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# A real dedicated server (not one a test or Host Game started) says in
|
||||
# its log when there's a newer version; server.json "check_updates".
|
||||
if not _args.has("exit-with") and bool(config.v("check_updates")):
|
||||
Updates.checked.connect(_on_update_checked, CONNECT_ONE_SHOT)
|
||||
Updates.check(true)
|
||||
|
||||
func _on_update_checked(s: String) -> void:
|
||||
if s == "available":
|
||||
print("[server] Emberwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [
|
||||
Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL,
|
||||
"Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""])
|
||||
|
||||
## Opens the server. Returns "" on success, otherwise what went wrong.
|
||||
func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
||||
@@ -577,7 +589,9 @@ func _c2s_hello(d: Dictionary) -> void:
|
||||
if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"):
|
||||
return
|
||||
if int(d.get("protocol", 0)) != PROTOCOL:
|
||||
_reject(peer, "Different game version (server %d, you %d): update the game" % [PROTOCOL, int(d.get("protocol", 0))])
|
||||
var theirs := int(d.get("protocol", 0))
|
||||
_reject(peer, "Different game version: this server runs Emberwild %s (protocol %d), you have protocol %d. %s" % [
|
||||
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
|
||||
return
|
||||
var pw := String(config.v("password"))
|
||||
if not pw.is_empty() and String(d.get("password", "")) != pw:
|
||||
|
||||
@@ -27,6 +27,7 @@ const DEFAULTS := {
|
||||
"autosave_minutes": 2,
|
||||
"backups_kept": 10,
|
||||
"backup_hours": 6,
|
||||
"check_updates": true,
|
||||
"admins": [],
|
||||
"allowlist": [],
|
||||
"banned": [],
|
||||
|
||||
@@ -100,6 +100,14 @@ func bind(p: CharacterBody3D) -> void:
|
||||
tips_row.add_child(again)
|
||||
_main.add_child(tips_row)
|
||||
|
||||
var upd := CheckButton.new()
|
||||
upd.text = "Check for updates at start (Emberwild %s)" % Updates.version
|
||||
upd.focus_mode = Control.FOCUS_NONE
|
||||
upd.button_pressed = Updates.enabled
|
||||
upd.add_theme_font_size_override("font_size", 16)
|
||||
upd.toggled.connect(Updates.set_enabled)
|
||||
_main.add_child(upd)
|
||||
|
||||
# The tutorial: skip a step, or leave it (saved, to carry on later).
|
||||
if Guide.active:
|
||||
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))
|
||||
|
||||
@@ -85,6 +85,7 @@ func _ready() -> void:
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 24)
|
||||
col.add_child(spacer)
|
||||
_build_update_banner(root)
|
||||
|
||||
_pages["main"] = _main_page()
|
||||
_pages["new"] = _new_page()
|
||||
@@ -498,7 +499,7 @@ func _on_join_failed(reason: String) -> void:
|
||||
_on_host_status(reason, true)
|
||||
return
|
||||
_join_btn.disabled = false
|
||||
_join_msg.text = reason
|
||||
_join_msg.text = reason + version_advice(reason)
|
||||
_join_msg.add_theme_color_override("font_color", UITheme.BAD)
|
||||
|
||||
func _on_left(reason: String) -> void:
|
||||
@@ -697,3 +698,74 @@ func _play_tutorial() -> void:
|
||||
func _play(id: String) -> void:
|
||||
Saves.current_id = id
|
||||
get_tree().change_scene_to_file(WORLD_SCENE)
|
||||
|
||||
# --- updates (scripts/core/updates.gd) ------------------------------------------------
|
||||
|
||||
## Asked once per run of the game, not every time you come back to the title.
|
||||
static var _update_asked := false
|
||||
var update_banner: PanelContainer
|
||||
var _update_text: Label
|
||||
var _update_btn: Button
|
||||
var version_label: Label
|
||||
|
||||
## The version, bottom left; and a card bottom right when a newer one is out.
|
||||
func _build_update_banner(root: Control) -> void:
|
||||
version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM)
|
||||
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
version_label.offset_left = 16
|
||||
version_label.offset_top = -30
|
||||
root.add_child(version_label)
|
||||
update_banner = UITheme.panel()
|
||||
update_banner.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
update_banner.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
update_banner.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
update_banner.offset_right = -24
|
||||
update_banner.offset_bottom = -24
|
||||
update_banner.visible = false
|
||||
root.add_child(update_banner)
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 8)
|
||||
v.custom_minimum_size = Vector2(360, 0)
|
||||
update_banner.add_child(v)
|
||||
_update_text = UITheme.label("", 16)
|
||||
_update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_update_text.custom_minimum_size = Vector2(360, 0)
|
||||
v.add_child(_update_text)
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 8)
|
||||
v.add_child(h)
|
||||
_update_btn = _button("Download", _on_update_download, 170)
|
||||
h.add_child(_update_btn)
|
||||
h.add_child(_button("Not now", func(): update_banner.visible = false, 120))
|
||||
Updates.checked.connect(_on_update_checked)
|
||||
if Updates.state == "available":
|
||||
_on_update_checked("available")
|
||||
elif not _update_asked:
|
||||
_update_asked = true
|
||||
Updates.check()
|
||||
|
||||
func _on_update_checked(s: String) -> void:
|
||||
if s != "available" or update_banner == null:
|
||||
return
|
||||
var notes := String(Updates.latest.get("notes", ""))
|
||||
var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart."
|
||||
_update_text.text = "Emberwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version,
|
||||
("\n" + notes) if not notes.is_empty() else "", how]
|
||||
_update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page"
|
||||
_update_btn.disabled = Updates.download_url().is_empty()
|
||||
update_banner.visible = true
|
||||
|
||||
func _on_update_download() -> void:
|
||||
var u := Updates.download_url()
|
||||
if not u.is_empty():
|
||||
OS.shell_open(u)
|
||||
|
||||
## Extra words for a "Different game version" refusal: what to do about it.
|
||||
static func version_advice(reason: String) -> String:
|
||||
if not reason.begins_with("Different game version"):
|
||||
return ""
|
||||
if Updates.state == "available":
|
||||
return "\nEmberwild %s is out: Download is on the title page." % Updates.latest_version()
|
||||
if reason.ends_with("Update the game to join."):
|
||||
return "\nThere's no newer release yet: the server may be running a newer copy from the source."
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user