Install games to persistent user://butler/games and reconcile orphaned caves

OGPU moves the whole extracted plugin directory (plugins/itch/) to trash
on every plugin update, so keeping installed games under
user://plugins/itch/games meant game files silently vanished on update
while butler.db kept the (now orphaned) cave. That made fresh installs
fail with butlerd's "That upload is already installed!" and broke
launches for games whose files were gone.

- New installs go to user://butler/games (persistent, next to butler.db)
  via a reworked _ensure_install_location; prereqs move there too
- _install/_update reconcile the cave first: a cave whose install folder
  no longer exists is uninstalled before a fresh (re)install, and a valid
  existing cave turns the request into an update instead
- _make_item treats missing-folder caves as not installed, and the cache
  path re-checks cwd so stale caches don't render a broken Play button
- orphaned caves are cleaned out of butler.db in the background on library
  load
- drop the plugin's global InstallLocation class stub: it collided with
  OGPU core's nested Library.InstallLocation and broke compilation on a
  cold .godot cache (the unused install_to arg is now untyped)
- Makefile: compute preset number with shell arithmetic instead of bc
This commit is contained in:
Jose Falanga 2026-08-07 22:48:17 -03:00
parent 7d5dedc0b9
commit 3473073582
4 changed files with 119 additions and 25 deletions

View file

@ -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 <game name>-<layout>.png, so games show their real