From ff2ae68d9e05ef025013ce323b1fcf7cea4df5be Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 10 Aug 2026 00:04:37 -0300 Subject: [PATCH] Add install locations, upload picker, update flags, move, and launch hooks (v0.1.24) --- core/itch_client.gd | 110 ++++++++++++++++++++++------ core/library_itch.gd | 169 +++++++++++++++++++++++++++++++++++++++++-- plugin.json | 2 +- 3 files changed, 252 insertions(+), 29 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 21309e6..836e427 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -515,10 +515,72 @@ func _ensure_install_location() -> String: return _install_location_id +## Returns every configured install location, each as: +## {"id": "...", "path": "...", "sizeInfo": {"installedSize", "freeSize", "totalSize"}} +func get_install_locations() -> Array: + return await thread_group.exec(_get_install_locations) + + +func _get_install_locations() -> Array: + var res := await _rpc_call("Install.Locations.List", {}) + if "error" in res: + logger.warn("Install.Locations.List failed: " + str(res["error"])) + return [] + return res.get("installLocations", []) + + +## Returns the uploads butlerd considers compatible with this machine for the +## given game. Each entry is a full Upload dict (id, filename, displayName, +## size, type, platforms, ...). +func get_compatible_uploads(game_id: int) -> Array: + return await thread_group.exec(_get_compatible_uploads.bind(game_id)) + + +func _get_compatible_uploads(game_id: int) -> Array: + var params := {"gameId": game_id, "compatible": true} + var res := await _rpc_call("Fetch.GameUploads", params) + if "error" in res: + logger.warn("Fetch.GameUploads failed for game " + str(game_id) + ": " + str(res["error"])) + return [] + # Uploads are cached by butlerd just like owned keys, so retry fresh when + # the cached result is stale, otherwise a cold database would report every + # game as having no compatible upload. + if res.get("stale", false): + logger.info("Uploads are stale. Refreshing from itch.io.") + params["fresh"] = true + res = await _rpc_call("Fetch.GameUploads", params) + if "error" in res: + logger.warn("Fetch.GameUploads (fresh) failed for game " + str(game_id) + ": " + str(res["error"])) + return [] + return res.get("uploads", []) + + +## Looks for updates to installed caves. When cave_ids is empty, butlerd checks +## every cave and respects per-cave snooze (matching the official itch.io app). +## Returns the list of GameUpdate dicts (each with a `caveId`). +func check_updates(cave_ids: Array = []) -> Array: + return await thread_group.exec(_check_updates.bind(cave_ids)) + + +func _check_updates(cave_ids: Array) -> Array: + var params := {} + if not cave_ids.is_empty(): + params["caveIds"] = cave_ids + var res := await _rpc_call("CheckUpdate", params) + if "error" in res: + logger.warn("CheckUpdate failed: " + str(res["error"])) + return [] + return res.get("updates", []) + + ## Installs (or updates, if a caveId is given) the given game. +## `options` may carry: +## - "install_location_id": the InstallLocation id to install into (fresh installs) +## - "upload": the upload filename/displayName chosen by the user (defaults to +## butlerd's first compatible upload otherwise) ## Returns true when the operation finished without errors. -func install(game: Dictionary, cave_id: String = "") -> bool: - return await thread_group.exec(_install.bind(game, cave_id)) +func install(game: Dictionary, cave_id: String = "", options: Dictionary = {}) -> bool: + return await thread_group.exec(_install.bind(game, cave_id, options)) ## Reconciles an install/update request against what butlerd actually has @@ -560,36 +622,30 @@ func _resolve_cave(game: Dictionary, cave_id: String) -> String: return "" -func _install(game: Dictionary, cave_id: String) -> bool: +func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool: var game_id: int = game.get("id", 0) cave_id = await _resolve_cave(game, cave_id) var reason := "update" if cave_id != "" else "install" - var uploads_params := {"gameId": game_id, "compatible": true} - var uploads_res := await _rpc_call("Fetch.GameUploads", uploads_params) - if "error" in uploads_res: - logger.warn("Fetch.GameUploads failed: " + str(uploads_res["error"])) - emit_signal.call_deferred("app_installed", cave_id, false) - return false - # Uploads are cached by butlerd just like owned keys, so retry fresh when - # the cached result is stale, otherwise a cold database would report every - # game as having no compatible upload. - if uploads_res.get("stale", false): - logger.info("Uploads are stale. Refreshing from itch.io.") - uploads_params["fresh"] = true - uploads_res = await _rpc_call("Fetch.GameUploads", uploads_params) - if "error" in uploads_res: - logger.warn("Fetch.GameUploads (fresh) failed: " + str(uploads_res["error"])) - emit_signal.call_deferred("app_installed", cave_id, false) - return false - var uploads: Array = uploads_res.get("uploads", []) + var uploads := await _get_compatible_uploads(game_id) 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) return false var upload: Dictionary = uploads[0] + # Respect the upload the user picked in the install-options dialog, when + # one was offered (the game has more than one compatible upload). + var chosen_upload: Variant = options.get("upload", null) + if chosen_upload != null: + var picked := _pick_upload(uploads, str(chosen_upload)) + if picked.is_empty(): + logger.warn("Selected upload '" + str(chosen_upload) + "' not found. Using default upload.") + else: + upload = picked - var location_id := await _ensure_install_location() + var location_id: String = options.get("install_location_id", "") as String + if location_id == "": + location_id = await _ensure_install_location() var queue_params := { "game": game, @@ -637,6 +693,16 @@ func _install(game: Dictionary, cave_id: String) -> bool: return success +## 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: + for u in uploads: + var upload: Dictionary = u + if upload.get("displayName", "") == chosen or upload.get("filename", "") == chosen: + return upload + return {} + + ## Uninstalls the given cave. Returns true on success. func uninstall(cave_id: String) -> bool: return await thread_group.exec(_uninstall.bind(cave_id)) diff --git a/core/library_itch.gd b/core/library_itch.gd index 72f91b5..cc72a93 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -16,6 +16,9 @@ var _active_item: LibraryLaunchItem var _launch_watch := {} ## pids already diagnosed, so a failed game is only reported once. 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 := {} func _ready() -> void: @@ -35,16 +38,121 @@ func _ready() -> void: watch_timer.autostart = true watch_timer.timeout.connect(_check_failed_launches) add_child(watch_timer) + # Keep the has_update() flags fresh in the background (CheckUpdate hits the + # itch.io API, so it must never run on the UI thread or per-library-load). + var update_timer := Timer.new() + update_timer.wait_time = 600.0 + update_timer.autostart = true + update_timer.timeout.connect(_refresh_update_flags) + add_child(update_timer) + _refresh_update_flags.call_deferred() func get_library_launch_items() -> Array[LibraryLaunchItem]: return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary = {}) -> void: +## Every butlerd install location, surfaced as OGPU InstallLocation objects. +## OGPU opens the install-location picker (game_launch_menu.gd) whenever this +## returns at least one entry, so installs let the user pick where the game +## goes. Size info comes from butlerd's Install.Locations.List (bytes). +func get_available_install_locations(_item: LibraryLaunchItem = null) -> Array[Library.InstallLocation]: + var raw := await itch.get_install_locations() + var out: Array[Library.InstallLocation] = [] + for loc in raw: + var entry: Dictionary = loc + var location := Library.InstallLocation.new() + location.id = entry.get("id", "") + location.name = entry.get("path", "") + location.description = "itch.io install folder" + var size_info: Dictionary = entry.get("sizeInfo", {}) + var total: int = int(size_info.get("totalSize", 0)) + var free: int = int(size_info.get("freeSize", 0)) + if total > 0: + location.total_space_mb = total / (1024 * 1024) + if free > 0: + location.free_space_mb = free / (1024 * 1024) + out.append(location) + return out + + +## Surfaces an upload picker when the game has more than one compatible upload +## (different platform builds, demo vs full version, ...), so the user can +## choose which one to install. The picked upload's display name comes back +## through the options dict and is matched against Fetch.GameUploads in the +## client's install() call. +func get_install_options(item: LibraryLaunchItem) -> Array[Library.InstallOption]: + if item.installed: + return [] + var game: Dictionary = item.metadata.get("game", {}) + var game_id: int = int(game.get("id", 0)) + if game_id == 0: + return [] + var uploads: Array = await itch.get_compatible_uploads(game_id) + if uploads.size() <= 1: + return [] + var option := Library.InstallOption.new() + option.id = "upload" + option.name = "Version" + option.description = "Which upload of this game to install" + option.value_type = TYPE_STRING + var values := [] + for u in uploads: + var upload: Dictionary = u + var label: String = upload.get("displayName", "") + if label == "": + label = upload.get("filename", "") + if label != "": + values.append(label) + if values.size() <= 1: + return [] + option.values = values + return [option] + + +## Lifecycle hooks OGPU's LaunchManager invokes around a game's lifetime. This +## build of OGPU never calls get_app_lifecycle_hooks() (the AppLifecycleHook API +## is dead code in core), so the PRE_LAUNCH hook here is defensive: it re-applies +## the user-library path to cached items that predate it, in case launch time is +## ever wired up to the hook API. +func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]: + var hooks: Array[AppLifecycleHook] = [] + hooks.append(LaunchHook.new(AppLifecycleHook.TYPE.PRE_LAUNCH, _on_pre_launch)) + return hooks + + +## Defensive pre-launch cleanup: cached LibraryLaunchItems built before the +## user-library path existed won't carry LD_LIBRARY_PATH. Re-apply it so games +## that need it (e.g. Friday Night Funkin' + libvlc) always launch, whatever +## path the item took into the launch manager. +func _on_pre_launch(item: LibraryLaunchItem) -> void: + _apply_user_library_path(item) + + +## Wraps a callback into OGPU's AppLifecycleHook contract. +class LaunchHook extends AppLifecycleHook: + var _callback: Callable + + func _init(hook_type: AppLifecycleHook.TYPE, callback: Callable) -> void: + super(hook_type) + _callback = callback + + func execute(item: LibraryLaunchItem) -> void: + _callback.call(item) + + +func install_to(item: LibraryLaunchItem, location = null, options: Dictionary = {}) -> void: var game := (item.metadata.get("game", {}) as Dictionary) _active_item = item - var success: bool = await itch.install(game) + # Forward the user's install-location and install-option choices (upload, + # ...) to butlerd. Install.Location objects come from the picker OGPU opens + # when get_available_install_locations() returns more than one entry. + var opts := {} + if location != null and not (location.id as String).is_empty(): + opts["install_location_id"] = location.id + for key in options: + opts[key] = options[key] + var success: bool = await itch.install(game, "", opts) if success: await _refresh_installed_item(item, game) _active_item = null @@ -52,6 +160,7 @@ func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary logger.info("Install of '" + item.name + "' completed with status: " + str(success)) if success: _refresh_library_menu(item) + _refresh_update_flags.call_deferred() func update(item: LibraryLaunchItem) -> void: @@ -65,6 +174,7 @@ func update(item: LibraryLaunchItem) -> void: logger.info("Update of '" + item.name + "' completed with status: " + str(success)) if success: _refresh_library_menu(item) + _refresh_update_flags.call_deferred() ## OpenGamepadUI's library menu doesn't re-render after an install or @@ -191,21 +301,68 @@ 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) _refresh_library_menu(item) _restore_launch_focus() -## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it -## synchronously here. LibraryManager is expected to periodically call -## get_library_launch_items() again, which re-fetches from butlerd's cache. +## itch.io's update availability comes from butlerd's CheckUpdate call, which +## is async and hits the itch.io API, so it runs in the background and results +## are cached per cave id. LibraryManager/OGPU's core doesn't poll this today, +## but implementations that do will get a cheap, synchronous answer. func has_update(_item: LibraryLaunchItem) -> bool: - return false + var cave_id: String = _item.provider_app_id + if cave_id == "": + return false + return _update_flags.get(cave_id, false) as bool + + +## Moves an install to another location. butlerd has no move RPC (the official +## itch app does exactly this too), so it's uninstall + reinstall into the +## target location. Save files live outside the install folder, so nothing is +## lost in the move. +func move(item: LibraryLaunchItem, to_location: Library.InstallLocation) -> void: + var game := (item.metadata.get("game", {}) as Dictionary) + _active_item = item + var uninstall_ok: bool = await itch.uninstall(item.provider_app_id) + if not uninstall_ok: + _active_item = null + move_completed.emit(item, false) + return + item.provider_app_id = "" + item.installed = false + var install_ok: bool = await itch.install(game, "", {"install_location_id": to_location.id}) + if install_ok: + await _refresh_installed_item(item, game) + _active_item = null + move_completed.emit(item, install_ok) + if install_ok: + _refresh_library_menu(item) + _refresh_update_flags.call_deferred() + + +## Refresh the caveId -> update-available map from butlerd's CheckUpdate. Runs +## on the background thread (network + rate limits); pass no cave ids so it +## respects each cave's snooze setting, like the official itch.io app. +func _refresh_update_flags() -> void: + if not itch.is_logged_in: + return + var updates: Array = await itch.check_updates() + var flags := {} + for u in updates: + var update: Dictionary = u + var cave_id: String = update.get("caveId", "") + if cave_id != "": + flags[cave_id] = true + _update_flags = flags + logger.debug("Refreshed update flags for " + str(flags.size()) + " game(s)") func _on_logged_in(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void: if status != ItchClient.LOGIN_STATUS.OK: return logger.info("Logged in. Refreshing itch.io library.") + _refresh_update_flags.call_deferred() var items: Array = await _load_library(Cache.FLAGS.SAVE) for i in items: var item: LibraryLaunchItem = i diff --git a/plugin.json b/plugin.json index 5354611..25f3078 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.23", + "plugin.version": "0.1.24", "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",