"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>
199 lines
7.3 KiB
GDScript
199 lines
7.3 KiB
GDScript
class_name FakeHttpTransport
|
|
extends HttpTransport
|
|
## Stands in for the MusicServer in tests.
|
|
##
|
|
## Register canned responses per path with [method respond_json] or
|
|
## [method respond_bytes], then install it with [method install]. Requests to paths
|
|
## without a response get a 404. Responses are delivered immediately.
|
|
##
|
|
## Paths are relative to the API, e.g. [code]/music/list[/code] for
|
|
## [code]<Settings.default_path>/api/v1/music/list[/code].
|
|
##
|
|
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
|
|
## to requests without a valid one. Token requests are counted in
|
|
## [member token_requests], not in [member requested_paths].
|
|
##
|
|
## Set [member queue_id] to answer with an [code]X-Queue-Id[/code] header, like the
|
|
## server's music endpoints.
|
|
|
|
## Paths that need no token, like on the real server.
|
|
const OPEN_PATHS: Array[String] = ["/token", "/health"]
|
|
|
|
## Paths requested so far, relative to the API.
|
|
var requested_paths: Array[String] = []
|
|
## HTTP method of each request, in the same order as [member requested_paths].
|
|
var requested_methods: Array[int] = []
|
|
## Headers of each request, in the same order as [member requested_paths].
|
|
var requested_headers: Array[PackedStringArray] = []
|
|
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
|
|
## [member requested_paths].
|
|
var requested_bodies: Array[String] = []
|
|
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
|
|
## [member requested_paths].
|
|
var requested_raw_bodies: Array[PackedByteArray] = []
|
|
## Whether each request followed redirects, in the same order as
|
|
## [member requested_paths].
|
|
var requested_follow_redirects: Array[bool] = []
|
|
## How many tokens have been asked for.
|
|
var token_requests: int = 0
|
|
## The JSON body of the last token request.
|
|
var last_token_request: Dictionary = {}
|
|
## Sent in an X-Queue-Id header with every answer that is not about a token, unless empty.
|
|
var queue_id: String = ""
|
|
|
|
var _responses: Dictionary = {}
|
|
var _successes_left: Dictionary = {}
|
|
var _held_paths: Dictionary = {}
|
|
var _previous_transport: HttpTransport
|
|
var _previous_queue_ids: Dictionary[String, String]
|
|
var _valid_tokens: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
super(null)
|
|
|
|
|
|
## Replaces the MusicServerClient transport with this fake.
|
|
func install() -> FakeHttpTransport:
|
|
_previous_transport = MusicServerClient.transport
|
|
MusicServerClient.transport = self
|
|
MusicServerClient.clear_token()
|
|
_previous_queue_ids = Settings.queue_ids.duplicate()
|
|
Settings.queue_ids.clear()
|
|
return self
|
|
|
|
|
|
## Restores the transport that was active before [method install].
|
|
func uninstall() -> void:
|
|
MusicServerClient.transport = _previous_transport
|
|
MusicServerClient.clear_token()
|
|
Settings.queue_ids = _previous_queue_ids
|
|
|
|
|
|
## Answers requests to [param path] with a redirect to [param location], like the
|
|
## server's download routes. Requests that follow redirects get an empty 200 instead, as
|
|
## if they had followed it.
|
|
func respond_redirect(path: String, location: String) -> void:
|
|
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
|
|
_successes_left.erase(path)
|
|
|
|
|
|
## Answers requests to [param path] with [param data] encoded as JSON.
|
|
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
|
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
|
|
|
|
|
## Answers requests to [param path] with [param body] as-is.
|
|
func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200) -> void:
|
|
_responses[path] = {"code": response_code, "body": body}
|
|
_successes_left.erase(path)
|
|
|
|
|
|
## Lets [param successes] more requests to [param path] succeed, then answers 503 until
|
|
## a new response is registered for the path.
|
|
func fail_after(path: String, successes: int) -> void:
|
|
_successes_left[path] = successes
|
|
|
|
|
|
## Holds back responses to [param path] until [method release] is called, like a slow
|
|
## server.
|
|
func hold(path: String) -> void:
|
|
_held_paths[path] = []
|
|
|
|
|
|
## Delivers the responses held back for [param path] and stops holding it.
|
|
func release(path: String) -> void:
|
|
var waiting: Array = _held_paths.get(path, [])
|
|
_held_paths.erase(path)
|
|
for pending: Callable in waiting:
|
|
pending.call()
|
|
|
|
|
|
## Returns how many times [param path] has been requested.
|
|
func count(path: String) -> int:
|
|
return requested_paths.count(path)
|
|
|
|
|
|
## Makes every token handed out so far invalid, like after 24 hours or a server restart.
|
|
func expire_tokens() -> void:
|
|
_valid_tokens.clear()
|
|
|
|
|
|
func request(
|
|
url: String,
|
|
method: int,
|
|
on_complete: Callable,
|
|
headers: PackedStringArray = PackedStringArray(),
|
|
body: PackedByteArray = PackedByteArray(),
|
|
follow_redirects: bool = true,
|
|
) -> void:
|
|
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
|
if path == "/token" && method == HTTPClient.METHOD_POST:
|
|
token_requests += 1
|
|
if _held_paths.has(path):
|
|
_held_paths[path].append(
|
|
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
|
|
return
|
|
_answer_token_request(body.get_string_from_utf8(), on_complete)
|
|
return
|
|
requested_paths.append(path)
|
|
requested_methods.append(method)
|
|
requested_headers.append(headers)
|
|
requested_bodies.append(body.get_string_from_utf8())
|
|
requested_raw_bodies.append(body)
|
|
requested_follow_redirects.append(follow_redirects)
|
|
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
|
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
|
PackedStringArray())
|
|
return
|
|
if _held_paths.has(path):
|
|
_held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
|
|
return
|
|
_answer(path, on_complete, follow_redirects)
|
|
|
|
|
|
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
|
|
var headers: PackedStringArray = []
|
|
if !queue_id.is_empty() && path != "/token":
|
|
headers.append("X-Queue-Id: " + queue_id)
|
|
if _successes_left.has(path):
|
|
if _successes_left[path] == 0:
|
|
on_complete.call(503, PackedByteArray(), headers)
|
|
return
|
|
_successes_left[path] -= 1
|
|
if _responses.has(path) && _responses[path].has("location"):
|
|
if follow_redirects:
|
|
on_complete.call(200, PackedByteArray(), headers)
|
|
else:
|
|
headers.append("Location: " + _responses[path].location)
|
|
on_complete.call(302, PackedByteArray(), headers)
|
|
elif _responses.has(path):
|
|
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
|
else:
|
|
on_complete.call(404, PackedByteArray(), headers)
|
|
|
|
|
|
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
|
var data: Variant = JSON.parse_string(body)
|
|
last_token_request = data if data is Dictionary else {}
|
|
if _responses.has("/token"):
|
|
# A token from a registered response is valid too
|
|
var registered: Dictionary = _responses["/token"]
|
|
var response: Variant = JSON.parse_string(registered.body.get_string_from_utf8())
|
|
if registered.code == 200 && response is Dictionary:
|
|
_valid_tokens.append(str(response.get("token", "")))
|
|
_answer("/token", on_complete)
|
|
return
|
|
var token: String = "fake-token-%d" % token_requests
|
|
_valid_tokens.append(token)
|
|
on_complete.call(200, JSON.stringify({
|
|
"token": token, "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot",
|
|
}).to_utf8_buffer(), PackedStringArray())
|
|
|
|
|
|
func _has_valid_token(headers: PackedStringArray) -> bool:
|
|
for header: String in headers:
|
|
if header.begins_with("Authorization: Bearer "):
|
|
return _valid_tokens.has(header.trim_prefix("Authorization: Bearer "))
|
|
return false
|