Update the game from inside it (#13)
"Version X is available" in the top bar is now a button that opens an Update popup: - Update and restart gets the download link from the server (the redirect is not followed, as the Gitea link needs no token), downloads the new game next to the running one with a progress bar, checks it (complete, and an MZ or ELF file), swaps it in and restarts. The quiz is saved first and continues. On Windows the running .exe is renamed to .old, put back if the swap fails, and deleted at the next start. - Download in browser opens the .exe, the Linux package, or the release page on other platforms. - From the editor, an unwritable folder or another platform, only the browser download is offered, with the reason. The message now fits between the tabs and the buttons in every theme; in Nord and Christmas it ran into Sync. The macOS export preset is removed, and the unused download_client_* functions are replaced by fetch_client_download_url. Closes #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c03c0e4efe
commit
f39865fffc
30 files changed
+2363
-354
No files matched your search
@@ -27,3 +27,15 @@ Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
|
|||||||
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
|
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
|
||||||
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
|
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
|
||||||
who just wants to run the file.
|
who just wants to run the file.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
When the server has a newer release, the top bar says "Version X is available". Clicking
|
||||||
|
it offers **Update and restart**: the game downloads its new file from the release
|
||||||
|
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
|
||||||
|
the server's `/download/<platform>` routes), puts it in place of the running file and
|
||||||
|
starts it, and the quiz continues where it was. On Windows the old file is kept as
|
||||||
|
`<game>.old` until the next start. So keep the release file names as they are: the server
|
||||||
|
finds them by their ending. Where the game can't replace itself (run from the editor, a
|
||||||
|
folder it can't write to, or another platform), it opens the download in the browser
|
||||||
|
instead.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
class_name Updater
|
||||||
|
extends Node
|
||||||
|
## Updates the game in place to the newest release: downloads the new game next to the
|
||||||
|
## running one, checks it, swaps the files and starts the new version.
|
||||||
|
##
|
||||||
|
## The game is one file on Windows and Linux (the game data is embedded). A running .exe
|
||||||
|
## can't be overwritten on Windows, but it can be renamed, so it is moved aside to
|
||||||
|
## [code]<exe>.old[/code], which [method remove_old_files] deletes at the next start. On
|
||||||
|
## Linux the new file simply replaces the running one.
|
||||||
|
##
|
||||||
|
## [member executable_path], [member os_name], [member in_editor], [member start_process]
|
||||||
|
## and [member quit] can be replaced, so tests choose the platform and nothing restarts.
|
||||||
|
|
||||||
|
## Where the newest release can be downloaded by hand.
|
||||||
|
const RELEASES_PAGE: String = "https://gitea.sanplex.xyz/Sansan/MusicPlayer/releases/latest"
|
||||||
|
## Added to the game's path for the downloaded new version.
|
||||||
|
const UPDATE_SUFFIX: String = ".update"
|
||||||
|
## Added to the game's path for the old version, on Windows.
|
||||||
|
const OLD_SUFFIX: String = ".old"
|
||||||
|
## rwxr-xr-x, for the new game on Linux.
|
||||||
|
const EXECUTABLE_PERMISSIONS: int = 0b111_101_101
|
||||||
|
## Seconds the whole download may take.
|
||||||
|
const DOWNLOAD_TIMEOUT: float = 900.0
|
||||||
|
|
||||||
|
## The running game's file.
|
||||||
|
var executable_path: String = OS.get_executable_path()
|
||||||
|
## The platform, as [method OS.get_name] names it.
|
||||||
|
var os_name: String = OS.get_name()
|
||||||
|
## Whether the game runs from the Godot editor, where there is no game file to replace.
|
||||||
|
var in_editor: bool = OS.has_feature("editor")
|
||||||
|
## Starts a program: [code](path: String, arguments: PackedStringArray) -> int[/code].
|
||||||
|
var start_process: Callable = OS.create_process
|
||||||
|
## Quits the game.
|
||||||
|
var quit: Callable
|
||||||
|
|
||||||
|
var _request: HTTPRequest
|
||||||
|
var _on_progress: Callable
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
quit = func() -> void: get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if _request != null && _on_progress.is_valid():
|
||||||
|
_on_progress.call(_request.get_downloaded_bytes(), _request.get_body_size())
|
||||||
|
|
||||||
|
|
||||||
|
## The release file to download for [param os_name]: the game itself to update in place,
|
||||||
|
## or, when not [param in_place], what a person downloads, which on Linux is the package
|
||||||
|
## with an installer. Empty when there is no release for the platform.
|
||||||
|
static func asset_for(for_os: String, in_place: bool) -> String:
|
||||||
|
match for_os:
|
||||||
|
"Windows":
|
||||||
|
return "windows"
|
||||||
|
"Linux":
|
||||||
|
return "linux" if in_place else "linux-package"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## Why the game can't update itself here, or empty when it can.
|
||||||
|
func can_update_in_place() -> String:
|
||||||
|
if in_editor:
|
||||||
|
return "The game runs from the Godot editor."
|
||||||
|
if asset_for(os_name, true).is_empty():
|
||||||
|
return "The game updates itself on Windows and Linux."
|
||||||
|
var probe: String = executable_path + ".write-test"
|
||||||
|
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return "The game's folder %s can't be written to." % executable_path.get_base_dir()
|
||||||
|
file.close()
|
||||||
|
DirAccess.remove_absolute(probe)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## Where the new version is downloaded to, next to the game so it can be renamed into
|
||||||
|
## place.
|
||||||
|
func update_path() -> String:
|
||||||
|
return executable_path + UPDATE_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
## Downloads the new game from [param url]. Calls [param on_progress] with the bytes
|
||||||
|
## downloaded and the total (-1 while unknown) every frame, then [param on_done] with the
|
||||||
|
## file's path once it is checked, or [param on_failed] with the reason.
|
||||||
|
func download(url: String, on_progress: Callable, on_done: Callable,
|
||||||
|
on_failed: Callable) -> void:
|
||||||
|
cancel()
|
||||||
|
_on_progress = on_progress
|
||||||
|
_request = HTTPRequest.new()
|
||||||
|
_request.download_file = update_path()
|
||||||
|
_request.timeout = DOWNLOAD_TIMEOUT
|
||||||
|
add_child(_request)
|
||||||
|
_request.request_completed.connect(_on_downloaded.bind(on_done, on_failed))
|
||||||
|
var error: Error = _request.request(url)
|
||||||
|
if error != OK:
|
||||||
|
cancel()
|
||||||
|
on_failed.call("The download could not start: " + error_string(error))
|
||||||
|
|
||||||
|
|
||||||
|
## Stops a download and deletes what was downloaded.
|
||||||
|
func cancel() -> void:
|
||||||
|
if _request != null:
|
||||||
|
_request.cancel_request()
|
||||||
|
_request.queue_free()
|
||||||
|
_request = null
|
||||||
|
DirAccess.remove_absolute(update_path())
|
||||||
|
|
||||||
|
|
||||||
|
## Whether [param path] looks like the game for [param for_os]: a Windows program starts
|
||||||
|
## with "MZ", a Linux one with the ELF header. An error page saved by mistake does not.
|
||||||
|
static func check_file(path: String, for_os: String) -> bool:
|
||||||
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null || file.get_length() < 4:
|
||||||
|
return false
|
||||||
|
var start: PackedByteArray = file.get_buffer(4)
|
||||||
|
match for_os:
|
||||||
|
"Windows":
|
||||||
|
return start.slice(0, 2) == "MZ".to_ascii_buffer()
|
||||||
|
"Linux":
|
||||||
|
return start == PackedByteArray([0x7F, 0x45, 0x4C, 0x46])
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Puts the new game at [param update] in place of the game at [param exe]. On Windows
|
||||||
|
## the running game is moved aside first, and put back if the new one can't be moved in.
|
||||||
|
static func install(update: String, exe: String, for_os: String) -> Error:
|
||||||
|
if for_os == "Windows":
|
||||||
|
var old: String = exe + OLD_SUFFIX
|
||||||
|
if FileAccess.file_exists(old):
|
||||||
|
DirAccess.remove_absolute(old)
|
||||||
|
var error: Error = DirAccess.rename_absolute(exe, old)
|
||||||
|
if error != OK:
|
||||||
|
return error
|
||||||
|
error = DirAccess.rename_absolute(update, exe)
|
||||||
|
if error != OK:
|
||||||
|
DirAccess.rename_absolute(old, exe)
|
||||||
|
return error
|
||||||
|
FileAccess.set_unix_permissions(update, EXECUTABLE_PERMISSIONS)
|
||||||
|
return DirAccess.rename_absolute(update, exe)
|
||||||
|
|
||||||
|
|
||||||
|
## Deletes what an update left next to the game at [param exe]: the old version, and a
|
||||||
|
## download that was not finished.
|
||||||
|
static func remove_old_files(exe: String) -> void:
|
||||||
|
for suffix: String in [OLD_SUFFIX, UPDATE_SUFFIX]:
|
||||||
|
if FileAccess.file_exists(exe + suffix):
|
||||||
|
DirAccess.remove_absolute(exe + suffix)
|
||||||
|
|
||||||
|
|
||||||
|
## Starts the game again, now the new version, and quits this one.
|
||||||
|
func restart() -> void:
|
||||||
|
start_process.call(executable_path, OS.get_cmdline_args())
|
||||||
|
quit.call()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_downloaded(result: int, response_code: int, _headers: PackedStringArray,
|
||||||
|
_body: PackedByteArray, on_done: Callable, on_failed: Callable) -> void:
|
||||||
|
var expected: int = _request.get_body_size()
|
||||||
|
_request.queue_free()
|
||||||
|
_request = null
|
||||||
|
var reason: String = ""
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
reason = "The download failed. Check the connection and try again."
|
||||||
|
elif response_code != 200:
|
||||||
|
reason = "The download failed: the server answered %d." % response_code
|
||||||
|
elif expected >= 0 && FileAccess.get_size(update_path()) != expected:
|
||||||
|
reason = "The download stopped before the end. Try again."
|
||||||
|
elif !check_file(update_path(), os_name):
|
||||||
|
reason = "The downloaded file is not the game for %s." % os_name
|
||||||
|
if !reason.is_empty():
|
||||||
|
DirAccess.remove_absolute(update_path())
|
||||||
|
on_failed.call(reason)
|
||||||
|
return
|
||||||
|
on_done.call(update_path())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bu3bi7qhxr7it
|
||||||
+3
-263
@@ -75,266 +75,6 @@ debug/export_console_script=1
|
|||||||
|
|
||||||
[preset.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"
|
name="Web"
|
||||||
platform="Web"
|
platform="Web"
|
||||||
runnable=false
|
runnable=false
|
||||||
@@ -353,7 +93,7 @@ encrypt_pck=false
|
|||||||
encrypt_directory=false
|
encrypt_directory=false
|
||||||
script_export_mode=2
|
script_export_mode=2
|
||||||
|
|
||||||
[preset.2.options]
|
[preset.1.options]
|
||||||
|
|
||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
|
|||||||
dotnet/include_debug_symbols=true
|
dotnet/include_debug_symbols=true
|
||||||
dotnet/embed_build_outputs=false
|
dotnet/embed_build_outputs=false
|
||||||
|
|
||||||
[preset.3]
|
[preset.2]
|
||||||
|
|
||||||
name="Linux"
|
name="Linux"
|
||||||
platform="Linux"
|
platform="Linux"
|
||||||
@@ -400,7 +140,7 @@ encrypt_pck=false
|
|||||||
encrypt_directory=false
|
encrypt_directory=false
|
||||||
script_export_mode=2
|
script_export_mode=2
|
||||||
|
|
||||||
[preset.3.options]
|
[preset.2.options]
|
||||||
|
|
||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
|
[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" 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" 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]
|
[node name="MainWindow" type="Control" unique_id=407149696]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -109,3 +110,12 @@ offset_left = 610.0
|
|||||||
offset_top = 140.0
|
offset_top = 140.0
|
||||||
offset_right = 610.0
|
offset_right = 610.0
|
||||||
offset_bottom = 140.0
|
offset_bottom = 140.0
|
||||||
|
|
||||||
|
[node name="UpdatePopup" parent="." instance=ExtResource("update_popup_scene")]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
offset_left = 640.0
|
||||||
|
offset_top = 390.0
|
||||||
|
offset_right = 640.0
|
||||||
|
offset_bottom = 390.0
|
||||||
+13
-5
@@ -40,19 +40,27 @@ theme_type_variation = &"HighlightLarge"
|
|||||||
text = "DEBUG"
|
text = "DEBUG"
|
||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="UpdateLabel" type="Label" parent="." unique_id=1959221258]
|
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1096.0
|
offset_left = 1096.0
|
||||||
offset_top = 12.0
|
offset_top = 12.0
|
||||||
offset_right = 1432.0
|
offset_right = 1432.0
|
||||||
offset_bottom = 50.0
|
offset_bottom = 50.0
|
||||||
mouse_filter = 1
|
mouse_filter = 2
|
||||||
theme_type_variation = &"HighlightMedium"
|
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"
|
text = "Version 0.0.0 is available"
|
||||||
horizontal_alignment = 2
|
|
||||||
vertical_alignment = 1
|
|
||||||
text_overrun_behavior = 3
|
text_overrun_behavior = 3
|
||||||
|
clip_text = true
|
||||||
|
alignment = 2
|
||||||
|
|
||||||
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
|
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
|
||||||
visible = false
|
visible = false
|
||||||
|
|||||||
+7
-1
@@ -20,6 +20,7 @@ var popup_backdrop: PopupBackdrop
|
|||||||
@onready var about_popup: AboutPopup = %AboutPopup
|
@onready var about_popup: AboutPopup = %AboutPopup
|
||||||
@onready var log_popup: LogPopup = %LogPopup
|
@onready var log_popup: LogPopup = %LogPopup
|
||||||
@onready var settings_popup: SettingsPopup = %SettingsPopup
|
@onready var settings_popup: SettingsPopup = %SettingsPopup
|
||||||
|
@onready var update_popup: UpdatePopup = %UpdatePopup
|
||||||
@onready var statistics_view: StatisticsView = $StatisticsView
|
@onready var statistics_view: StatisticsView = $StatisticsView
|
||||||
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ func _ready() -> void:
|
|||||||
top_bar.about_pressed.connect(show_about)
|
top_bar.about_pressed.connect(show_about)
|
||||||
top_bar.qr_pressed.connect(show_qr)
|
top_bar.qr_pressed.connect(show_qr)
|
||||||
top_bar.settings_pressed.connect(settings_popup.open)
|
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.server_changed.connect(quiz.on_server_changed)
|
||||||
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
|
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
|
||||||
settings_popup.clear_cache_requested.connect(clear_cache)
|
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())
|
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
|
||||||
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
|
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
|
# A saved quiz may have been played from a music folder
|
||||||
top_bar.update_offline_state()
|
top_bar.update_offline_state()
|
||||||
search_popup.get_list_of_soundtracks()
|
search_popup.get_list_of_soundtracks()
|
||||||
@@ -110,7 +116,7 @@ func _input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func _popups() -> Array[BasePopup]:
|
func _popups() -> Array[BasePopup]:
|
||||||
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
|
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
|
# How many songs to download ahead can be changed in the settings. Waiting until the
|
||||||
|
|||||||
+30
-6
@@ -16,10 +16,19 @@ signal about_pressed
|
|||||||
signal qr_pressed
|
signal qr_pressed
|
||||||
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
||||||
signal tab_selected(tab: StringName)
|
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 sync_button: Button = %SyncButton
|
||||||
@onready var debug_label: Label = $DebugLabel
|
@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 offline_label: Label = $OfflineLabel
|
||||||
@onready var qr_button: Button = %QRButton
|
@onready var qr_button: Button = %QRButton
|
||||||
@onready var quiz_tab: Button = %QuizTab
|
@onready var quiz_tab: Button = %QuizTab
|
||||||
@@ -36,6 +45,11 @@ func _ready() -> void:
|
|||||||
qr_button.pressed.connect(qr_pressed.emit)
|
qr_button.pressed.connect(qr_pressed.emit)
|
||||||
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
||||||
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
|
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()
|
update_offline_state()
|
||||||
check_for_update()
|
check_for_update()
|
||||||
|
|
||||||
@@ -51,14 +65,14 @@ func update_offline_state() -> void:
|
|||||||
if offline:
|
if offline:
|
||||||
offline_label.text = "Offline: " + Settings.music_folder.display_name()
|
offline_label.text = "Offline: " + Settings.music_folder.display_name()
|
||||||
offline_label.tooltip_text = Settings.music_folder.path
|
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
|
## 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.
|
## than this one. Not asked while a music folder is used.
|
||||||
func check_for_update() -> void:
|
func check_for_update() -> void:
|
||||||
if Settings.is_offline():
|
if Settings.is_offline():
|
||||||
update_label.visible = false
|
update_box.visible = false
|
||||||
return
|
return
|
||||||
MusicServerClient.fetch_latest_client_version(show_latest_version)
|
MusicServerClient.fetch_latest_client_version(show_latest_version)
|
||||||
|
|
||||||
@@ -68,10 +82,20 @@ func check_for_update() -> void:
|
|||||||
func show_latest_version(latest: Variant) -> void:
|
func show_latest_version(latest: Variant) -> void:
|
||||||
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
|
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
|
||||||
&& !Settings.is_offline()
|
&& !Settings.is_offline()
|
||||||
update_label.visible = newer
|
update_box.visible = newer
|
||||||
if newer:
|
if newer:
|
||||||
update_label.text = "Version %s is available" % latest
|
latest_version = latest
|
||||||
update_label.tooltip_text = "You have %s" % Settings.version
|
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
|
## Whether version [param latest] is newer than [param current], comparing numbers such as
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dupwb11ovvn7j
|
||||||
+3
-2
@@ -181,10 +181,11 @@ func save_soon() -> void:
|
|||||||
if _restoring || _save_queued:
|
if _restoring || _save_queued:
|
||||||
return
|
return
|
||||||
_save_queued = true
|
_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
|
_save_queued = false
|
||||||
if is_inside_tree():
|
if is_inside_tree():
|
||||||
QuizSave.capture(stats_paused_by_win).write()
|
QuizSave.capture(stats_paused_by_win).write()
|
||||||
|
|||||||
@@ -19,16 +19,20 @@ func _init(owner: Node) -> void:
|
|||||||
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
|
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
|
||||||
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
|
## 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
|
## 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(
|
func request(
|
||||||
url: String,
|
url: String,
|
||||||
method: int,
|
method: int,
|
||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
headers: PackedStringArray = PackedStringArray(),
|
headers: PackedStringArray = PackedStringArray(),
|
||||||
body: PackedByteArray = PackedByteArray(),
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var http_request: HTTPRequest = HTTPRequest.new()
|
var http_request: HTTPRequest = HTTPRequest.new()
|
||||||
http_request.timeout = timeout_seconds
|
http_request.timeout = timeout_seconds
|
||||||
|
if !follow_redirects:
|
||||||
|
http_request.max_redirects = 0
|
||||||
_owner.add_child(http_request)
|
_owner.add_child(http_request)
|
||||||
http_request.request_completed.connect(
|
http_request.request_completed.connect(
|
||||||
func(
|
func(
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const CLIENT_TYPE: String = "godot"
|
|||||||
const QUEUE_HEADER: String = "X-Queue-Id"
|
const QUEUE_HEADER: String = "X-Queue-Id"
|
||||||
## The type of the JSON bodies sent.
|
## The type of the JSON bodies sent.
|
||||||
const JSON_TYPE: String = "application/json"
|
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.
|
## 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"]
|
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)
|
get_json("/download/list", on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
## Downloads the newest MusicPlayer for Windows.
|
## Calls [param on_done] with the link to download the newest MusicPlayer file
|
||||||
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
|
||||||
get_bytes("/download/windows", on_done, on_failed)
|
## 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(
|
||||||
## Downloads the newest MusicPlayer for Linux.
|
asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
_request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
|
||||||
get_bytes("/download/linux", on_done, on_failed)
|
PackedByteArray(), JSON_TYPE, false)
|
||||||
|
|
||||||
|
|
||||||
# Tokens
|
# Tokens
|
||||||
@@ -391,14 +394,9 @@ func queue_id() -> String:
|
|||||||
|
|
||||||
# Keeps the queue ID in [param headers] from [param server] when it is new
|
# Keeps the queue ID in [param headers] from [param server] when it is new
|
||||||
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
|
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
|
||||||
var prefix: String = QUEUE_HEADER.to_lower() + ":"
|
var id: String = header_value(headers, QUEUE_HEADER)
|
||||||
for header: String in headers:
|
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
|
||||||
if !header.to_lower().begins_with(prefix):
|
Settings.set_queue_id(server, id)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# Generic requests
|
# 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
|
# 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(
|
func _request(
|
||||||
path: String,
|
path: String,
|
||||||
method: int,
|
method: int,
|
||||||
@@ -491,17 +491,25 @@ func _request(
|
|||||||
may_retry: bool = true,
|
may_retry: bool = true,
|
||||||
body: PackedByteArray = PackedByteArray(),
|
body: PackedByteArray = PackedByteArray(),
|
||||||
content_type: String = JSON_TYPE,
|
content_type: String = JSON_TYPE,
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
_with_token(func(token: String) -> 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:
|
if response_code == 401 && may_retry:
|
||||||
GameLog.debug("Token refused by the server, getting a new one")
|
GameLog.debug("Token refused by the server, getting a new one")
|
||||||
if _token == token:
|
if _token == token:
|
||||||
clear_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
|
return
|
||||||
_handle_response(path, response_code, response, on_success, on_failed)
|
_handle_response(path, response_code, response, on_success, on_failed)
|
||||||
, body, content_type)
|
, body, content_type, follow_redirects)
|
||||||
, on_failed)
|
, on_failed)
|
||||||
|
|
||||||
|
|
||||||
@@ -509,7 +517,8 @@ func _request(
|
|||||||
func _send_once(
|
func _send_once(
|
||||||
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
||||||
) -> void:
|
) -> 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)
|
_handle_response(path, response_code, body, on_success, on_failed)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -521,6 +530,7 @@ func _send(
|
|||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
body: PackedByteArray = PackedByteArray(),
|
body: PackedByteArray = PackedByteArray(),
|
||||||
content_type: String = JSON_TYPE,
|
content_type: String = JSON_TYPE,
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var server: String = Settings.default_path
|
var server: String = Settings.default_path
|
||||||
var headers: PackedStringArray = []
|
var headers: PackedStringArray = []
|
||||||
@@ -535,8 +545,8 @@ func _send(
|
|||||||
func(response_code: int, response_body: PackedByteArray,
|
func(response_code: int, response_body: PackedByteArray,
|
||||||
response_headers: PackedStringArray) -> void:
|
response_headers: PackedStringArray) -> void:
|
||||||
_remember_queue_id(server, response_headers)
|
_remember_queue_id(server, response_headers)
|
||||||
on_complete.call(response_code, response_body)
|
on_complete.call(response_code, response_body, response_headers)
|
||||||
, headers, body)
|
, headers, body, follow_redirects)
|
||||||
|
|
||||||
|
|
||||||
func _handle_response(
|
func _handle_response(
|
||||||
@@ -575,7 +585,7 @@ func _request_token() -> void:
|
|||||||
token_request.client_type = CLIENT_TYPE
|
token_request.client_type = CLIENT_TYPE
|
||||||
var server: String = Settings.default_path
|
var server: String = Settings.default_path
|
||||||
_send("/token", HTTPClient.METHOD_POST, "",
|
_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()
|
var waiters: Array[Array] = _token_waiters.duplicate()
|
||||||
_token_waiters.clear()
|
_token_waiters.clear()
|
||||||
var token: String = _read_token(response_code, body)
|
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
|
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:
|
static func _cover_path(uuid: String) -> String:
|
||||||
return "/soundtracks/%s/cover" % uuid.uri_encode()
|
return "/soundtracks/%s/cover" % uuid.uri_encode()
|
||||||
|
|
||||||
|
|||||||
+1274
-35
File diff suppressed because it is too large.
Load diff
@@ -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
|
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
|
||||||
## [member requested_paths].
|
## [member requested_paths].
|
||||||
var requested_raw_bodies: Array[PackedByteArray] = []
|
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.
|
## How many tokens have been asked for.
|
||||||
var token_requests: int = 0
|
var token_requests: int = 0
|
||||||
## The JSON body of the last token request.
|
## The JSON body of the last token request.
|
||||||
@@ -67,6 +70,14 @@ func uninstall() -> void:
|
|||||||
Settings.queue_ids = _previous_queue_ids
|
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.
|
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||||
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
||||||
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
||||||
@@ -114,6 +125,7 @@ func request(
|
|||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
headers: PackedStringArray = PackedStringArray(),
|
headers: PackedStringArray = PackedStringArray(),
|
||||||
body: PackedByteArray = PackedByteArray(),
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
||||||
if path == "/token" && method == HTTPClient.METHOD_POST:
|
if path == "/token" && method == HTTPClient.METHOD_POST:
|
||||||
@@ -129,17 +141,18 @@ func request(
|
|||||||
requested_headers.append(headers)
|
requested_headers.append(headers)
|
||||||
requested_bodies.append(body.get_string_from_utf8())
|
requested_bodies.append(body.get_string_from_utf8())
|
||||||
requested_raw_bodies.append(body)
|
requested_raw_bodies.append(body)
|
||||||
|
requested_follow_redirects.append(follow_redirects)
|
||||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
||||||
PackedStringArray())
|
PackedStringArray())
|
||||||
return
|
return
|
||||||
if _held_paths.has(path):
|
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
|
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 = []
|
var headers: PackedStringArray = []
|
||||||
if !queue_id.is_empty() && path != "/token":
|
if !queue_id.is_empty() && path != "/token":
|
||||||
headers.append("X-Queue-Id: " + queue_id)
|
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)
|
on_complete.call(503, PackedByteArray(), headers)
|
||||||
return
|
return
|
||||||
_successes_left[path] -= 1
|
_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)
|
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
||||||
else:
|
else:
|
||||||
on_complete.call(404, PackedByteArray(), headers)
|
on_complete.call(404, PackedByteArray(), headers)
|
||||||
|
|||||||
@@ -276,6 +276,27 @@ func run(on_state: Callable) -> void:
|
|||||||
top_bar.show_latest_version("99.10.10")
|
top_bar.show_latest_version("99.10.10")
|
||||||
await _settle()
|
await _settle()
|
||||||
await on_state.call("update_available", null)
|
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")
|
top_bar.show_latest_version("1.0.0")
|
||||||
await _settle()
|
await _settle()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ extends MainWindowTestSuite
|
|||||||
const MAIN_WINDOW_CHILDREN: Array[String] = [
|
const MAIN_WINDOW_CHILDREN: Array[String] = [
|
||||||
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
|
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
|
||||||
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
|
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
|
||||||
"SettingsPopup",
|
"SettingsPopup", "UpdatePopup",
|
||||||
]
|
]
|
||||||
|
|
||||||
## The parts of the quiz and the scene each one comes from.
|
## 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] = [
|
const TOP_BAR_CHILDREN: Array[String] = [
|
||||||
"TopPanelContainer", "Title", "DebugLabel", "UpdateLabel", "OfflineLabel", "Buttons",
|
"TopPanelContainer", "Title", "DebugLabel", "UpdateBox", "OfflineLabel", "Buttons",
|
||||||
"QuizTab", "StatisticsTab",
|
"QuizTab", "StatisticsTab",
|
||||||
]
|
]
|
||||||
## The buttons in the top bar's row on the right, in order.
|
## The buttons in the top bar's row on the right, in order.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const STYLES: Dictionary = {
|
|||||||
"PlayerButtonPrimary": "Button",
|
"PlayerButtonPrimary": "Button",
|
||||||
"PlayerTime": "Label",
|
"PlayerTime": "Label",
|
||||||
"PlayerIcon": "Button",
|
"PlayerIcon": "Button",
|
||||||
|
"UpdateButton": "Button",
|
||||||
"ProgressSlider": "HSlider",
|
"ProgressSlider": "HSlider",
|
||||||
"VolumeBar": "HSlider",
|
"VolumeBar": "HSlider",
|
||||||
"StatisticsCard": "PanelContainer",
|
"StatisticsCard": "PanelContainer",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func before_test() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_no_message_when_this_is_the_newest_version() -> 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)
|
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()
|
_top_bar.check_for_update()
|
||||||
|
|
||||||
assert_bool(_top_bar.update_label.is_visible_in_tree()).is_true()
|
assert_bool(_top_bar.update_button.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_button.text).is_equal("Version 99.0.0 is available")
|
||||||
assert_str(_top_bar.update_label.tooltip_text).contains(Settings.version)
|
assert_str(_top_bar.update_button.tooltip_text).contains(Settings.version)
|
||||||
|
|
||||||
|
|
||||||
func test_message_fits_between_the_tabs_and_the_buttons() -> void:
|
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()
|
_top_bar.check_for_update()
|
||||||
await _runner.simulate_frames(1)
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
var label: Rect2 = _top_bar.update_label.get_global_rect()
|
var label: Rect2 = _top_bar.update_box.get_global_rect()
|
||||||
var text_width: float = _top_bar.update_label.get_minimum_size().x
|
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(text_width).is_less_equal(label.size.x)
|
||||||
assert_float(label.position.x).is_greater(
|
assert_float(label.position.x).is_greater(
|
||||||
(_top_bar.get_node("%StatisticsTab") as Control).get_global_rect().end.x)
|
(_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")
|
_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:
|
func test_server_change_checks_again() -> void:
|
||||||
@@ -52,7 +54,7 @@ func test_server_change_checks_again() -> void:
|
|||||||
|
|
||||||
_quiz.on_server_changed()
|
_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:
|
func test_failed_check_shows_nothing() -> void:
|
||||||
@@ -60,7 +62,7 @@ func test_failed_check_shows_nothing() -> void:
|
|||||||
|
|
||||||
_top_bar.check_for_update()
|
_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:
|
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("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.0", "2.0.0")).is_false()
|
||||||
assert_bool(TopBar.is_newer_version("1.9.9", "1.10.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")
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## Getting the newest version from "Version X is available" in the top bar: updating in
|
||||||
|
## place and restarting, or downloading in the browser.
|
||||||
|
|
||||||
|
const LINK: String = "https://gitea.example/releases/download/99.0.0/MusicPlayer_99.0.0.exe"
|
||||||
|
|
||||||
|
var _top_bar: TopBar
|
||||||
|
var _popup: UpdatePopup
|
||||||
|
var _updater: FakeUpdater
|
||||||
|
var _opened: Array[String] = []
|
||||||
|
var _dir: String
|
||||||
|
|
||||||
|
|
||||||
|
## Downloads nothing: hands over a prepared file, or fails, at once.
|
||||||
|
class FakeUpdater:
|
||||||
|
extends Updater
|
||||||
|
|
||||||
|
var downloaded: PackedByteArray = [0x4D, 0x5A, 0x90, 0x00]
|
||||||
|
var fail_with: String = ""
|
||||||
|
var urls: Array[String] = []
|
||||||
|
var started: Array[String] = []
|
||||||
|
var quits: int = 0
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
super()
|
||||||
|
start_process = func(path: String, _arguments: PackedStringArray) -> int:
|
||||||
|
started.append(path)
|
||||||
|
return 1
|
||||||
|
quit = func() -> void: quits += 1
|
||||||
|
|
||||||
|
func download(url: String, on_progress: Callable, on_done: Callable,
|
||||||
|
on_failed: Callable) -> void:
|
||||||
|
urls.append(url)
|
||||||
|
if !fail_with.is_empty():
|
||||||
|
on_failed.call(fail_with)
|
||||||
|
return
|
||||||
|
on_progress.call(downloaded.size() / 2, downloaded.size())
|
||||||
|
var file: FileAccess = FileAccess.open(update_path(), FileAccess.WRITE)
|
||||||
|
file.store_buffer(downloaded)
|
||||||
|
file.close()
|
||||||
|
on_done.call(update_path())
|
||||||
|
|
||||||
|
|
||||||
|
func _before_window_starts() -> void:
|
||||||
|
_server.respond_json("/download", "99.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
# The quiz is saved before restarting, next to the settings
|
||||||
|
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("update_%d" % randi()))
|
||||||
|
await super()
|
||||||
|
_top_bar = _window.get_node("TopBar")
|
||||||
|
_popup = _window.get_node("UpdatePopup")
|
||||||
|
_opened = []
|
||||||
|
_popup.open_url = func(url: String) -> Error:
|
||||||
|
_opened.append(url)
|
||||||
|
return OK
|
||||||
|
_dir = create_temp_dir("game_%d" % randi())
|
||||||
|
_updater = FakeUpdater.new()
|
||||||
|
_updater.in_editor = false
|
||||||
|
_updater.os_name = "Windows"
|
||||||
|
_updater.executable_path = _dir.path_join("MusicPlayer.exe")
|
||||||
|
var file: FileAccess = FileAccess.open(_updater.executable_path, FileAccess.WRITE)
|
||||||
|
file.store_string("old game")
|
||||||
|
file.close()
|
||||||
|
_popup.add_child(_updater)
|
||||||
|
_popup.updater = _updater
|
||||||
|
|
||||||
|
|
||||||
|
func _open_popup() -> void:
|
||||||
|
_top_bar.update_button.pressed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func test_clicking_the_message_opens_the_popup() -> void:
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
assert_bool(_popup.visible).is_true()
|
||||||
|
assert_str(_popup.title_label.text).is_equal("Version 99.0.0 is available")
|
||||||
|
assert_str(_popup.info_label.text).contains(Settings.version)
|
||||||
|
assert_bool(_popup.update_now_button.visible).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_update_downloads_swaps_and_restarts() -> void:
|
||||||
|
_server.respond_redirect("/download/windows", LINK)
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.update_now()
|
||||||
|
|
||||||
|
assert_array(_updater.urls).contains_exactly([LINK])
|
||||||
|
var exe: String = _updater.executable_path
|
||||||
|
assert_array(FileAccess.get_file_as_bytes(exe)).is_equal(_updater.downloaded)
|
||||||
|
assert_str(FileAccess.get_file_as_string(exe + Updater.OLD_SUFFIX)).is_equal("old game")
|
||||||
|
assert_array(_updater.started).contains_exactly([exe])
|
||||||
|
assert_int(_updater.quits).is_equal(1)
|
||||||
|
# The quiz continues after the restart
|
||||||
|
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_failed_download_says_why_and_keeps_the_game() -> void:
|
||||||
|
_server.respond_redirect("/download/windows", LINK)
|
||||||
|
_updater.fail_with = "The download failed. Check the connection and try again."
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.update_now()
|
||||||
|
|
||||||
|
assert_str(_popup.status_label.text).is_equal(_updater.fail_with)
|
||||||
|
assert_bool(_popup.update_now_button.disabled).is_false()
|
||||||
|
assert_bool(_popup.browser_button.disabled).is_false()
|
||||||
|
assert_str(FileAccess.get_file_as_string(_updater.executable_path)).is_equal("old game")
|
||||||
|
assert_int(_updater.quits).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_no_download_on_the_server_says_so() -> void:
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.update_now()
|
||||||
|
|
||||||
|
assert_array(_updater.urls).is_empty()
|
||||||
|
assert_str(_popup.status_label.text).contains("no download")
|
||||||
|
assert_int(_updater.quits).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_browser_gets_the_package_on_linux() -> void:
|
||||||
|
var package: String = LINK.replace(".exe", "_linux.tar.gz")
|
||||||
|
_server.respond_redirect("/download/linux-package", package)
|
||||||
|
_updater.os_name = "Linux"
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.download_in_browser()
|
||||||
|
|
||||||
|
assert_array(_opened).contains_exactly([package])
|
||||||
|
|
||||||
|
|
||||||
|
func test_browser_gets_the_release_page_on_other_platforms() -> void:
|
||||||
|
_updater.os_name = "macOS"
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.download_in_browser()
|
||||||
|
|
||||||
|
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
|
||||||
|
assert_int(_server.count("/download/windows")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_browser_gets_the_release_page_when_the_server_has_no_link() -> void:
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
_popup.download_in_browser()
|
||||||
|
|
||||||
|
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
|
||||||
|
|
||||||
|
|
||||||
|
func test_from_the_editor_only_the_browser_is_offered() -> void:
|
||||||
|
_updater.in_editor = true
|
||||||
|
|
||||||
|
_open_popup()
|
||||||
|
|
||||||
|
assert_bool(_popup.update_now_button.visible).is_false()
|
||||||
|
assert_bool(_popup.reason_label.visible).is_true()
|
||||||
|
assert_str(_popup.reason_label.text).contains("editor")
|
||||||
|
assert_bool(_popup.browser_button.visible).is_true()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d4lsj4jjqht4d
|
||||||
@@ -81,6 +81,40 @@ func test_fetch_song_passes_raw_bytes() -> void:
|
|||||||
assert_array(received).contains_exactly([mp3])
|
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:
|
func test_failed_request_does_not_call_back() -> void:
|
||||||
_server.respond_json("/music/list", [], 500)
|
_server.respond_json("/music/list", [], 500)
|
||||||
var called: Array[bool] = [false]
|
var called: Array[bool] = [false]
|
||||||
@@ -402,9 +436,9 @@ func test_endpoint_paths_and_methods() -> void:
|
|||||||
"/download", HTTPClient.METHOD_GET],
|
"/download", HTTPClient.METHOD_GET],
|
||||||
[func() -> void: MusicServerClient.fetch_client_assets(noop),
|
[func() -> void: MusicServerClient.fetch_client_assets(noop),
|
||||||
"/download/list", HTTPClient.METHOD_GET],
|
"/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],
|
"/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],
|
"/download/linux", HTTPClient.METHOD_GET],
|
||||||
[func() -> void: MusicServerClient.cleanup_tokens(noop),
|
[func() -> void: MusicServerClient.cleanup_tokens(noop),
|
||||||
"/token/cleanup", HTTPClient.METHOD_POST],
|
"/token/cleanup", HTTPClient.METHOD_POST],
|
||||||
@@ -640,6 +674,7 @@ class _HeaderTransport:
|
|||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
_request_headers: PackedStringArray = PackedStringArray(),
|
_request_headers: PackedStringArray = PackedStringArray(),
|
||||||
_body: PackedByteArray = PackedByteArray(),
|
_body: PackedByteArray = PackedByteArray(),
|
||||||
|
_follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
|
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
|
||||||
.to_utf8_buffer(), _headers)
|
.to_utf8_buffer(), _headers)
|
||||||
@@ -660,6 +695,7 @@ class _RecordingTransport:
|
|||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
_headers: PackedStringArray = PackedStringArray(),
|
_headers: PackedStringArray = PackedStringArray(),
|
||||||
_body: PackedByteArray = PackedByteArray(),
|
_body: PackedByteArray = PackedByteArray(),
|
||||||
|
_follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
_urls.append(url)
|
_urls.append(url)
|
||||||
if url.ends_with("/token"):
|
if url.ends_with("/token"):
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
extends GdUnitTestSuite
|
||||||
|
## Updating the game in place: which file to get, checking it, swapping it in, and
|
||||||
|
## cleaning up after.
|
||||||
|
|
||||||
|
const WINDOWS_GAME: PackedByteArray = [0x4D, 0x5A, 0x90, 0x00, 0x03]
|
||||||
|
const LINUX_GAME: PackedByteArray = [0x7F, 0x45, 0x4C, 0x46, 0x02]
|
||||||
|
|
||||||
|
var _dir: String
|
||||||
|
var _updater: Updater
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
_dir = create_temp_dir("updater_%d" % randi())
|
||||||
|
_updater = auto_free(Updater.new())
|
||||||
|
_updater.in_editor = false
|
||||||
|
_updater.os_name = "Windows"
|
||||||
|
_updater.executable_path = _dir.path_join("MusicPlayer.exe")
|
||||||
|
|
||||||
|
|
||||||
|
func _write(path: String, bytes: PackedByteArray) -> String:
|
||||||
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
file.store_buffer(bytes)
|
||||||
|
file.close()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
func _read(path: String) -> PackedByteArray:
|
||||||
|
return FileAccess.get_file_as_bytes(path)
|
||||||
|
|
||||||
|
|
||||||
|
@warning_ignore("unused_parameter")
|
||||||
|
func test_asset_for(os_name: String, in_place: bool, asset: String, test_parameters: Array = [
|
||||||
|
["Windows", true, "windows"],
|
||||||
|
["Windows", false, "windows"],
|
||||||
|
["Linux", true, "linux"],
|
||||||
|
["Linux", false, "linux-package"],
|
||||||
|
["macOS", true, ""],
|
||||||
|
["macOS", false, ""],
|
||||||
|
]) -> void:
|
||||||
|
assert_str(Updater.asset_for(os_name, in_place)).is_equal(asset)
|
||||||
|
|
||||||
|
|
||||||
|
@warning_ignore("unused_parameter")
|
||||||
|
func test_check_file(bytes: PackedByteArray, os_name: String, ok: bool,
|
||||||
|
test_parameters: Array = [
|
||||||
|
[WINDOWS_GAME, "Windows", true],
|
||||||
|
[LINUX_GAME, "Linux", true],
|
||||||
|
[LINUX_GAME, "Windows", false],
|
||||||
|
[WINDOWS_GAME, "Linux", false],
|
||||||
|
["<html>Not Found</html>".to_utf8_buffer(), "Windows", false],
|
||||||
|
[PackedByteArray(), "Linux", false],
|
||||||
|
]) -> void:
|
||||||
|
var path: String = _write(_dir.path_join("game"), bytes)
|
||||||
|
|
||||||
|
assert_bool(Updater.check_file(path, os_name)).is_equal(ok)
|
||||||
|
|
||||||
|
|
||||||
|
func test_missing_file_is_not_the_game() -> void:
|
||||||
|
assert_bool(Updater.check_file(_dir.path_join("nothing"), "Windows")).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_windows_moves_the_running_game_aside() -> void:
|
||||||
|
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
|
||||||
|
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
|
||||||
|
|
||||||
|
assert_int(Updater.install(update, exe, "Windows")).is_equal(OK)
|
||||||
|
|
||||||
|
assert_str(_read(exe).get_string_from_utf8()).is_equal("new")
|
||||||
|
assert_str(_read(exe + Updater.OLD_SUFFIX).get_string_from_utf8()).is_equal("old")
|
||||||
|
assert_bool(FileAccess.file_exists(update)).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_windows_replaces_an_old_version_left_from_before() -> void:
|
||||||
|
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
|
||||||
|
_write(exe + Updater.OLD_SUFFIX, "older".to_utf8_buffer())
|
||||||
|
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
|
||||||
|
|
||||||
|
assert_int(Updater.install(update, exe, "Windows")).is_equal(OK)
|
||||||
|
|
||||||
|
assert_str(_read(exe + Updater.OLD_SUFFIX).get_string_from_utf8()).is_equal("old")
|
||||||
|
|
||||||
|
|
||||||
|
func test_windows_puts_the_game_back_when_the_new_one_cannot_be_moved_in() -> void:
|
||||||
|
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "old".to_utf8_buffer())
|
||||||
|
|
||||||
|
var error: Error = Updater.install(exe + Updater.UPDATE_SUFFIX, exe, "Windows")
|
||||||
|
|
||||||
|
assert_int(error).is_not_equal(OK)
|
||||||
|
assert_str(_read(exe).get_string_from_utf8()).is_equal("old")
|
||||||
|
assert_bool(FileAccess.file_exists(exe + Updater.OLD_SUFFIX)).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_linux_replaces_the_game_and_makes_it_runnable() -> void:
|
||||||
|
var exe: String = _write(_dir.path_join("MusicPlayer.x86_64"), "old".to_utf8_buffer())
|
||||||
|
var update: String = _write(exe + Updater.UPDATE_SUFFIX, "new".to_utf8_buffer())
|
||||||
|
|
||||||
|
assert_int(Updater.install(update, exe, "Linux")).is_equal(OK)
|
||||||
|
|
||||||
|
assert_str(_read(exe).get_string_from_utf8()).is_equal("new")
|
||||||
|
assert_bool(FileAccess.file_exists(update)).is_false()
|
||||||
|
assert_int(FileAccess.get_unix_permissions(exe) & FileAccess.UNIX_EXECUTE_OWNER) \
|
||||||
|
.is_not_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_leftovers_are_deleted_at_start() -> void:
|
||||||
|
var exe: String = _write(_dir.path_join("MusicPlayer.exe"), "new".to_utf8_buffer())
|
||||||
|
_write(exe + Updater.OLD_SUFFIX, "old".to_utf8_buffer())
|
||||||
|
_write(exe + Updater.UPDATE_SUFFIX, "half".to_utf8_buffer())
|
||||||
|
|
||||||
|
Updater.remove_old_files(exe)
|
||||||
|
|
||||||
|
assert_bool(FileAccess.file_exists(exe)).is_true()
|
||||||
|
assert_bool(FileAccess.file_exists(exe + Updater.OLD_SUFFIX)).is_false()
|
||||||
|
assert_bool(FileAccess.file_exists(exe + Updater.UPDATE_SUFFIX)).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_can_update_in_a_writable_folder() -> void:
|
||||||
|
assert_str(_updater.can_update_in_place()).is_empty()
|
||||||
|
assert_array(DirAccess.get_files_at(_dir)).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_not_from_the_editor() -> void:
|
||||||
|
_updater.in_editor = true
|
||||||
|
|
||||||
|
assert_str(_updater.can_update_in_place()).contains("editor")
|
||||||
|
|
||||||
|
|
||||||
|
func test_not_on_other_platforms() -> void:
|
||||||
|
_updater.os_name = "macOS"
|
||||||
|
|
||||||
|
assert_str(_updater.can_update_in_place()).contains("Windows and Linux")
|
||||||
|
|
||||||
|
|
||||||
|
func test_not_when_the_folder_cannot_be_written_to() -> void:
|
||||||
|
_updater.executable_path = _dir.path_join("missing/MusicPlayer.exe")
|
||||||
|
|
||||||
|
assert_str(_updater.can_update_in_place()).contains("can't be written to")
|
||||||
|
|
||||||
|
|
||||||
|
func test_restart_starts_the_game_and_quits() -> void:
|
||||||
|
var started: Array[String] = []
|
||||||
|
var quits: Array[bool] = []
|
||||||
|
_updater.start_process = func(path: String, _arguments: PackedStringArray) -> int:
|
||||||
|
started.append(path)
|
||||||
|
return 1
|
||||||
|
_updater.quit = func() -> void: quits.append(true)
|
||||||
|
|
||||||
|
_updater.restart()
|
||||||
|
|
||||||
|
assert_array(started).contains_exactly([_updater.executable_path])
|
||||||
|
assert_array(quits).has_size(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Answers one HTTP request on a local port with [param status] and [param body], and
|
||||||
|
# downloads it with the updater. Returns [done paths, failure reasons].
|
||||||
|
func _download_from_local_server(status: String, body: PackedByteArray) -> Array:
|
||||||
|
var server: TCPServer = TCPServer.new()
|
||||||
|
assert_int(server.listen(0, "127.0.0.1")).is_equal(OK)
|
||||||
|
add_child(_updater)
|
||||||
|
var done: Array[String] = []
|
||||||
|
var failed: Array[String] = []
|
||||||
|
var progress: Array[int] = []
|
||||||
|
_updater.download("http://127.0.0.1:%d/MusicPlayer.exe" % server.get_local_port(),
|
||||||
|
func(bytes: int, _total: int) -> void: progress.append(bytes),
|
||||||
|
func(path: String) -> void: done.append(path),
|
||||||
|
func(reason: String) -> void: failed.append(reason))
|
||||||
|
var connection: StreamPeerTCP = null
|
||||||
|
for i: int in 100:
|
||||||
|
await await_millis(20)
|
||||||
|
if connection == null && server.is_connection_available():
|
||||||
|
connection = server.take_connection()
|
||||||
|
if connection != null && connection.get_available_bytes() > 0:
|
||||||
|
connection.get_data(connection.get_available_bytes())
|
||||||
|
var head: String = "HTTP/1.1 %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" \
|
||||||
|
% [status, body.size()]
|
||||||
|
connection.put_data(head.to_ascii_buffer() + body)
|
||||||
|
break
|
||||||
|
for i: int in 100:
|
||||||
|
await await_millis(20)
|
||||||
|
if !done.is_empty() || !failed.is_empty():
|
||||||
|
break
|
||||||
|
if connection != null:
|
||||||
|
connection.disconnect_from_host()
|
||||||
|
server.stop()
|
||||||
|
remove_child(_updater)
|
||||||
|
return [done, failed, progress]
|
||||||
|
|
||||||
|
|
||||||
|
func test_download_keeps_a_checked_game() -> void:
|
||||||
|
var game: PackedByteArray = WINDOWS_GAME.duplicate()
|
||||||
|
game.resize(4096)
|
||||||
|
|
||||||
|
var result: Array = await _download_from_local_server("200 OK", game)
|
||||||
|
|
||||||
|
assert_array(result[1]).is_empty()
|
||||||
|
assert_array(result[0]).contains_exactly([_updater.update_path()])
|
||||||
|
assert_array(_read(_updater.update_path())).is_equal(game)
|
||||||
|
|
||||||
|
|
||||||
|
func test_download_of_an_error_page_is_deleted() -> void:
|
||||||
|
var result: Array = await _download_from_local_server("404 Not Found",
|
||||||
|
"Not Found".to_utf8_buffer())
|
||||||
|
|
||||||
|
assert_array(result[0]).is_empty()
|
||||||
|
assert_str(result[1][0]).contains("404")
|
||||||
|
assert_bool(FileAccess.file_exists(_updater.update_path())).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_download_that_is_not_the_game_is_deleted() -> void:
|
||||||
|
var result: Array = await _download_from_local_server("200 OK",
|
||||||
|
"<html>Sign in</html>".to_utf8_buffer())
|
||||||
|
|
||||||
|
assert_array(result[0]).is_empty()
|
||||||
|
assert_str(result[1][0]).contains("not the game")
|
||||||
|
assert_bool(FileAccess.file_exists(_updater.update_path())).is_false()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ctxevissal7tm
|
||||||
@@ -81,6 +81,7 @@ inspector or `theme_type_variation` in code:
|
|||||||
| `ProgressSlider`, `VolumeBar` | HSlider | How far the song has played (orange), and the volume |
|
| `ProgressSlider`, `VolumeBar` | HSlider | How far the song has played (orange), and the volume |
|
||||||
| `PlayerTime` | Label | The time played and the song's length |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `SongStatsText`, `SongStatsSecondary` | RichTextLabel | A song's name and its soundtrack (dimmer) in the statistics song list |
|
||||||
|
|
||||||
|
|||||||
@@ -1408,6 +1408,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
|
|||||||
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/focus = 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/base_type = &"PanelContainer"
|
||||||
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
||||||
PlayerScore/base_type = &"Label"
|
PlayerScore/base_type = &"Label"
|
||||||
|
|||||||
@@ -1216,6 +1216,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
|
|||||||
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/focus = 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/base_type = &"PanelContainer"
|
||||||
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
||||||
PlayerScore/base_type = &"Label"
|
PlayerScore/base_type = &"Label"
|
||||||
|
|||||||
@@ -1391,6 +1391,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
|
|||||||
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/focus = 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/base_type = &"PanelContainer"
|
||||||
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
||||||
PlayerScore/base_type = &"Label"
|
PlayerScore/base_type = &"Label"
|
||||||
|
|||||||
@@ -1317,6 +1317,17 @@ PlayerIcon/styles/disabled = SubResource("StyleBoxEmpty_playericon")
|
|||||||
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/hover = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
PlayerIcon/styles/pressed = SubResource("StyleBoxEmpty_playericon")
|
||||||
PlayerIcon/styles/focus = 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/base_type = &"PanelContainer"
|
||||||
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
PlayerList/styles/panel = SubResource("StyleBoxFlat_playerlist")
|
||||||
PlayerScore/base_type = &"Label"
|
PlayerScore/base_type = &"Label"
|
||||||
|
|||||||
Reference in new issue
Block a user