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 + " (" + _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 owned: Array = await itch.get_owned_games() var caves: Variant = 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. 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 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") 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": []} var out := [] OS.execute("bash", [ "-c", "find " + install_folder.c_escape() + " -maxdepth 4 -type f \\( -name '*.love' -o -name '*.jar' \\) -printf '%s %p\\n' | sort -rn" ], out) if out.is_empty(): return {"command": "", "args": []} var lines := (out[0] as String).split("\n") for line in lines: var path := line.strip_edges().get_slice(" ", 1) 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 "" var out := [] OS.execute("bash", [ "-c", "find " + install_folder.c_escape() + " -maxdepth 4 -type f -executable -printf '%s %p\\n' | sort -rn" ], out) if out.is_empty(): return "" # OS.execute hands back the whole stdout as a single string, split by line. var lines := (out[0] as String).split("\n") for line in lines: var path := line.strip_edges().get_slice(" ", 1) if path != "" and _candidate_is_game_binary(path): return path return "" ## Filters out files that are clearly not the game itself (butler's metadata, ## Unity debug players, shared libraries, Windows binaries). 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 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]