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.
This commit is contained in:
Jose Falanga 2026-08-08 00:00:26 -03:00
parent 18ef80e868
commit 8a2d8b5374
2 changed files with 123 additions and 12 deletions

View file

@ -152,9 +152,9 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
if cave_valid: if cave_valid:
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
var exe := _find_executable(install_folder) var launch := _resolve_launch(install_folder)
item.command = exe item.command = launch.get("command", "")
item.args = [] item.args = launch.get("args", [])
item.cwd = install_folder item.cwd = install_folder
return item return item
@ -178,6 +178,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
item.installed = false item.installed = false
item.command = "" item.command = ""
item.provider_app_id = "" 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", {}) var game: Dictionary = item.metadata.get("game", {})
if not _game_available_on_current_platform(game, item.installed): if not _game_available_on_current_platform(game, item.installed):
continue continue
@ -388,19 +396,122 @@ func _is_raster_image(body: PackedByteArray) -> bool:
return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff
## Best-effort discovery of the game's launch executable inside its install ## Resolves the authoritative launch command for an installed game.
## 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. ## butlerd writes .itch/receipt.json.gz into every install folder and resolves
## TODO: parse .itch/receipt.json.gz for the authoritative launch target ## the actual launch target there (the manifest knows a Unity game launches
## and any declared manifest Actions instead of guessing. ## via its .x64 launcher rather than the LinuxPlayer_s.debug stub, that a .love
func _find_executable(install_folder: String) -> String: ## 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 == "": if install_folder == "":
return "" return ""
var out := [] var out := []
OS.execute("bash", [ OS.execute("bash", [
"-c", "-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) ], out)
if out.is_empty(): if out.is_empty():
return "" 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 ""

View file

@ -1,7 +1,7 @@
{ {
"plugin.id": "itch", "plugin.id": "itch",
"plugin.name": "itch.io", "plugin.name": "itch.io",
"plugin.version": "0.1.11", "plugin.version": "0.1.12",
"plugin.min-api-version": "1.1.0", "plugin.min-api-version": "1.1.0",
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",