diff --git a/Makefile b/Makefile index 52acd14..de217ad 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ $(PLUGINS_DIR)/$(PLUGIN_ID): $(OPENGAMEPAD_UI_BASE) export_preset: $(OPENGAMEPAD_UI_BASE) ## Configure plugin export preset $(eval LAST_PRESET=$(shell grep -oEi '^\[preset\.([0-9]+)]' $(EXPORT_PRESETS) | tail -n 1)) $(eval LAST_PRESET_NUM=$(shell echo "$(LAST_PRESET)" | grep -oE '([0-9]+)')) - $(eval PRESET_NUM=$(shell echo "$(LAST_PRESET_NUM)+1" | bc)) + $(eval PRESET_NUM=$(shell echo "$$(($(LAST_PRESET_NUM)+1))")) @if grep 'name="$(PLUGIN_NAME)"' $(EXPORT_PRESETS) > /dev/null; then \ echo "Export preset already configured"; \ else \ diff --git a/core/install_location.gd b/core/install_location.gd deleted file mode 100644 index 4049927..0000000 --- a/core/install_location.gd +++ /dev/null @@ -1,8 +0,0 @@ -## Stub mirroring OpenGamepadUI's nested Library.InstallLocation class. -## Required only so the plugin's GDScript type annotation `InstallLocation` -## compiles against a global class and resolves at runtime from the plugin -## pack (OGPU has no top-level InstallLocation global class). -class_name InstallLocation - -var id: String = "" -var path: String = "" diff --git a/core/itch_client.gd b/core/itch_client.gd index 68ed7b4..128462e 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -20,6 +20,14 @@ const broth_base := "https://broth.itch.zone/butler" ## of user://plugins// because OGPU wipes that directory (and everything ## inside it) whenever the plugin is updated or re-extracted. const butler_dir := "user://butler" +## Where installed games live. This is intentionally NOT user://plugins/itch: +## OGPU moves the entire extracted plugin directory (plugins//) to the +## trash on every plugin update, so any install kept under it silently loses +## its files on update while butler.db keeps the (now orphaned) cave, which +## used to break both fresh installs ("That upload is already installed!") +## and launches. Keeping games next to butler.db (user://butler) means they +## survive plugin updates untouched. +const games_dir := "user://butler/games" const CACHE_DIR := "itch" enum STATE { @@ -476,21 +484,31 @@ func _get_caves() -> Array: ## Ensures at least one install location exists and returns its ID, creating -## a default one under the plugin's data directory on first run. +## one at the persistent user://butler/games path on first run. Older builds +## created the location under user://plugins/itch/games, which OGPU trashes on +## every plugin update; existing locations pointing there are left alone (their +## orphaned caves are cleaned up by [method _resolve_cave]) but never reused. func _ensure_install_location() -> String: if _install_location_id != "": return _install_location_id + var target_path := ProjectSettings.globalize_path(games_dir) var res := await _rpc_call("Install.Locations.List", {}) var locations: Array = res.get("installLocations", []) - if locations.size() > 0: - _install_location_id = locations[0].get("id", "") - return _install_location_id + for loc in locations: + var location: Dictionary = loc + if location.get("path", "") == target_path: + _install_location_id = location.get("id", "") + return _install_location_id - var path := ProjectSettings.globalize_path("user://plugins/itch/games") - DirAccess.make_dir_recursive_absolute(path) - var add_res := await _rpc_call("Install.Locations.Add", {"id": "default", "path": path}) - _install_location_id = add_res.get("installLocation", {}).get("id", "default") + DirAccess.make_dir_recursive_absolute(target_path) + # Let butlerd generate the id so we never collide with a stale location + # that happens to carry the same id under a different path. + var add_res := await _rpc_call("Install.Locations.Add", {"path": target_path}) + if "error" in add_res: + logger.error("Install.Locations.Add failed: " + str(add_res["error"])) + return "" + _install_location_id = add_res.get("installLocation", {}).get("id", "") return _install_location_id @@ -500,8 +518,48 @@ func install(game: Dictionary, cave_id: String = "") -> bool: return await thread_group.exec(_install.bind(game, cave_id)) +## Reconciles an install/update request against what butlerd actually has +## installed. OGPU moves the entire extracted plugin directory (including any +## games previously installed under user://plugins/itch/games) to the trash +## whenever the plugin is updated, leaving "orphan" caves in butler.db that +## point at folders which no longer exist. Those caves make a fresh install +## fail with butlerd's "That upload is already installed!" error while the +## game can't actually be launched, so we detect them here and uninstall them +## first (the folder is already gone, so nothing is lost). +## +## Returns the cave id to pass to Install.Queue: a valid existing cave when +## one is found (so the request becomes an update), or an empty string when +## the game needs a fresh install. +func _resolve_cave(game: Dictionary, cave_id: String) -> String: + var game_id: int = game.get("id", 0) + var res := await _rpc_call("Fetch.Caves", {"filters": {"gameId": game_id}}) + if "error" in res: + logger.warn("Fetch.Caves failed while reconciling game " + str(game_id) + ": " + str(res["error"])) + return cave_id + + var matched: Array = [] + for c in res.get("items", []): + var cave: Dictionary = c + if cave_id == "" or cave.get("id", "") == cave_id: + matched.append(cave) + if matched.is_empty(): + return cave_id + + for cave in matched: + var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") + if DirAccess.dir_exists_absolute(install_folder): + return cave.get("id", cave_id) + + for cave in matched: + var orphan_id: String = cave.get("id", "") + logger.info("Uninstalling orphaned cave " + orphan_id + " for game " + str(game_id) + " (install folder is missing)") + await _rpc_call("Uninstall.Perform", {"caveId": orphan_id}) + return "" + + func _install(game: Dictionary, cave_id: String) -> 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} @@ -598,7 +656,7 @@ func launch(cave_id: String) -> void: func _launch(cave_id: String) -> void: - var prereqs_dir := ProjectSettings.globalize_path("user://plugins/itch/prereqs") + var prereqs_dir := ProjectSettings.globalize_path("user://butler/prereqs") DirAccess.make_dir_recursive_absolute(prereqs_dir) # `Launch` may send interactive requests mid-call (PickManifestAction, diff --git a/core/library_itch.gd b/core/library_itch.gd index e74df03..949d846 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -27,7 +27,7 @@ func get_library_launch_items() -> Array[LibraryLaunchItem]: return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -func install_to(item: LibraryLaunchItem, _location: InstallLocation = null, _options: Dictionary = {}) -> void: +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) @@ -113,6 +113,21 @@ func _filter_unsupported() -> bool: return settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool +## A cave is only usable when its install folder actually exists on disk. +## OGPU moves the whole extracted plugin directory (where older builds kept +## installed games) to the trash on every plugin update, so butler.db can hold +## "orphan" caves whose files are gone. Those must not show as installed: the +## user can't launch them, and Install.Queue would reject a fresh install with +## "That upload is already installed!". +func _cave_has_files(cave: Dictionary) -> bool: + if cave.is_empty(): + return false + var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") + if install_folder == "": + return false + return DirAccess.dir_exists_absolute(install_folder) + + ## Builds a LibraryLaunchItem for a game, or null when the entry isn't an ## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...) ## or is filtered out by the "available on this platform" setting. @@ -123,18 +138,19 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: return null var game_id: int = game.get("id", 0) var cave: Dictionary = caves_by_game_id.get(game_id, {}) - if not _game_available_on_current_platform(game, not cave.is_empty()): + var cave_valid: bool = _cave_has_files(cave) + if not _game_available_on_current_platform(game, cave_valid): return null var item := LibraryLaunchItem.new() - item.provider_app_id = cave.get("id", "") + item.provider_app_id = cave.get("id", "") if cave_valid else "" item.name = game.get("title", "Unknown itch.io game") item.tags = ["itch"] item.categories = ["Game"] - item.installed = not cave.is_empty() + item.installed = cave_valid item.metadata = {"game": game} - if not cave.is_empty(): + if cave_valid: var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") var exe := _find_executable(install_folder) item.command = exe @@ -154,6 +170,14 @@ 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) + # 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 = "" var game: Dictionary = item.metadata.get("game", {}) if not _game_available_on_current_platform(game, item.installed): continue @@ -170,6 +194,18 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> var caves: Array = 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. + var orphan_cave_ids := [] + for c in caves: + var cave: Dictionary = c + if not _cave_has_files(cave): + orphan_cave_ids.append(cave.get("id", "")) + if not orphan_cave_ids.is_empty(): + logger.info("Found " + str(orphan_cave_ids.size()) + " orphaned cave(s) with missing files. Cleaning up.") + _cleanup_orphan_caves(orphan_cave_ids) + var caves_by_game_id := {} for c in caves: var cave: Dictionary = c @@ -181,7 +217,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> for o in owned: var owned_entry: Dictionary = o var game: Dictionary = owned_entry.get("game", {}) - var item := _make_item(game, caves_by_game_id) + var item: Variant = _make_item(game, caves_by_game_id) if item == null: continue seen_game_ids[game.get("id", 0)] = true @@ -194,7 +230,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> var game_id: int = game.get("id", 0) if game_id in seen_game_ids: continue - var item := _make_item(game, caves_by_game_id) + var item: Variant = _make_item(game, caves_by_game_id) if item == null: continue seen_game_ids[game_id] = true @@ -214,6 +250,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> return items +## Fire-and-forget removal of butler.db caves whose install folders no longer +## exist. Runs sequentially (one butlerd uninstall at a time) so the shared +## thread's one-shot queue never holds concurrent RPCs. +func _cleanup_orphan_caves(cave_ids: Array) -> void: + for cave_id in cave_ids: + 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