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:
parent
18ef80e868
commit
8a2d8b5374
2 changed files with 123 additions and 12 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue