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
+2361 -352

No files matched your search

+12
View File
@@ -27,3 +27,15 @@ Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
who just wants to run the file.
## Updating
When the server has a newer release, the top bar says "Version X is available". Clicking
it offers **Update and restart**: the game downloads its new file from the release
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
the server's `/download/<platform>` routes), puts it in place of the running file and
starts it, and the quiz continues where it was. On Windows the old file is kept as
`<game>.old` until the next start. So keep the release file names as they are: the server
finds them by their ending. Where the game can't replace itself (run from the editor, a
folder it can't write to, or another platform), it opens the download in the browser
instead.
+174
View File
@@ -0,0 +1,174 @@
class_name Updater
extends Node
## Updates the game in place to the newest release: downloads the new game next to the
## running one, checks it, swaps the files and starts the new version.
##
## The game is one file on Windows and Linux (the game data is embedded). A running .exe
## can't be overwritten on Windows, but it can be renamed, so it is moved aside to
## [code]<exe>.old[/code], which [method remove_old_files] deletes at the next start. On
## Linux the new file simply replaces the running one.
##
## [member executable_path], [member os_name], [member in_editor], [member start_process]
## and [member quit] can be replaced, so tests choose the platform and nothing restarts.
## Where the newest release can be downloaded by hand.
const RELEASES_PAGE: String = "https://gitea.sanplex.xyz/Sansan/MusicPlayer/releases/latest"
## Added to the game's path for the downloaded new version.
const UPDATE_SUFFIX: String = ".update"
## Added to the game's path for the old version, on Windows.
const OLD_SUFFIX: String = ".old"
## rwxr-xr-x, for the new game on Linux.
const EXECUTABLE_PERMISSIONS: int = 0b111_101_101
## Seconds the whole download may take.
const DOWNLOAD_TIMEOUT: float = 900.0
## The running game's file.
var executable_path: String = OS.get_executable_path()
## The platform, as [method OS.get_name] names it.
var os_name: String = OS.get_name()
## Whether the game runs from the Godot editor, where there is no game file to replace.
var in_editor: bool = OS.has_feature("editor")
## Starts a program: [code](path: String, arguments: PackedStringArray) -> int[/code].
var start_process: Callable = OS.create_process
## Quits the game.
var quit: Callable
var _request: HTTPRequest
var _on_progress: Callable
func _init() -> void:
quit = func() -> void: get_tree().quit()
func _process(_delta: float) -> void:
if _request != null && _on_progress.is_valid():
_on_progress.call(_request.get_downloaded_bytes(), _request.get_body_size())
## The release file to download for [param os_name]: the game itself to update in place,
## or, when not [param in_place], what a person downloads, which on Linux is the package
## with an installer. Empty when there is no release for the platform.
static func asset_for(for_os: String, in_place: bool) -> String:
match for_os:
"Windows":
return "windows"
"Linux":
return "linux" if in_place else "linux-package"
return ""
## Why the game can't update itself here, or empty when it can.
func can_update_in_place() -> String:
if in_editor:
return "The game runs from the Godot editor."
if asset_for(os_name, true).is_empty():
return "The game updates itself on Windows and Linux."
var probe: String = executable_path + ".write-test"
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return "The game's folder %s can't be written to." % executable_path.get_base_dir()
file.close()
DirAccess.remove_absolute(probe)
return ""
## Where the new version is downloaded to, next to the game so it can be renamed into
## place.
func update_path() -> String:
return executable_path + UPDATE_SUFFIX
## Downloads the new game from [param url]. Calls [param on_progress] with the bytes
## downloaded and the total (-1 while unknown) every frame, then [param on_done] with the
## file's path once it is checked, or [param on_failed] with the reason.
func download(url: String, on_progress: Callable, on_done: Callable,
on_failed: Callable) -> void:
cancel()
_on_progress = on_progress
_request = HTTPRequest.new()
_request.download_file = update_path()
_request.timeout = DOWNLOAD_TIMEOUT
add_child(_request)
_request.request_completed.connect(_on_downloaded.bind(on_done, on_failed))
var error: Error = _request.request(url)
if error != OK:
cancel()
on_failed.call("The download could not start: " + error_string(error))
## Stops a download and deletes what was downloaded.
func cancel() -> void:
if _request != null:
_request.cancel_request()
_request.queue_free()
_request = null
DirAccess.remove_absolute(update_path())
## Whether [param path] looks like the game for [param for_os]: a Windows program starts
## with "MZ", a Linux one with the ELF header. An error page saved by mistake does not.
static func check_file(path: String, for_os: String) -> bool:
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null || file.get_length() < 4:
return false
var start: PackedByteArray = file.get_buffer(4)
match for_os:
"Windows":
return start.slice(0, 2) == "MZ".to_ascii_buffer()
"Linux":
return start == PackedByteArray([0x7F, 0x45, 0x4C, 0x46])
return false
## Puts the new game at [param update] in place of the game at [param exe]. On Windows
## the running game is moved aside first, and put back if the new one can't be moved in.
static func install(update: String, exe: String, for_os: String) -> Error:
if for_os == "Windows":
var old: String = exe + OLD_SUFFIX
if FileAccess.file_exists(old):
DirAccess.remove_absolute(old)
var error: Error = DirAccess.rename_absolute(exe, old)
if error != OK:
return error
error = DirAccess.rename_absolute(update, exe)
if error != OK:
DirAccess.rename_absolute(old, exe)
return error
FileAccess.set_unix_permissions(update, EXECUTABLE_PERMISSIONS)
return DirAccess.rename_absolute(update, exe)
## Deletes what an update left next to the game at [param exe]: the old version, and a
## download that was not finished.
static func remove_old_files(exe: String) -> void:
for suffix: String in [OLD_SUFFIX, UPDATE_SUFFIX]:
if FileAccess.file_exists(exe + suffix):
DirAccess.remove_absolute(exe + suffix)
## Starts the game again, now the new version, and quits this one.
func restart() -> void:
start_process.call(executable_path, OS.get_cmdline_args())
quit.call()
func _on_downloaded(result: int, response_code: int, _headers: PackedStringArray,
_body: PackedByteArray, on_done: Callable, on_failed: Callable) -> void:
var expected: int = _request.get_body_size()
_request.queue_free()
_request = null
var reason: String = ""
if result != HTTPRequest.RESULT_SUCCESS:
reason = "The download failed. Check the connection and try again."
elif response_code != 200:
reason = "The download failed: the server answered %d." % response_code
elif expected >= 0 && FileAccess.get_size(update_path()) != expected:
reason = "The download stopped before the end. Try again."
elif !check_file(update_path(), os_name):
reason = "The downloaded file is not the game for %s." % os_name
if !reason.is_empty():
DirAccess.remove_absolute(update_path())
on_failed.call(reason)
return
on_done.call(update_path())
+1
View File
@@ -0,0 +1 @@
uid://bu3bi7qhxr7it
+3 -263
View File
@@ -75,266 +75,6 @@ debug/export_console_script=1
[preset.1]
name="macOS"
platform="macOS"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="tests/*, addons/gdUnit4/*, linux/*"
export_path="./MusicPlayer_0.9_Beta.dmg"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
export/distribution_type=1
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
application/icon=""
application/icon_interpolation=4
application/bundle_identifier="tech.sanplex.musicplayer"
application/signature=""
application/app_category="Music-games"
application/short_version=""
application/version=""
application/copyright=""
application/copyright_localized={}
application/min_macos_version_x86_64="10.12"
application/min_macos_version_arm64="11.00"
application/export_angle=0
display/high_res=true
application/additional_plist_content=""
xcode/platform_build="14C18"
xcode/sdk_version="13.1"
xcode/sdk_build="22C55"
xcode/sdk_name="macosx13.1"
xcode/xcode_version="1420"
xcode/xcode_build="14C18"
codesign/codesign=1
codesign/installer_identity=""
codesign/apple_team_id=""
codesign/identity=""
codesign/entitlements/custom_file=""
codesign/entitlements/allow_jit_code_execution=false
codesign/entitlements/allow_unsigned_executable_memory=false
codesign/entitlements/allow_dyld_environment_variables=false
codesign/entitlements/disable_library_validation=false
codesign/entitlements/audio_input=false
codesign/entitlements/camera=false
codesign/entitlements/location=false
codesign/entitlements/address_book=false
codesign/entitlements/calendars=false
codesign/entitlements/photos_library=false
codesign/entitlements/apple_events=false
codesign/entitlements/debugging=false
codesign/entitlements/app_sandbox/enabled=false
codesign/entitlements/app_sandbox/network_server=false
codesign/entitlements/app_sandbox/network_client=false
codesign/entitlements/app_sandbox/device_usb=false
codesign/entitlements/app_sandbox/device_bluetooth=false
codesign/entitlements/app_sandbox/files_downloads=0
codesign/entitlements/app_sandbox/files_pictures=0
codesign/entitlements/app_sandbox/files_music=0
codesign/entitlements/app_sandbox/files_movies=0
codesign/entitlements/app_sandbox/files_user_selected=0
codesign/entitlements/app_sandbox/helper_executables=[]
codesign/entitlements/additional=""
codesign/custom_options=PackedStringArray()
notarization/notarization=0
privacy/microphone_usage_description=""
privacy/microphone_usage_description_localized={}
privacy/camera_usage_description=""
privacy/camera_usage_description_localized={}
privacy/location_usage_description=""
privacy/location_usage_description_localized={}
privacy/address_book_usage_description=""
privacy/address_book_usage_description_localized={}
privacy/calendar_usage_description=""
privacy/calendar_usage_description_localized={}
privacy/photos_library_usage_description=""
privacy/photos_library_usage_description_localized={}
privacy/desktop_folder_usage_description=""
privacy/desktop_folder_usage_description_localized={}
privacy/documents_folder_usage_description=""
privacy/documents_folder_usage_description_localized={}
privacy/downloads_folder_usage_description=""
privacy/downloads_folder_usage_description_localized={}
privacy/network_volumes_usage_description=""
privacy/network_volumes_usage_description_localized={}
privacy/removable_volumes_usage_description=""
privacy/removable_volumes_usage_description_localized={}
privacy/tracking_enabled=false
privacy/tracking_domains=PackedStringArray()
privacy/collected_data/name/collected=false
privacy/collected_data/name/linked_to_user=false
privacy/collected_data/name/used_for_tracking=false
privacy/collected_data/name/collection_purposes=0
privacy/collected_data/email_address/collected=false
privacy/collected_data/email_address/linked_to_user=false
privacy/collected_data/email_address/used_for_tracking=false
privacy/collected_data/email_address/collection_purposes=0
privacy/collected_data/phone_number/collected=false
privacy/collected_data/phone_number/linked_to_user=false
privacy/collected_data/phone_number/used_for_tracking=false
privacy/collected_data/phone_number/collection_purposes=0
privacy/collected_data/physical_address/collected=false
privacy/collected_data/physical_address/linked_to_user=false
privacy/collected_data/physical_address/used_for_tracking=false
privacy/collected_data/physical_address/collection_purposes=0
privacy/collected_data/other_contact_info/collected=false
privacy/collected_data/other_contact_info/linked_to_user=false
privacy/collected_data/other_contact_info/used_for_tracking=false
privacy/collected_data/other_contact_info/collection_purposes=0
privacy/collected_data/health/collected=false
privacy/collected_data/health/linked_to_user=false
privacy/collected_data/health/used_for_tracking=false
privacy/collected_data/health/collection_purposes=0
privacy/collected_data/fitness/collected=false
privacy/collected_data/fitness/linked_to_user=false
privacy/collected_data/fitness/used_for_tracking=false
privacy/collected_data/fitness/collection_purposes=0
privacy/collected_data/payment_info/collected=false
privacy/collected_data/payment_info/linked_to_user=false
privacy/collected_data/payment_info/used_for_tracking=false
privacy/collected_data/payment_info/collection_purposes=0
privacy/collected_data/credit_info/collected=false
privacy/collected_data/credit_info/linked_to_user=false
privacy/collected_data/credit_info/used_for_tracking=false
privacy/collected_data/credit_info/collection_purposes=0
privacy/collected_data/other_financial_info/collected=false
privacy/collected_data/other_financial_info/linked_to_user=false
privacy/collected_data/other_financial_info/used_for_tracking=false
privacy/collected_data/other_financial_info/collection_purposes=0
privacy/collected_data/precise_location/collected=false
privacy/collected_data/precise_location/linked_to_user=false
privacy/collected_data/precise_location/used_for_tracking=false
privacy/collected_data/precise_location/collection_purposes=0
privacy/collected_data/coarse_location/collected=false
privacy/collected_data/coarse_location/linked_to_user=false
privacy/collected_data/coarse_location/used_for_tracking=false
privacy/collected_data/coarse_location/collection_purposes=0
privacy/collected_data/sensitive_info/collected=false
privacy/collected_data/sensitive_info/linked_to_user=false
privacy/collected_data/sensitive_info/used_for_tracking=false
privacy/collected_data/sensitive_info/collection_purposes=0
privacy/collected_data/contacts/collected=false
privacy/collected_data/contacts/linked_to_user=false
privacy/collected_data/contacts/used_for_tracking=false
privacy/collected_data/contacts/collection_purposes=0
privacy/collected_data/emails_or_text_messages/collected=false
privacy/collected_data/emails_or_text_messages/linked_to_user=false
privacy/collected_data/emails_or_text_messages/used_for_tracking=false
privacy/collected_data/emails_or_text_messages/collection_purposes=0
privacy/collected_data/photos_or_videos/collected=false
privacy/collected_data/photos_or_videos/linked_to_user=false
privacy/collected_data/photos_or_videos/used_for_tracking=false
privacy/collected_data/photos_or_videos/collection_purposes=0
privacy/collected_data/audio_data/collected=false
privacy/collected_data/audio_data/linked_to_user=false
privacy/collected_data/audio_data/used_for_tracking=false
privacy/collected_data/audio_data/collection_purposes=0
privacy/collected_data/gameplay_content/collected=false
privacy/collected_data/gameplay_content/linked_to_user=false
privacy/collected_data/gameplay_content/used_for_tracking=false
privacy/collected_data/gameplay_content/collection_purposes=0
privacy/collected_data/customer_support/collected=false
privacy/collected_data/customer_support/linked_to_user=false
privacy/collected_data/customer_support/used_for_tracking=false
privacy/collected_data/customer_support/collection_purposes=0
privacy/collected_data/other_user_content/collected=false
privacy/collected_data/other_user_content/linked_to_user=false
privacy/collected_data/other_user_content/used_for_tracking=false
privacy/collected_data/other_user_content/collection_purposes=0
privacy/collected_data/browsing_history/collected=false
privacy/collected_data/browsing_history/linked_to_user=false
privacy/collected_data/browsing_history/used_for_tracking=false
privacy/collected_data/browsing_history/collection_purposes=0
privacy/collected_data/search_hhistory/collected=false
privacy/collected_data/search_hhistory/linked_to_user=false
privacy/collected_data/search_hhistory/used_for_tracking=false
privacy/collected_data/search_hhistory/collection_purposes=0
privacy/collected_data/user_id/collected=false
privacy/collected_data/user_id/linked_to_user=false
privacy/collected_data/user_id/used_for_tracking=false
privacy/collected_data/user_id/collection_purposes=0
privacy/collected_data/device_id/collected=false
privacy/collected_data/device_id/linked_to_user=false
privacy/collected_data/device_id/used_for_tracking=false
privacy/collected_data/device_id/collection_purposes=0
privacy/collected_data/purchase_history/collected=false
privacy/collected_data/purchase_history/linked_to_user=false
privacy/collected_data/purchase_history/used_for_tracking=false
privacy/collected_data/purchase_history/collection_purposes=0
privacy/collected_data/product_interaction/collected=false
privacy/collected_data/product_interaction/linked_to_user=false
privacy/collected_data/product_interaction/used_for_tracking=false
privacy/collected_data/product_interaction/collection_purposes=0
privacy/collected_data/advertising_data/collected=false
privacy/collected_data/advertising_data/linked_to_user=false
privacy/collected_data/advertising_data/used_for_tracking=false
privacy/collected_data/advertising_data/collection_purposes=0
privacy/collected_data/other_usage_data/collected=false
privacy/collected_data/other_usage_data/linked_to_user=false
privacy/collected_data/other_usage_data/used_for_tracking=false
privacy/collected_data/other_usage_data/collection_purposes=0
privacy/collected_data/crash_data/collected=false
privacy/collected_data/crash_data/linked_to_user=false
privacy/collected_data/crash_data/used_for_tracking=false
privacy/collected_data/crash_data/collection_purposes=0
privacy/collected_data/performance_data/collected=false
privacy/collected_data/performance_data/linked_to_user=false
privacy/collected_data/performance_data/used_for_tracking=false
privacy/collected_data/performance_data/collection_purposes=0
privacy/collected_data/other_diagnostic_data/collected=false
privacy/collected_data/other_diagnostic_data/linked_to_user=false
privacy/collected_data/other_diagnostic_data/used_for_tracking=false
privacy/collected_data/other_diagnostic_data/collection_purposes=0
privacy/collected_data/environment_scanning/collected=false
privacy/collected_data/environment_scanning/linked_to_user=false
privacy/collected_data/environment_scanning/used_for_tracking=false
privacy/collected_data/environment_scanning/collection_purposes=0
privacy/collected_data/hands/collected=false
privacy/collected_data/hands/linked_to_user=false
privacy/collected_data/hands/used_for_tracking=false
privacy/collected_data/hands/collection_purposes=0
privacy/collected_data/head/collected=false
privacy/collected_data/head/linked_to_user=false
privacy/collected_data/head/used_for_tracking=false
privacy/collected_data/head/collection_purposes=0
privacy/collected_data/other_data_types/collected=false
privacy/collected_data/other_data_types/linked_to_user=false
privacy/collected_data/other_data_types/used_for_tracking=false
privacy/collected_data/other_data_types/collection_purposes=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
open \"{temp_dir}/{exe_name}.app\" --args {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name}.app/Contents/MacOS/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
application/min_macos_version="10.12"
debug/export_console_script=1
notarization/apple_team_id=""
[preset.2]
name="Web"
platform="Web"
runnable=false
@@ -353,7 +93,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
[preset.1.options]
custom_template/debug=""
custom_template/release=""
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
[preset.3]
[preset.2]
name="Linux"
platform="Linux"
@@ -400,7 +140,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
[preset.2.options]
custom_template/debug=""
custom_template/release=""
+10
View File
@@ -12,6 +12,7 @@
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"]
[ext_resource type="PackedScene" path="res://popups/UpdatePopup.tscn" id="update_popup_scene"]
[node name="MainWindow" type="Control" unique_id=407149696]
layout_mode = 3
@@ -109,3 +110,12 @@ offset_left = 610.0
offset_top = 140.0
offset_right = 610.0
offset_bottom = 140.0
[node name="UpdatePopup" parent="." instance=ExtResource("update_popup_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 640.0
offset_top = 390.0
offset_right = 640.0
offset_bottom = 390.0
+13 -5
View File
@@ -40,19 +40,27 @@ theme_type_variation = &"HighlightLarge"
text = "DEBUG"
vertical_alignment = 1
[node name="UpdateLabel" type="Label" parent="." unique_id=1959221258]
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
visible = false
layout_mode = 0
offset_left = 1096.0
offset_top = 12.0
offset_right = 1432.0
offset_bottom = 50.0
mouse_filter = 1
theme_type_variation = &"HighlightMedium"
mouse_filter = 2
alignment = 2
[node name="UpdateButton" type="Button" parent="UpdateBox"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 0
mouse_default_cursor_shape = 2
theme_type_variation = &"UpdateButton"
text = "Version 0.0.0 is available"
horizontal_alignment = 2
vertical_alignment = 1
text_overrun_behavior = 3
clip_text = true
alignment = 2
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
visible = false
+7 -1
View File
@@ -20,6 +20,7 @@ var popup_backdrop: PopupBackdrop
@onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup
@onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var statistics_view: StatisticsView = $StatisticsView
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
@@ -37,6 +38,8 @@ func _ready() -> void:
top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr)
top_bar.settings_pressed.connect(settings_popup.open)
top_bar.update_pressed.connect(update_popup.show_update)
update_popup.restarting.connect(quiz.save_now)
settings_popup.server_changed.connect(quiz.on_server_changed)
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
settings_popup.clear_cache_requested.connect(clear_cache)
@@ -58,6 +61,9 @@ func _ready() -> void:
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
# An update leaves the old version next to the game, to delete once the new one runs
if !OS.has_feature("editor"):
Updater.remove_old_files(OS.get_executable_path())
# A saved quiz may have been played from a music folder
top_bar.update_offline_state()
search_popup.get_list_of_soundtracks()
@@ -110,7 +116,7 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup, settings_popup]
soundtrack_stats_popup, settings_popup, update_popup]
# How many songs to download ahead can be changed in the settings. Waiting until the
+30 -6
View File
@@ -16,10 +16,19 @@ signal about_pressed
signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
signal tab_selected(tab: StringName)
## Emitted when "Version X is available" is clicked, with the newest version.
signal update_pressed(latest_version: String)
## Space between the message in the middle and the tabs and buttons around it.
const MIDDLE_GAP: float = 16.0
## The newest release the server knows of, once asked, or empty.
var latest_version: String = ""
@onready var sync_button: Button = %SyncButton
@onready var debug_label: Label = $DebugLabel
@onready var update_label: Label = $UpdateLabel
@onready var update_box: HBoxContainer = $UpdateBox
@onready var update_button: Button = %UpdateButton
@onready var offline_label: Label = $OfflineLabel
@onready var qr_button: Button = %QRButton
@onready var quiz_tab: Button = %QuizTab
@@ -36,6 +45,11 @@ func _ready() -> void:
qr_button.pressed.connect(qr_pressed.emit)
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
update_button.pressed.connect(func() -> void: update_pressed.emit(latest_version))
# The buttons are as wide as the theme makes them, so the message fits between
statistics_tab.item_rect_changed.connect(_fit_middle)
sync_button.item_rect_changed.connect(_fit_middle)
_fit_middle()
update_offline_state()
check_for_update()
@@ -51,14 +65,14 @@ func update_offline_state() -> void:
if offline:
offline_label.text = "Offline: " + Settings.music_folder.display_name()
offline_label.tooltip_text = Settings.music_folder.path
update_label.visible = false
update_box.visible = false
## Asks the server for the newest MusicPlayer release and tells the user when it is newer
## than this one. Not asked while a music folder is used.
func check_for_update() -> void:
if Settings.is_offline():
update_label.visible = false
update_box.visible = false
return
MusicServerClient.fetch_latest_client_version(show_latest_version)
@@ -68,10 +82,20 @@ func check_for_update() -> void:
func show_latest_version(latest: Variant) -> void:
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
&& !Settings.is_offline()
update_label.visible = newer
update_box.visible = newer
if newer:
update_label.text = "Version %s is available" % latest
update_label.tooltip_text = "You have %s" % Settings.version
latest_version = latest
update_button.text = "Version %s is available" % latest
update_button.tooltip_text = "You have %s. Click to update." % Settings.version
# Fits "Version X is available" and the offline label between the tabs and the buttons
func _fit_middle() -> void:
var left: float = statistics_tab.position.x + statistics_tab.size.x + MIDDLE_GAP
var right: float = sync_button.global_position.x - global_position.x - MIDDLE_GAP
for middle: Control in [update_box, offline_label]:
middle.position.x = left
middle.size.x = maxf(right - left, 0.0)
## Whether version [param latest] is newer than [param current], comparing numbers such as
+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
+3 -2
View File
@@ -181,10 +181,11 @@ func save_soon() -> void:
if _restoring || _save_queued:
return
_save_queued = true
_save_now.call_deferred()
save_now.call_deferred()
func _save_now() -> void:
## Saves the quiz at once, e.g. before the game restarts with a new version.
func save_now() -> void:
_save_queued = false
if is_inside_tree():
QuizSave.capture(stats_paused_by_win).write()
+5 -1
View File
@@ -19,16 +19,20 @@ func _init(owner: Node) -> void:
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
## reported with response code 0 and no headers. [param headers], which name the
## [param body]'s type, are sent as-is.
## [param body]'s type, are sent as-is. Without [param follow_redirects] a redirect is
## answered as it is, with its [code]Location[/code] header.
func request(
url: String,
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
if !follow_redirects:
http_request.max_redirects = 0
_owner.add_child(http_request)
http_request.request_completed.connect(
func(
+43 -22
View File
@@ -30,6 +30,9 @@ const CLIENT_TYPE: String = "godot"
const QUEUE_HEADER: String = "X-Queue-Id"
## The type of the JSON bodies sent.
const JSON_TYPE: String = "application/json"
## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux
## game, and the Linux package with an installer.
const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"]
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
@@ -346,14 +349,14 @@ func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) ->
get_json("/download/list", on_done, on_failed)
## Downloads the newest MusicPlayer for Windows.
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/windows", on_done, on_failed)
## Downloads the newest MusicPlayer for Linux.
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/linux", on_done, on_failed)
## Calls [param on_done] with the link to download the newest MusicPlayer file
## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
## redirect to the release on Gitea, which is not followed: the link needs no token, so
## the browser can open it.
func fetch_client_download_url(
asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
PackedByteArray(), JSON_TYPE, false)
# Tokens
@@ -391,14 +394,9 @@ func queue_id() -> String:
# Keeps the queue ID in [param headers] from [param server] when it is new
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
var prefix: String = QUEUE_HEADER.to_lower() + ":"
for header: String in headers:
if !header.to_lower().begins_with(prefix):
continue
var id: String = header.substr(prefix.length()).strip_edges()
var id: String = header_value(headers, QUEUE_HEADER)
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
Settings.set_queue_id(server, id)
return
# Generic requests
@@ -482,7 +480,9 @@ func _parse_json(
# Sends the request with the token, and once more with a new token if the server
# answers 401. A [param body] of the type [param content_type] is sent with it.
# answers 401. A [param body] of the type [param content_type] is sent with it. Without
# [param follow_redirects], a redirect is a success and [param on_success] gets where it
# points to.
func _request(
path: String,
method: int,
@@ -491,17 +491,25 @@ func _request(
may_retry: bool = true,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void:
_with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, response: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, response: PackedByteArray,
headers: PackedStringArray) -> void:
if response_code == 401 && may_retry:
GameLog.debug("Token refused by the server, getting a new one")
if _token == token:
clear_token()
_request(path, method, on_success, on_failed, false, body, content_type)
_request(path, method, on_success, on_failed, false, body, content_type,
follow_redirects)
return
var location: String = header_value(headers, "Location")
if !follow_redirects && response_code >= 300 && response_code < 400 \
&& !location.is_empty():
on_success.call(location)
return
_handle_response(path, response_code, response, on_success, on_failed)
, body, content_type)
, body, content_type, follow_redirects)
, on_failed)
@@ -509,7 +517,8 @@ func _request(
func _send_once(
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray,
_headers: PackedStringArray) -> void:
_handle_response(path, response_code, body, on_success, on_failed)
)
@@ -521,6 +530,7 @@ func _send(
on_complete: Callable,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void:
var server: String = Settings.default_path
var headers: PackedStringArray = []
@@ -535,8 +545,8 @@ func _send(
func(response_code: int, response_body: PackedByteArray,
response_headers: PackedStringArray) -> void:
_remember_queue_id(server, response_headers)
on_complete.call(response_code, response_body)
, headers, body)
on_complete.call(response_code, response_body, response_headers)
, headers, body, follow_redirects)
func _handle_response(
@@ -575,7 +585,7 @@ func _request_token() -> void:
token_request.client_type = CLIENT_TYPE
var server: String = Settings.default_path
_send("/token", HTTPClient.METHOD_POST, "",
func(response_code: int, body: PackedByteArray) -> void:
func(response_code: int, body: PackedByteArray, _headers: PackedStringArray) -> void:
var waiters: Array[Array] = _token_waiters.duplicate()
_token_waiters.clear()
var token: String = _read_token(response_code, body)
@@ -616,6 +626,17 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
## The value of the header [param header_name] in [param headers], given as
## [code]"Name: value"[/code] lines, or empty when it isn't there. Names are matched
## without regard to case.
static func header_value(headers: PackedStringArray, header_name: String) -> String:
for header: String in headers:
var colon: int = header.find(":")
if colon > 0 && header.left(colon).strip_edges().to_lower() == header_name.to_lower():
return header.substr(colon + 1).strip_edges()
return ""
static func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid.uri_encode()
File diff suppressed because it is too large. Load diff
+23 -4
View File
@@ -31,6 +31,9 @@ var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## Whether each request followed redirects, in the same order as
## [member requested_paths].
var requested_follow_redirects: Array[bool] = []
## How many tokens have been asked for.
var token_requests: int = 0
## The JSON body of the last token request.
@@ -67,6 +70,14 @@ func uninstall() -> void:
Settings.queue_ids = _previous_queue_ids
## Answers requests to [param path] with a redirect to [param location], like the
## server's download routes. Requests that follow redirects get an empty 200 instead, as
## if they had followed it.
func respond_redirect(path: String, location: String) -> void:
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
_successes_left.erase(path)
## Answers requests to [param path] with [param data] encoded as JSON.
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
@@ -114,6 +125,7 @@ func request(
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void:
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
if path == "/token" && method == HTTPClient.METHOD_POST:
@@ -129,17 +141,18 @@ func request(
requested_headers.append(headers)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_bodies.append(body)
requested_follow_redirects.append(follow_redirects)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray())
return
if _held_paths.has(path):
_held_paths[path].append(_answer.bind(path, on_complete))
_held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
return
_answer(path, on_complete)
_answer(path, on_complete, follow_redirects)
func _answer(path: String, on_complete: Callable) -> void:
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
var headers: PackedStringArray = []
if !queue_id.is_empty() && path != "/token":
headers.append("X-Queue-Id: " + queue_id)
@@ -148,7 +161,13 @@ func _answer(path: String, on_complete: Callable) -> void:
on_complete.call(503, PackedByteArray(), headers)
return
_successes_left[path] -= 1
if _responses.has(path):
if _responses.has(path) && _responses[path].has("location"):
if follow_redirects:
on_complete.call(200, PackedByteArray(), headers)
else:
headers.append("Location: " + _responses[path].location)
on_complete.call(302, PackedByteArray(), headers)
elif _responses.has(path):
on_complete.call(_responses[path].code, _responses[path].body, headers)
else:
on_complete.call(404, PackedByteArray(), headers)
+21
View File
@@ -276,6 +276,27 @@ func run(on_state: Callable) -> void:
top_bar.show_latest_version("99.10.10")
await _settle()
await on_state.call("update_available", null)
var update_popup: UpdatePopup = _node("UpdatePopup")
var updater: Updater = update_popup.updater
updater.in_editor = false
updater.os_name = "Windows"
DirAccess.make_dir_recursive_absolute(Settings.config_dir())
updater.executable_path = Settings.config_dir().path_join("MusicPlayer.exe")
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow", update_popup)
update_popup.download_progress.visible = true
update_popup._set_busy(true)
update_popup._show_status("")
update_popup._on_progress(31 << 20, 80 << 20)
await _settle()
await on_state.call("popup_UpdateWindow_downloading", update_popup)
updater.in_editor = true
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow_browser_only", update_popup)
update_popup.close()
await _settle()
top_bar.show_latest_version("1.0.0")
await _settle()
+2 -2
View File
@@ -7,7 +7,7 @@ extends MainWindowTestSuite
const MAIN_WINDOW_CHILDREN: Array[String] = [
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
"SettingsPopup",
"SettingsPopup", "UpdatePopup",
]
## The parts of the quiz and the scene each one comes from.
@@ -28,7 +28,7 @@ const LOCKED_BY_SYNC: Array[String] = [
]
const TOP_BAR_CHILDREN: Array[String] = [
"TopPanelContainer", "Title", "DebugLabel", "UpdateLabel", "OfflineLabel", "Buttons",
"TopPanelContainer", "Title", "DebugLabel", "UpdateBox", "OfflineLabel", "Buttons",
"QuizTab", "StatisticsTab",
]
## The buttons in the top bar's row on the right, in order.
+1
View File
@@ -47,6 +47,7 @@ const STYLES: Dictionary = {
"PlayerButtonPrimary": "Button",
"PlayerTime": "Label",
"PlayerIcon": "Button",
"UpdateButton": "Button",
"ProgressSlider": "HSlider",
"VolumeBar": "HSlider",
"StatisticsCard": "PanelContainer",
+27 -9
View File
@@ -10,7 +10,7 @@ func before_test() -> void:
func test_no_message_when_this_is_the_newest_version() -> void:
assert_bool(_top_bar.update_label.visible).is_false()
assert_bool(_top_bar.update_box.visible).is_false()
assert_int(_server.count("/download")).is_equal(1)
@@ -19,9 +19,9 @@ func test_newer_release_shows_a_message() -> void:
_top_bar.check_for_update()
assert_bool(_top_bar.update_label.is_visible_in_tree()).is_true()
assert_str(_top_bar.update_label.text).is_equal("Version 99.0.0 is available")
assert_str(_top_bar.update_label.tooltip_text).contains(Settings.version)
assert_bool(_top_bar.update_button.is_visible_in_tree()).is_true()
assert_str(_top_bar.update_button.text).is_equal("Version 99.0.0 is available")
assert_str(_top_bar.update_button.tooltip_text).contains(Settings.version)
func test_message_fits_between_the_tabs_and_the_buttons() -> void:
@@ -29,8 +29,10 @@ func test_message_fits_between_the_tabs_and_the_buttons() -> void:
_top_bar.check_for_update()
await _runner.simulate_frames(1)
var label: Rect2 = _top_bar.update_label.get_global_rect()
var text_width: float = _top_bar.update_label.get_minimum_size().x
var label: Rect2 = _top_bar.update_box.get_global_rect()
var button: Button = _top_bar.update_button
var text_width: float = button.get_theme_font(&"font").get_string_size(button.text,
HORIZONTAL_ALIGNMENT_LEFT, -1, button.get_theme_font_size(&"font_size")).x
assert_float(text_width).is_less_equal(label.size.x)
assert_float(label.position.x).is_greater(
(_top_bar.get_node("%StatisticsTab") as Control).get_global_rect().end.x)
@@ -44,7 +46,7 @@ func test_message_stays_on_the_statistics_tab() -> void:
_window.show_tab(&"statistics")
assert_bool(_top_bar.update_label.is_visible_in_tree()).is_true()
assert_bool(_top_bar.update_button.is_visible_in_tree()).is_true()
func test_server_change_checks_again() -> void:
@@ -52,7 +54,7 @@ func test_server_change_checks_again() -> void:
_quiz.on_server_changed()
assert_bool(_top_bar.update_label.visible).is_true()
assert_bool(_top_bar.update_box.visible).is_true()
func test_failed_check_shows_nothing() -> void:
@@ -60,7 +62,7 @@ func test_failed_check_shows_nothing() -> void:
_top_bar.check_for_update()
assert_bool(_top_bar.update_label.visible).is_false()
assert_bool(_top_bar.update_box.visible).is_false()
func test_version_comparison() -> void:
@@ -72,3 +74,19 @@ func test_version_comparison() -> void:
assert_bool(TopBar.is_newer_version("2.0", "2.0.0")).is_false()
assert_bool(TopBar.is_newer_version("1.9.0", "2.0.0")).is_false()
assert_bool(TopBar.is_newer_version("1.9.9", "1.10.0")).is_false()
@warning_ignore("unused_parameter")
func test_message_stays_clear_of_the_buttons_in_every_theme(theme_id: String,
test_parameters: Array = [["classic"], ["original_web"], ["nord"], ["christmas"]]) -> void:
Settings.apply_theme(theme_id)
_server.respond_json("/download", "99.10.10")
_top_bar.check_for_update()
await _runner.simulate_frames(2)
var message: Rect2 = _top_bar.update_box.get_global_rect()
assert_float(message.end.x).is_less(
(_top_bar.get_node("%SyncButton") as Control).get_global_rect().position.x)
assert_float(message.position.x).is_greater(
(_top_bar.get_node("%StatisticsTab") as Control).get_global_rect().end.x)
Settings.apply_theme("classic")
+160
View File
@@ -0,0 +1,160 @@
extends MainWindowTestSuite
## Getting the newest version from "Version X is available" in the top bar: updating in
## place and restarting, or downloading in the browser.
const LINK: String = "https://gitea.example/releases/download/99.0.0/MusicPlayer_99.0.0.exe"
var _top_bar: TopBar
var _popup: UpdatePopup
var _updater: FakeUpdater
var _opened: Array[String] = []
var _dir: String
## Downloads nothing: hands over a prepared file, or fails, at once.
class FakeUpdater:
extends Updater
var downloaded: PackedByteArray = [0x4D, 0x5A, 0x90, 0x00]
var fail_with: String = ""
var urls: Array[String] = []
var started: Array[String] = []
var quits: int = 0
func _init() -> void:
super()
start_process = func(path: String, _arguments: PackedStringArray) -> int:
started.append(path)
return 1
quit = func() -> void: quits += 1
func download(url: String, on_progress: Callable, on_done: Callable,
on_failed: Callable) -> void:
urls.append(url)
if !fail_with.is_empty():
on_failed.call(fail_with)
return
on_progress.call(downloaded.size() / 2, downloaded.size())
var file: FileAccess = FileAccess.open(update_path(), FileAccess.WRITE)
file.store_buffer(downloaded)
file.close()
on_done.call(update_path())
func _before_window_starts() -> void:
_server.respond_json("/download", "99.0.0")
func before_test() -> void:
# The quiz is saved before restarting, next to the settings
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("update_%d" % randi()))
await super()
_top_bar = _window.get_node("TopBar")
_popup = _window.get_node("UpdatePopup")
_opened = []
_popup.open_url = func(url: String) -> Error:
_opened.append(url)
return OK
_dir = create_temp_dir("game_%d" % randi())
_updater = FakeUpdater.new()
_updater.in_editor = false
_updater.os_name = "Windows"
_updater.executable_path = _dir.path_join("MusicPlayer.exe")
var file: FileAccess = FileAccess.open(_updater.executable_path, FileAccess.WRITE)
file.store_string("old game")
file.close()
_popup.add_child(_updater)
_popup.updater = _updater
func _open_popup() -> void:
_top_bar.update_button.pressed.emit()
func test_clicking_the_message_opens_the_popup() -> void:
_open_popup()
assert_bool(_popup.visible).is_true()
assert_str(_popup.title_label.text).is_equal("Version 99.0.0 is available")
assert_str(_popup.info_label.text).contains(Settings.version)
assert_bool(_popup.update_now_button.visible).is_true()
func test_update_downloads_swaps_and_restarts() -> void:
_server.respond_redirect("/download/windows", LINK)
_open_popup()
_popup.update_now()
assert_array(_updater.urls).contains_exactly([LINK])
var exe: String = _updater.executable_path
assert_array(FileAccess.get_file_as_bytes(exe)).is_equal(_updater.downloaded)
assert_str(FileAccess.get_file_as_string(exe + Updater.OLD_SUFFIX)).is_equal("old game")
assert_array(_updater.started).contains_exactly([exe])
assert_int(_updater.quits).is_equal(1)
# The quiz continues after the restart
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
func test_failed_download_says_why_and_keeps_the_game() -> void:
_server.respond_redirect("/download/windows", LINK)
_updater.fail_with = "The download failed. Check the connection and try again."
_open_popup()
_popup.update_now()
assert_str(_popup.status_label.text).is_equal(_updater.fail_with)
assert_bool(_popup.update_now_button.disabled).is_false()
assert_bool(_popup.browser_button.disabled).is_false()
assert_str(FileAccess.get_file_as_string(_updater.executable_path)).is_equal("old game")
assert_int(_updater.quits).is_equal(0)
func test_no_download_on_the_server_says_so() -> void:
_open_popup()
_popup.update_now()
assert_array(_updater.urls).is_empty()
assert_str(_popup.status_label.text).contains("no download")
assert_int(_updater.quits).is_equal(0)
func test_browser_gets_the_package_on_linux() -> void:
var package: String = LINK.replace(".exe", "_linux.tar.gz")
_server.respond_redirect("/download/linux-package", package)
_updater.os_name = "Linux"
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([package])
func test_browser_gets_the_release_page_on_other_platforms() -> void:
_updater.os_name = "macOS"
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
assert_int(_server.count("/download/windows")).is_equal(0)
func test_browser_gets_the_release_page_when_the_server_has_no_link() -> void:
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
func test_from_the_editor_only_the_browser_is_offered() -> void:
_updater.in_editor = true
_open_popup()
assert_bool(_popup.update_now_button.visible).is_false()
assert_bool(_popup.reason_label.visible).is_true()
assert_str(_popup.reason_label.text).contains("editor")
assert_bool(_popup.browser_button.visible).is_true()
@@ -0,0 +1 @@
uid://d4lsj4jjqht4d
+38 -2
View File
@@ -81,6 +81,40 @@ func test_fetch_song_passes_raw_bytes() -> void:
assert_array(received).contains_exactly([mp3])
func test_download_url_is_where_the_server_redirects() -> void:
var link: String = "https://gitea.example/releases/download/2.0.0/MusicPlayer_2.0.0.exe"
_server.respond_redirect("/download/windows", link)
var urls: Array[String] = []
MusicServerClient.fetch_client_download_url("windows",
func(url: String) -> void: urls.append(url))
assert_array(urls).contains_exactly([link])
# The token is only for the server, so the redirect is not followed with it
assert_array(_server.requested_follow_redirects).contains_exactly([false])
func test_missing_download_calls_on_failed() -> void:
var failed: Array[bool] = []
MusicServerClient.fetch_client_download_url("linux-package",
func(_url: String) -> void: pass, func() -> void: failed.append(true))
assert_array(failed).has_size(1)
@warning_ignore("unused_parameter")
func test_header_value(name: String, value: String, test_parameters: Array = [
["Location", "https://x/y"],
["location", "https://x/y"],
["X-Queue-Id", "abc"],
["Missing", ""],
]) -> void:
var headers: PackedStringArray = ["Location: https://x/y", "x-queue-id: abc "]
assert_str(MusicServerClient.header_value(headers, name)).is_equal(value)
func test_failed_request_does_not_call_back() -> void:
_server.respond_json("/music/list", [], 500)
var called: Array[bool] = [false]
@@ -402,9 +436,9 @@ func test_endpoint_paths_and_methods() -> void:
"/download", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.fetch_client_assets(noop),
"/download/list", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.download_client_windows(noop),
[func() -> void: MusicServerClient.fetch_client_download_url("windows", noop),
"/download/windows", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.download_client_linux(noop),
[func() -> void: MusicServerClient.fetch_client_download_url("linux", noop),
"/download/linux", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.cleanup_tokens(noop),
"/token/cleanup", HTTPClient.METHOD_POST],
@@ -640,6 +674,7 @@ class _HeaderTransport:
on_complete: Callable,
_request_headers: PackedStringArray = PackedStringArray(),
_body: PackedByteArray = PackedByteArray(),
_follow_redirects: bool = true,
) -> void:
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
.to_utf8_buffer(), _headers)
@@ -660,6 +695,7 @@ class _RecordingTransport:
on_complete: Callable,
_headers: PackedStringArray = PackedStringArray(),
_body: PackedByteArray = PackedByteArray(),
_follow_redirects: bool = true,
) -> void:
_urls.append(url)
if url.ends_with("/token"):
+215
View File
@@ -0,0 +1,215 @@
extends GdUnitTestSuite
## Updating the game in place: which file to get, checking it, swapping it in, and
## cleaning up after.
const WINDOWS_GAME: PackedByteArray = [0x4D, 0x5A, 0x90, 0x00, 0x03]
const LINUX_GAME: PackedByteArray = [0x7F, 0x45, 0x4C, 0x46, 0x02]
var _dir: String
var _updater: Updater
func before_test() -> void:
_dir = create_temp_dir("updater_%d" % randi())
_updater = auto_free(Updater.new())
_updater.in_editor = false
_updater.os_name = "Windows"
_updater.executable_path = _dir.path_join("MusicPlayer.exe")
func _write(path: String, bytes: PackedByteArray) -> String:
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(bytes)
file.close()
return path
func _read(path: String) -> PackedByteArray:
return FileAccess.get_file_as_bytes(path)
@warning_ignore("unused_parameter")
func test_asset_for(os_name: String, in_place: bool, asset: String, test_parameters: Array = [
["Windows", true, "windows"],
["Windows", false, "windows"],
["Linux", true, "linux"],
["Linux", false, "linux-package"],
["macOS", true, ""],
["macOS", false, ""],
]) -> void:
assert_str(Updater.asset_for(os_name, in_place)).is_equal(asset)
@warning_ignore("unused_parameter")
func test_check_file(bytes: PackedByteArray, os_name: String, ok: bool,
test_parameters: Array = [
[WINDOWS_GAME, "Windows", true],
[LINUX_GAME, "Linux", true],
[LINUX_GAME, "Windows", false],
[WINDOWS_GAME, "Linux", false],
["<html>Not Found</html>".to_utf8_buffer(), "Windows", false],
[PackedByteArray(), "Linux", false],
]) -> void:
var path: String = _write(_dir.path_join("game"), bytes)
assert_bool(Updater.check_file(path, os_name)).is_equal(ok)
func test_missing_file_is_not_the_game() -> void:
assert_bool(Updater.check_file(_dir.path_join("nothing"), "Windows")).is_false()
func test_windows_moves_the_running_game_aside() -> void:
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
assert_int(Updater.install(update, exe, "Windows")).is_equal(OK)
assert_str(_read(exe).get_string_from_utf8()).is_equal("new")
assert_str(_read(exe + Updater.OLD_SUFFIX).get_string_from_utf8()).is_equal("old")
assert_bool(FileAccess.file_exists(update)).is_false()
func test_windows_replaces_an_old_version_left_from_before() -> void:
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
_write(exe + Updater.OLD_SUFFIX, "older".to_utf8_buffer())
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
assert_int(Updater.install(update, exe, "Windows")).is_equal(OK)
assert_str(_read(exe + Updater.OLD_SUFFIX).get_string_from_utf8()).is_equal("old")
func test_windows_puts_the_game_back_when_the_new_one_cannot_be_moved_in() -> void:
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
var error: Error = Updater.install(exe + Updater.UPDATE_SUFFIX, exe, "Windows")
assert_int(error).is_not_equal(OK)
assert_str(_read(exe).get_string_from_utf8()).is_equal("old")
assert_bool(FileAccess.file_exists(exe + Updater.OLD_SUFFIX)).is_false()
func test_linux_replaces_the_game_and_makes_it_runnable() -> void:
var exe: String = _write(_dir.path_join("MusicPlayer.x86_64"), "old".to_utf8_buffer())
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
assert_int(Updater.install(update, exe, "Linux")).is_equal(OK)
assert_str(_read(exe).get_string_from_utf8()).is_equal("new")
assert_bool(FileAccess.file_exists(update)).is_false()
assert_int(FileAccess.get_unix_permissions(exe) & FileAccess.UNIX_EXECUTE_OWNER) \
.is_not_equal(0)
func test_leftovers_are_deleted_at_start() -> void:
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "new".to_utf8_buffer())
_write(exe + Updater.OLD_SUFFIX, "old".to_utf8_buffer())
_write(exe + Updater.UPDATE_SUFFIX, "half".to_utf8_buffer())
Updater.remove_old_files(exe)
assert_bool(FileAccess.file_exists(exe)).is_true()
assert_bool(FileAccess.file_exists(exe + Updater.OLD_SUFFIX)).is_false()
assert_bool(FileAccess.file_exists(exe + Updater.UPDATE_SUFFIX)).is_false()
func test_can_update_in_a_writable_folder() -> void:
assert_str(_updater.can_update_in_place()).is_empty()
assert_array(DirAccess.get_files_at(_dir)).is_empty()
func test_not_from_the_editor() -> void:
_updater.in_editor = true
assert_str(_updater.can_update_in_place()).contains("editor")
func test_not_on_other_platforms() -> void:
_updater.os_name = "macOS"
assert_str(_updater.can_update_in_place()).contains("Windows and Linux")
func test_not_when_the_folder_cannot_be_written_to() -> void:
_updater.executable_path = _dir.path_join("missing/MusicPlayer.exe")
assert_str(_updater.can_update_in_place()).contains("can't be written to")
func test_restart_starts_the_game_and_quits() -> void:
var started: Array[String] = []
var quits: Array[bool] = []
_updater.start_process = func(path: String, _arguments: PackedStringArray) -> int:
started.append(path)
return 1
_updater.quit = func() -> void: quits.append(true)
_updater.restart()
assert_array(started).contains_exactly([_updater.executable_path])
assert_array(quits).has_size(1)
# Answers one HTTP request on a local port with [param status] and [param body], and
# downloads it with the updater. Returns [done paths, failure reasons].
func _download_from_local_server(status: String, body: PackedByteArray) -> Array:
var server: TCPServer = TCPServer.new()
assert_int(server.listen(0, "127.0.0.1")).is_equal(OK)
add_child(_updater)
var done: Array[String] = []
var failed: Array[String] = []
var progress: Array[int] = []
_updater.download("http://127.0.0.1:%d/MusicPlayer.exe" % server.get_local_port(),
func(bytes: int, _total: int) -> void: progress.append(bytes),
func(path: String) -> void: done.append(path),
func(reason: String) -> void: failed.append(reason))
var connection: StreamPeerTCP = null
for i: int in 100:
await await_millis(20)
if connection == null && server.is_connection_available():
connection = server.take_connection()
if connection != null && connection.get_available_bytes() > 0:
connection.get_data(connection.get_available_bytes())
var head: String = "HTTP/1.1 %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" \
% [status, body.size()]
connection.put_data(head.to_ascii_buffer() + body)
break
for i: int in 100:
await await_millis(20)
if !done.is_empty() || !failed.is_empty():
break
if connection != null:
connection.disconnect_from_host()
server.stop()
remove_child(_updater)
return [done, failed, progress]
func test_download_keeps_a_checked_game() -> void:
var game: PackedByteArray = WINDOWS_GAME.duplicate()
game.resize(4096)
var result: Array = await _download_from_local_server("200 OK", game)
assert_array(result[1]).is_empty()
assert_array(result[0]).contains_exactly([_updater.update_path()])
assert_array(_read(_updater.update_path())).is_equal(game)
func test_download_of_an_error_page_is_deleted() -> void:
var result: Array = await _download_from_local_server("404 Not Found",
"Not Found".to_utf8_buffer())
assert_array(result[0]).is_empty()
assert_str(result[1][0]).contains("404")
assert_bool(FileAccess.file_exists(_updater.update_path())).is_false()
func test_download_that_is_not_the_game_is_deleted() -> void:
var result: Array = await _download_from_local_server("200 OK",
"<html>Sign in</html>".to_utf8_buffer())
assert_array(result[0]).is_empty()
assert_str(result[1][0]).contains("not the game")
assert_bool(FileAccess.file_exists(_updater.update_path())).is_false()
+1
View File
@@ -0,0 +1 @@
uid://ctxevissal7tm
+1
View File
@@ -81,6 +81,7 @@ inspector or `theme_type_variation` in code:
| `ProgressSlider`, `VolumeBar` | HSlider | How far the song has played (orange), and the volume |
| `PlayerTime` | Label | The time played and the song's length |
| `PlayerIcon` | Button | The volume icon in the music player: a flat button that only shows its icon, in the theme's colour |
| `UpdateButton` | Button | "Version X is available" in the top bar: highlighted text without a background, lighter under the mouse |
| `SongStatsHeader` | PanelContainer | The column titles above the statistics song list, spaced like its rows |
| `SongStatsText`, `SongStatsSecondary` | RichTextLabel | A song's name and its soundtrack (dimmer) in the statistics song list |
+11
View File
@@ -1408,6 +1408,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/base_type = &"Button"
UpdateButton/colors/font_color = Color(0.949, 0.843, 0.478, 1.0)
UpdateButton/colors/font_focus_color = Color(0.949, 0.843, 0.478, 1.0)
UpdateButton/colors/font_hover_color = Color(1.0, 0.93, 0.7, 1.0)
UpdateButton/colors/font_hover_pressed_color = Color(1.0, 0.93, 0.7, 1.0)
UpdateButton/colors/font_pressed_color = Color(1.0, 0.93, 0.7, 1.0)
UpdateButton/font_sizes/font_size = 18
UpdateButton/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/hover = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/normal = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerList/base_type = &"PanelContainer"
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
PlayerScore/base_type = &"Label"
+11
View File
@@ -1216,6 +1216,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/base_type = &"Button"
UpdateButton/colors/font_color = Color(1, 0.5, 0, 1)
UpdateButton/colors/font_focus_color = Color(1, 0.5, 0, 1)
UpdateButton/colors/font_hover_color = Color(1, 0.68, 0.35, 1)
UpdateButton/colors/font_hover_pressed_color = Color(1, 0.68, 0.35, 1)
UpdateButton/colors/font_pressed_color = Color(1, 0.68, 0.35, 1)
UpdateButton/font_sizes/font_size = 18
UpdateButton/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/hover = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/normal = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerList/base_type = &"PanelContainer"
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
PlayerScore/base_type = &"Label"
+11
View File
@@ -1391,6 +1391,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/base_type = &"Button"
UpdateButton/colors/font_color = Color(0.922, 0.796, 0.545, 1.0)
UpdateButton/colors/font_focus_color = Color(0.922, 0.796, 0.545, 1.0)
UpdateButton/colors/font_hover_color = Color(0.96, 0.88, 0.72, 1.0)
UpdateButton/colors/font_hover_pressed_color = Color(0.96, 0.88, 0.72, 1.0)
UpdateButton/colors/font_pressed_color = Color(0.96, 0.88, 0.72, 1.0)
UpdateButton/font_sizes/font_size = 18
UpdateButton/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/hover = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/normal = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerList/base_type = &"PanelContainer"
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
PlayerScore/base_type = &"Label"
+11
View File
@@ -1317,6 +1317,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerIcon/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/base_type = &"Button"
UpdateButton/colors/font_color = Color(1, 0.612, 0, 1)
UpdateButton/colors/font_focus_color = Color(1, 0.612, 0, 1)
UpdateButton/colors/font_hover_color = Color(1, 0.75, 0.35, 1)
UpdateButton/colors/font_hover_pressed_color = Color(1, 0.75, 0.35, 1)
UpdateButton/colors/font_pressed_color = Color(1, 0.75, 0.35, 1)
UpdateButton/font_sizes/font_size = 18
UpdateButton/styles/focus = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/hover = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/normal = SubResource("StyleBoxEmpty_playericon")
UpdateButton/styles/pressed = SubResource("StyleBoxEmpty_playericon")
PlayerList/base_type = &"PanelContainer"
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
PlayerScore/base_type = &"Label"