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

@ -20,6 +20,14 @@ const broth_base := "https://broth.itch.zone/butler"
## of user://plugins/<id>/ 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/<id>/) 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,