itchio-opengamepadui-plugin/core/library_itch.gd
Jose Falanga bbc9b248bb
Some checks failed
Build plugin / build (push) Failing after 10s
Fix install of bought games and launch of games with spaces in paths
Three bugs, all seen on Bubble Tower (itch.io game 4845491):

- Installs of bought games failed with "you must buy this game to download".
  butlerd only learns itch.io download keys from Fetch.ProfileOwnedKeys,
  which the library skips when "show_purchases" is off (the listing then
  comes from collections, so owned games still show up). butlerd queued
  the install without credentials and itch.io rejected the build request.
  Install.Queue is now retried once after a fresh owned-keys sync when
  itch.io answers with that error.

- The install-options upload dropdown never applied the user's pick: it
  offers "<name>  (<size>)" while _pick_upload compared against the bare
  displayName/filename, so every multi-upload game silently fell back to
  the default upload. The label format is now shared through
  UPLOAD_SIZE_SEPARATOR and the name prefix counts as a match.

- Games whose receipt carries no launch block were resolved by scanning
  for the largest executable and splitting "size path" on a space, which
  truncated the path of any file with a space in its name (Unity's
  "unity default resources") and pointed the launch at a file that does
  not exist. Sizes and paths are now tab-separated, and Unity's data
  files are skipped.
2026-10-15 11:25:05 -03:00

970 lines
39 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
## pid -> launch time (msec) for itch.io games being watched for launch failure.
var _launch_watch := {}
## pids already diagnosed, so a failed game is only reported once.
var _diagnosed_pids := {}
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
## background by CheckUpdate so has_update() stays cheap and synchronous.
var _update_flags := {}
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)
itch.install_failed.connect(_on_install_failed)
# Detect itch.io games that die before creating a window (a missing shared
# library, a broken install, ...) and surface a clear, actionable error
# instead of leaving the user stuck on a black in-game screen.
if load("res://core/global/launch_manager.tres") != null:
var watch_timer := Timer.new()
watch_timer.wait_time = 2.0
watch_timer.autostart = true
watch_timer.timeout.connect(_check_failed_launches)
add_child(watch_timer)
# Keep the has_update() flags fresh in the background (CheckUpdate hits the
# itch.io API, so it must never run on the UI thread or per-library-load).
var update_timer := Timer.new()
update_timer.wait_time = 600.0
update_timer.autostart = true
update_timer.timeout.connect(_refresh_update_flags)
add_child(update_timer)
_refresh_update_flags.call_deferred()
func get_library_launch_items() -> Array[LibraryLaunchItem]:
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
## Every butlerd install location, surfaced as OGPU InstallLocation objects.
## OGPU opens the install-location picker (game_launch_menu.gd) whenever this
## returns at least one entry, so installs let the user pick where the game
## goes. Size info comes from butlerd's Install.Locations.List (bytes).
func get_available_install_locations(_item: LibraryLaunchItem = null) -> Array[Library.InstallLocation]:
var raw := await itch.get_install_locations()
var out: Array[Library.InstallLocation] = []
for loc in raw:
var entry: Dictionary = loc
var location := Library.InstallLocation.new()
location.id = entry.get("id", "")
location.name = entry.get("path", "")
location.description = "itch.io install folder"
var size_info: Dictionary = entry.get("sizeInfo", {})
var total: int = int(size_info.get("totalSize", 0))
var free: int = int(size_info.get("freeSize", 0))
if total > 0:
location.total_space_mb = total / (1024 * 1024)
if free > 0:
location.free_space_mb = free / (1024 * 1024)
out.append(location)
return out
## Surfaces an upload picker when the game has more than one compatible upload
## (different platform builds, demo vs full version, ...), so the user can
## choose which one to install. The picked upload's display name comes back
## through the options dict and is matched against Fetch.GameUploads in the
## client's install() call.
func get_install_options(item: LibraryLaunchItem) -> Array[Library.InstallOption]:
if item.installed:
return []
var game: Dictionary = item.metadata.get("game", {})
var game_id: int = int(game.get("id", 0))
if game_id == 0:
return []
var uploads: Array = await itch.get_compatible_uploads(game_id)
if uploads.size() <= 1:
return []
var option := Library.InstallOption.new()
option.id = "upload"
option.name = "Version"
option.description = "Which upload of this game to install"
option.value_type = TYPE_STRING
var values := []
for u in uploads:
var upload: Dictionary = u
var label: String = upload.get("displayName", "")
if label == "":
label = upload.get("filename", "")
if label != "":
var size: int = int(upload.get("size", 0))
values.append(label + ItchClient.UPLOAD_SIZE_SEPARATOR + _format_bytes(size) + ")")
if values.size() <= 1:
return []
option.values = values
return [option]
## Lifecycle hooks OGPU's LaunchManager invokes around a game's lifetime. This
## build of OGPU never calls get_app_lifecycle_hooks() (the AppLifecycleHook API
## is dead code in core), so the PRE_LAUNCH hook here is defensive: it re-applies
## the user-library path to cached items that predate it, in case launch time is
## ever wired up to the hook API.
func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]:
var hooks: Array[AppLifecycleHook] = []
hooks.append(LaunchHook.new(AppLifecycleHook.TYPE.PRE_LAUNCH, _on_pre_launch))
return hooks
## Defensive pre-launch cleanup: cached LibraryLaunchItems built before the
## user-library path existed won't carry LD_LIBRARY_PATH. Re-apply it so games
## that need it (e.g. Friday Night Funkin' + libvlc) always launch, whatever
## path the item took into the launch manager.
func _on_pre_launch(item: LibraryLaunchItem) -> void:
_apply_user_library_path(item)
## Wraps a callback into OGPU's AppLifecycleHook contract.
class LaunchHook extends AppLifecycleHook:
var _callback: Callable
func _init(hook_type: AppLifecycleHook.TYPE, callback: Callable) -> void:
super(hook_type)
_callback = callback
func execute(item: LibraryLaunchItem) -> void:
_callback.call(item)
func install_to(item: LibraryLaunchItem, location = null, options: Dictionary = {}) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
# Forward the user's install-location and install-option choices (upload,
# ...) to butlerd. Install.Location objects come from the picker OGPU opens
# when get_available_install_locations() returns more than one entry.
var opts := {}
if location != null and not (location.id as String).is_empty():
opts["install_location_id"] = location.id
for key in options:
opts[key] = options[key]
var success: bool = await itch.install(game, "", opts)
if success:
await _refresh_installed_item(item, game)
_active_item = null
install_completed.emit(item, success)
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
if success:
_refresh_library_menu(item)
_refresh_update_flags.call_deferred()
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)
if success:
await _refresh_installed_item(item, game)
_active_item = null
update_completed.emit(item, success)
logger.info("Update of '" + item.name + "' completed with status: " + str(success))
if success:
_refresh_library_menu(item)
_refresh_update_flags.call_deferred()
## OpenGamepadUI's library menu doesn't re-render after an install or
## uninstall (the core install handler is a no-op upstream), so the tabs end
## up showing stale install state. Signal the menu to queue a refresh.
##
## The app must NOT be dropped and re-added here: remove_library_launch_item()
## erases the launch item from the existing LibraryItem and (when empty) the
## whole app from the manager, so the next add builds a brand new LibraryItem.
## The launch page and game settings hold a reference to the *old* item in
## their state data, and once its launch_items is emptied the provider dropdown
## in game_launch_settings.gd ends up with zero entries and segfaults on
## select(). Patching the same instance in place keeps those references valid.
func _refresh_library_menu(item: LibraryLaunchItem) -> void:
var library_manager := load("res://core/global/library_manager.tres") as LibraryManager
if library_manager == null:
return
if not library_manager.has_app(item.name):
library_manager.add_library_launch_item("itch", item)
return
var app := library_manager.get_app_by_name(item.name)
if app.get_launch_item("itch") == null:
item._provider_id = "itch"
app.launch_items.push_back(item)
# library_item_added/library_item_removed both just queue a menu refresh, so
# a single emit re-renders the grids with the item's current install state.
library_manager.library_item_added.emit(app)
## OpenGamepadUI hides the Uninstall button once the item is no longer
## installed. Godot releases focus entirely when the focused control becomes
## hidden, so the game's launch page ends up unnavigable (D-pad dead) until the
## user presses the guide button. Hand focus back to the Play/Install button.
func _restore_launch_focus() -> void:
logger.info("Restore focus: restoring launch page focus after uninstall.")
await get_tree().process_frame
await get_tree().process_frame
if not is_inside_tree():
logger.warn("Restore focus: library not in tree.")
return
var root := get_tree().root
if root == null:
logger.warn("Restore focus: no tree root.")
return
var grabbed := false
for c in root.find_children("LaunchButton", "", true, false):
if c is Control and c.is_visible_in_tree():
logger.info("Restore focus: grabbing focus on " + str(c.get_path()))
(c as Control).grab_focus.call_deferred()
grabbed = true
break
if not grabbed:
logger.warn("Restore focus: no visible LaunchButton found.")
return
await get_tree().process_frame
var owner := root.gui_get_focus_owner()
logger.info("Restore focus: focus owner after grab: " + (owner.get_path() if owner != null else "null"))
## User-level shared libraries for itch games, e.g. libvlc.so.5 for games that
## link against it. Lives under the persistent OGPU data dir so it survives
## plugin updates and OS re-images, and is injected into the launch environment
## via LD_LIBRARY_PATH.
func _user_lib_dir() -> String:
var dir := _lib_hint()
if DirAccess.dir_exists_absolute(dir):
return dir
return ""
## The path users drop missing shared libraries into (see _user_lib_dir).
func _lib_hint() -> String:
return OS.get_environment("HOME") + "/.local/share/opengamepadui/lib"
## User-level runtime binaries for itch games (the LOVE runtime, a JRE, ...),
## e.g. ~/.local/share/opengamepadui/libexec. Lives under the persistent OGPU
## data dir so it survives plugin updates and OS re-images. Drop `love` and/or
## `java` (or symlinks to them) here.
func _runtime_dir() -> String:
return OS.get_environment("HOME") + "/.local/share/opengamepadui/libexec"
func _apply_user_library_path(item: LibraryLaunchItem) -> void:
var lib_dir := _user_lib_dir()
if lib_dir == "":
return
var existing: Variant = item.env.get("LD_LIBRARY_PATH", "")
if existing is String and not (existing as String).is_empty():
for entry in (existing as String).split(":", false):
if entry == lib_dir:
return
item.env["LD_LIBRARY_PATH"] = lib_dir + ":" + (existing as String)
else:
item.env["LD_LIBRARY_PATH"] = lib_dir
## The LibraryLaunchItem the UI holds was built from a cached library listing
## that can be stale (old install folder, empty or wrong command). After a
## successful install or update, patch the SAME item instance in place so the
## Play button launches the freshly installed binary right away.
func _refresh_installed_item(item: LibraryLaunchItem, game: Dictionary) -> void:
var game_id: int = int(game.get("id", 0))
var caves: Variant = await itch.get_caves()
if caves == null:
logger.warn("Could not fetch caves to refresh freshly installed item '" + item.name + "'")
return
for c in caves:
var cave: Dictionary = c
if int(cave.get("game", {}).get("id", 0)) != game_id:
continue
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
if install_folder == "" or not DirAccess.dir_exists_absolute(install_folder):
continue
var launch := _resolve_launch(install_folder)
item.installed = true
item.provider_app_id = cave.get("id", "")
item.cwd = install_folder
item.command = launch.get("command", "")
item.args = launch.get("args", [])
_apply_user_library_path(item)
logger.info("Refreshed installed item '" + item.name + "' to run: " + item.command)
return
logger.warn("Could not locate cave for freshly installed game id " + str(game_id))
func uninstall(item: LibraryLaunchItem) -> void:
var cave_id := item.provider_app_id
_active_item = item
var success: bool = await itch.uninstall(item.provider_app_id)
if success:
item.installed = false
item.command = ""
item.args = []
item.cwd = ""
item.provider_app_id = ""
_active_item = null
uninstall_completed.emit(item, success)
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
if success:
_update_flags.erase(cave_id)
_refresh_library_menu(item)
_restore_launch_focus()
## itch.io's update availability comes from butlerd's CheckUpdate call, which
## is async and hits the itch.io API, so it runs in the background and results
## are cached per cave id. LibraryManager/OGPU's core doesn't poll this today,
## but implementations that do will get a cheap, synchronous answer.
func has_update(_item: LibraryLaunchItem) -> bool:
var cave_id: String = _item.provider_app_id
if cave_id == "":
return false
return _update_flags.get(cave_id, false) as bool
## Moves an install to another location. butlerd has no move RPC (the official
## itch app does exactly this too), so it's uninstall + reinstall into the
## target location. Save files live outside the install folder, so nothing is
## lost in the move.
func move(item: LibraryLaunchItem, to_location: Library.InstallLocation) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var uninstall_ok: bool = await itch.uninstall(item.provider_app_id)
if not uninstall_ok:
_active_item = null
move_completed.emit(item, false)
return
item.provider_app_id = ""
item.installed = false
var install_ok: bool = await itch.install(game, "", {"install_location_id": to_location.id})
if install_ok:
await _refresh_installed_item(item, game)
_active_item = null
move_completed.emit(item, install_ok)
if install_ok:
_refresh_library_menu(item)
_refresh_update_flags.call_deferred()
## Refresh the caveId -> update-available map from butlerd's CheckUpdate. Runs
## on the background thread (network + rate limits); pass no cave ids so it
## respects each cave's snooze setting, like the official itch.io app.
func _refresh_update_flags() -> void:
if not itch.is_logged_in:
return
var updates: Array = await itch.check_updates()
var flags := {}
for u in updates:
var update: Dictionary = u
var cave_id: String = update.get("caveId", "")
if cave_id != "":
flags[cave_id] = true
_update_flags = flags
logger.debug("Refreshed update flags for " + str(flags.size()) + " game(s)")
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.")
_refresh_update_flags.call_deferred()
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 = 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 launch := _resolve_launch(install_folder)
item.command = launch.get("command", "")
item.args = launch.get("args", [])
item.cwd = install_folder
_apply_user_library_path(item)
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.")
# The cached install folder (and the launch command resolved from it)
# can be stale: older builds kept games under user://plugins/itch/games,
# which OGPU trashes on every plugin update, and newer builds
# re-installed everything under the persistent butler/games location.
# Reconcile each cached entry against butlerd's live cave list so the
# install state, install folder and command all point at reality.
# `get_caves()` returns null on error, [] when nothing is installed.
var caves: Variant = await itch.get_caves()
# Butlerd is spawned asynchronously and the first library loads can
# beat it to the punch, getting an empty cave list and wrongly
# uninstalling every cached game. When the cache claims installs,
# give butlerd a moment to come up. Once butlerd is connected, an
# empty list is the truth (e.g. the user uninstalled everything).
var cached_installed := false
for i in json_items:
if (i as Dictionary).get("installed", false):
cached_installed = true
break
var attempts := 0
while cached_installed and attempts < 10 and (caves == null or (caves.is_empty() and itch.state != ItchClient.STATE.CONNECTED)):
await get_tree().create_timer(0.5).timeout
caves = await itch.get_caves()
attempts += 1
var caves_by_game_id := {}
if caves != null:
for c in caves:
var cave: Dictionary = c
# Live cave ids are ints, but ids that round-tripped through the
# JSON cache come back as floats (Godot parses every number as
# float), so normalize the lookup key to int on both sides.
caves_by_game_id[int(cave.get("game", {}).get("id", 0))] = cave
var items := [] as Array[LibraryLaunchItem]
for i in json_items:
var item := LibraryLaunchItem.from_dict(i)
var game: Dictionary = item.metadata.get("game", {})
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
if not cave.is_empty() and _cave_has_files(cave):
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
if not item.installed or item.cwd != install_folder:
logger.info("Refreshing install of '" + item.name + "' to: " + install_folder)
item.installed = true
item.provider_app_id = cave.get("id", "")
item.cwd = install_folder
var launch := _resolve_launch(install_folder)
item.command = launch.get("command", "")
item.args = launch.get("args", [])
elif caves == null:
# Fetch.Caves failed (e.g. butlerd still booting). Keep the
# cached state rather than wrongly uninstalling.
pass
elif item.installed:
logger.info("Cached install '" + item.name + "' has no cave on disk. Marking as not installed.")
item.installed = false
item.command = ""
item.args = []
item.provider_app_id = ""
if item.installed:
_apply_user_library_path(item)
if not _game_available_on_current_platform(game, item.installed):
continue
items.append(item)
if caching_flags & Cache.FLAGS.SAVE:
var json_out := []
for it in items:
var entry: LibraryLaunchItem = it
json_out.append(entry.to_dict())
if Cache.save_json(_cache_dir, _apps_cache_file, json_out) != OK:
logger.warn("Unable to save reconciled itch.io apps cache")
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 show_purchases: bool = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool
var hidden_collections: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary
var owned: Array = []
if show_purchases:
owned = await itch.get_owned_games()
var caves: Variant = await itch.get_caves()
var collection_groups: Array = await itch.get_collection_groups()
# 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.
if caves == null:
caves = []
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 group in collection_groups:
var group_dict: Dictionary = group
var col_id: String = str(group_dict.get("id", 0))
if hidden_collections.has(col_id):
continue
for g in group_dict.get("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")
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)
## Resolves the authoritative launch command for an installed game.
##
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
## the actual launch target there (the manifest knows a Unity game launches
## via its .x64 launcher rather than the LinuxPlayer_s.debug stub, that a .love
## game needs the LOVE runtime, etc.). Guessing with `find | head -n 1` picks
## the wrong binary for many real games, so the receipt is trusted first and a
## best-effort scan only kicks in when it's missing or unusable.
func _resolve_launch(install_folder: String) -> Dictionary:
if install_folder == "":
return {"command": "", "args": []}
var launch := _receipt_launch(install_folder)
if not launch.is_empty():
var resolved := _resolve_receipt_launch(install_folder, launch)
if not (resolved.get("command", "") as String).is_empty():
return resolved
return _resolve_fallback_launch(install_folder)
## Best-effort launch resolution when the receipt is missing or doesn't name a
## usable target. Preference order: real native executables (largest first),
## then runtime-bundled games detected by file extension (.love / .jar), which
## butlerd itself doesn't stub into receipts. Each runtime is looked up via
## _find_on_path(); if missing, a warning tells the user where to drop it.
func _resolve_fallback_launch(install_folder: String) -> Dictionary:
if install_folder == "":
return {"command": "", "args": []}
var exe := _find_executable_fallback(install_folder)
if exe != "":
return {"command": exe, "args": []}
for path in _find_files_largest_first(install_folder, "\\( -name '*.love' -o -name '*.jar' \\)"):
if path == "" or path.contains("/.itch/"):
continue
if path.ends_with(".love"):
var love_bin := _find_on_path("love")
if love_bin == "":
logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir())
return {"command": "", "args": []}
return {"command": love_bin, "args": [path]}
if path.ends_with(".jar"):
var java_bin := _find_on_path("java")
if java_bin == "":
logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir())
return {"command": "", "args": []}
return {"command": java_bin, "args": ["-jar", path]}
return {"command": "", "args": []}
## Reads the `launch` block of butlerd's install receipt, if any.
func _receipt_launch(install_folder: String) -> Dictionary:
var receipt_path := "/".join([install_folder, ".itch", "receipt.json.gz"])
if not FileAccess.file_exists(receipt_path):
return {}
var file := FileAccess.open(receipt_path, FileAccess.READ)
if file == null:
return {}
var bytes := file.get_buffer(file.get_length())
file.close()
# Receipts are plain gzip. FileAccess.open_compressed only reads Godot's
# own GCPF container, not standard gzip, so decompress the raw stream.
var text := bytes.decompress_dynamic(1024 * 1024, FileAccess.COMPRESSION_GZIP).get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return {}
var launch: Variant = (parsed as Dictionary).get("launch", {})
if typeof(launch) != TYPE_DICTIONARY:
return {}
return launch
## Turns the receipt's `launch` block into an actual command to run.
func _resolve_receipt_launch(install_folder: String, launch: Dictionary) -> Dictionary:
var launch_type: String = launch.get("type", "")
var rel_path: String = launch.get("path", "")
var args: Array = launch.get("withArgs", [])
var abs_path := install_folder
if not rel_path.is_empty():
abs_path = "/".join([install_folder, rel_path])
match launch_type:
"executable", "shell":
if not _is_runnable_file(abs_path):
return {}
return {"command": abs_path, "args": args}
"love":
if not FileAccess.file_exists(abs_path):
return {}
var love_bin := _find_on_path("love")
if love_bin == "":
logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir())
return {}
return {"command": love_bin, "args": [abs_path] + args}
"jar":
if not FileAccess.file_exists(abs_path):
return {}
var java_bin := _find_on_path("java")
if java_bin == "":
logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir())
return {}
return {"command": java_bin, "args": ["-jar", abs_path] + args}
"web", "html":
logger.warn("HTML5 games aren't launchable in OpenGamepadUI yet: " + abs_path)
return {}
return {}
## Best-effort scan for the game's executable when the receipt is missing or
## doesn't name a usable one. Picks the largest real executable, skipping
## butler's own metadata dir, Unity debug stubs and shared libraries.
func _find_executable_fallback(install_folder: String) -> String:
if install_folder == "":
return ""
for path in _find_files_largest_first(install_folder, "-executable"):
if _candidate_is_game_binary(path):
return path
return ""
## Runs `find` over the install folder and returns the paths of the files it
## matched, largest file first. `find_expression` is extra `find` arguments
## (-executable, -name filters, ...). Size and path are printed separated by a
## TAB because game paths may contain spaces (Unity ships "unity default
## resources"), which truncates the path when the line is split on a space.
func _find_files_largest_first(install_folder: String, find_expression: String) -> PackedStringArray:
var paths := PackedStringArray()
var out := []
OS.execute("bash", [
"-c",
"find " + install_folder.c_escape() +
" -maxdepth 4 -type f " + find_expression +
" -printf '%s\\t%p\\n' | sort -rn"
], out)
if out.is_empty():
return paths
# OS.execute hands back the whole stdout as a single string, split by line.
for line in (out[0] as String).split("\n"):
var fields := line.strip_edges().split("\t", true, 1)
if fields.size() == 2:
paths.append(fields[1])
return paths
## Filters out files that are clearly not the game itself (butler's metadata,
## Unity debug players, shared libraries, Windows binaries, Unity data files).
func _candidate_is_game_binary(path: String) -> bool:
if path.contains("/.itch/"):
return false
if path.ends_with("Player_s.debug"):
return false
if path.ends_with(".so") or path.ends_with(".debug"):
return false
if path.ends_with(".dll"):
return false
# Unity's data files land with the exec bit set (they come out of a zip) and
# "unity default resources" is bigger than the real launcher.
if path.ends_with("unity default resources") or path.ends_with("unity_builtin_extra"):
return false
return true
## Returns true when the file exists and is marked executable.
func _is_runnable_file(path: String) -> bool:
if path == "" or not FileAccess.file_exists(path):
return false
# 0o111 = execute bit set for any of owner/group/others.
return FileAccess.get_unix_permissions(path) & 73 != 0
## Looks up a runtime binary (love, java, ...). Checks the persistent runtime
## dir first (where users can drop a LOVE runtime or JRE), then PATH.
func _find_on_path(bin: String) -> String:
var candidate := "/".join([_runtime_dir(), bin])
if _is_runnable_file(candidate):
return candidate
for dir in OS.get_environment("PATH").split(":"):
if dir == "":
continue
candidate = "/".join([dir, bin])
if _is_runnable_file(candidate):
return candidate
return ""
## When a game dies before creating a window, OGPU's LaunchManager never marks
## it stopped (its app type stays UNKNOWN), so the user is left on a black
## in-game screen with no clue what happened. Poll running apps; for one of
## ours that is dead and windowless, run diagnostics (ldd for missing shared
## libraries, ...) and show a notification pointing at the fix. Forcing the app
## to STOPPED triggers LaunchManager's normal cleanup, which removes the stuck
## in-game state and returns the user to the library UI where the notification
## is visible.
func _check_failed_launches() -> void:
var launch_manager := load("res://core/global/launch_manager.tres") as LaunchManager
if launch_manager == null:
return
var now := Time.get_ticks_msec()
var running_pids := {}
for app in launch_manager.get_running():
var item: LibraryLaunchItem = app.launch_item
if item == null or item._provider_id != library_id:
continue
running_pids[app.pid] = true
if app.created_window:
# The game got a window, so it launched fine. Stop watching it.
_launch_watch.erase(app.pid)
continue
if not _launch_watch.has(app.pid):
_launch_watch[app.pid] = now
continue
if app.is_running():
continue
if now - int(_launch_watch[app.pid]) < 5000:
continue
if app.pid in _diagnosed_pids:
continue
_diagnosed_pids[app.pid] = true
_launch_watch.erase(app.pid)
_handle_launch_failure(app)
# Forget apps that are no longer running at all (launched fine and exited,
# or were stopped some other way) so the watch list stays small.
var stale := []
for pid in _launch_watch:
if not running_pids.has(pid):
stale.append(pid)
for pid in stale:
_launch_watch.erase(pid)
func _handle_launch_failure(app: RunningApp) -> void:
var item: LibraryLaunchItem = app.launch_item
var message := _diagnose_launch_failure(item)
logger.warn("Game '" + item.name + "' failed to start: " + message)
# Return to the library UI first (LaunchManager cleans up a STOPPED app and
# pops the stuck black in-game state), then show the failure as a popup.
app.state = app.STATE.STOPPED
_show_failure_dialog.call_deferred(item.name, message)
## Shows the launch failure as a modal popup (OGPU's Dialog component) instead
## of a transient toast, so the user has time to read and act on the message.
func _show_failure_dialog(game_name: String, message: String) -> void:
_show_modal_dialog("Failed to start: " + game_name + "\n\n" + message)
## Surfaces an install/update failure (butlerd RPC error, corrupt download,
## ...) in the same modal Dialog component the launch failure uses. Before
## this, install errors were only written to the log, so a failed install
## looked like a silent no-op.
func _on_install_failed(_cave_or_game_id: String, game_title: String, message: String) -> void:
logger.warn("Install of '" + game_title + "' failed: " + message)
_show_modal_dialog("Failed to install: " + game_title + "\n\n" + message)
## Shows a message in OGPU's modal Dialog component. The Dialog scene is
## authored with zero-size anchors; make it (and its Spacer) fill the screen
## so the panel is centered over whatever UI is currently up.
func _show_modal_dialog(body: String) -> void:
var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene
if dialog_scene == null:
logger.warn("Dialog component unavailable; message not shown to user.")
return
var ui: Node = get_tree().get_first_node_in_group("main")
if ui == null:
ui = get_tree().root
var dialog: Control = dialog_scene.instantiate()
ui.add_child(dialog)
dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dialog.size = (ui as Control).size if ui is Control else Vector2(1920, 1080)
(dialog.get_node("Spacer") as Control).set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dialog.cancel_visible = false
dialog.closed.connect(func() -> void: dialog.queue_free())
# Hand focus back to whatever had it before the modal opened. Without a
# return node the Dialog leaves the UI unfocused on close, so gamepad
# navigation goes dead until the user presses the guide button.
var return_node: Control = get_tree().root.gui_get_focus_owner()
dialog.open(return_node, body, "OK")
## Figures out why a game failed to start and returns a human-readable,
## actionable explanation (missing shared library, missing binary, missing
## game file, ...).
func _diagnose_launch_failure(item: LibraryLaunchItem) -> String:
var command: String = item.command
if command.is_empty():
return "No launch command was set for this game. Reinstall it to repair the install."
if not FileAccess.file_exists(command):
return "Executable not found: " + command + ". Reinstall the game to repair the install."
if not _is_runnable_file(command):
return "Executable is missing the execute bit: " + command
var missing := _missing_shared_libs(command)
if not missing.is_empty():
return "Missing shared libraries: " + missing + ". Drop the .so files into " + _lib_hint() + " and launch again."
var args := item.args
if args.size() > 0 and not FileAccess.file_exists(args[0]):
return "Game file not found: " + args[0] + ". Reinstall the game."
return "The game exited before opening a window. Command was: " + command
## Runs ldd on an ELF executable and returns the shared libraries the loader
## can't find, e.g. "libvlc.so.5" (the exact cause of Friday Night Funkin'
## failing to launch until its libraries were made available).
func _missing_shared_libs(binary: String) -> String:
var out := []
OS.execute("ldd", [binary], out)
if out.is_empty():
return ""
var missing := []
for raw in (out[0] as String).split("\n"):
var line: String = raw.strip_edges()
# A missing dependency looks like "libfoo.so => not found".
if not line.contains("=>") or not line.contains("not found"):
continue
var name := line.get_slice("=>", 0).strip_edges()
if name != "":
missing.append(name)
return ", ".join(missing)
## Formats a byte count for display, e.g. 53712463 -> "51.2 MB". A zero-byte
## upload (like a "latest" external redirect placeholder) shows as "0 B" so
## the user can spot it before picking it.
func _format_bytes(bytes: int) -> String:
if bytes <= 0:
return "0 B"
var units := ["B", "KB", "MB", "GB", "TB"]
var value: float = float(bytes)
var i := 0
while value >= 1024.0 and i < units.size() - 1:
value /= 1024.0
i += 1
if i == 0:
return str(int(value)) + " B"
return str(snapped(value, 0.1)) + " " + units[i]