From 8a2d8b53742f7e9fba77ecaa205e7d6dd0852a1f Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Sat, 8 Aug 2026 00:00:26 -0300 Subject: [PATCH] Resolve launch commands from butlerd's install receipt (v0.1.12) The old `find | head -n 1` guess picked the wrong binary for many games (e.g. a Unity LinuxPlayer_s.debug stub instead of the real .x64 launcher, or nothing at all for .love games), so most fresh installs failed to launch. Read .itch/receipt.json.gz (butlerd writes the authoritative launch target on every install) and use its resolved command, with a smarter best-effort scan as fallback. Re-resolve commands on cache load so existing installs are fixed without a reinstall. --- core/library_itch.gd | 133 +++++++++++++++++++++++++++++++++++++++---- plugin.json | 2 +- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/core/library_itch.gd b/core/library_itch.gd index 949d846..b75e6c4 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -152,9 +152,9 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: if cave_valid: var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") - var exe := _find_executable(install_folder) - item.command = exe - item.args = [] + var launch := _resolve_launch(install_folder) + item.command = launch.get("command", "") + item.args = launch.get("args", []) item.cwd = install_folder return item @@ -178,6 +178,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> item.installed = false item.command = "" item.provider_app_id = "" + # Caches from older builds carried a wrong launch command (the + # old `find | head` guess picked e.g. a Unity debug stub instead + # of the real launcher). Re-resolve it from butlerd's receipt so + # existing installs launch correctly. + elif item.installed and item.cwd != "": + var launch := _resolve_launch(item.cwd) + item.command = launch.get("command", "") + item.args = launch.get("args", []) var game: Dictionary = item.metadata.get("game", {}) if not _game_available_on_current_platform(game, item.installed): continue @@ -388,19 +396,122 @@ func _is_raster_image(body: PackedByteArray) -> bool: return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff -## Best-effort discovery of the game's launch executable inside its install -## folder. butlerd doesn't hand back a ready-to-exec command the way a -## "gog://" or "steam://" URI does, so we look at what's actually on disk. -## TODO: parse .itch/receipt.json.gz for the authoritative launch target -## and any declared manifest Actions instead of guessing. -func _find_executable(install_folder: String) -> String: +## 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 {"command": _find_executable_fallback(install_folder), "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": + var love_bin := _find_on_path("love") + if love_bin == "" or not FileAccess.file_exists(abs_path): + return {} + return {"command": love_bin, "args": [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 2 -type f -executable | head -n 1" + "find " + install_folder.c_escape() + + " -maxdepth 4 -type f -executable -printf '%s %p\\n' | sort -rn" ], out) if out.is_empty(): return "" - return (out[0] as String).strip_edges() + # 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 binary on PATH, e.g. the LOVE runtime for .love games. +func _find_on_path(bin: String) -> String: + for dir in OS.get_environment("PATH").split(":"): + if dir == "": + continue + var candidate := "/".join([dir, bin]) + if FileAccess.file_exists(candidate): + return candidate + return "" diff --git a/plugin.json b/plugin.json index de1bdfe..7e716ab 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.11", + "plugin.version": "0.1.12", "plugin.min-api-version": "1.1.0", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",