From 8b393090ca8d57d8025c8ed857804baff3c763f8 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Sun, 9 Aug 2026 15:43:30 -0300 Subject: [PATCH] Fix stale cache launches, FNF runtime deps, and uninstall focus death (v0.1.20) - Inject the user-level shared-libs dir into LD_LIBRARY_PATH for installed games so FNF finds libvlc.so.5 (idempotent, avoids duplicate entries) - Reconcile cached installs against butlerd's live caves: normalize int/float ids, wait for butlerd when the cache claims installs, keep the cache when Fetch.Caves fails, and re-save the reconciled cache - get_caves() returns null on RPC error (vs [] for genuinely nothing installed) so callers can tell a transient failure apart - Patch freshly installed/updated items in place so the Play button launches the new binary immediately - Drop and re-add items through the LibraryManager so the library menu re-renders after install/uninstall (core handler is a no-op upstream) - Restore launch-page focus after uninstall by searching get_tree().root: get_tree().current_scene is null in OGPU, so the old fix silently no-oped and left the D-pad dead --- core/itch_client.gd | 11 ++- core/library_itch.gd | 197 +++++++++++++++++++++++++++++++++++++++---- plugin.json | 2 +- 3 files changed, 188 insertions(+), 22 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 128462e..21309e6 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -463,23 +463,26 @@ func _fetch_collection_games(profile_id: int, collections: Array) -> Array: ## Returns every installed game (a "cave" in butlerd terminology). -func get_caves() -> Array: +## Returns `null` when butlerd is unreachable or the call errored, so callers +## can tell a genuine "no games installed" (`[]`) apart from a transient +## failure. +func get_caves() -> Variant: return await thread_group.exec(_get_caves) -func _get_caves() -> Array: +func _get_caves() -> Variant: var params := {} var res := await _rpc_call("Fetch.Caves", params) if "error" in res: logger.warn("Fetch.Caves failed: " + str(res["error"])) - return [] + return null if res.get("stale", false): logger.info("Caves are stale. Refreshing from itch.io.") params["fresh"] = true res = await _rpc_call("Fetch.Caves", params) if "error" in res: logger.warn("Fetch.Caves (fresh) failed: " + str(res["error"])) - return [] + return null return res.get("items", []) diff --git a/core/library_itch.gd b/core/library_itch.gd index b75e6c4..6b5aef6 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -31,26 +31,141 @@ func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary var game := (item.metadata.get("game", {}) as Dictionary) _active_item = item var success: bool = await itch.install(game) + if success: + await _refresh_installed_item(item, game) _active_item = null install_completed.emit(item, success) logger.info("Install of '" + item.name + "' completed with status: " + str(success)) + if success: + _refresh_library_menu(item) func update(item: LibraryLaunchItem) -> void: var game := (item.metadata.get("game", {}) as Dictionary) _active_item = item var success: bool = await itch.install(game, item.provider_app_id) + if success: + await _refresh_installed_item(item, game) _active_item = null update_completed.emit(item, success) logger.info("Update of '" + item.name + "' completed with status: " + str(success)) + if success: + _refresh_library_menu(item) + + +## 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. +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) + + +## OpenGamepadUI hides the Uninstall button once the item is no longer +## installed. Godot releases focus entirely when the focused control becomes +## hidden, so the game's launch page ends up unnavigable (D-pad dead) until the +## user presses the guide button. Hand focus back to the Play/Install button. +func _restore_launch_focus() -> void: + logger.info("Restore focus: restoring launch page focus after uninstall.") + await get_tree().process_frame + await get_tree().process_frame + if not is_inside_tree(): + logger.warn("Restore focus: library not in tree.") + return + var root := get_tree().root + if root == null: + logger.warn("Restore focus: no tree root.") + return + var grabbed := false + for c in root.find_children("LaunchButton", "", true, false): + if c is Control and c.is_visible_in_tree(): + logger.info("Restore focus: grabbing focus on " + str(c.get_path())) + (c as Control).grab_focus.call_deferred() + grabbed = true + break + if not grabbed: + logger.warn("Restore focus: no visible LaunchButton found.") + return + await get_tree().process_frame + var owner := root.gui_get_focus_owner() + logger.info("Restore focus: focus owner after grab: " + (owner.get_path() if owner != null else "null")) + + +## User-level shared libraries for itch games, e.g. libvlc.so.5 for games that +## link against it. Lives under the persistent OGPU data dir so it survives +## plugin updates and OS re-images, and is injected into the launch environment +## via LD_LIBRARY_PATH. +func _user_lib_dir() -> String: + var dir := OS.get_environment("HOME") + "/.local/share/opengamepadui/lib" + if DirAccess.dir_exists_absolute(dir): + return dir + return "" + + +func _apply_user_library_path(item: LibraryLaunchItem) -> void: + var lib_dir := _user_lib_dir() + if lib_dir == "": + return + var existing: Variant = item.env.get("LD_LIBRARY_PATH", "") + if existing is String and not (existing as String).is_empty(): + for entry in (existing as String).split(":", false): + if entry == lib_dir: + return + item.env["LD_LIBRARY_PATH"] = lib_dir + ":" + (existing as String) + else: + item.env["LD_LIBRARY_PATH"] = lib_dir + + +## The LibraryLaunchItem the UI holds was built from a cached library listing +## that can be stale (old install folder, empty or wrong command). After a +## successful install or update, patch the SAME item instance in place so the +## Play button launches the freshly installed binary right away. +func _refresh_installed_item(item: LibraryLaunchItem, game: Dictionary) -> void: + var game_id: int = int(game.get("id", 0)) + var caves: Variant = await itch.get_caves() + if caves == null: + logger.warn("Could not fetch caves to refresh freshly installed item '" + item.name + "'") + return + for c in caves: + var cave: Dictionary = c + if int(cave.get("game", {}).get("id", 0)) != game_id: + continue + var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") + if install_folder == "" or not DirAccess.dir_exists_absolute(install_folder): + continue + var launch := _resolve_launch(install_folder) + item.installed = true + item.provider_app_id = cave.get("id", "") + item.cwd = install_folder + item.command = launch.get("command", "") + item.args = launch.get("args", []) + _apply_user_library_path(item) + logger.info("Refreshed installed item '" + item.name + "' to run: " + item.command) + return + logger.warn("Could not locate cave for freshly installed game id " + str(game_id)) func uninstall(item: LibraryLaunchItem) -> void: _active_item = item var success: bool = await itch.uninstall(item.provider_app_id) + if success: + item.installed = false + item.command = "" + item.args = [] + item.cwd = "" + item.provider_app_id = "" _active_item = null uninstall_completed.emit(item, success) logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success)) + if success: + _refresh_library_menu(item) + _restore_launch_focus() ## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it @@ -136,7 +251,7 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: return null if game.get("classification", "game") != "game": return null - var game_id: int = game.get("id", 0) + var game_id: int = int(game.get("id", 0)) var cave: Dictionary = caves_by_game_id.get(game_id, {}) var cave_valid: bool = _cave_has_files(cave) if not _game_available_on_current_platform(game, cave_valid): @@ -156,6 +271,7 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: item.command = launch.get("command", "") item.args = launch.get("args", []) item.cwd = install_folder + _apply_user_library_path(item) return item @@ -167,30 +283,75 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> var json_items = Cache.get_json(_cache_dir, _apps_cache_file) if json_items != null: logger.info("itch.io apps found in cache. Using cache.") + # The cached install folder (and the launch command resolved from it) + # can be stale: older builds kept games under user://plugins/itch/games, + # which OGPU trashes on every plugin update, and newer builds + # re-installed everything under the persistent butler/games location. + # Reconcile each cached entry against butlerd's live cave list so the + # install state, install folder and command all point at reality. + # `get_caves()` returns null on error, [] when nothing is installed. + var caves: Variant = await itch.get_caves() + # Butlerd is spawned asynchronously and the first library loads can + # beat it to the punch, getting an empty cave list and wrongly + # uninstalling every cached game. When the cache claims installs, + # give butlerd a moment to come up. Once butlerd is connected, an + # empty list is the truth (e.g. the user uninstalled everything). + var cached_installed := false + for i in json_items: + if (i as Dictionary).get("installed", false): + cached_installed = true + break + var attempts := 0 + while cached_installed and attempts < 10 and (caves == null or (caves.is_empty() and itch.state != ItchClient.STATE.CONNECTED)): + await get_tree().create_timer(0.5).timeout + caves = await itch.get_caves() + attempts += 1 + var caves_by_game_id := {} + if caves != null: + for c in caves: + var cave: Dictionary = c + # Live cave ids are ints, but ids that round-tripped through the + # JSON cache come back as floats (Godot parses every number as + # float), so normalize the lookup key to int on both sides. + caves_by_game_id[int(cave.get("game", {}).get("id", 0))] = cave var items := [] as Array[LibraryLaunchItem] for i in json_items: var item := LibraryLaunchItem.from_dict(i) - # A cached item can claim to be installed while its folder was - # wiped by a plugin update (see _cave_has_files). Re-check the - # folder so stale caches don't render a broken Play button. - if item.installed and item.cwd != "" and not DirAccess.dir_exists_absolute(item.cwd): - logger.info("Cached install '" + item.name + "' has no files on disk. Marking as not installed.") - item.installed = false - item.command = "" - item.provider_app_id = "" - # Caches from older builds carried a wrong launch command (the - # old `find | head` guess picked e.g. a Unity debug stub instead - # of the real launcher). Re-resolve it from butlerd's receipt so - # existing installs launch correctly. - elif item.installed and item.cwd != "": - var launch := _resolve_launch(item.cwd) + 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): + var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") + if not item.installed or item.cwd != install_folder: + logger.info("Refreshing install of '" + item.name + "' to: " + install_folder) + item.installed = true + item.provider_app_id = cave.get("id", "") + item.cwd = install_folder + var launch := _resolve_launch(install_folder) item.command = launch.get("command", "") item.args = launch.get("args", []) - var game: Dictionary = item.metadata.get("game", {}) + elif caves == null: + # Fetch.Caves failed (e.g. butlerd still booting). Keep the + # cached state rather than wrongly uninstalling. + pass + elif item.installed: + logger.info("Cached install '" + item.name + "' has no cave on disk. Marking as not installed.") + item.installed = false + item.command = "" + item.args = [] + item.provider_app_id = "" + if item.installed: + _apply_user_library_path(item) if not _game_available_on_current_platform(game, item.installed): continue items.append(item) _queue_boxart(items) + if caching_flags & Cache.FLAGS.SAVE: + var json_out := [] + for it in items: + var entry: LibraryLaunchItem = it + json_out.append(entry.to_dict()) + if Cache.save_json(_cache_dir, _apps_cache_file, json_out) != OK: + logger.warn("Unable to save reconciled itch.io apps cache") return items if not itch.is_logged_in: @@ -199,12 +360,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> logger.info("Fetching itch.io library...") var owned: Array = await itch.get_owned_games() - var caves: Array = await itch.get_caves() + var caves: Variant = await itch.get_caves() var collection_games: Array = await itch.get_collection_games() # Clean up "orphan" caves (butler.db entries whose install folder was wiped # by a plugin update) in the background so a later Install.Queue doesn't # trip over them. The folder is already gone, so nothing is lost. + if caves == null: + caves = [] var orphan_cave_ids := [] for c in caves: var cave: Dictionary = c diff --git a/plugin.json b/plugin.json index 7e716ab..f3abf1b 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.12", + "plugin.version": "0.1.20", "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",