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
This commit is contained in:
parent
8a2d8b5374
commit
8b393090ca
3 changed files with 188 additions and 22 deletions
|
|
@ -463,23 +463,26 @@ func _fetch_collection_games(profile_id: int, collections: Array) -> Array:
|
||||||
|
|
||||||
|
|
||||||
## Returns every installed game (a "cave" in butlerd terminology).
|
## 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)
|
return await thread_group.exec(_get_caves)
|
||||||
|
|
||||||
|
|
||||||
func _get_caves() -> Array:
|
func _get_caves() -> Variant:
|
||||||
var params := {}
|
var params := {}
|
||||||
var res := await _rpc_call("Fetch.Caves", params)
|
var res := await _rpc_call("Fetch.Caves", params)
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
logger.warn("Fetch.Caves failed: " + str(res["error"]))
|
logger.warn("Fetch.Caves failed: " + str(res["error"]))
|
||||||
return []
|
return null
|
||||||
if res.get("stale", false):
|
if res.get("stale", false):
|
||||||
logger.info("Caves are stale. Refreshing from itch.io.")
|
logger.info("Caves are stale. Refreshing from itch.io.")
|
||||||
params["fresh"] = true
|
params["fresh"] = true
|
||||||
res = await _rpc_call("Fetch.Caves", params)
|
res = await _rpc_call("Fetch.Caves", params)
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
logger.warn("Fetch.Caves (fresh) failed: " + str(res["error"]))
|
logger.warn("Fetch.Caves (fresh) failed: " + str(res["error"]))
|
||||||
return []
|
return null
|
||||||
return res.get("items", [])
|
return res.get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,26 +31,141 @@ func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary
|
||||||
var game := (item.metadata.get("game", {}) as Dictionary)
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
||||||
_active_item = item
|
_active_item = item
|
||||||
var success: bool = await itch.install(game)
|
var success: bool = await itch.install(game)
|
||||||
|
if success:
|
||||||
|
await _refresh_installed_item(item, game)
|
||||||
_active_item = null
|
_active_item = null
|
||||||
install_completed.emit(item, success)
|
install_completed.emit(item, success)
|
||||||
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
|
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
|
||||||
|
if success:
|
||||||
|
_refresh_library_menu(item)
|
||||||
|
|
||||||
|
|
||||||
func update(item: LibraryLaunchItem) -> void:
|
func update(item: LibraryLaunchItem) -> void:
|
||||||
var game := (item.metadata.get("game", {}) as Dictionary)
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
||||||
_active_item = item
|
_active_item = item
|
||||||
var success: bool = await itch.install(game, item.provider_app_id)
|
var success: bool = await itch.install(game, item.provider_app_id)
|
||||||
|
if success:
|
||||||
|
await _refresh_installed_item(item, game)
|
||||||
_active_item = null
|
_active_item = null
|
||||||
update_completed.emit(item, success)
|
update_completed.emit(item, success)
|
||||||
logger.info("Update of '" + item.name + "' completed with status: " + str(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:
|
func uninstall(item: LibraryLaunchItem) -> void:
|
||||||
_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:
|
||||||
|
item.installed = false
|
||||||
|
item.command = ""
|
||||||
|
item.args = []
|
||||||
|
item.cwd = ""
|
||||||
|
item.provider_app_id = ""
|
||||||
_active_item = null
|
_active_item = null
|
||||||
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:
|
||||||
|
_refresh_library_menu(item)
|
||||||
|
_restore_launch_focus()
|
||||||
|
|
||||||
|
|
||||||
## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it
|
## 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
|
return null
|
||||||
if game.get("classification", "game") != "game":
|
if game.get("classification", "game") != "game":
|
||||||
return null
|
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: Dictionary = caves_by_game_id.get(game_id, {})
|
||||||
var cave_valid: bool = _cave_has_files(cave)
|
var cave_valid: bool = _cave_has_files(cave)
|
||||||
if not _game_available_on_current_platform(game, cave_valid):
|
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.command = launch.get("command", "")
|
||||||
item.args = launch.get("args", [])
|
item.args = launch.get("args", [])
|
||||||
item.cwd = install_folder
|
item.cwd = install_folder
|
||||||
|
_apply_user_library_path(item)
|
||||||
return 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)
|
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
||||||
if json_items != null:
|
if json_items != null:
|
||||||
logger.info("itch.io apps found in cache. Using cache.")
|
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]
|
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)
|
||||||
# A cached item can claim to be installed while its folder was
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
# wiped by a plugin update (see _cave_has_files). Re-check the
|
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
||||||
# folder so stale caches don't render a broken Play button.
|
if not cave.is_empty() and _cave_has_files(cave):
|
||||||
if item.installed and item.cwd != "" and not DirAccess.dir_exists_absolute(item.cwd):
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
||||||
logger.info("Cached install '" + item.name + "' has no files on disk. Marking as not installed.")
|
if not item.installed or item.cwd != install_folder:
|
||||||
item.installed = false
|
logger.info("Refreshing install of '" + item.name + "' to: " + install_folder)
|
||||||
item.command = ""
|
item.installed = true
|
||||||
item.provider_app_id = ""
|
item.provider_app_id = cave.get("id", "")
|
||||||
# Caches from older builds carried a wrong launch command (the
|
item.cwd = install_folder
|
||||||
# old `find | head` guess picked e.g. a Unity debug stub instead
|
var launch := _resolve_launch(install_folder)
|
||||||
# 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)
|
|
||||||
item.command = launch.get("command", "")
|
item.command = launch.get("command", "")
|
||||||
item.args = launch.get("args", [])
|
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):
|
if not _game_available_on_current_platform(game, item.installed):
|
||||||
continue
|
continue
|
||||||
items.append(item)
|
items.append(item)
|
||||||
_queue_boxart(items)
|
_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
|
return items
|
||||||
|
|
||||||
if not itch.is_logged_in:
|
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...")
|
logger.info("Fetching itch.io library...")
|
||||||
var owned: Array = await itch.get_owned_games()
|
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()
|
var collection_games: Array = await itch.get_collection_games()
|
||||||
|
|
||||||
# Clean up "orphan" caves (butler.db entries whose install folder was wiped
|
# 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
|
# 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.
|
# trip over them. The folder is already gone, so nothing is lost.
|
||||||
|
if caves == null:
|
||||||
|
caves = []
|
||||||
var orphan_cave_ids := []
|
var orphan_cave_ids := []
|
||||||
for c in caves:
|
for c in caves:
|
||||||
var cave: Dictionary = c
|
var cave: Dictionary = c
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"plugin.id": "itch",
|
"plugin.id": "itch",
|
||||||
"plugin.name": "itch.io",
|
"plugin.name": "itch.io",
|
||||||
"plugin.version": "0.1.12",
|
"plugin.version": "0.1.20",
|
||||||
"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