itchio-opengamepadui-plugin/core/library_itch.gd
Jose Falanga 3473073582 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
2026-08-07 22:48:17 -03:00

406 lines
15 KiB
GDScript

extends Library
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
const _apps_cache_file: String = "apps.json"
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
## The item currently being installed/updated/uninstalled. OGPU's InstallManager
## only runs one install at a time, so a single slot is enough to map butlerd's
## install_progressed/app_* signals back to the LibraryLaunchItem.
var _active_item: LibraryLaunchItem
func _ready() -> void:
super()
library_id = "itch"
logger_name = "itch"
logger = Log.get_logger(logger_name, log_level)
logger.info("itch.io library loaded")
itch.logged_in.connect(_on_logged_in)
itch.install_progressed.connect(_on_install_progressed)
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:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var success: bool = await itch.install(game)
_active_item = null
install_completed.emit(item, success)
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
func update(item: LibraryLaunchItem) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var success: bool = await itch.install(game, item.provider_app_id)
_active_item = null
update_completed.emit(item, success)
logger.info("Update of '" + item.name + "' completed with status: " + str(success))
func uninstall(item: LibraryLaunchItem) -> void:
_active_item = item
var success: bool = await itch.uninstall(item.provider_app_id)
_active_item = null
uninstall_completed.emit(item, success)
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
## 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.
func has_update(_item: LibraryLaunchItem) -> bool:
return false
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.")
var items: Array = await _load_library(Cache.FLAGS.SAVE)
for i in items:
var item: LibraryLaunchItem = i
if not library_manager.has_app(item.name):
logger.debug("App '" + item.name + "' was not loaded. Reloading library.")
library_manager.reload_library()
return
## Forwards butlerd's install progress (current/total are percentage points,
## 0-100) to the base [Library] install_progressed contract (a fraction,
## 0.0-1.0) that OGPU's InstallManager and launch menu render as a progress
## bar.
func _on_install_progressed(_id: String, current: int, total: int) -> void:
if _active_item == null:
return
if total <= 0:
return
logger.info("Install progressing: " + str(current) + "/" + str(total))
install_progressed.emit(_active_item, float(current) / float(total))
## Returns true unless the "only show games available on this platform"
## filter is enabled and the game's platforms don't include the OS
## OpenGamepadUI is running on. Installed games always pass: they were
## installed on this machine, so they're runnable regardless of what
## platform info butlerd reported. Everything else needs an explicit
## platform match, so games with no platform info at all (e.g. HTML5) are
## hidden while the filter is on.
func _game_available_on_current_platform(game: Dictionary, installed := false) -> bool:
if not _filter_unsupported():
return true
if installed:
return true
var platforms: Dictionary = game.get("platforms", {})
if platforms.is_empty():
return false
var current := "windows"
if OS.get_name() == "Linux":
current = "linux"
elif OS.get_name() == "macOS":
current = "osx"
return not (platforms.get(current, "") as String).is_empty()
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.
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
if game.is_empty():
return null
if game.get("classification", "game") != "game":
return null
var game_id: int = game.get("id", 0)
var cave: Dictionary = caves_by_game_id.get(game_id, {})
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", "") if cave_valid else ""
item.name = game.get("title", "Unknown itch.io game")
item.tags = ["itch"]
item.categories = ["Game"]
item.installed = cave_valid
item.metadata = {"game": game}
if cave_valid:
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
var exe := _find_executable(install_folder)
item.command = exe
item.args = []
item.cwd = install_folder
return item
## Builds the full itch.io library: owned games merged with install state
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
## butlerd (and, transitively, the itch.io API) on every library refresh.
func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]:
if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file):
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
if json_items != null:
logger.info("itch.io apps found in cache. Using cache.")
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
items.append(item)
_queue_boxart(items)
return items
if not itch.is_logged_in:
logger.info("itch.io client is not logged in yet.")
return []
logger.info("Fetching itch.io library...")
var owned: Array = await itch.get_owned_games()
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
var game_id: int = cave.get("game", {}).get("id", 0)
caves_by_game_id[game_id] = cave
var items := [] as Array[LibraryLaunchItem]
var seen_game_ids := {}
for o in owned:
var owned_entry: Dictionary = o
var game: Dictionary = owned_entry.get("game", {})
var item: Variant = _make_item(game, caves_by_game_id)
if item == null:
continue
seen_game_ids[game.get("id", 0)] = true
items.append(item)
# Collection games aren't necessarily owned (e.g. a free game the user
# bookmarked), so merge them in too, deduped against owned games.
for g in collection_games:
var game: Dictionary = g
var game_id: int = game.get("id", 0)
if game_id in seen_game_ids:
continue
var item: Variant = _make_item(game, caves_by_game_id)
if item == null:
continue
seen_game_ids[game_id] = true
items.append(item)
if caching_flags & Cache.FLAGS.SAVE:
logger.debug("Saving itch.io apps to cache.")
var json_items := []
for i in items:
var item: LibraryLaunchItem = i
json_items.append(item.to_dict())
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
logger.warn("Unable to save itch.io apps cache")
_queue_boxart(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
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
## picks those files up by <game name>-<layout>.png, so games show their real
## cover instead of the placeholder. Covers are only downloaded once (the task
## bails out if a layout file already exists). For games with an animated GIF
## cover, itch.io provides a stillCoverUrl (a static frame, served as PNG) which
## we prefer, since Godot has no GIF decoder.
func _queue_boxart(items: Array) -> void:
for i in items:
var item: LibraryLaunchItem = i
var game: Dictionary = item.metadata.get("game", {})
var title: String = game.get("title", "")
var cover_url := _cover_url(game)
if title.is_empty() or cover_url.is_empty():
continue
if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])):
continue
itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0)
## Downloads the given game's cover (blocking, runs on the shared thread) and
## writes it to every boxart layout. itch.zone only serves one fixed landscape
## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a
## single image is reused for portrait, landscape, banner and logo.
func _ensure_boxart(game: Dictionary) -> void:
var title: String = game.get("title", "")
var cover_url := _cover_url(game)
if title.is_empty() or cover_url.is_empty():
return
var dir := _boxart_dir()
if FileAccess.file_exists("/".join([dir, title + "-portrait.png"])):
return
var body := _download_cover(cover_url)
if body.is_empty():
logger.warn("Unable to download cover art for '" + title + "'")
return
if not _is_raster_image(body):
# OGPU's local provider only loads png/jpg. If itch.io didn't give us a
# still for an animated cover, skip rather than write an unloadable file.
logger.warn("Skipping unsupported cover format for '" + title + "'")
return
DirAccess.make_dir_recursive_absolute(dir)
for layout in ["portrait", "landscape", "banner", "logo"]:
var path := "/".join([dir, title + "-" + layout + ".png"])
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_buffer(body)
file.close()
logger.info("Downloaded boxart for '" + title + "'")
func _boxart_dir() -> String:
return ProjectSettings.globalize_path("user://boxart/local")
## itch.io exposes stillCoverUrl for games whose cover is an animated GIF: a
## static frame that the CDN serves as PNG. Prefer it over coverUrl so those
## games still get art (Godot can't decode GIFs at runtime).
func _cover_url(game: Dictionary) -> String:
var still: String = game.get("stillCoverUrl", "")
if not still.is_empty():
return still
return game.get("coverUrl", "")
## Blocking HTTPS GET of a single cover image, for use on a background thread.
func _download_cover(url: String) -> PackedByteArray:
var parts := url.split("/")
var use_tls := parts[0] == "https:"
var http := HTTPClient.new()
var err: int = http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
if err != OK:
return PackedByteArray()
var deadline := Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(50)
if http.get_status() != HTTPClient.STATUS_CONNECTED:
http.close()
return PackedByteArray()
http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray())
deadline = Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_REQUESTING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
var body := PackedByteArray()
while http.get_status() == HTTPClient.STATUS_BODY:
http.poll()
var chunk: PackedByteArray = http.read_response_body_chunk()
if chunk.is_empty():
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
continue
body.append_array(chunk)
var code: int = http.get_response_code()
http.close()
if code != 200:
return PackedByteArray()
return body
## Returns true if the bytes look like a PNG or JPEG (the only formats OGPU's
## local boxart provider can load).
func _is_raster_image(body: PackedByteArray) -> bool:
if body.size() < 12:
return false
var png_magic := [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
var is_png := true
for i in png_magic.size():
if body[i] != png_magic[i]:
is_png = false
break
if is_png:
return true
return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff
## Best-effort discovery of the game's launch executable inside its install
## folder. butlerd doesn't hand back a ready-to-exec command the way a
## "gog://" or "steam://" URI does, so we look at what's actually on disk.
## TODO: parse .itch/receipt.json.gz for the authoritative launch target
## and any declared manifest Actions instead of guessing.
func _find_executable(install_folder: String) -> String:
if install_folder == "":
return ""
var out := []
OS.execute("bash", [
"-c",
"find " + install_folder.c_escape() + " -maxdepth 2 -type f -executable | head -n 1"
], out)
if out.is_empty():
return ""
return (out[0] as String).strip_edges()