Enriched metadata, per-layout boxart, install failure UI, and modal focus fix (v0.1.31)
This commit is contained in:
parent
ff2ae68d9e
commit
404ee87a64
4 changed files with 377 additions and 47 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue