itchio-opengamepadui-plugin/core/library_itch.gd

878 lines
34 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 := {}
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)
# 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)
func get_library_launch_items() -> Array[LibraryLaunchItem]:
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary = {}) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var success: bool = await itch.install(game)
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)
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)
## 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. Drop and re-add this app through the
## LibraryManager, which fires the library_item_removed/library_item_added
## signals the menu uses to queue a refresh.
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 library_manager.has_app(item.name):
library_manager.remove_library_launch_item("itch", item.name)
library_manager.add_library_launch_item("itch", item)
## 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:
_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:
_refresh_library_menu(item)
_restore_launch_focus()
## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it
## synchronously here. LibraryManager is expected to periodically call
## get_library_launch_items() again, which re-fetches from butlerd's cache.
func has_update(_item: LibraryLaunchItem) -> bool:
return false
func _on_logged_in(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
if status != ItchClient.LOGIN_STATUS.OK:
return
logger.info("Logged in. Refreshing itch.io library.")
var items: Array = await _load_library(Cache.FLAGS.SAVE)
for i in items:
var item: LibraryLaunchItem = i
if not library_manager.has_app(item.name):
logger.debug("App '" + item.name + "' was not loaded. Reloading library.")
library_manager.reload_library()
return
## Forwards butlerd's install progress (current/total are percentage points,
## 0-100) to the base [Library] install_progressed contract (a fraction,
## 0.0-1.0) that OGPU's InstallManager and launch menu render as a progress
## bar.
func _on_install_progressed(_id: String, current: int, total: int) -> void:
if _active_item == null:
return
if total <= 0:
return
logger.info("Install progressing: " + str(current) + "/" + str(total))
install_progressed.emit(_active_item, float(current) / float(total))
## Returns true unless the "only show games available on this platform"
## filter is enabled and the game's platforms don't include the OS
## OpenGamepadUI is running on. Installed games always pass: they were
## installed on this machine, so they're runnable regardless of what
## platform info butlerd reported. Everything else needs an explicit
## platform match, so games with no platform info at all (e.g. HTML5) are
## hidden while the filter is on.
func _game_available_on_current_platform(game: Dictionary, installed := false) -> bool:
if not _filter_unsupported():
return true
if installed:
return true
var platforms: Dictionary = game.get("platforms", {})
if platforms.is_empty():
return false
var current := "windows"
if OS.get_name() == "Linux":
current = "linux"
elif OS.get_name() == "macOS":
current = "osx"
return not (platforms.get(current, "") as String).is_empty()
func _filter_unsupported() -> bool:
return settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool
## A cave is only usable when its install folder actually exists on disk.
## OGPU moves the whole extracted plugin directory (where older builds kept
## installed games) to the trash on every plugin update, so butler.db can hold
## "orphan" caves whose files are gone. Those must not show as installed: the
## user can't launch them, and Install.Queue would reject a fresh install with
## "That upload is already installed!".
func _cave_has_files(cave: Dictionary) -> bool:
if cave.is_empty():
return false
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
if install_folder == "":
return false
return DirAccess.dir_exists_absolute(install_folder)
## Builds a LibraryLaunchItem for a game, or null when the entry isn't an
## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...)
## or is filtered out by the "available on this platform" setting.
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
if game.is_empty():
return null
if game.get("classification", "game") != "game":
return null
var game_id: int = 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)
_queue_boxart(items)
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")
_queue_boxart(items)
return items
## Fire-and-forget removal of butler.db caves whose install folders no longer
## exist. Runs sequentially (one butlerd uninstall at a time) so the shared
## thread's one-shot queue never holds concurrent RPCs.
func _cleanup_orphan_caves(cave_ids: Array) -> void:
for cave_id in cave_ids:
await itch.uninstall(cave_id)
## Queues background downloads of each game's cover art into OGPU's local
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
## picks those files up by <game name>-<layout>.png, so games show their real
## cover instead of the placeholder. Covers are only downloaded once (the task
## bails out if a layout file already exists). For games with an animated GIF
## cover, itch.io provides a stillCoverUrl (a static frame, served as PNG) which
## we prefer, since Godot has no GIF decoder.
func _queue_boxart(items: Array) -> void:
for i in items:
var item: LibraryLaunchItem = i
var game: Dictionary = item.metadata.get("game", {})
var title: String = game.get("title", "")
var cover_url := _cover_url(game)
if title.is_empty() or cover_url.is_empty():
continue
if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])):
continue
itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0)
## Downloads the given game's cover (blocking, runs on the shared thread) and
## writes it to every boxart layout. itch.zone only serves one fixed landscape
## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a
## single image is reused for portrait, landscape, banner and logo.
func _ensure_boxart(game: Dictionary) -> void:
var title: String = game.get("title", "")
var cover_url := _cover_url(game)
if title.is_empty() or cover_url.is_empty():
return
var dir := _boxart_dir()
if FileAccess.file_exists("/".join([dir, title + "-portrait.png"])):
return
var body := _download_cover(cover_url)
if body.is_empty():
logger.warn("Unable to download cover art for '" + title + "'")
return
if not _is_raster_image(body):
# OGPU's local provider only loads png/jpg. If itch.io didn't give us a
# still for an animated cover, skip rather than write an unloadable file.
logger.warn("Skipping unsupported cover format for '" + title + "'")
return
DirAccess.make_dir_recursive_absolute(dir)
for layout in ["portrait", "landscape", "banner", "logo"]:
var path := "/".join([dir, title + "-" + layout + ".png"])
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_buffer(body)
file.close()
logger.info("Downloaded boxart for '" + title + "'")
func _boxart_dir() -> String:
return ProjectSettings.globalize_path("user://boxart/local")
## itch.io exposes stillCoverUrl for games whose cover is an animated GIF: a
## static frame that the CDN serves as PNG. Prefer it over coverUrl so those
## games still get art (Godot can't decode GIFs at runtime).
func _cover_url(game: Dictionary) -> String:
var still: String = game.get("stillCoverUrl", "")
if not still.is_empty():
return still
return game.get("coverUrl", "")
## Blocking HTTPS GET of a single cover image, for use on a background thread.
func _download_cover(url: String) -> PackedByteArray:
var parts := url.split("/")
var use_tls := parts[0] == "https:"
var http := HTTPClient.new()
var err: int = http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
if err != OK:
return PackedByteArray()
var deadline := Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(50)
if http.get_status() != HTTPClient.STATUS_CONNECTED:
http.close()
return PackedByteArray()
http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray())
deadline = Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_REQUESTING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
var body := PackedByteArray()
while http.get_status() == HTTPClient.STATUS_BODY:
http.poll()
var chunk: PackedByteArray = http.read_response_body_chunk()
if chunk.is_empty():
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
continue
body.append_array(chunk)
var code: int = http.get_response_code()
http.close()
if code != 200:
return PackedByteArray()
return body
## Returns true if the bytes look like a PNG or JPEG (the only formats OGPU's
## local boxart provider can load).
func _is_raster_image(body: PackedByteArray) -> bool:
if body.size() < 12:
return false
var png_magic := [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
var is_png := true
for i in png_magic.size():
if body[i] != png_magic[i]:
is_png = false
break
if is_png:
return true
return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff
## 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:
var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene
if dialog_scene == null:
logger.warn("Dialog component unavailable; launch failure 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)
# The Dialog scene is authored with zero-size anchors; make it (and its
# Spacer) fill the screen so the panel is centered over the UI.
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())
dialog.open(null, "Failed to start: " + game_name + "\n\n" + message, "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)