Fix install of bought games and launch of games with spaces in paths
Some checks failed
Build plugin / build (push) Failing after 10s

Three bugs, all seen on Bubble Tower (itch.io game 4845491):

- Installs of bought games failed with "you must buy this game to download".
  butlerd only learns itch.io download keys from Fetch.ProfileOwnedKeys,
  which the library skips when "show_purchases" is off (the listing then
  comes from collections, so owned games still show up). butlerd queued
  the install without credentials and itch.io rejected the build request.
  Install.Queue is now retried once after a fresh owned-keys sync when
  itch.io answers with that error.

- The install-options upload dropdown never applied the user's pick: it
  offers "<name>  (<size>)" while _pick_upload compared against the bare
  displayName/filename, so every multi-upload game silently fell back to
  the default upload. The label format is now shared through
  UPLOAD_SIZE_SEPARATOR and the name prefix counts as a match.

- Games whose receipt carries no launch block were resolved by scanning
  for the largest executable and splitting "size path" on a space, which
  truncated the path of any file with a space in its name (Unity's
  "unity default resources") and pointed the launch at a file that does
  not exist. Sizes and paths are now tab-separated, and Unity's data
  files are skipped.
This commit is contained in:
Jose Falanga 2026-10-15 11:25:05 -03:00
parent c15d49bfd1
commit bbc9b248bb
3 changed files with 103 additions and 41 deletions

View file

@ -105,7 +105,7 @@ func get_install_options(item: LibraryLaunchItem) -> Array[Library.InstallOption
label = upload.get("filename", "")
if label != "":
var size: int = int(upload.get("size", 0))
values.append(label + " (" + _format_bytes(size) + ")")
values.append(label + ItchClient.UPLOAD_SIZE_SEPARATOR + _format_bytes(size) + ")")
if values.size() <= 1:
return []
option.values = values
@ -666,17 +666,7 @@ func _resolve_fallback_launch(install_folder: String) -> Dictionary:
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)
for path in _find_files_largest_first(install_folder, "\\( -name '*.love' -o -name '*.jar' \\)"):
if path == "" or path.contains("/.itch/"):
continue
if path.ends_with(".love"):
@ -757,25 +747,38 @@ func _resolve_receipt_launch(install_folder: String, launch: Dictionary) -> Dict
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):
for path in _find_files_largest_first(install_folder, "-executable"):
if _candidate_is_game_binary(path):
return path
return ""
## Runs `find` over the install folder and returns the paths of the files it
## matched, largest file first. `find_expression` is extra `find` arguments
## (-executable, -name filters, ...). Size and path are printed separated by a
## TAB because game paths may contain spaces (Unity ships "unity default
## resources"), which truncates the path when the line is split on a space.
func _find_files_largest_first(install_folder: String, find_expression: String) -> PackedStringArray:
var paths := PackedStringArray()
var out := []
OS.execute("bash", [
"-c",
"find " + install_folder.c_escape() +
" -maxdepth 4 -type f " + find_expression +
" -printf '%s\\t%p\\n' | sort -rn"
], out)
if out.is_empty():
return paths
# OS.execute hands back the whole stdout as a single string, split by line.
for line in (out[0] as String).split("\n"):
var fields := line.strip_edges().split("\t", true, 1)
if fields.size() == 2:
paths.append(fields[1])
return paths
## Filters out files that are clearly not the game itself (butler's metadata,
## Unity debug players, shared libraries, Windows binaries).
## Unity debug players, shared libraries, Windows binaries, Unity data files).
func _candidate_is_game_binary(path: String) -> bool:
if path.contains("/.itch/"):
return false
@ -785,6 +788,10 @@ func _candidate_is_game_binary(path: String) -> bool:
return false
if path.ends_with(".dll"):
return false
# Unity's data files land with the exec bit set (they come out of a zip) and
# "unity default resources" is bigger than the real launcher.
if path.ends_with("unity default resources") or path.ends_with("unity_builtin_extra"):
return false
return true