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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
.godot
|
.godot
|
||||||
dist
|
dist
|
||||||
|
.deckcheck
|
||||||
*.uid
|
*.uid
|
||||||
*.remap
|
*.remap
|
||||||
*.import
|
*.import
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ signal bootstrap_finished
|
||||||
signal client_ready
|
signal client_ready
|
||||||
signal logged_in(status: LOGIN_STATUS, profile: Dictionary)
|
signal logged_in(status: LOGIN_STATUS, profile: Dictionary)
|
||||||
signal install_progressed(cave_or_game_id: String, current: int, total: int)
|
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_installed(cave_id: String, success: bool)
|
||||||
signal app_updated(cave_id: String, success: bool)
|
signal app_updated(cave_id: String, success: bool)
|
||||||
signal app_uninstalled(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:
|
## Returns every configured install location, each as:
|
||||||
## {"id": "...", "path": "...", "sizeInfo": {"installedSize", "freeSize", "totalSize"}}
|
## {"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:
|
func get_install_locations() -> Array:
|
||||||
return await thread_group.exec(_get_install_locations)
|
return await thread_group.exec(_get_install_locations)
|
||||||
|
|
||||||
|
|
@ -526,7 +532,22 @@ func _get_install_locations() -> Array:
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
logger.warn("Install.Locations.List failed: " + str(res["error"]))
|
logger.warn("Install.Locations.List failed: " + str(res["error"]))
|
||||||
return []
|
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
|
## 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():
|
if uploads.is_empty():
|
||||||
logger.error("No compatible uploads found for game id " + str(game_id))
|
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("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
|
return false
|
||||||
var upload: Dictionary = uploads[0]
|
var upload: Dictionary = uploads[0]
|
||||||
# Respect the upload the user picked in the install-options dialog, when
|
# 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)
|
var queue_res := await _rpc_call("Install.Queue", queue_params)
|
||||||
if "error" in queue_res:
|
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: " + 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("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
|
return false
|
||||||
|
|
||||||
var task_id: String = queue_res.get("id", "")
|
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})
|
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)
|
rpc_notification.disconnect(on_notification)
|
||||||
|
|
||||||
var success := not ("error" in perform_res)
|
var success := not ("error" in perform_res)
|
||||||
if not success:
|
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: " + 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":
|
if reason == "update":
|
||||||
emit_signal.call_deferred("app_updated", cave_id, success)
|
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
|
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
|
## Returns the upload whose displayName or filename matches the user's pick
|
||||||
## from the install-options dropdown, or {} when nothing matches.
|
## from the install-options dropdown, or {} when nothing matches.
|
||||||
func _pick_upload(uploads: Array, chosen: String) -> Dictionary:
|
func _pick_upload(uploads: Array, chosen: String) -> Dictionary:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ var _diagnosed_pids := {}
|
||||||
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
||||||
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
||||||
var _update_flags := {}
|
var _update_flags := {}
|
||||||
|
## gameId -> {genre, inputs, screenshots} parsed from each game's itch.io page.
|
||||||
|
## butlerd's Game struct has none of these fields, so they're cached here and
|
||||||
|
## merged into the game dict on every library load, powering per-layout boxart
|
||||||
|
## (and, potentially, gamepad/genre filters).
|
||||||
|
var _enriched: Dictionary = {}
|
||||||
|
|
||||||
|
const _meta_cache_file: String = "itch_meta.json"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -29,6 +36,7 @@ func _ready() -> void:
|
||||||
logger.info("itch.io library loaded")
|
logger.info("itch.io library loaded")
|
||||||
itch.logged_in.connect(_on_logged_in)
|
itch.logged_in.connect(_on_logged_in)
|
||||||
itch.install_progressed.connect(_on_install_progressed)
|
itch.install_progressed.connect(_on_install_progressed)
|
||||||
|
itch.install_failed.connect(_on_install_failed)
|
||||||
# Detect itch.io games that die before creating a window (a missing shared
|
# Detect itch.io games that die before creating a window (a missing shared
|
||||||
# library, a broken install, ...) and surface a clear, actionable error
|
# library, a broken install, ...) and surface a clear, actionable error
|
||||||
# instead of leaving the user stuck on a black in-game screen.
|
# instead of leaving the user stuck on a black in-game screen.
|
||||||
|
|
@ -103,7 +111,8 @@ func get_install_options(item: LibraryLaunchItem) -> Array[Library.InstallOption
|
||||||
if label == "":
|
if label == "":
|
||||||
label = upload.get("filename", "")
|
label = upload.get("filename", "")
|
||||||
if label != "":
|
if label != "":
|
||||||
values.append(label)
|
var size: int = int(upload.get("size", 0))
|
||||||
|
values.append(label + " (" + _format_bytes(size) + ")")
|
||||||
if values.size() <= 1:
|
if values.size() <= 1:
|
||||||
return []
|
return []
|
||||||
option.values = values
|
option.values = values
|
||||||
|
|
@ -179,16 +188,29 @@ func update(item: LibraryLaunchItem) -> void:
|
||||||
|
|
||||||
## OpenGamepadUI's library menu doesn't re-render after an install or
|
## OpenGamepadUI's library menu doesn't re-render after an install or
|
||||||
## uninstall (the core install handler is a no-op upstream), so the tabs end
|
## uninstall (the core install handler is a no-op upstream), so the tabs end
|
||||||
## up showing stale install state. Drop and re-add this app through the
|
## up showing stale install state. Signal the menu to queue a refresh.
|
||||||
## LibraryManager, which fires the library_item_removed/library_item_added
|
##
|
||||||
## signals the menu uses to queue a refresh.
|
## The app must NOT be dropped and re-added here: remove_library_launch_item()
|
||||||
|
## erases the launch item from the existing LibraryItem and (when empty) the
|
||||||
|
## whole app from the manager, so the next add builds a brand new LibraryItem.
|
||||||
|
## The launch page and game settings hold a reference to the *old* item in
|
||||||
|
## their state data, and once its launch_items is emptied the provider dropdown
|
||||||
|
## in game_launch_settings.gd ends up with zero entries and segfaults on
|
||||||
|
## select(). Patching the same instance in place keeps those references valid.
|
||||||
func _refresh_library_menu(item: LibraryLaunchItem) -> void:
|
func _refresh_library_menu(item: LibraryLaunchItem) -> void:
|
||||||
var library_manager := load("res://core/global/library_manager.tres") as LibraryManager
|
var library_manager := load("res://core/global/library_manager.tres") as LibraryManager
|
||||||
if library_manager == null:
|
if library_manager == null:
|
||||||
return
|
return
|
||||||
if library_manager.has_app(item.name):
|
if not library_manager.has_app(item.name):
|
||||||
library_manager.remove_library_launch_item("itch", item.name)
|
|
||||||
library_manager.add_library_launch_item("itch", item)
|
library_manager.add_library_launch_item("itch", item)
|
||||||
|
return
|
||||||
|
var app := library_manager.get_app_by_name(item.name)
|
||||||
|
if app.get_launch_item("itch") == null:
|
||||||
|
item._provider_id = "itch"
|
||||||
|
app.launch_items.push_back(item)
|
||||||
|
# library_item_added/library_item_removed both just queue a menu refresh, so
|
||||||
|
# a single emit re-renders the grids with the item's current install state.
|
||||||
|
library_manager.library_item_added.emit(app)
|
||||||
|
|
||||||
|
|
||||||
## OpenGamepadUI hides the Uninstall button once the item is no longer
|
## OpenGamepadUI hides the Uninstall button once the item is no longer
|
||||||
|
|
@ -289,6 +311,7 @@ func _refresh_installed_item(item: LibraryLaunchItem, game: Dictionary) -> void:
|
||||||
|
|
||||||
|
|
||||||
func uninstall(item: LibraryLaunchItem) -> void:
|
func uninstall(item: LibraryLaunchItem) -> void:
|
||||||
|
var cave_id := item.provider_app_id
|
||||||
_active_item = item
|
_active_item = item
|
||||||
var success: bool = await itch.uninstall(item.provider_app_id)
|
var success: bool = await itch.uninstall(item.provider_app_id)
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -301,7 +324,7 @@ func uninstall(item: LibraryLaunchItem) -> void:
|
||||||
uninstall_completed.emit(item, success)
|
uninstall_completed.emit(item, success)
|
||||||
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
|
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
|
||||||
if success:
|
if success:
|
||||||
_update_flags.erase(item.provider_app_id)
|
_update_flags.erase(cave_id)
|
||||||
_refresh_library_menu(item)
|
_refresh_library_menu(item)
|
||||||
_restore_launch_focus()
|
_restore_launch_focus()
|
||||||
|
|
||||||
|
|
@ -433,6 +456,7 @@ func _cave_has_files(cave: Dictionary) -> bool:
|
||||||
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
||||||
if game.is_empty():
|
if game.is_empty():
|
||||||
return null
|
return null
|
||||||
|
game = _apply_enriched(game)
|
||||||
if game.get("classification", "game") != "game":
|
if game.get("classification", "game") != "game":
|
||||||
return null
|
return null
|
||||||
var game_id: int = int(game.get("id", 0))
|
var game_id: int = int(game.get("id", 0))
|
||||||
|
|
@ -463,6 +487,7 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
||||||
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
||||||
## butlerd (and, transitively, the itch.io API) on every library refresh.
|
## butlerd (and, transitively, the itch.io API) on every library refresh.
|
||||||
func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]:
|
func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]:
|
||||||
|
_enriched = _load_enriched_meta()
|
||||||
if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file):
|
if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file):
|
||||||
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
||||||
if json_items != null:
|
if json_items != null:
|
||||||
|
|
@ -501,6 +526,10 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
var items := [] as Array[LibraryLaunchItem]
|
var items := [] as Array[LibraryLaunchItem]
|
||||||
for i in json_items:
|
for i in json_items:
|
||||||
var item := LibraryLaunchItem.from_dict(i)
|
var item := LibraryLaunchItem.from_dict(i)
|
||||||
|
# Re-apply enriched page metadata (genre/inputs/screenshots) from
|
||||||
|
# the itch_meta cache so a cached library still writes per-layout
|
||||||
|
# boxart (and could filter by gamepad/genre) on cold loads.
|
||||||
|
item.metadata["game"] = _apply_enriched(item.metadata.get("game", {}))
|
||||||
var game: Dictionary = item.metadata.get("game", {})
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
||||||
if not cave.is_empty() and _cave_has_files(cave):
|
if not cave.is_empty() and _cave_has_files(cave):
|
||||||
|
|
@ -613,57 +642,191 @@ func _cleanup_orphan_caves(cave_ids: Array) -> void:
|
||||||
await itch.uninstall(cave_id)
|
await itch.uninstall(cave_id)
|
||||||
|
|
||||||
|
|
||||||
## Queues background downloads of each game's cover art into OGPU's local
|
## Queues background downloads of each game's art into OGPU's local boxart
|
||||||
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
|
## directory (user://boxart/local/). The built-in "local" BoxArtProvider picks
|
||||||
## picks those files up by <game name>-<layout>.png, so games show their real
|
## those files up by <game name>-<layout>.png, so games show their real cover
|
||||||
## cover instead of the placeholder. Covers are only downloaded once (the task
|
## instead of the placeholder. Each task enriches the game from its itch.io page
|
||||||
## bails out if a layout file already exists). For games with an animated GIF
|
## first (genre/inputs/screenshots, cached in itch_meta.json) and then writes
|
||||||
## cover, itch.io provides a stillCoverUrl (a static frame, served as PNG) which
|
## per-layout art: the cover for portrait/logo, full-res screenshots for
|
||||||
## we prefer, since Godot has no GIF decoder.
|
## landscape/banner. For games with an animated GIF cover, itch.io provides a
|
||||||
|
## stillCoverUrl (a static frame, served as PNG) which we prefer, since Godot
|
||||||
|
## has no GIF decoder.
|
||||||
func _queue_boxart(items: Array) -> void:
|
func _queue_boxart(items: Array) -> void:
|
||||||
for i in items:
|
for i in items:
|
||||||
var item: LibraryLaunchItem = i
|
var item: LibraryLaunchItem = i
|
||||||
var game: Dictionary = item.metadata.get("game", {})
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
var title: String = game.get("title", "")
|
if int(game.get("id", 0)) == 0:
|
||||||
var cover_url := _cover_url(game)
|
|
||||||
if title.is_empty() or cover_url.is_empty():
|
|
||||||
continue
|
continue
|
||||||
if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])):
|
itch.thread_group.scheduled_exec(_enrich_and_boxart.bind(game), 0)
|
||||||
continue
|
|
||||||
itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0)
|
|
||||||
|
|
||||||
|
|
||||||
## Downloads the given game's cover (blocking, runs on the shared thread) and
|
## Downloads the given game's art (blocking, runs on the shared thread) and
|
||||||
## writes it to every boxart layout. itch.zone only serves one fixed landscape
|
## writes it to the boxart layouts. The cover (or a full-res screenshot when
|
||||||
## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a
|
## enriched metadata is available) fills every slot itch.io can serve: OGPU's
|
||||||
## single image is reused for portrait, landscape, banner and logo.
|
## boxart slots all keep-aspect (scale + crop), so the 315x250 cover works for
|
||||||
func _ensure_boxart(game: Dictionary) -> void:
|
## portrait/logo while screenshots give landscape/banner real resolution.
|
||||||
|
## When force is set (right after a fresh page fetch), landscape/banner get
|
||||||
|
## overwritten so installs that only ever had the cover upgraded with art.
|
||||||
|
func _ensure_boxart(game: Dictionary, meta: Dictionary = {}, force := false) -> void:
|
||||||
var title: String = game.get("title", "")
|
var title: String = game.get("title", "")
|
||||||
var cover_url := _cover_url(game)
|
var cover_url := _cover_url(game)
|
||||||
if title.is_empty() or cover_url.is_empty():
|
if title.is_empty() or cover_url.is_empty():
|
||||||
return
|
return
|
||||||
var dir := _boxart_dir()
|
var dir := _boxart_dir()
|
||||||
if FileAccess.file_exists("/".join([dir, title + "-portrait.png"])):
|
|
||||||
return
|
|
||||||
|
|
||||||
var body := _download_cover(cover_url)
|
|
||||||
if body.is_empty():
|
|
||||||
logger.warn("Unable to download cover art for '" + title + "'")
|
|
||||||
return
|
|
||||||
if not _is_raster_image(body):
|
|
||||||
# OGPU's local provider only loads png/jpg. If itch.io didn't give us a
|
|
||||||
# still for an animated cover, skip rather than write an unloadable file.
|
|
||||||
logger.warn("Skipping unsupported cover format for '" + title + "'")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
# Screenshots are full-res originals from the game page; the cover is only
|
||||||
|
# reused for landscape/banner when a game has no screenshots at all.
|
||||||
|
var screenshots: Array = meta.get("screenshots", [])
|
||||||
|
var sources := {
|
||||||
|
"portrait": cover_url,
|
||||||
|
"landscape": screenshots[0] if not screenshots.is_empty() else cover_url,
|
||||||
|
"banner": screenshots[1] if screenshots.size() > 1 else (screenshots[0] if not screenshots.is_empty() else cover_url),
|
||||||
|
"logo": cover_url,
|
||||||
|
}
|
||||||
|
# portrait/logo keep the cover forever; landscape/banner are replaced by
|
||||||
|
# screenshot art on the first enrichment after an upgrade.
|
||||||
|
var fixed := {"portrait": true, "logo": true}
|
||||||
|
var bodies := {}
|
||||||
DirAccess.make_dir_recursive_absolute(dir)
|
DirAccess.make_dir_recursive_absolute(dir)
|
||||||
for layout in ["portrait", "landscape", "banner", "logo"]:
|
for layout in ["portrait", "landscape", "banner", "logo"]:
|
||||||
var path := "/".join([dir, title + "-" + layout + ".png"])
|
var path := "/".join([dir, title + "-" + layout + ".png"])
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
if fixed.has(layout) or not force:
|
||||||
|
continue
|
||||||
|
var url: String = sources[layout]
|
||||||
|
if url.is_empty():
|
||||||
|
continue
|
||||||
|
var body: PackedByteArray
|
||||||
|
if bodies.has(url):
|
||||||
|
body = bodies[url]
|
||||||
|
else:
|
||||||
|
body = _http_get(url)
|
||||||
|
bodies[url] = body
|
||||||
|
if body.is_empty() or not _is_raster_image(body):
|
||||||
|
# OGPU's local provider only loads png/jpg. If itch.io didn't give us a
|
||||||
|
# still for an animated cover, skip rather than write an unloadable file.
|
||||||
|
logger.warn("Skipping unsupported art for '" + title + "' layout " + layout)
|
||||||
|
continue
|
||||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
if file:
|
if file:
|
||||||
file.store_buffer(body)
|
file.store_buffer(body)
|
||||||
file.close()
|
file.close()
|
||||||
logger.info("Downloaded boxart for '" + title + "'")
|
logger.info("Downloaded " + layout + " boxart for '" + title + "'")
|
||||||
|
|
||||||
|
|
||||||
|
## Runs on the shared thread: enrich the game from its itch.io page (once per
|
||||||
|
## game, cached in itch_meta.json) and write per-layout boxart. A fresh fetch
|
||||||
|
## force-refreshes landscape/banner so installs that only ever had the cover
|
||||||
|
## copied into every slot get upgraded to full-res screenshots.
|
||||||
|
func _enrich_and_boxart(game: Dictionary) -> void:
|
||||||
|
var game_id := int(game.get("id", 0))
|
||||||
|
if game_id == 0:
|
||||||
|
return
|
||||||
|
var meta := {}
|
||||||
|
var force := false
|
||||||
|
if _enriched.has(game_id):
|
||||||
|
meta = _enriched[game_id]
|
||||||
|
else:
|
||||||
|
var fetched := _fetch_game_page_metadata(game)
|
||||||
|
if not fetched.is_empty():
|
||||||
|
meta = fetched
|
||||||
|
force = true
|
||||||
|
_save_enriched_meta(game_id, meta)
|
||||||
|
logger.info("Enriched page metadata for '" + game.get("title", "") + "': genre='" + str(meta.get("genre", "")) + "' inputs=" + str(meta.get("inputs", [])))
|
||||||
|
_ensure_boxart(game, meta, force)
|
||||||
|
|
||||||
|
|
||||||
|
## Fetches a game's itch.io page and parses the Details table (genre, inputs)
|
||||||
|
## plus the screenshot gallery. Returns {} on failure, otherwise
|
||||||
|
## {genre: String, inputs: Array[String], screenshots: Array[String]}. None of
|
||||||
|
## these fields exist in butlerd's Game struct; data.json exposes only freeform
|
||||||
|
## tags and a price, so the page HTML is the only keyless source.
|
||||||
|
func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
|
||||||
|
var page_url: String = game.get("url", "")
|
||||||
|
if page_url.is_empty():
|
||||||
|
return {}
|
||||||
|
var body := _http_get(page_url)
|
||||||
|
if body.is_empty():
|
||||||
|
return {}
|
||||||
|
var html := body.get_string_from_utf8()
|
||||||
|
if html.is_empty():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# RegEx instances are created here (background thread) to avoid sharing
|
||||||
|
# RegEx objects across threads.
|
||||||
|
var genre_re := RegEx.new()
|
||||||
|
genre_re.compile('<tr><td>Genre</td><td><a[^>]*>([^<]+)</a>')
|
||||||
|
var inputs_row_re := RegEx.new()
|
||||||
|
inputs_row_re.compile('(?s)<tr><td>Inputs</td><td>(.*?)</td></tr>')
|
||||||
|
var input_slug_re := RegEx.new()
|
||||||
|
input_slug_re.compile('input-([a-z0-9-]+)')
|
||||||
|
var shot_re := RegEx.new()
|
||||||
|
shot_re.compile('https://img\\.itch\\.zone/aW1hZ2Uv[^"]*/original/[^"]*')
|
||||||
|
|
||||||
|
var genre := ""
|
||||||
|
var genre_match := genre_re.search(html)
|
||||||
|
if genre_match != null:
|
||||||
|
genre = _decode_html(genre_match.get_string(1).strip_edges())
|
||||||
|
|
||||||
|
var inputs: Array = []
|
||||||
|
var inputs_match := inputs_row_re.search(html)
|
||||||
|
if inputs_match != null:
|
||||||
|
for m in input_slug_re.search_all(inputs_match.get_string(1)):
|
||||||
|
inputs.append(m.get_string(1))
|
||||||
|
|
||||||
|
var screenshots: Array = []
|
||||||
|
for m in shot_re.search_all(html):
|
||||||
|
screenshots.append(m.get_string(0))
|
||||||
|
|
||||||
|
return {"genre": genre, "inputs": inputs, "screenshots": screenshots}
|
||||||
|
|
||||||
|
|
||||||
|
## Decodes the HTML entities itch.io's Details table can contain.
|
||||||
|
func _decode_html(value: String) -> String:
|
||||||
|
value = value.replace("&", "&")
|
||||||
|
value = value.replace("<", "<")
|
||||||
|
value = value.replace(">", ">")
|
||||||
|
value = value.replace(""", "\"")
|
||||||
|
value = value.replace("'", "'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
## Loads the itch_meta.json cache: gameId -> {genre, inputs, screenshots}.
|
||||||
|
## JSON round-trips integer keys as strings, so they are normalized to int.
|
||||||
|
func _load_enriched_meta() -> Dictionary:
|
||||||
|
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file)
|
||||||
|
if typeof(raw) != TYPE_DICTIONARY:
|
||||||
|
return {}
|
||||||
|
var out := {}
|
||||||
|
for key in raw:
|
||||||
|
out[int(key)] = raw[key]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Merges one game's enriched metadata into the shared itch_meta.json cache.
|
||||||
|
func _save_enriched_meta(game_id: int, meta: Dictionary) -> void:
|
||||||
|
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file)
|
||||||
|
if typeof(raw) != TYPE_DICTIONARY:
|
||||||
|
raw = {}
|
||||||
|
raw[str(game_id)] = meta
|
||||||
|
Cache.save_json(_cache_dir, _meta_cache_file, raw)
|
||||||
|
|
||||||
|
|
||||||
|
## Merges the enriched metadata (genre/inputs/screenshots) into a game dict so
|
||||||
|
## it survives and is available during _make_item and boxart setup.
|
||||||
|
func _apply_enriched(game: Dictionary) -> Dictionary:
|
||||||
|
if game.is_empty():
|
||||||
|
return game
|
||||||
|
var meta: Dictionary = _enriched.get(int(game.get("id", 0)), {})
|
||||||
|
if meta.is_empty():
|
||||||
|
return game
|
||||||
|
if not game.has("genre"):
|
||||||
|
game["genre"] = meta.get("genre", "")
|
||||||
|
if not game.has("inputs"):
|
||||||
|
game["inputs"] = meta.get("inputs", [])
|
||||||
|
if not game.has("screenshots"):
|
||||||
|
game["screenshots"] = meta.get("screenshots", [])
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
func _boxart_dir() -> String:
|
func _boxart_dir() -> String:
|
||||||
|
|
@ -680,8 +843,9 @@ func _cover_url(game: Dictionary) -> String:
|
||||||
return game.get("coverUrl", "")
|
return game.get("coverUrl", "")
|
||||||
|
|
||||||
|
|
||||||
## Blocking HTTPS GET of a single cover image, for use on a background thread.
|
## Blocking HTTPS GET of a single URL (cover image or game page HTML), for use
|
||||||
func _download_cover(url: String) -> PackedByteArray:
|
## on a background thread.
|
||||||
|
func _http_get(url: String) -> PackedByteArray:
|
||||||
var parts := url.split("/")
|
var parts := url.split("/")
|
||||||
var use_tls := parts[0] == "https:"
|
var use_tls := parts[0] == "https:"
|
||||||
var http := HTTPClient.new()
|
var http := HTTPClient.new()
|
||||||
|
|
@ -976,23 +1140,41 @@ func _handle_launch_failure(app: RunningApp) -> void:
|
||||||
## Shows the launch failure as a modal popup (OGPU's Dialog component) instead
|
## Shows the launch failure as a modal popup (OGPU's Dialog component) instead
|
||||||
## of a transient toast, so the user has time to read and act on the message.
|
## of a transient toast, so the user has time to read and act on the message.
|
||||||
func _show_failure_dialog(game_name: String, message: String) -> void:
|
func _show_failure_dialog(game_name: String, message: String) -> void:
|
||||||
|
_show_modal_dialog("Failed to start: " + game_name + "\n\n" + message)
|
||||||
|
|
||||||
|
|
||||||
|
## Surfaces an install/update failure (butlerd RPC error, corrupt download,
|
||||||
|
## ...) in the same modal Dialog component the launch failure uses. Before
|
||||||
|
## this, install errors were only written to the log, so a failed install
|
||||||
|
## looked like a silent no-op.
|
||||||
|
func _on_install_failed(_cave_or_game_id: String, game_title: String, message: String) -> void:
|
||||||
|
logger.warn("Install of '" + game_title + "' failed: " + message)
|
||||||
|
_show_modal_dialog("Failed to install: " + game_title + "\n\n" + message)
|
||||||
|
|
||||||
|
|
||||||
|
## Shows a message in OGPU's modal Dialog component. The Dialog scene is
|
||||||
|
## authored with zero-size anchors; make it (and its Spacer) fill the screen
|
||||||
|
## so the panel is centered over whatever UI is currently up.
|
||||||
|
func _show_modal_dialog(body: String) -> void:
|
||||||
var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene
|
var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene
|
||||||
if dialog_scene == null:
|
if dialog_scene == null:
|
||||||
logger.warn("Dialog component unavailable; launch failure not shown to user.")
|
logger.warn("Dialog component unavailable; message not shown to user.")
|
||||||
return
|
return
|
||||||
var ui: Node = get_tree().get_first_node_in_group("main")
|
var ui: Node = get_tree().get_first_node_in_group("main")
|
||||||
if ui == null:
|
if ui == null:
|
||||||
ui = get_tree().root
|
ui = get_tree().root
|
||||||
var dialog: Control = dialog_scene.instantiate()
|
var dialog: Control = dialog_scene.instantiate()
|
||||||
ui.add_child(dialog)
|
ui.add_child(dialog)
|
||||||
# The Dialog scene is authored with zero-size anchors; make it (and its
|
|
||||||
# Spacer) fill the screen so the panel is centered over the UI.
|
|
||||||
dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
dialog.size = (ui as Control).size if ui is Control else Vector2(1920, 1080)
|
dialog.size = (ui as Control).size if ui is Control else Vector2(1920, 1080)
|
||||||
(dialog.get_node("Spacer") as Control).set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
(dialog.get_node("Spacer") as Control).set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
dialog.cancel_visible = false
|
dialog.cancel_visible = false
|
||||||
dialog.closed.connect(func() -> void: dialog.queue_free())
|
dialog.closed.connect(func() -> void: dialog.queue_free())
|
||||||
dialog.open(null, "Failed to start: " + game_name + "\n\n" + message, "OK")
|
# Hand focus back to whatever had it before the modal opened. Without a
|
||||||
|
# return node the Dialog leaves the UI unfocused on close, so gamepad
|
||||||
|
# navigation goes dead until the user presses the guide button.
|
||||||
|
var return_node: Control = get_tree().root.gui_get_focus_owner()
|
||||||
|
dialog.open(return_node, body, "OK")
|
||||||
|
|
||||||
|
|
||||||
## Figures out why a game failed to start and returns a human-readable,
|
## Figures out why a game failed to start and returns a human-readable,
|
||||||
|
|
@ -1033,3 +1215,20 @@ func _missing_shared_libs(binary: String) -> String:
|
||||||
if name != "":
|
if name != "":
|
||||||
missing.append(name)
|
missing.append(name)
|
||||||
return ", ".join(missing)
|
return ", ".join(missing)
|
||||||
|
|
||||||
|
|
||||||
|
## Formats a byte count for display, e.g. 53712463 -> "51.2 MB". A zero-byte
|
||||||
|
## upload (like a "latest" external redirect placeholder) shows as "0 B" so
|
||||||
|
## the user can spot it before picking it.
|
||||||
|
func _format_bytes(bytes: int) -> String:
|
||||||
|
if bytes <= 0:
|
||||||
|
return "0 B"
|
||||||
|
var units := ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var value: float = float(bytes)
|
||||||
|
var i := 0
|
||||||
|
while value >= 1024.0 and i < units.size() - 1:
|
||||||
|
value /= 1024.0
|
||||||
|
i += 1
|
||||||
|
if i == 0:
|
||||||
|
return str(int(value)) + " B"
|
||||||
|
return str(snapped(value, 0.1)) + " " + units[i]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"plugin.id": "itch",
|
"plugin.id": "itch",
|
||||||
"plugin.name": "itch.io",
|
"plugin.name": "itch.io",
|
||||||
"plugin.version": "0.1.24",
|
"plugin.version": "0.1.31",
|
||||||
"plugin.min-api-version": "1.1.0",
|
"plugin.min-api-version": "1.1.0",
|
||||||
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue