Some checks failed
Build plugin / build (push) Failing after 10s
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.
970 lines
39 KiB
GDScript
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]
|