diff --git a/RELEASING.md b/RELEASING.md index 5ea1bd5..df924d1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,3 +27,15 @@ Unpack `MusicPlayer__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_.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_.exe` on Windows, `MusicPlayer_.x86_64` on Linux, through +the server's `/download/` 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 +`.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. diff --git a/common/updater.gd b/common/updater.gd new file mode 100644 index 0000000..cd5dcb9 --- /dev/null +++ b/common/updater.gd @@ -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].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()) diff --git a/common/updater.gd.uid b/common/updater.gd.uid new file mode 100644 index 0000000..87263db --- /dev/null +++ b/common/updater.gd.uid @@ -0,0 +1 @@ +uid://bu3bi7qhxr7it diff --git a/export_presets.cfg b/export_presets.cfg index 86ddd90..cb2c885 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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="" diff --git a/main/MainWindow.tscn b/main/MainWindow.tscn index e37f34d..b802a33 100644 --- a/main/MainWindow.tscn +++ b/main/MainWindow.tscn @@ -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 diff --git a/main/TopBar.tscn b/main/TopBar.tscn index 74b178f..4b400f1 100644 --- a/main/TopBar.tscn +++ b/main/TopBar.tscn @@ -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 diff --git a/main/main_window.gd b/main/main_window.gd index 16be0eb..229a9f0 100644 --- a/main/main_window.gd +++ b/main/main_window.gd @@ -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 diff --git a/main/top_bar.gd b/main/top_bar.gd index 4713b31..cd0033d 100644 --- a/main/top_bar.gd +++ b/main/top_bar.gd @@ -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 diff --git a/popups/UpdatePopup.tscn b/popups/UpdatePopup.tscn new file mode 100644 index 0000000..bac1437 --- /dev/null +++ b/popups/UpdatePopup.tscn @@ -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") diff --git a/popups/update_popup.gd b/popups/update_popup.gd new file mode 100644 index 0000000..7dee46d --- /dev/null +++ b/popups/update_popup.gd @@ -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) diff --git a/popups/update_popup.gd.uid b/popups/update_popup.gd.uid new file mode 100644 index 0000000..83c105b --- /dev/null +++ b/popups/update_popup.gd.uid @@ -0,0 +1 @@ +uid://dupwb11ovvn7j diff --git a/quiz/quiz.gd b/quiz/quiz.gd index 2e8ddb9..892f536 100644 --- a/quiz/quiz.gd +++ b/quiz/quiz.gd @@ -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() diff --git a/server/http_transport.gd b/server/http_transport.gd index d0621ef..3dd768b 100644 --- a/server/http_transport.gd +++ b/server/http_transport.gd @@ -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( diff --git a/server/music_server_client.gd b/server/music_server_client.gd index 9030c6a..229dfa4 100644 --- a/server/music_server_client.gd +++ b/server/music_server_client.gd @@ -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() - if !id.is_empty() && Settings.queue_ids.get(server, "") != id: - Settings.set_queue_id(server, id) - return + var id: String = header_value(headers, QUEUE_HEADER) + if !id.is_empty() && Settings.queue_ids.get(server, "") != id: + Settings.set_queue_id(server, id) # 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() diff --git a/tests/data/layout_baseline.json b/tests/data/layout_baseline.json index f1fb546..5207b55 100644 --- a/tests/data/layout_baseline.json +++ b/tests/data/layout_baseline.json @@ -4205,9 +4205,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -4248,14 +4248,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 0.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } }, "offline": { @@ -11992,9 +12118,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": true @@ -12035,14 +12161,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 332.0, + 38.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 459.0, + 640.0, + 161.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 475.0, + 592.0, + 129.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 573.0, + 592.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 1067.0, + 573.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 965.0, + 573.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 1084.0, + 573.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 583.0, + 592.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 522.0, + 592.0, + 49.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 522.0, + 592.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 557.0, + 592.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 599.0, + 592.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 475.0, + 592.0, + 35.0 + ], + "visible": false } }, "players_added": { @@ -16431,9 +16683,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -16474,14 +16726,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 0.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } }, "playing": { @@ -20987,9 +21365,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -21030,14 +21408,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 0.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } }, "popup_AboutWindow": { @@ -26009,6 +26513,363 @@ "visible": true } }, + "popup_UpdateWindow": { + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 446.0, + 640.0, + 187.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 462.0, + 592.0, + 155.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 586.0, + 592.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 883.0, + 586.0, + 189.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 781.0, + 586.0, + 90.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 1084.0, + 586.0, + 172.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 462.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 509.0, + 592.0, + 49.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 462.0, + 592.0, + 829.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 570.0, + 592.0, + 4.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 462.0, + 592.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 462.0, + 592.0, + 35.0 + ], + "visible": true + } + }, + "popup_UpdateWindow_browser_only": { + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 459.0, + 640.0, + 161.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 475.0, + 592.0, + 129.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 573.0, + 592.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 1067.0, + 573.0, + 189.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 965.0, + 573.0, + 90.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 1084.0, + 573.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 583.0, + 592.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 522.0, + 592.0, + 49.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 522.0, + 592.0, + 23.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 557.0, + 592.0, + 4.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 599.0, + 592.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 475.0, + 592.0, + 35.0 + ], + "visible": true + } + }, + "popup_UpdateWindow_downloading": { + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 419.0, + 640.0, + 241.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 435.0, + 592.0, + 209.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 613.0, + 592.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 883.0, + 613.0, + 189.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 795.0, + 613.0, + 76.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 1084.0, + 613.0, + 172.0, + 31.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 543.0, + 592.0, + 4.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 482.0, + 592.0, + 49.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 435.0, + 592.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 597.0, + 592.0, + 4.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 559.0, + 592.0, + 26.0 + ], + "visible": true + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 435.0, + 592.0, + 35.0 + ], + "visible": true + } + }, "popup_WinnerWindow": { "Quiz/WinnerPopup": { "rect": [ @@ -31247,9 +32108,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -31290,14 +32151,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 0.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } }, "statistics": { @@ -47441,9 +48428,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -47484,14 +48471,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 0.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } }, "sync_in_progress": { @@ -52685,9 +53798,9 @@ }, "TopBar/OfflineLabel": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": false @@ -52728,14 +53841,140 @@ ], "visible": true }, - "TopBar/UpdateLabel": { + "TopBar/UpdateBox": { "rect": [ - 1096.0, + 1097.0, 12.0, - 336.0, + 332.0, 38.0 ], "visible": true + }, + "TopBar/UpdateBox/UpdateButton": { + "rect": [ + 1097.0, + 12.0, + 332.0, + 38.0 + ], + "visible": true + }, + "UpdatePopup": { + "rect": [ + 640.0, + 390.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel": { + "rect": [ + 640.0, + 390.0, + 640.0, + 300.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows": { + "rect": [ + 664.0, + 406.0, + 592.0, + 268.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons": { + "rect": [ + 664.0, + 406.0, + 475.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/BrowserButton": { + "rect": [ + 664.0, + 406.0, + 189.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/NotNowButton": { + "rect": [ + 664.0, + 406.0, + 90.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Buttons/UpdateNowButton": { + "rect": [ + 664.0, + 406.0, + 172.0, + 31.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/DownloadProgress": { + "rect": [ + 664.0, + 406.0, + 4.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/InfoLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/ReasonLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/Space": { + "rect": [ + 664.0, + 406.0, + 0.0, + 4.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/StatusLabel": { + "rect": [ + 664.0, + 406.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UpdatePopup/UpdatePanel/Rows/TitleLabel": { + "rect": [ + 664.0, + 406.0, + 294.0, + 35.0 + ], + "visible": false } } } \ No newline at end of file diff --git a/tests/fakes/fake_http_transport.gd b/tests/fakes/fake_http_transport.gd index ff0094c..7e295a9 100644 --- a/tests/fakes/fake_http_transport.gd +++ b/tests/fakes/fake_http_transport.gd @@ -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) diff --git a/tests/fakes/ui_tour.gd b/tests/fakes/ui_tour.gd index 0e93c47..763d524 100644 --- a/tests/fakes/ui_tour.gd +++ b/tests/fakes/ui_tour.gd @@ -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() diff --git a/tests/integration/test_scene_split.gd b/tests/integration/test_scene_split.gd index 1f7d4a0..103b6c2 100644 --- a/tests/integration/test_scene_split.gd +++ b/tests/integration/test_scene_split.gd @@ -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. diff --git a/tests/integration/test_theme.gd b/tests/integration/test_theme.gd index fff37af..ba96892 100644 --- a/tests/integration/test_theme.gd +++ b/tests/integration/test_theme.gd @@ -47,6 +47,7 @@ const STYLES: Dictionary = { "PlayerButtonPrimary": "Button", "PlayerTime": "Label", "PlayerIcon": "Button", + "UpdateButton": "Button", "ProgressSlider": "HSlider", "VolumeBar": "HSlider", "StatisticsCard": "PanelContainer", diff --git a/tests/integration/test_update_notice.gd b/tests/integration/test_update_notice.gd index e811e18..d0b4546 100644 --- a/tests/integration/test_update_notice.gd +++ b/tests/integration/test_update_notice.gd @@ -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") diff --git a/tests/integration/test_update_popup.gd b/tests/integration/test_update_popup.gd new file mode 100644 index 0000000..8f6a9d1 --- /dev/null +++ b/tests/integration/test_update_popup.gd @@ -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() diff --git a/tests/integration/test_update_popup.gd.uid b/tests/integration/test_update_popup.gd.uid new file mode 100644 index 0000000..16d9191 --- /dev/null +++ b/tests/integration/test_update_popup.gd.uid @@ -0,0 +1 @@ +uid://d4lsj4jjqht4d diff --git a/tests/unit/test_music_server_client.gd b/tests/unit/test_music_server_client.gd index 1ee329c..09cc752 100644 --- a/tests/unit/test_music_server_client.gd +++ b/tests/unit/test_music_server_client.gd @@ -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"): diff --git a/tests/unit/test_updater.gd b/tests/unit/test_updater.gd new file mode 100644 index 0000000..8cce9f8 --- /dev/null +++ b/tests/unit/test_updater.gd @@ -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], + ["Not Found".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", + "Sign in".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() diff --git a/tests/unit/test_updater.gd.uid b/tests/unit/test_updater.gd.uid new file mode 100644 index 0000000..f53e8dc --- /dev/null +++ b/tests/unit/test_updater.gd.uid @@ -0,0 +1 @@ +uid://ctxevissal7tm diff --git a/themes/README.md b/themes/README.md index 1bdd8e4..3d31337 100644 --- a/themes/README.md +++ b/themes/README.md @@ -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 | diff --git a/themes/christmas_theme.tres b/themes/christmas_theme.tres index 39d25e0..27c644c 100644 --- a/themes/christmas_theme.tres +++ b/themes/christmas_theme.tres @@ -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" diff --git a/themes/main_theme.tres b/themes/main_theme.tres index ce99ac3..979497f 100644 --- a/themes/main_theme.tres +++ b/themes/main_theme.tres @@ -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" diff --git a/themes/nord_theme.tres b/themes/nord_theme.tres index 17e07be..0ce23c1 100644 --- a/themes/nord_theme.tres +++ b/themes/nord_theme.tres @@ -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" diff --git a/themes/original_web_theme.tres b/themes/original_web_theme.tres index 2f0ed88..3b88699 100644 --- a/themes/original_web_theme.tres +++ b/themes/original_web_theme.tres @@ -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"