Update the game from inside it (#13)
Build / test (push) Successful in 1m53s
Build / build (push) Successful in 1m15s

"Version X is available" in the top bar is now a button that opens an
Update popup:
- Update and restart gets the download link from the server (the redirect
  is not followed, as the Gitea link needs no token), downloads the new game
  next to the running one with a progress bar, checks it (complete, and an
  MZ or ELF file), swaps it in and restarts. The quiz is saved first and
  continues. On Windows the running .exe is renamed to .old, put back if
  the swap fails, and deleted at the next start.
- Download in browser opens the .exe, the Linux package, or the release
  page on other platforms.
- From the editor, an unwritable folder or another platform, only the
  browser download is offered, with the reason.

The message now fits between the tabs and the buttons in every theme; in
Nord and Christmas it ran into Sync. The macOS export preset is removed,
and the unused download_client_* functions are replaced by
fetch_client_download_url.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-15 21:23:26 +02:00
1 parent c03c0e4efe
commit f39865fffc
30 files changed
+2363 -354

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/update_popup.gd" id="popup_script"]
[ext_resource type="Script" path="res://common/updater.gd" id="updater_script"]
[node name="UpdatePopup" type="Control" node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("popup_script")
panel = NodePath("UpdatePanel")
[node name="UpdatePanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 640.0
offset_bottom = 300.0
theme_type_variation = &"PopupBackground"
[node name="Rows" type="VBoxContainer" parent="UpdatePanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 24.0
offset_top = 16.0
offset_right = 616.0
offset_bottom = 284.0
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"Title"
text = "Version 0.0.0 is available"
horizontal_alignment = 1
[node name="InfoLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="ReasonLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
autowrap_mode = 3
[node name="DownloadProgress" type="ProgressBar" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
max_value = 1.0
show_percentage = false
[node name="StatusLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="Space" type="Control" parent="UpdatePanel/Rows"]
custom_minimum_size = Vector2(0, 4)
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Buttons" type="HBoxContainer" parent="UpdatePanel/Rows"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="NotNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Not now"
[node name="BrowserButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Download in browser"
[node name="UpdateNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Update and restart"
[node name="Updater" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("updater_script")
+156
View File
@@ -0,0 +1,156 @@
class_name UpdatePopup
extends BasePopup
## Gets the newest version of the game, opened from "Version X is available" in the top
## bar.
##
## Update and restart downloads the new game, puts it in place of this one and starts it;
## the quiz is saved first, so it continues where it was. Where the game can't update
## itself, e.g. when it runs from the editor, only Download in browser is offered, which
## opens the download (or, on other platforms, the release page) in the browser.
## Emitted just before the game quits to start the new version, so the quiz can be saved.
signal restarting
## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(24, 16)
## Opens a link in the browser. The tests replace it, so no browser opens.
var open_url: Callable = OS.shell_open
## The version offered.
var latest_version: String = ""
var _downloading: bool = false
# Where the panel's middle is, so it stays centred as it grows and shrinks
var _panel_middle_y: float = 0.0
@onready var title_label: Label = %TitleLabel
@onready var info_label: Label = %InfoLabel
@onready var reason_label: Label = %ReasonLabel
@onready var download_progress: ProgressBar = %DownloadProgress
@onready var status_label: Label = %StatusLabel
@onready var not_now_button: Button = %NotNowButton
@onready var browser_button: Button = %BrowserButton
@onready var update_now_button: Button = %UpdateNowButton
@onready var updater: Updater = %Updater
@onready var rows: VBoxContainer = %Rows
func _ready() -> void:
not_now_button.pressed.connect(close)
browser_button.pressed.connect(download_in_browser)
update_now_button.pressed.connect(update_now)
_panel_middle_y = panel.position.y + panel.size.y / 2
## Offers version [param latest] and opens the popup.
func show_update(latest: String) -> void:
latest_version = latest
title_label.text = "Version %s is available" % latest
info_label.text = "You have %s. The game restarts with the new version, and the quiz " \
% Settings.version + "continues where it was."
var reason: String = updater.can_update_in_place()
update_now_button.visible = reason.is_empty()
reason_label.visible = !reason.is_empty()
reason_label.text = reason + " Download it in the browser instead."
info_label.visible = reason.is_empty()
download_progress.visible = false
status_label.visible = false
_set_busy(false)
_fit_to_rows()
open()
## Stops a download that is under way when the popup is closed.
func close() -> void:
if _downloading:
updater.cancel()
_downloading = false
_set_busy(false)
super()
## Downloads the new version, puts it in place and restarts the game.
func update_now() -> void:
_set_busy(true)
_show_status("Getting the download link…")
MusicServerClient.fetch_client_download_url(Updater.asset_for(updater.os_name, true),
_download, _on_failed.bind("The server has no download for this version."))
## Opens the new version's download in the browser, or the release page when there is
## none for this platform.
func download_in_browser() -> void:
var asset: String = Updater.asset_for(updater.os_name, false)
if asset.is_empty():
open_url.call(Updater.RELEASES_PAGE)
return
_show_status("Opening the download in the browser…")
MusicServerClient.fetch_client_download_url(asset,
func(url: String) -> void: open_url.call(url),
func() -> void: open_url.call(Updater.RELEASES_PAGE))
func _download(url: String) -> void:
_downloading = true
download_progress.value = 0.0
download_progress.visible = true
_show_status("Downloading…")
_fit_to_rows()
updater.download(url, _on_progress, _install, _on_failed)
func _on_progress(done: int, total: int) -> void:
if total > 0:
download_progress.value = float(done) / float(total)
status_label.text = "Downloading… %s of %s" % [SettingsPopup.size_text(done),
SettingsPopup.size_text(total)]
func _install(path: String) -> void:
_downloading = false
_show_status("Installing…")
var error: Error = Updater.install(path, updater.executable_path, updater.os_name)
if error != OK:
DirAccess.remove_absolute(path)
_on_failed("The game could not be replaced: %s." % error_string(error))
return
_show_status("Restarting…")
restarting.emit()
updater.restart()
func _on_failed(reason: String) -> void:
_downloading = false
download_progress.visible = false
_set_busy(false)
_show_status(reason)
_fit_to_rows()
func _set_busy(busy: bool) -> void:
update_now_button.disabled = busy
browser_button.disabled = busy
# Closing while busy stops the download
not_now_button.text = "Cancel" if busy else "Not now"
func _show_status(message: String) -> void:
status_label.text = message
if !status_label.visible:
status_label.visible = true
_fit_to_rows()
# Makes the panel as tall as its rows, around the same middle
func _fit_to_rows() -> void:
# A popup that was never shown hasn't laid out its rows, and wrapped text measures
# its height from its width
var width: float = panel.size.x - PADDING.x * 2
for label: Label in [info_label, reason_label, status_label]:
label.size = Vector2(width, 0)
label.update_minimum_size()
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
panel.size.y = height
panel.position.y = floorf(_panel_middle_y - height / 2)
rows.position = PADDING
rows.size = Vector2(panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
+1
View File
@@ -0,0 +1 @@
uid://dupwb11ovvn7j