ajhahn.de
← Theria commits

Commit

Theria

fix: footer names the running content version, add manual update check

The menu footer printed the launcher's baked config/version — frozen at the
build a player first downloaded — so the client read as stuck on an old
version even after the auto-updater had marched the payload several releases
on. Record the installed pck's version on swap (.payload_version) and show
that in the footer, falling back to the launcher version only for the bundled
seed.

Add a "Check for updates now" button to Settings that bypasses the once-a-day
launch throttle, so a player who hears a new build is out can pull it without
relaunching. It reports inline and asks for a restart on success, since the
new payload loads at the next launch.

ajhahnde · Jun 2026 · 93c6764f6c0208e1281ae30a0da8adf1dbf420be · parent: 6d7493d · view on GitHub →

modified CHANGELOG.md
@@ -25,6 +25,20 @@ protocol version.
## [Unreleased]
### Added
- **"Check for updates now" in Settings** — a force-check that bypasses the once-a-day launch
throttle, so a player who hears a new build is out can pull it without relaunching. It reports
inline (Checking… → download progress → Up to date / Updated — restart to apply), and asks for a
restart on success, since the new payload loads at the next launch.
### Fixed
- **Menu footer now names the version you are actually running** — it showed the launcher's frozen
build number (the version of the installer you first downloaded), so the client read as "stuck on
an old version" even after the auto-updater had marched the content several releases on. The footer
now reads the installed payload's version, written by the updater on each swap.
## [v0.4.2] — 2026-06-17
### Changed
modified src/client/connect_menu.gd
@@ -69,6 +69,14 @@ var _difficulty_picker: OptionButton
## The Settings dialog, built on first open. Carries the update-channel toggle today;
## video/audio options join it as they land.
var _settings_dialog: AcceptDialog
## The network updater, created lazily on the first manual "Check now" so a player who never
## opens Settings never spins up an HTTPRequest. The boot scene runs its own at launch; this one
## drives the in-menu force-check that bypasses the launch-time throttle.
var _updater: Updater
## The Settings dialog's update-status line, written by the manual check as it runs.
var _check_status: Label
## The manual-check button, disabled while a check is in flight so it cannot be re-entered.
var _check_button: Button
func _ready() -> void:
@@ -195,7 +203,13 @@ func _footer() -> Control:
func _build_id() -> String:
var sha := UpdateManifest.local_sha()
var build := sha.substr(0, 7) if not sha.is_empty() else "seed"
var parts := PackedStringArray(["v%s" % UpdateManifest.client_version(), "build %s" % build])
# Name the version of the *content* the player is running — the installed pck's version, written
# by the updater on swap — not the launcher's baked `config/version`, which is frozen at the build
# they first downloaded and so reads as "never updates" even after the pck has marched several
# versions on. Falls back to the launcher version when running the bundled seed (no payload yet).
var content_version := UpdateManifest.payload_version()
var version := content_version if not content_version.is_empty() else UpdateManifest.client_version()
var parts := PackedStringArray(["v%s" % version, "build %s" % build])
var status := str(Engine.get_meta(UiTheme.STATUS_META, ""))
if not status.is_empty():
parts.append(status)
@@ -248,6 +262,21 @@ func _build_settings_dialog() -> AcceptDialog:
hint.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
box.add_child(hint)
# A force-check that bypasses the launch-time throttle, so a player who hears a new build is out
# can pull it now instead of waiting out the cold-start window. Its result lands in the status
# line below rather than in a hand-off, since the menu stays up.
_check_button = Button.new()
_check_button.text = "Check for updates now"
_check_button.pressed.connect(_on_check_now_pressed)
box.add_child(_check_button)
_check_status = Label.new()
_check_status.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
_check_status.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
_check_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_check_status.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
box.add_child(_check_status)
dialog.add_child(box)
return dialog
@@ -258,6 +287,71 @@ func _on_channel_selected(index: int, picker: OptionButton) -> void:
Settings.set_update_channel(picker.get_item_metadata(index))
## Runs a manual update check that bypasses the launch-time throttle, so a player who just heard a
## new build is out can pull it without relaunching. Creates the updater on first use and points it
## at the saved channel each time (the picker may have just changed it). The downloaded pck is loaded
## only at the next launch — `load_resource_pack` runs in the boot scene — so a successful apply asks
## for a restart rather than swapping the running game out from under the player.
func _on_check_now_pressed() -> void:
if _updater == null:
_updater = Updater.new()
add_child(_updater)
_updater.check_done.connect(_on_check_now_done)
_updater.download_progress.connect(_on_check_now_progress)
_updater.applied.connect(_on_check_now_applied)
_updater.channel = Settings.update_channel()
_check_button.disabled = true
_check_status.text = "Checking…"
_updater.check()
## The manual check finished: the boot screen's branches — offline, a client too old for the build,
## a newer build (download it), or already current — but staying on the menu instead of handing off,
## and re-enabling the button on every terminal branch.
func _on_check_now_done(available: bool, info: Dictionary) -> void:
if info.get("offline", false):
_finish_check("Offline — check your connection")
return
if info.get("needs_client_upgrade", false):
_finish_check("A new Theria is out — please re-download the client")
return
if available:
_check_status.text = "Downloading %s…" % _check_label(info)
_updater.apply(info)
return
_finish_check("Up to date")
## Mirrors the download fraction into the status line while a manual-check pck is being fetched.
func _on_check_now_progress(ratio: float) -> void:
_check_status.text = "Downloading… %d%%" % int(ratio * 100.0)
## A manual-check apply finished. On success the new pck is staged live, but the running client still
## holds the one it booted with (the pck loads at boot, not mid-session), so ask for a restart; on
## failure the install was left untouched.
func _on_check_now_applied(ok: bool) -> void:
if ok:
_finish_check("Updated — restart Theria to apply")
else:
_finish_check("Update failed — try again")
## Single exit for every manual-check branch: shows the message and re-enables the button.
func _finish_check(message: String) -> void:
_check_status.text = message
_check_button.disabled = false
## A human label for the build a manual check is installing: its version when the manifest carried
## one, else a short sha — the same fallback the boot screen uses.
func _check_label(info: Dictionary) -> String:
var version: String = info.get("version", "")
if not version.is_empty():
return version
return (info.get("sha", "") as String).substr(0, 7)
## Fills the hero picker from the tribe rosters — one item per hero, labelled
## "Tribe — Hero", carrying its kit id as metadata — and selects `default_hero` (or the
## first hero when none was injected). Reading `AbilityData.TRIBE` keeps the menu's roster
modified src/update/update_manifest.gd
@@ -31,6 +31,12 @@ const PCK_PREV_PATH := PAYLOAD_DIR + "/game.pck.prev"
## The git sha of the installed pck, written after a swap; empty/absent before the
## first successful update (the client then runs its bundled seed).
const VERSION_PATH := PAYLOAD_DIR + "/.version"
## The human version string of the installed pck (the manifest's `version`), written
## alongside the sha after a swap. Read by the menu footer so it names the *content* the
## player is running rather than the launcher's frozen `config/version`. Empty/absent
## before the first update or after a swap that predates this marker (an older install
## carries only `.version`); the footer then falls back to the launcher version.
const PAYLOAD_VERSION_PATH := PAYLOAD_DIR + "/.payload_version"
## Touched after every successful reach to the channel; its mtime throttles the
## cold-start probe (see `Updater.should_check`).
const LAST_CHECK_PATH := PAYLOAD_DIR + "/.last_check"
@@ -153,6 +159,19 @@ static func local_sha() -> String:
return f.get_as_text().strip_edges()
## The human version string of the installed pck (the manifest's `version`), read from
## `.payload_version`, or empty when nothing has been installed yet or the install predates
## this marker. Best-effort, like `local_sha`: any read failure reads as "unknown", which
## the footer treats as a cue to show the launcher's own version instead.
static func payload_version() -> String:
if not FileAccess.file_exists(PAYLOAD_VERSION_PATH):
return ""
var f := FileAccess.open(PAYLOAD_VERSION_PATH, FileAccess.READ)
if f == null:
return ""
return f.get_as_text().strip_edges()
## This client's own version, read from the `config/version` project setting baked into the
## build, with a leading `v` stripped so it compares directly against a manifest's
## `min_client`. Always present in the editor and in any export (it is a core project
modified src/update/updater.gd
@@ -157,15 +157,17 @@ func apply(info: Dictionary) -> void:
DirAccess.remove_absolute(UpdateManifest.PCK_NEW_PATH)
applied.emit(false)
return
applied.emit(_swap_in(info.get("sha", "")))
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
## 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.
func _swap_in(sha: String) -> bool:
## `.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:
@@ -176,6 +178,9 @@ func _swap_in(sha: String) -> bool:
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
modified test/unit/test_connect_menu.gd
@@ -121,3 +121,15 @@ func test_settings_dialog_preselects_the_saved_channel() -> void:
Settings.update_channel(),
"the channel picker opens on the saved channel"
)
func test_settings_dialog_offers_a_manual_update_check() -> void:
# The Settings dialog carries a force-check button so a player who hears a new build is out can
# pull it without waiting out the launch-time throttle.
var menu := _menu()
var dialog := menu._build_settings_dialog()
add_child_autoqfree(dialog)
var labels := PackedStringArray()
for button in dialog.find_children("", "Button", true, false):
labels.append((button as Button).text)
assert_has(labels, "Check for updates now", "the Settings dialog offers a manual update check")