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:
parent
7d5dedc0b9
commit
3473073582
4 changed files with 119 additions and 25 deletions
2
Makefile
2
Makefile
|
|
@ -60,7 +60,7 @@ $(PLUGINS_DIR)/$(PLUGIN_ID): $(OPENGAMEPAD_UI_BASE)
|
||||||
export_preset: $(OPENGAMEPAD_UI_BASE) ## Configure plugin export preset
|
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=$(shell grep -oEi '^\[preset\.([0-9]+)]' $(EXPORT_PRESETS) | tail -n 1))
|
||||||
$(eval LAST_PRESET_NUM=$(shell echo "$(LAST_PRESET)" | grep -oE '([0-9]+)'))
|
$(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 \
|
@if grep 'name="$(PLUGIN_NAME)"' $(EXPORT_PRESETS) > /dev/null; then \
|
||||||
echo "Export preset already configured"; \
|
echo "Export preset already configured"; \
|
||||||
else \
|
else \
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
|
||||||
|
|
@ -20,6 +20,14 @@ const broth_base := "https://broth.itch.zone/butler"
|
||||||
## of user://plugins/<id>/ because OGPU wipes that directory (and everything
|
## of user://plugins/<id>/ because OGPU wipes that directory (and everything
|
||||||
## inside it) whenever the plugin is updated or re-extracted.
|
## inside it) whenever the plugin is updated or re-extracted.
|
||||||
const butler_dir := "user://butler"
|
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"
|
const CACHE_DIR := "itch"
|
||||||
|
|
||||||
enum STATE {
|
enum STATE {
|
||||||
|
|
@ -476,21 +484,31 @@ func _get_caves() -> Array:
|
||||||
|
|
||||||
|
|
||||||
## Ensures at least one install location exists and returns its ID, creating
|
## 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:
|
func _ensure_install_location() -> String:
|
||||||
if _install_location_id != "":
|
if _install_location_id != "":
|
||||||
return _install_location_id
|
return _install_location_id
|
||||||
|
|
||||||
|
var target_path := ProjectSettings.globalize_path(games_dir)
|
||||||
var res := await _rpc_call("Install.Locations.List", {})
|
var res := await _rpc_call("Install.Locations.List", {})
|
||||||
var locations: Array = res.get("installLocations", [])
|
var locations: Array = res.get("installLocations", [])
|
||||||
if locations.size() > 0:
|
for loc in locations:
|
||||||
_install_location_id = locations[0].get("id", "")
|
var location: Dictionary = loc
|
||||||
|
if location.get("path", "") == target_path:
|
||||||
|
_install_location_id = location.get("id", "")
|
||||||
return _install_location_id
|
return _install_location_id
|
||||||
|
|
||||||
var path := ProjectSettings.globalize_path("user://plugins/itch/games")
|
DirAccess.make_dir_recursive_absolute(target_path)
|
||||||
DirAccess.make_dir_recursive_absolute(path)
|
# Let butlerd generate the id so we never collide with a stale location
|
||||||
var add_res := await _rpc_call("Install.Locations.Add", {"id": "default", "path": path})
|
# that happens to carry the same id under a different path.
|
||||||
_install_location_id = add_res.get("installLocation", {}).get("id", "default")
|
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
|
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))
|
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:
|
func _install(game: Dictionary, cave_id: String) -> bool:
|
||||||
var game_id: int = game.get("id", 0)
|
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 reason := "update" if cave_id != "" else "install"
|
||||||
|
|
||||||
var uploads_params := {"gameId": game_id, "compatible": true}
|
var uploads_params := {"gameId": game_id, "compatible": true}
|
||||||
|
|
@ -598,7 +656,7 @@ func launch(cave_id: String) -> void:
|
||||||
|
|
||||||
|
|
||||||
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)
|
DirAccess.make_dir_recursive_absolute(prereqs_dir)
|
||||||
|
|
||||||
# `Launch` may send interactive requests mid-call (PickManifestAction,
|
# `Launch` may send interactive requests mid-call (PickManifestAction,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func get_library_launch_items() -> Array[LibraryLaunchItem]:
|
||||||
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
|
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)
|
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)
|
||||||
|
|
@ -113,6 +113,21 @@ func _filter_unsupported() -> bool:
|
||||||
return settings_manager.get_value("plugin.itch", "filter_unsupported", true) as 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
|
## Builds a LibraryLaunchItem for a game, or null when the entry isn't an
|
||||||
## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...)
|
## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...)
|
||||||
## or is filtered out by the "available on this platform" setting.
|
## 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
|
return null
|
||||||
var game_id: int = game.get("id", 0)
|
var game_id: 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, {})
|
||||||
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
|
return null
|
||||||
|
|
||||||
var item := LibraryLaunchItem.new()
|
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.name = game.get("title", "Unknown itch.io game")
|
||||||
item.tags = ["itch"]
|
item.tags = ["itch"]
|
||||||
item.categories = ["Game"]
|
item.categories = ["Game"]
|
||||||
item.installed = not cave.is_empty()
|
item.installed = cave_valid
|
||||||
item.metadata = {"game": game}
|
item.metadata = {"game": game}
|
||||||
|
|
||||||
if not cave.is_empty():
|
if cave_valid:
|
||||||
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
||||||
var exe := _find_executable(install_folder)
|
var exe := _find_executable(install_folder)
|
||||||
item.command = exe
|
item.command = exe
|
||||||
|
|
@ -154,6 +170,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
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
|
||||||
|
# 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", {})
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
if not _game_available_on_current_platform(game, item.installed):
|
if not _game_available_on_current_platform(game, item.installed):
|
||||||
continue
|
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 caves: Array = 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
|
||||||
|
# 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 := {}
|
var caves_by_game_id := {}
|
||||||
for c in caves:
|
for c in caves:
|
||||||
var cave: Dictionary = c
|
var cave: Dictionary = c
|
||||||
|
|
@ -181,7 +217,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
for o in owned:
|
for o in owned:
|
||||||
var owned_entry: Dictionary = o
|
var owned_entry: Dictionary = o
|
||||||
var game: Dictionary = owned_entry.get("game", {})
|
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:
|
if item == null:
|
||||||
continue
|
continue
|
||||||
seen_game_ids[game.get("id", 0)] = true
|
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)
|
var game_id: int = game.get("id", 0)
|
||||||
if game_id in seen_game_ids:
|
if game_id in seen_game_ids:
|
||||||
continue
|
continue
|
||||||
var item := _make_item(game, caves_by_game_id)
|
var item: Variant = _make_item(game, caves_by_game_id)
|
||||||
if item == null:
|
if item == null:
|
||||||
continue
|
continue
|
||||||
seen_game_ids[game_id] = true
|
seen_game_ids[game_id] = true
|
||||||
|
|
@ -214,6 +250,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
return items
|
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
|
## Queues background downloads of each game's cover art into OGPU's local
|
||||||
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
|
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
|
||||||
## picks those files up by <game name>-<layout>.png, so games show their real
|
## picks those files up by <game name>-<layout>.png, so games show their real
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue