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:
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue