Enriched metadata, per-layout boxart, install failure UI, and modal focus fix (v0.1.31)

This commit is contained in:
Jose Falanga 2026-08-16 15:33:36 -03:00
parent ff2ae68d9e
commit 404ee87a64
4 changed files with 377 additions and 47 deletions

View file

@ -51,6 +51,7 @@ signal bootstrap_finished
signal client_ready
signal logged_in(status: LOGIN_STATUS, profile: Dictionary)
signal install_progressed(cave_or_game_id: String, current: int, total: int)
signal install_failed(cave_or_game_id: String, game_title: String, message: String)
signal app_installed(cave_id: String, success: bool)
signal app_updated(cave_id: String, success: bool)
signal app_uninstalled(cave_id: String, success: bool)
@ -517,6 +518,11 @@ func _ensure_install_location() -> String:
## Returns every configured install location, each as:
## {"id": "...", "path": "...", "sizeInfo": {"installedSize", "freeSize", "totalSize"}}
## The preferred location (user://butler/games) is always listed first, so it
## becomes the default choice in OGPU's install-location picker (the picker
## focuses the first card). Locations pointing inside the plugin's own
## directory are dropped: games installed there get wiped on every plugin
## update (the plugin dir is moved to trash by OGPU when a new zip is loaded).
func get_install_locations() -> Array:
return await thread_group.exec(_get_install_locations)
@ -526,7 +532,22 @@ func _get_install_locations() -> Array:
if "error" in res:
logger.warn("Install.Locations.List failed: " + str(res["error"]))
return []
return res.get("installLocations", [])
var locations: Array = res.get("installLocations", [])
var preferred_path := ProjectSettings.globalize_path(games_dir)
var plugin_dir := ProjectSettings.globalize_path("user://plugins/itch")
var preferred: Array = []
var others: Array = []
for raw in locations:
var location: Dictionary = raw
var path: String = location.get("path", "")
if path.begins_with(plugin_dir):
logger.info("Ignoring install location inside the plugin directory: " + path)
continue
if path == preferred_path:
preferred.append(location)
else:
others.append(location)
return preferred + others
## Returns the uploads butlerd considers compatible with this machine for the
@ -631,6 +652,12 @@ func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
if uploads.is_empty():
logger.error("No compatible uploads found for game id " + str(game_id))
emit_signal.call_deferred("app_installed", cave_id, false)
emit_signal.call_deferred(
"install_failed",
cave_id if cave_id != "" else str(game_id),
game.get("title", "Unknown itch.io game"),
"No compatible upload found for this game."
)
return false
var upload: Dictionary = uploads[0]
# Respect the upload the user picked in the install-options dialog, when
@ -659,8 +686,16 @@ func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
var queue_res := await _rpc_call("Install.Queue", queue_params)
if "error" in queue_res:
var queue_error: String = _rpc_error_message(queue_res)
logger.error("Install.Queue failed: " + str(queue_res["error"]))
logger.error("Install.Queue failed (message): " + queue_error)
emit_signal.call_deferred("app_installed", cave_id, false)
emit_signal.call_deferred(
"install_failed",
cave_id if cave_id != "" else str(game_id),
game.get("title", "Unknown itch.io game"),
queue_error
)
return false
var task_id: String = queue_res.get("id", "")
@ -680,11 +715,35 @@ func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
var perform_res := await _rpc_call("Install.Perform", {"id": task_id, "stagingFolder": staging_folder})
# butler can panic ("runtime error: slice bounds out of range") when
# resuming a download whose checkpoint recorded an invalid offset (a
# stale/corrupt partial download in the staging folder). Clear just the
# download checkpoint and partial copy and retry once so the download
# starts from scratch. The rest of the staging folder (the queue metadata
# that holds the game/upload) must be left intact, or butler fails the
# retry with "Corrupted download info (missing game)".
if "error" in perform_res and staging_folder != "" and _is_download_resume_panic(perform_res.get("error")):
logger.warn("butler panicked resuming download; clearing download state and retrying once")
_clear_download_state(staging_folder)
perform_res = await _rpc_call("Install.Perform", {"id": task_id, "stagingFolder": staging_folder})
if "error" in perform_res:
logger.error("Install.Perform retry after clearing download state also failed: " + str(perform_res.get("error")))
else:
logger.info("Install.Perform recovered after clearing corrupt download state")
rpc_notification.disconnect(on_notification)
var success := not ("error" in perform_res)
if not success:
var error_msg: String = _rpc_error_message(perform_res)
logger.error("Install.Perform failed: " + str(perform_res.get("error")))
logger.error("Install.Perform failed (message): " + error_msg)
emit_signal.call_deferred(
"install_failed",
cave_id if cave_id != "" else str(game_id),
game.get("title", "Unknown itch.io game"),
error_msg
)
if reason == "update":
emit_signal.call_deferred("app_updated", cave_id, success)
@ -693,6 +752,77 @@ func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
return success
## Extracts a concise, human-readable message from a butlerd RPC error dict
## (the "message" field, falling back to the whole error when absent).
func _rpc_error_message(res: Dictionary) -> String:
var error: Variant = res.get("error", null)
if error == null:
return "Unknown error"
if error is Dictionary:
var msg: String = error.get("message", "")
if msg != "":
return msg
return str(error)
## True when butler panicked resuming a partially-downloaded game. The panic
## message ("runtime error: slice bounds out of range") comes from savior's
## seekSource when a corrupt checkpoint leaves the read offset beyond the
## section size, so the only reliable way forward is a fresh download.
func _is_download_resume_panic(error: Variant) -> bool:
return str(error).contains("slice bounds out of range")
## Removes butler's download checkpoint file(s) and the partially-downloaded
## install source from the staging folder, so the next Install.Perform attempt
## downloads from scratch. Only the *download* state is cleared: the queue
## metadata butler persisted alongside it (which holds the game/upload the
## install was queued with) must survive, or the retry fails with "Corrupted
## download info (missing game)".
func _clear_download_state(staging_folder: String) -> void:
if staging_folder == "" or not DirAccess.dir_exists_absolute(staging_folder):
return
var dir := DirAccess.open(staging_folder)
if dir == null:
logger.warn("Could not open staging folder to clear download state: " + staging_folder)
return
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if fname.begins_with("downsource-") and fname.ends_with("-state.dat"):
DirAccess.remove_absolute(staging_folder + "/" + fname)
logger.info("Removed corrupt download checkpoint: " + fname)
fname = dir.get_next()
dir.list_dir_end()
# The partially-downloaded copy butler was resuming from also has to go.
if DirAccess.dir_exists_absolute(staging_folder + "/install-source"):
_wipe_dir(staging_folder + "/install-source")
logger.info("Cleared partial download copy in staging install-source")
## Recursively removes the contents of a directory, leaving the directory
## itself in place.
func _wipe_dir(path: String) -> void:
if path == "" or not DirAccess.dir_exists_absolute(path):
return
var dir := DirAccess.open(path)
if dir == null:
logger.warn("Could not open staging folder to clear: " + path)
return
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if fname != "." and fname != "..":
var full: String = path + "/" + fname
if dir.current_is_dir():
_wipe_dir(full)
DirAccess.remove_absolute(full)
else:
DirAccess.remove_absolute(full)
fname = dir.get_next()
dir.list_dir_end()
## Returns the upload whose displayName or filename matches the user's pick
## from the install-options dropdown, or {} when nothing matches.
func _pick_upload(uploads: Array, chosen: String) -> Dictionary: