From 404ee87a644941c21f1a09393d1ba11094fa03e7 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Sun, 16 Aug 2026 15:33:36 -0300 Subject: [PATCH] Enriched metadata, per-layout boxart, install failure UI, and modal focus fix (v0.1.31) --- .gitignore | 1 + core/itch_client.gd | 132 +++++++++++++++++++- core/library_itch.gd | 289 ++++++++++++++++++++++++++++++++++++------- plugin.json | 2 +- 4 files changed, 377 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index ac2e6cf..15af34a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .godot dist +.deckcheck *.uid *.remap *.import diff --git a/core/itch_client.gd b/core/itch_client.gd index 836e427..ee83933 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -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: diff --git a/core/library_itch.gd b/core/library_itch.gd index cc72a93..8100989 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -19,6 +19,13 @@ var _diagnosed_pids := {} ## caveId -> true when butlerd reports an update for that cave. Refreshed in the ## background by CheckUpdate so has_update() stays cheap and synchronous. 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: @@ -29,6 +36,7 @@ func _ready() -> void: logger.info("itch.io library loaded") itch.logged_in.connect(_on_logged_in) 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 # library, a broken install, ...) and surface a clear, actionable error # 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 == "": label = upload.get("filename", "") if label != "": - values.append(label) + var size: int = int(upload.get("size", 0)) + values.append(label + " (" + _format_bytes(size) + ")") if values.size() <= 1: return [] option.values = values @@ -179,16 +188,29 @@ func update(item: LibraryLaunchItem) -> void: ## 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 -## up showing stale install state. Drop and re-add this app through the -## LibraryManager, which fires the library_item_removed/library_item_added -## signals the menu uses to queue a refresh. +## up showing stale install state. Signal the menu 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: var library_manager := load("res://core/global/library_manager.tres") as LibraryManager if library_manager == null: return - if library_manager.has_app(item.name): - library_manager.remove_library_launch_item("itch", item.name) - library_manager.add_library_launch_item("itch", item) + if not library_manager.has_app(item.name): + 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 @@ -289,6 +311,7 @@ func _refresh_installed_item(item: LibraryLaunchItem, game: Dictionary) -> void: func uninstall(item: LibraryLaunchItem) -> void: + var cave_id := item.provider_app_id _active_item = item var success: bool = await itch.uninstall(item.provider_app_id) if success: @@ -301,7 +324,7 @@ func uninstall(item: LibraryLaunchItem) -> void: uninstall_completed.emit(item, success) logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success)) if success: - _update_flags.erase(item.provider_app_id) + _update_flags.erase(cave_id) _refresh_library_menu(item) _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: if game.is_empty(): return null + game = _apply_enriched(game) if game.get("classification", "game") != "game": return null 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 ## 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]: + _enriched = _load_enriched_meta() 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) 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] for i in json_items: 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 cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {}) 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) -## Queues background downloads of each game's cover art into OGPU's local -## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider -## picks those files up by -.png, so games show their real -## cover instead of the placeholder. Covers are only downloaded once (the task -## bails out if a layout file already exists). 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. +## Queues background downloads of each game's art into OGPU's local boxart +## directory (user://boxart/local/). The built-in "local" BoxArtProvider picks +## those files up by -.png, so games show their real cover +## instead of the placeholder. Each task enriches the game from its itch.io page +## first (genre/inputs/screenshots, cached in itch_meta.json) and then writes +## per-layout art: the cover for portrait/logo, full-res screenshots for +## 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: for i in items: var item: LibraryLaunchItem = i var game: Dictionary = item.metadata.get("game", {}) - var title: String = game.get("title", "") - var cover_url := _cover_url(game) - if title.is_empty() or cover_url.is_empty(): + if int(game.get("id", 0)) == 0: continue - if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])): - continue - itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0) + itch.thread_group.scheduled_exec(_enrich_and_boxart.bind(game), 0) -## Downloads the given game's cover (blocking, runs on the shared thread) and -## writes it to every boxart layout. itch.zone only serves one fixed landscape -## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a -## single image is reused for portrait, landscape, banner and logo. -func _ensure_boxart(game: Dictionary) -> void: +## Downloads the given game's art (blocking, runs on the shared thread) and +## writes it to the boxart layouts. The cover (or a full-res screenshot when +## enriched metadata is available) fills every slot itch.io can serve: OGPU's +## boxart slots all keep-aspect (scale + crop), so the 315x250 cover works for +## 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 cover_url := _cover_url(game) if title.is_empty() or cover_url.is_empty(): return 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) for layout in ["portrait", "landscape", "banner", "logo"]: 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) if file: file.store_buffer(body) 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('Genre]*>([^<]+)') + var inputs_row_re := RegEx.new() + inputs_row_re.compile('(?s)Inputs(.*?)') + 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: @@ -680,8 +843,9 @@ func _cover_url(game: Dictionary) -> String: return game.get("coverUrl", "") -## Blocking HTTPS GET of a single cover image, for use on a background thread. -func _download_cover(url: String) -> PackedByteArray: +## Blocking HTTPS GET of a single URL (cover image or game page HTML), for use +## on a background thread. +func _http_get(url: String) -> PackedByteArray: var parts := url.split("/") var use_tls := parts[0] == "https:" 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 ## 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: + _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 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 var ui: Node = get_tree().get_first_node_in_group("main") if ui == null: ui = get_tree().root var dialog: Control = dialog_scene.instantiate() 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.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.cancel_visible = false 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, @@ -1033,3 +1215,20 @@ func _missing_shared_libs(binary: String) -> String: if name != "": missing.append(name) 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] diff --git a/plugin.json b/plugin.json index 25f3078..4889a23 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.24", + "plugin.version": "0.1.31", "plugin.min-api-version": "1.1.0", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",